qwen-code/packages/web-shell/client/components/dialogs/ModelFallbacksDialog.dom.test.tsx
Shaojin Wen 71c7e448f0
feat(web-shell): editable user-scope settings and in-panel model management (#6768)
* feat(web-shell): editable user-scope settings and in-panel model management

Make the Settings panel able to manage ~/.qwen/settings.json (user scope),
and add model management inside the panel's Model category.

User scope: the User tab was read-only; enable writing user-scope settings
end-to-end (route allows the user scope, and the client/UI thread it through),
so the same structured controls edit ~/.qwen/settings.json.

Model management: list configured models (grouped by provider), set the
current model, add a model (reusing the existing provider-setup wizard), and
delete a model. Delete is the only new backend surface: DELETE /workspace/models
rewrites modelProviders in the owning scope, empties (rather than drops) a
provider whose last model is removed so the format-preserving settings writer
clears it cleanly, and clears model.name when the active model is deleted.

Voice Model and Model Fallbacks now use pickers instead of free text: Voice
Model reuses the voice picker; Model Fallbacks opens a multi-select (up to 3,
ordered) whose value round-trips through the comma-separated setting.

* fix(web-shell): label model sub-dialog buttons "Select" not "Edit"

Fast/Vision/Voice Model and Model Fallbacks open a picker, so their
empty-state button now reads "Select" instead of "Edit", which wrongly
implied free-text editing.

* fix(sdk): export DaemonModelDelete{Request,Result} from daemon entry

The DELETE /workspace/models types were added to daemon types but not
re-exported from @qwen-code/sdk/daemon, so consumers resolving the built
dist (webui's dts rollup in CI) failed with TS2305.

* fix(web-shell): address automated review of model management

- reset modelSettingScope when the Add Model (auth) dialog closes too, not
  only the model picker / fallbacks dialog (Critical)
- delete: don't surface a reload failure as "delete failed"
- ModelFallbacksDialog: drop role=listbox/option (no arrow-key nav) for
  aria-pressed toggle buttons matching the click-only interaction
- add :focus-visible outlines to model-management and fallbacks buttons
- mock daemon: handle DELETE /workspace/models
- document first-id-match-wins in removeModelFromProviders
- tests: cover 500 path, broadcast assertions, parseTarget validation codes,
  active-model clearing with a pinned baseUrl, and scope the runtime-model
  no-delete assertion to that row

* fix(serve): keep workspace-qualified settings route workspace-only

Widening VALID_WRITE_SCOPES to include 'user' for the primary
/workspace/settings route also loosened the trust-gated
/workspaces/:workspace/settings route, breaking its deliberate
"reject user scope" contract. Give the qualified route its own
workspace-only scope set; the primary route keeps user scope.

* fix(web-shell): address second review round of model management

- isActiveModelSelection: when the active model is pinned to a baseUrl, an
  id-only delete no longer clears it (may have removed a different variant)
- DELETE /workspace/models: on a partial multi-key persist failure, broadcast
  the committed writes before returning 500 (matches workspace-voice)
- model pickers (fast/vision/voice + fallbacks) read the value for the scope
  being edited, so the User tab no longer shows/clears workspace values
- voice sub-dialog: functional setState so a late loadProviders().then() can't
  clobber a picker the user opened meanwhile
- ModelManagementSection cancel button honors the busy state
- doc fix (emptied provider keys are kept as empty arrays), plus tests for the
  baseUrl-asymmetry and partial-persist paths

* fix(web-shell): address third review round of model management

- DELETE /workspace/models returns a structured partial-persist response
  ({ code: 'partial_persist_error', committedKeys }) so callers can reconcile
- scrub the deleted model id out of modelFallbacks so no dangling reference
  remains; trim padded request fields before matching
- reload workspace settings after a delete so a cleared active model / scrubbed
  fallback isn't shown stale
- narrow the workspace-qualified SDK client back to scope: 'workspace' (that
  route is workspace-only); drop the now-dead qualified scope ternary
- remove the unused providerKey from RemoveModelResult

* fix(web-shell): address fourth review round of model management

- handleFallbacksConfirm isolates the settings-reload failure from the
  save-failed toast (a reload reject no longer looks like a save failure)
- readScopedModelSetting returns only the edited scope's value (no effective
  fallback), so the User tab doesn't show/appear-to-clear inherited values
- Add Model button honors the busy state
- widen DaemonSettingUpdateResult.scope to 'workspace' | 'user' to match the
  server echo
- tests: workspace-scope owner path, modelFallbacks scrub broadcast,
  baseModelId current-match, and a delete target without baseUrl

* fix(web-shell): use theme --error-color for model delete buttons

Replace hardcoded #d64545 with var(--error-color) so the destructive model
buttons match the per-theme error color used across the web-shell (dark
#fc8181 / light #c0362c) instead of a fixed mid-red.

* fix(web-shell): address qwen /review self-review of model management

- surface requiresRestart for modelFallbacks changes: handleFallbacksConfirm
  and handleDeleteModel show the restart notice, and DELETE /workspace/models
  reports requiresRestart when a committed write targets a restart-required key
- only scrub a deleted model from modelFallbacks when no other provider still
  configures the same bare id (fallbacks are bare-id, so a same-id model under
  another provider may still want that fallback)
- widen DaemonModelDeleteResult with requiresRestart; add tests for the
  keep-fallback case and the requiresRestart response

* fix(web-shell): address fifth review round of model management

Backend (mixed-scope correctness for model deletion):
- Clear the active model selection in every writable scope whose own
  selection names the deleted model, comparing against the removed
  entry's stored (unsanitized) baseUrl so a credential-bearing URL is
  still recognized after the providers status sanitizes it.
- Scrub modelFallbacks in its own owning scope rather than the
  modelProviders owner scope; the two are independently scoped.
- removeModelFromProviders now reports removedBaseUrl.

CLI:
- /language ui accepts --project/--global so the settings panel can
  persist a UI-language change to the selected scope while still
  switching the daemon's live locale.

Web-shell:
- Fast-model picker forwards the selected scope via --project/--global.
- Theme/Language controls display the selected scope's value; Language
  persists through the scoped command.
- The model-management "current" badge uses a single-winner,
  endpoint-aware match so a bare current id no longer marks every
  same-base-id row current.
- Serialize Set current through the shared busy flag; reset the recorded
  scope if voice-provider loading rejects.

Tests: mixed-scope route cases, strict-mutation/client-id harness
assertions, SDK setWorkspaceSetting/deleteModel transport tests, a
useDaemonProviders hook test, language scope-flag tests, and dom tests
for the current-badge and fallbacks normalization.

* fix(web-shell): address second qwen /review self-review of model management

- onSubDialog now records the model persist scope per model sub-dialog
  (fast/vision/voice/fallbacks) and no longer for the non-model
  approvalMode dialog — the reset effect is gated on the dialog/fallback/
  auth flags, so it never covers approvalMode and would otherwise leave a
  stale scope for a later command-launched picker. (The flagged voice
  "scope-reset race" does not actually occur: that same effect gating means
  no render between the synchronous scope set and the picker opening
  re-runs it — but the scope handling is now explicit per branch.)
- Add an aria-label to the delete-confirm Cancel button so screen readers
  can tell which model's confirmation is being cancelled.

Tests:
- Unit tests for getWritableScopes / getOwnKeyScope (trust + per-scope
  ownership, incl. explicitly-set falsy values).
- Fast-model User-tab test asserting the /model --fast --global flag.
- Theme scoped-read test: the control shows the selected scope's value,
  not the effective merge.
- Fix the useDaemonProviders mock: `current` is a provider-current object,
  not a bare id.

* fix(web-shell): fix voice-picker scope race and provider memoization

- Voice model scope race: the voice picker opens asynchronously (after
  loadProviders), so recording the persist scope synchronously up front
  let it be clobbered — if the user opened and closed another picker while
  loadProviders was in flight, the reset effect reset the scope and the
  voice model persisted to the wrong scope. Now the scope is captured from
  the click and applied together with the open, guarded by a
  modelDialogMode ref so it only opens (and sets scope) when no other
  surface opened meanwhile. (Vision/fast are synchronous and unaffected.)
- Depend on the stable `reload` fn (extracted as reloadProviders) instead
  of the fresh-every-render providersState object, so handleDeleteModel /
  handleCloseAuthDialog aren't recreated each render.
- Add role="group" + aria-label to the model-fallbacks option list so
  screen readers announce it as a labeled multi-select group (+ test).

* fix(web-shell): address review round on model-management follow-up

Frontend:
- Remove the redundant `data-keyboard-scope` from ModelFallbacksDialog's
  inner div — the wrapping DialogShell already provides it, and the extra
  scope became the last match in DialogShell's close cleanup, whose
  role="dialog" lookup then failed and dropped focus when the dialog
  closed while another was stacked.
- Voice picker open-guard now also checks the fallbacks/auth dialog flags
  (via refs), matching "no other surface opened meanwhile" so it can't
  open on top of a dialog opened while providers were loading.
- Provider group key includes the index (two providers can share an
  authType) to avoid duplicate-key reconciliation.
- handleCloseAuthDialog logs a failed provider reload instead of
  swallowing it, like the sibling handlers.
- Model-fallbacks options at the max show a `title` explaining the limit
  (new localized string).

Backend:
- Split the DELETE /workspace/models baseUrl validation so a too-long
  value reports a length error, not "must be a string".

Tests:
- Workspace-scoped language change (`/language ui --project`).
- Security-sensitive key (tools.approvalMode) rejected at user scope.
- baseUrl length-limit rejection.
- Model-fallbacks Cancel → onClose; delete-confirm Cancel path restores
  the Delete button; fallbacks accessible grouping already covered.

* fix(web-shell): a11y + robustness follow-ups on model management

- Model-fallbacks max-limit options use aria-disabled instead of the
  native disabled attribute so they stay hoverable and can surface the
  "limit reached" title (disabled buttons fire no events); the toggle
  handler already no-ops at the max. CSS updated to match.
- Inline delete confirmation dismisses on Escape (the conventional
  gesture) so keyboard users need not Tab to Cancel.
- scopeToWire throws on an unexpected SettingScope instead of silently
  reporting it as 'user'.
- Re-export DaemonWorkspaceProviderCurrent from the webui daemon facade
  alongside the sibling provider types.
2026-07-13 14:44:54 +00:00

155 lines
5.5 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../../i18n';
import {
ModelFallbacksDialog,
type ModelFallbacksDialogProps,
} from './ModelFallbacksDialog';
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
});
function render(node: ReactNode): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(node));
mounted.push({ root, container });
return container;
}
function renderDialog(overrides: Partial<ModelFallbacksDialogProps> = {}) {
const props: ModelFallbacksDialogProps = {
models: [
{ baseId: 'gpt-4o', label: 'GPT-4o' },
{ baseId: 'deepseek-v4', label: 'DeepSeek V4' },
{ baseId: 'qwen3-coder', label: 'Qwen3 Coder' },
{ baseId: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
],
current: [],
max: 3,
onConfirm: vi.fn(),
onClose: vi.fn(),
...overrides,
};
const container = render(
<I18nProvider language="en">
<ModelFallbacksDialog {...props} />
</I18nProvider>,
);
return { container, props };
}
function options(container: HTMLElement): HTMLButtonElement[] {
return Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-pressed]'),
);
}
function clickConfirm(container: HTMLElement): void {
const confirm = Array.from(
container.querySelectorAll<HTMLButtonElement>('button'),
).find((b) => b.textContent?.trim() === 'Confirm');
if (!confirm) throw new Error('Confirm button not found');
act(() => confirm.click());
}
describe('ModelFallbacksDialog', () => {
it('confirms selected base ids in click order', () => {
const { container, props } = renderDialog();
const opts = options(container);
act(() => opts[1]!.click()); // deepseek-v4
act(() => opts[0]!.click()); // gpt-4o
clickConfirm(container);
expect(props.onConfirm).toHaveBeenCalledWith(['deepseek-v4', 'gpt-4o']);
});
it('enforces the max selection by disabling further options', () => {
const { container } = renderDialog({ current: ['gpt-4o', 'deepseek-v4'] });
const opts = options(container);
act(() => opts[2]!.click()); // qwen3-coder → now 3 selected
const refreshed = options(container);
// The 4th option (gemini) is not selected and the limit is reached. It uses
// aria-disabled (not native disabled) so it stays hoverable for the tooltip.
expect(refreshed[3]!.getAttribute('aria-disabled')).toBe('true');
expect(refreshed[3]!.getAttribute('title')).toContain('Maximum');
// Clicking an at-limit option is a no-op (toggle guards), so it can't be
// added beyond the max.
act(() => refreshed[3]!.click());
expect(
options(container).filter(
(o) => o.getAttribute('aria-pressed') === 'true',
).length,
).toBe(3);
});
it('toggles a selected option off', () => {
const { container, props } = renderDialog({ current: ['gpt-4o'] });
const opts = options(container);
act(() => opts[0]!.click()); // deselect gpt-4o
clickConfirm(container);
expect(props.onConfirm).toHaveBeenCalledWith([]);
});
it('shows an already-configured but now-unavailable fallback so it can be removed', () => {
const { container } = renderDialog({ current: ['removed-model'] });
expect(container.textContent).toContain('removed-model');
// 4 available + 1 extra unavailable = 5 options.
expect(options(container)).toHaveLength(5);
});
it('normalizes a persisted list that is oversized and has duplicates/blanks', () => {
// Hand-edited/legacy value: duplicates, whitespace, blanks, and more than
// max. Confirming without touching anything must write the trimmed, deduped,
// capped list — not the raw four+ entries.
const { container, props } = renderDialog({
current: [' gpt-4o ', 'gpt-4o', '', 'deepseek-v4', 'qwen3-coder'],
});
clickConfirm(container);
expect(props.onConfirm).toHaveBeenCalledWith([
'gpt-4o',
'deepseek-v4',
'qwen3-coder',
]);
});
it('shows the normalized count immediately on open', () => {
const { container } = renderDialog({
current: ['gpt-4o', 'gpt-4o', 'deepseek-v4'],
});
// Deduped to 2 of 3, not 3/3.
expect(container.textContent).toContain('2/3');
});
it('invokes onClose when Cancel is clicked', () => {
const { container, props } = renderDialog();
const cancel = Array.from(
container.querySelectorAll<HTMLButtonElement>('button'),
).find((b) => b.textContent?.trim() === 'Cancel');
if (!cancel) throw new Error('Cancel button not found');
act(() => cancel.click());
expect(props.onClose).toHaveBeenCalledTimes(1);
expect(props.onConfirm).not.toHaveBeenCalled();
});
it('groups the toggle options with an accessible name for screen readers', () => {
const { container } = renderDialog();
const group = container.querySelector<HTMLDivElement>('[role="group"]');
expect(group).toBeTruthy();
expect(group?.getAttribute('aria-label')).toBe('Model Fallbacks');
// The option buttons live inside the group.
expect(group?.querySelectorAll('button[aria-pressed]').length).toBe(4);
});
});