From 4cc7d9e88879d7a58ebaf485d18a253fa633d7db Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Sun, 30 Aug 2026 05:34:07 +0800 Subject: [PATCH] test(desktop): story-cover the model picker's failure and edge states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second surface under #3944 (one surface per PR): extend `Product/Model Picker` with the failure and edge states that never show up in normal use, driving the real production components with real props. - SavingDefaultModel — the Settings default-model row with `ModelPicker.loading` while a pick is being saved: catalog present, row disabled (a catalog- unavailable row renders a skeleton elsewhere). - NoModelsAvailable — the composer's "configure a connection" chip; a failed / offline / unauthorised catalog fetch collapse to this one honest state. - ManyConnections — seven connection groups; the play asserts the grouped structure and last-group reachability (selection/scroll are contracts left to focused tests — review feedback). - LongModelNames — a long connection name, model labels, and descriptions. - StaleCurrentModel — a session whose connection is still configured but whose pinned model was dropped from that connection's catalog: the leading-row branch, labelled with the raw model id, connection kept. Refs #3944, #3893 Generated-by: Claude Code --- packages/ui/stories/model-picker.stories.tsx | 223 ++++++++++++++++++- 1 file changed, 222 insertions(+), 1 deletion(-) diff --git a/packages/ui/stories/model-picker.stories.tsx b/packages/ui/stories/model-picker.stories.tsx index 769f2ac493..b7819fb016 100644 --- a/packages/ui/stories/model-picker.stories.tsx +++ b/packages/ui/stories/model-picker.stories.tsx @@ -23,12 +23,16 @@ import { expect, userEvent, within } from 'storybook/test'; import type { ProviderType } from '@maka/core/llm-connections'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { SessionSummary } from '@maka/core/session'; -import { ChatModelSwitcher, NewChatModelPicker, ThinkingLevelSelector } from '../src/chat-model-switcher.js'; +import { ChatModelSwitcher, ModelChipStatic, NewChatModelPicker, ThinkingLevelSelector } from '../src/chat-model-switcher.js'; import { exactModelChoiceValue, + modelChoiceValue, + modelMenuGroups, type ChatModelChoice, } from '../src/chat-model-helpers.js'; import { ModelPicker } from '../src/model-picker.js'; +import { getConversationCopy } from '../src/conversation-copy.js'; +import { useUiLocale } from '../src/locale-context.js'; // Fidelity convention (#1433): every story below names the real app path // that reaches it. See apps/desktop/stories/FIDELITY.md. @@ -65,6 +69,53 @@ const CHOICES: ChatModelChoice[] = [ // Canonical user-facing ladder when a model offers the common set. const THINKING_LEVELS: ThinkingLevel[] = ['off', 'low', 'medium', 'high', 'xhigh']; +// A workspace with far more connections than any reference screen probes. Two +// OpenAI keys share a provider, so `modelMenuGroups` disambiguates their +// headings with the connection slug. +const MANY_CHOICES: ChatModelChoice[] = ( + [ + { slug: 'openai-main', type: 'openai', label: 'OpenAI', models: ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'o3', 'o4-mini', 'gpt-4.1'] }, + { slug: 'openai-alt', type: 'openai', label: 'OpenAI', models: ['gpt-5', 'o3'] }, + { slug: 'anthropic-team', type: 'anthropic', label: 'Anthropic', models: ['claude-opus-4-1', 'claude-sonnet-4', 'claude-haiku-4-5'] }, + { slug: 'google-lab', type: 'google', label: 'Google Gemini', models: ['gemini-3-pro', 'gemini-3-flash'] }, + { slug: 'deepseek-main', type: 'deepseek', label: 'DeepSeek', models: ['deepseek-chat', 'deepseek-reasoner'] }, + { slug: 'moonshot-main', type: 'moonshot', label: 'Moonshot', models: ['kimi-k2-0711', 'kimi-k1-8k'] }, + { slug: 'relay', type: 'openai-compatible', label: 'Custom relay', models: ['vendor/alpha', 'vendor/beta', 'vendor/gamma'] }, + ] satisfies Array<{ slug: string; type: ProviderType; label: string; models: string[] }> +).flatMap((group) => group.models.map((model) => choice(group.slug, group.type, group.label, model, model))); + +// A relay whose connection name, model ids, and descriptions all overflow the +// trigger and option widths — the "very long text" state truncation must honour. +const LONG_MODEL_LABEL = + 'A very long model name that keeps going well past any reasonable trigger width so wrapping and truncation get exercised'; +const LONG_CHOICES: ChatModelChoice[] = [ + { + connectionId: 'connection-relay-verbose', + connectionSlug: 'relay-verbose', + providerType: 'openai-compatible', + providerLabel: 'Custom relay', + connectionName: 'My self-hosted relay with an unusually descriptive connection name that also overflows', + model: 'vendor/a-very-long-model-identifier-with-reasoning-tools-and-a-2026-preview-suffix', + label: LONG_MODEL_LABEL, + description: + 'A deliberately verbose description that runs onto several lines so the option body’s overflow handling stays legible instead of pushing the menu wider.', + knowledgeCutoff: '2026-01', + isDefault: false, + thinkingLevels: [], + }, + { + connectionId: 'connection-relay-verbose', + connectionSlug: 'relay-verbose', + providerType: 'openai-compatible', + providerLabel: 'Custom relay', + connectionName: 'My self-hosted relay with an unusually descriptive connection name that also overflows', + model: 'vendor/second-extremely-long-model-identifier-preview-with-an-extended-context-window', + label: 'Another exhaustively named preview model with an extended context window and a trailing note', + isDefault: false, + thinkingLevels: [], + }, +]; + function providerMark(type: ProviderType) { const labels: Partial> = { openai: 'O', @@ -291,3 +342,173 @@ export const ThinkingLevelSeparate: Story = { await within(document.body).findByRole('menuitem', { name: '中' }); }, }; + +// Real path: Settings → 通用 → default model, while the just-picked model is +// being saved. Production drives `ModelPicker.loading` from the save in flight +// (general-settings-page.tsx `loading={saving}`), with the catalog present and +// the row disabled — not an empty catalog. (When the catalog itself is +// unavailable the settings row renders a skeleton, which is a different +// component, so that is not modelled here.) +export const SavingDefaultModel: Story = { + render: () => ( +
+ {}} + /> +
+ ), +}; + +// Real path: composer left footer when no connection yields a usable model — +// what a failed / offline / unauthorised catalog fetch all collapse to. The +// picker cannot exist without choices, so the composer swaps in an honest +// "configure a connection" chip (ModelChipStatic's onOpenSettings button) +// rather than a dropdown with nothing behind it. +export const NoModelsAvailable: Story = { + render: function NoModelsAvailableRender() { + const copy = getConversationCopy(useUiLocale()).composer; + return ( +
+ {}} /> +
+ ); + }, + play: async ({ canvasElement }) => { + // It is a real button into Settings, not inert text wearing a dead chevron. + await expect( + within(canvasElement).getByRole('button', { + name: /配置模型连接|Configure model connections/, + }), + ).toBeInTheDocument(); + }, +}; + +// Real path: home / new-chat model control for a workspace with many configured +// connections — the breadth #3446 F5 says a single reference screen never +// exercises. Two OpenAI keys land in the same provider, so their headings carry +// the disambiguating slug suffix. +export const ManyConnections: Story = { + render: function ManyConnectionsRender() { + const [value, setValue] = useState(choiceValue(MANY_CHOICES[0]!)); + return ( +
+ choiceValue(candidate) === value)?.label ?? value} + choices={MANY_CHOICES} + currentValue={value} + currentProviderType="openai" + renderProviderMark={providerMark} + onPick={(next) => { + const picked = MANY_CHOICES.find( + (candidate) => + candidate.connectionId === next.llmConnectionId && + candidate.connectionSlug === next.llmConnectionSlug && + candidate.model === next.model, + ); + if (picked) setValue(choiceValue(picked)); + }} + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole('button', { + name: /选择新任务模型|Choose a model for the new task/, + }); + await userEvent.click(trigger); + const menu = within(document.body); + // Every connection is its own labelled group and the last group's model is + // reachable in the menu's accessibility tree. This drives the visual state; + // selection behaviour and scroll geometry are contracts left to focused + // tests / e2e, not asserted here. + const groups = await menu.findAllByRole('group'); + await expect(groups.length).toBeGreaterThanOrEqual(7); + await menu.findByRole('menuitem', { name: 'vendor/gamma' }); + }, +}; + +// Real path: a custom relay connection exposing verbose model identifiers with +// a long user-set connection name — very long text in the trigger, the option +// labels, and the descriptions at once. +export const LongModelNames: Story = { + render: () => ( +
+ undefined} + /> +
+ ), + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole('button', { + name: /选择新任务模型|Choose a model for the new task/, + }); + await userEvent.click(trigger); + // Verifies the long-labelled model is reachable as a menuitem. Whether the + // long text truncates or wraps within the menu bounds is a visual check, + // not asserted here. + await within(document.body).findByRole('menuitem', { + name: /A very long model name that keeps going/, + }); + }, +}; + +// Real path: an existing Session whose connection is still configured but whose +// pinned model was dropped from that connection's catalog. ChatModelSwitcher +// surfaces the unknown current model as a leading row above the connection's +// remaining models (the `leadingOption` branch), labelled with the raw model id +// the session carries — not a hand-written label, and without removing the +// connection itself. +export const StaleCurrentModel: Story = { + render: function StaleCurrentModelRender() { + return ( +
+ undefined} + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole('button', { + name: /切换当前任务模型|Switch model for this task/, + }); + await userEvent.click(trigger); + const menu = within(document.body); + // The dropped model leads the menu as the current selection… + await menu.findByRole('menuitem', { name: /claude-opus-3-retired/ }); + // …while its connection's remaining models still follow underneath. + await menu.findByRole('menuitem', { name: 'Claude Sonnet 4' }); + }, +};