diff --git a/apps/desktop/src/app/chat/composer/contrib.ts b/apps/desktop/src/app/chat/composer/contrib.ts index b3cfc80124..05b4ef0b14 100644 --- a/apps/desktop/src/app/chat/composer/contrib.ts +++ b/apps/desktop/src/app/chat/composer/contrib.ts @@ -201,7 +201,10 @@ export function useComposerModelPillLabel({ compact, model, reasoningEffort }: C try { const label = provider?.label?.(ctx) - if (label) { + // Only a non-empty string is a label. ModelPill renders the value + // straight into JSX with no error boundary, so an object/array/number + // from a plugin would throw and blank the composer — treat it as declining. + if (typeof label === 'string' && label.trim() !== '') { return label } } catch { diff --git a/apps/desktop/src/app/chat/composer/model-pill.test.tsx b/apps/desktop/src/app/chat/composer/model-pill.test.tsx index 819fcdf59f..1e6270d8ab 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.test.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.test.tsx @@ -1,7 +1,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { atom } from 'nanostores' import { useContext } from 'react' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { ChatBarState } from '@/app/chat/composer/types' import { type SessionView, SessionViewProvider } from '@/app/chat/session-view' @@ -10,7 +10,7 @@ import { registry } from '@/contrib/registry' import { formatModelPillLabel } from '@/lib/model-status-label' import { $activeSessionId, $currentModel, setCurrentModel, setCurrentModelSource } from '@/store/session' -import { COMPOSER_AREAS, type ComposerModelPillProvider } from './contrib' +import { COMPOSER_AREAS, type ComposerModelPillContext, type ComposerModelPillProvider } from './contrib' import { requestModelMenuToggle } from './focus' import { ModelPill } from './model-pill' import { RICH_INPUT_SLOT } from './rich-editor' @@ -191,22 +191,26 @@ describe('ModelPill label providers', () => { it('renders a provider-supplied label, and the core label once the provider declines', () => { setCurrentModel('deepseek/deepseek-v4-flash') - register(({ model, reasoningEffort }) => `${model} · ${reasoningEffort || 'none'}`) + const label = vi.fn(({ model, reasoningEffort }: ComposerModelPillContext) => `${model} · ${reasoningEffort || 'none'}`) + register(label) const { unmount } = render( ) expect(screen.getByText('deepseek/deepseek-v4-flash · none')).toBeTruthy() + expect(label).toHaveBeenCalled() unmount() // Floating-composer (compact) mode renders only the chevron: providers are - // not consulted, so the provider text must not leak into the DOM. + // not consulted at all (the chevron has no text to leak, so only the spy + // proves the skip). + label.mockClear() const compactRender = render( ) - expect(screen.queryByText(/· none/)).toBeNull() + expect(label).not.toHaveBeenCalled() compactRender.unmount() // Declining provider: the override text is gone, the core label is back. @@ -219,17 +223,22 @@ describe('ModelPill label providers', () => { expect(screen.getByText(formatModelPillLabel('deepseek/deepseek-v4-flash', { fastMode: false }))).toBeTruthy() }) - it('treats a throwing provider as declining and lets the next provider win', () => { + it('falls through on throw and on a non-string return; the first string wins and later providers are not asked', () => { setCurrentModel('deepseek/deepseek-v4-flash') register(() => { throw new Error('broken provider') }, 'broken') - register(() => 'next wins', 'working') + // A non-string (object/array/number) is not a label: rendering it would + // throw inside ModelPill (no error boundary there) and blank the composer. + register(() => ({ text: 'object label' }) as unknown as string, 'wrong-type') + register(() => 'first wins', 'first') + const second = vi.fn(() => 'second loses') + register(second, 'second') render() - // The broken provider declined; the next one's label renders. - expect(screen.getByText('next wins')).toBeTruthy() + expect(screen.getByText('first wins')).toBeTruthy() + expect(second).not.toHaveBeenCalled() }) }) diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index e474e45e41..0eff36d102 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -596,11 +596,14 @@ ctx.register({ ``` **Arbitration.** Providers are consulted in registry order and the *first -non-null, non-empty string wins*. A provider that returns `null` (or `''`) -declines and the next one is asked; a provider that **throws** is treated as -declining — the error is swallowed and the pill falls through to the next -provider, then to the core label, so a broken plugin can never blank the pill. -In compact (floating) mode the pill renders only the chevron and no provider is +non-empty string wins*; later providers are not called. Anything else declines +and the next provider is asked: `null`, `''`, a whitespace-only string, and any +non-string value (an object, array or number is never rendered — the label is +placed straight into JSX). A provider that **throws** also declines — the error +is swallowed and the pill falls through to the next provider, then to the core +label, so a broken plugin can never blank the pill. `reasoningEffort` is always +a `string` (`''` when the model has no effort level, never `undefined`). In +compact (floating) mode the pill renders only the chevron and no provider is called. `label()` is re-evaluated only when the registry, the model, the effort level or the compact flag changes.