diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx index 1bf53b36d9..f7532b2332 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -96,6 +96,10 @@ export function GeneralSettingsPage(props: { patch: Parameters[0], ): Promise; onRefreshConnections(): Promise; + connectionsReadGeneration: number; + getConnectionsReadGeneration(): number; + getRuntimeHostTargetRevision(): number; + runtimeHostGenerationKey: string; onRetryRuntimeHost(): Promise; }) { const host = useOptionalRuntimeHostSettingsTarget(); @@ -294,6 +298,12 @@ export function GeneralSettingsPage(props: { {showRuntimeHostDefaults ? ( ; + connectionsReadGeneration: number; + getConnectionsReadGeneration(): number; + getRuntimeHostTargetRevision(): number; permissionMode: ChatDefaultPermissionMode; thinkingLevel?: ThinkingLevel; onUpdate( @@ -549,6 +565,14 @@ function GeneralDefaultsCard(props: { if (!props.connectionsBridge || !props.connectionsInteractive) return; const releaseSave = persistGuard.begin("default-model"); if (!releaseSave) return; + // Fence this save to the Host generation it started in, synchronously via + // the request authority (bumped by selectTarget on an epoch change before + // React renders) — not by relying on unmount, which the old continuation + // can outrun in the window between the authority's setState and commit. + const startTargetRevision = props.getRuntimeHostTargetRevision(); + const isCurrentEpoch = () => + mountedRef.current && + props.getRuntimeHostTargetRevision() === startTargetRevision; setSaving(true); try { const parsed = parseModelChoiceValue(nextValue); @@ -560,10 +584,10 @@ function GeneralDefaultsCard(props: { } : null, ); - if (!mountedRef.current) return; + if (!isCurrentEpoch()) return; await props.onRefresh(); } catch (error) { - if (mountedRef.current) { + if (isCurrentEpoch()) { toast.error( copy.saveDefaultModelFailed, settingsActionErrorMessage(error, locale), @@ -571,9 +595,13 @@ function GeneralDefaultsCard(props: { host ? { profileId: host.profileId } : undefined, ); } + // Re-throw so ModelPicker rolls back its optimistic pick to the persisted + // model — the trigger owns the optimistic value (@maka/ui), fed the + // connections read generation via props; this row adds no local state. + throw error; } finally { releaseSave(); - if (mountedRef.current) setSaving(false); + if (isCurrentEpoch()) setSaving(false); } } @@ -663,11 +691,12 @@ function GeneralDefaultsCard(props: { } ariaLabel={copy.defaultModel} disabled={saving || !props.connectionsInteractive} - loading={saving} triggerClassName="settingsModelPickerTrigger" onValueChange={persistDefault} /> diff --git a/apps/desktop/src/renderer/settings/settings-request-authority.ts b/apps/desktop/src/renderer/settings/settings-request-authority.ts index 250779183c..4fd8ed4305 100644 --- a/apps/desktop/src/renderer/settings/settings-request-authority.ts +++ b/apps/desktop/src/renderer/settings/settings-request-authority.ts @@ -89,6 +89,22 @@ export function createSettingsRequestAuthority( return ticket(key, connectionsReadGeneration); }, + // The latest connections read generation issued so far. A read barrier + // captured here right after a write is exceeded only by reads issued + // afterwards (the caller's post-write refresh), never by ones already in + // flight — see useOptimisticSelection. + currentConnectionsReadGeneration(): number { + return connectionsReadGeneration; + }, + + // The current target revision, bumped synchronously by selectTarget on any + // Host key/epoch change (before React renders). A save that captures this at + // start and re-checks it in its async continuation is fenced to its epoch + // synchronously — it does not depend on the card unmounting. + currentTargetRevision(): number { + return targetRevision; + }, + acceptsConnectionsRead(candidate: SettingsRequestTicket): boolean { return isCurrentTarget(candidate) && candidate.requestGeneration === connectionsReadGeneration; diff --git a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts index b06a09041e..6a3443828e 100644 --- a/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts +++ b/apps/desktop/src/renderer/settings/settings-snapshot-cache.ts @@ -29,6 +29,13 @@ import type { export interface RuntimeHostConnectionsSnapshot { readonly connections: ProjectedLlmConnection[]; readonly defaultSlug: string | null; + /** + * The connections read generation that produced this snapshot. Advances only + * on an accepted read, so the default-model row's optimistic pick clears + * strictly on a read issued after its write. Optional: a cache-restored + * snapshot from a prior session carries no live generation. + */ + readonly readGeneration?: number; } export interface SettingsSnapshotCache { diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx index 2fd53045e2..5b44239504 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -440,6 +440,10 @@ function SettingsSurfaceContent( ); const connections = selectedConnections?.connections ?? []; const defaultSlug = selectedConnections?.defaultSlug ?? null; + // Read generation of the shown snapshot (carried on the snapshot itself, so + // no extra state here); drives the default-model row's optimistic read + // barrier in @maka/ui ModelPicker. + const committedConnectionsReadGeneration = selectedConnections?.readGeneration ?? 0; const sectionScope = settingsSectionScope(section); const showsRuntimeHost = sectionScope !== 'client'; const requiresRuntimeHost = sectionScope === 'runtime-host'; @@ -577,6 +581,9 @@ function SettingsSurfaceContent( const next = { connections: snapshot.connections, defaultSlug: snapshot.defaultConnection, + // The accepted read's generation, so the row's optimistic pick clears + // only on a read issued after its write (see @maka/ui ModelPicker). + readGeneration: ticket.requestGeneration, }; snapshotCache.commitRuntimeHostConnectionsRead(key, next); setRuntimeHostConnections(completeSettingsResourceLoad(key, next)); @@ -1018,6 +1025,14 @@ function SettingsSurfaceContent( themePref={props.themePref} themePalette={props.themePalette} onRefreshConnections={reloadConnections} + connectionsReadGeneration={committedConnectionsReadGeneration} + getConnectionsReadGeneration={ + runtimeHostRequestAuthority.currentConnectionsReadGeneration + } + getRuntimeHostTargetRevision={ + runtimeHostRequestAuthority.currentTargetRevision + } + runtimeHostGenerationKey={`${selectedRuntimeHostKey ?? "client"}@${selectedRuntimeHostEpoch ?? "unversioned"}`} onUpdateSettings={updateSettings} onReloadSettings={reloadRuntimeHostSettings} onReloadClientSettings={reloadClientSettings} @@ -1075,6 +1090,10 @@ function SettingsPageBody(props: { themePref: ThemePreference; themePalette: ThemePalette; onRefreshConnections(): Promise; + connectionsReadGeneration: number; + getConnectionsReadGeneration(): number; + getRuntimeHostTargetRevision(): number; + runtimeHostGenerationKey: string; onUpdateSettings(patch: Parameters[0]): Promise; onReloadSettings(): Promise; onReloadClientSettings(): Promise; @@ -1156,6 +1175,10 @@ function SettingsPageBody(props: { : undefined} onUpdate={props.onUpdateSettings} onRefreshConnections={props.onRefreshConnections} + connectionsReadGeneration={props.connectionsReadGeneration} + getConnectionsReadGeneration={props.getConnectionsReadGeneration} + getRuntimeHostTargetRevision={props.getRuntimeHostTargetRevision} + runtimeHostGenerationKey={props.runtimeHostGenerationKey} onRetryRuntimeHost={props.onRetryRuntimeHost} /> ); diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index a3a13a12c7..2b334c9b6d 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -1899,12 +1899,17 @@ export const GeneralHostGenerationRevalidation: Story = { isDefault: true, }); + // The default-model row now remounts on the new Host generation (retiring + // the old epoch's save/optimistic state), so re-query rather than reuse the + // pre-epoch element, which detaches on remount. await waitForStoryCondition( - () => tone.matches(':disabled') && defaultModel.matches(':disabled'), + () => + tone.matches(':disabled') && + (canvas.queryByRole('button', { name: '默认模型' })?.matches(':disabled') ?? false), 'Previous Runtime Host generation remained writable', ); await expect(tone).toBeDisabled(); - await expect(defaultModel).toBeDisabled(); + await expect(canvas.getByRole('button', { name: '默认模型' })).toBeDisabled(); await expect( canvas.getByRole('switch', { name: '完成时发送系统通知' }), ).toBeEnabled(); diff --git a/packages/ui/src/__tests__/use-optimistic-selection.test.tsx b/packages/ui/src/__tests__/use-optimistic-selection.test.tsx new file mode 100644 index 0000000000..f6eeece2e8 --- /dev/null +++ b/packages/ui/src/__tests__/use-optimistic-selection.test.tsx @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The default-model row (Settings › 通用 › 默认模型) drives Astryx's Selector on + * the synchronous `onChange` path so the trigger never spins. On that path the + * Selector's own optimistic value never advances, so the row supplies the + * "reflect the pick immediately" half of the fix — this hook. + * + * The clear signal is a monotonic read GENERATION captured at the pick (the + * write's start), and these pin why that ordering point is the correct one: + * + * - a read already IN FLIGHT at pick time (generation ≤ floor) can only carry + * the pre-write value, so it must NOT clear the pick; + * - a read issued AFTER the pick (generation > floor) — the row's own refresh + * OR a concurrent external write's read that lands mid-write — reports a + * newer authority and MUST clear the pick, even if the row's own explicit + * refresh never lands (the production-order regression). + * + * Also covered: convergence to this pick / an external write / the prior value + * restored (A→B→A), a refresh that lands nothing keeping the pick, and cancel. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { useOptimisticSelection, type OptimisticSelection } from '../use-optimistic-selection.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + Node: globalThis.Node, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +let mountedRoot: ReturnType | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +interface Harness { + render(authoritative: string, committedGeneration: number): Promise; + begin(next: string, floor: number): Promise; + cancel(): Promise; + value(): string | null; +} + +async function mount(): Promise { + const { document, window } = parseHTML('
'); + const root = document.querySelector('#root'); + assert.ok(root); + Object.assign(globalThis, { + document, + window, + Element: window.Element, + HTMLElement: window.HTMLElement, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + const api: { current: OptimisticSelection | null } = { current: null }; + function Probe({ authoritative, committedGeneration }: { authoritative: string; committedGeneration: number }) { + const selection = useOptimisticSelection(authoritative, committedGeneration); + api.current = selection; + return ; + } + + mountedRoot = createRoot(root); + const el = () => root.querySelector('span'); + return { + async render(authoritative, committedGeneration) { + await act(() => + mountedRoot?.render(), + ); + }, + async begin(next, floor) { + await act(() => api.current?.begin(next, floor)); + }, + async cancel() { + await act(() => api.current?.cancel()); + }, + value: () => el()?.getAttribute('data-value') ?? null, + }; +} + +test('a pick shows immediately', async () => { + const h = await mount(); + await h.render('A', 5); + assert.equal(h.value(), 'A'); + + // Picked at floor 5 (the read generation issued as of the pick). + await h.begin('B', 5); + assert.equal(h.value(), 'B'); +}); + +test('an in-flight read from before the pick (generation ≤ floor) does not clear', async () => { + const h = await mount(); + await h.render('A', 5); + await h.begin('B', 5); + // A read that was already in flight at pick time commits the pre-write value + // at its own generation (≤ floor). It must not clear the pick. + await h.render('A', 5); + assert.equal(h.value(), 'B'); +}); + +test("the row's own refresh (generation past the floor) clears to this pick", async () => { + const h = await mount(); + await h.render('A', 5); + await h.begin('B', 5); + await h.render('B', 6); + assert.equal(h.value(), 'B'); +}); + +test('an after-the-pick read clears to authority even with no explicit refresh (ordering regression)', async () => { + const h = await mount(); + await h.render('A', 5); + await h.begin('B', 5); + // A concurrent external write's read, issued after the pick, lands mid-write + // reporting C. It is > floor, so it clears the pick and settles on C — WITHOUT + // the row's own refresh ever running. Under the old "arm the floor after the + // refresh resolves" ordering this read was absorbed into the floor and, if the + // refresh then failed, C was masked by B forever. + await h.render('C', 6); + assert.equal(h.value(), 'C'); +}); + +test('A→B→A: authority restored to the pre-pick value still clears the pick', async () => { + // This input shape — pick B, then an accepted read past the floor still + // reporting A — is indistinguishable to the hook from the documented + // limitation: an unrelated connection event's read clearing pending B back to + // A. Generation alone cannot separate "authority genuinely went back to A" + // from "an unrelated read observed pre-write A". We pin the A→B→A resolution + // as correct and accept that the limitation shares this exact sequence. + const h = await mount(); + await h.render('A', 5); + await h.begin('B', 5); + await h.render('A', 6); + assert.equal(h.value(), 'A'); +}); + +test('a refresh that lands no after-the-pick read keeps the pick (does not revert to stale)', async () => { + const h = await mount(); + await h.render('A', 5); + await h.begin('B', 5); + // No read newer than the floor ever commits (refresh failed/invalidated). The + // write persisted B, so B must remain shown. + await h.render('A', 5); + assert.equal(h.value(), 'B'); +}); + +test('cancel rolls back to the authoritative value (write threw)', async () => { + const h = await mount(); + await h.render('A', 5); + await h.begin('B', 5); + assert.equal(h.value(), 'B'); + + await h.cancel(); + assert.equal(h.value(), 'A'); +}); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2ebac40575..c8948d9882 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -23,6 +23,7 @@ export * from './chat-empty-hero.js'; export * from './chat-model-helpers.js'; export * from './use-mounted-ref.js'; export * from './session-setting-intent.js'; +export * from './use-optimistic-selection.js'; export * from './components.js'; export type { ComposerProps } from './components.js'; export type { SandboxBoundaryPromptProps } from './sandbox-boundary-prompt.js'; diff --git a/packages/ui/src/model-picker.tsx b/packages/ui/src/model-picker.tsx index df29709df4..9b13c5cf22 100644 --- a/packages/ui/src/model-picker.tsx +++ b/packages/ui/src/model-picker.tsx @@ -26,7 +26,9 @@ */ import { + useCallback, useMemo, + useRef, type ReactNode, } from 'react'; import { @@ -44,6 +46,7 @@ import { } from './model-picker-internals.js'; import { useUiLocale } from './locale-context.js'; import { getSharedUiCopy } from './shared-ui-copy.js'; +import { useOptimisticSelection } from './use-optimistic-selection.js'; export interface ModelPickerProps { groups: readonly ModelMenuGroup[]; @@ -51,7 +54,18 @@ export interface ModelPickerProps { onValueChange(value: string): void | Promise; renderProviderMark?(type: ProviderType): ReactNode; disabled?: boolean; - loading?: boolean; + /** + * The read generation of the snapshot that produced the current `value`. When + * it advances past the generation captured at a pick, the optimistic pick is + * dropped and the trigger settles on `value`. Omit to disable optimism (the + * trigger just follows `value`). + */ + committedGeneration?: number; + /** + * Returns the latest read generation issued so far, sampled at the pick to + * floor the barrier before the write starts. See useOptimisticSelection. + */ + getReadGeneration?(): number; /** * An ordinary option placed before the catalog for product values such as * “not set” or a current model that is no longer listed. Astryx search treats @@ -80,6 +94,42 @@ export function ModelPicker(props: ModelPickerProps) { [locale, props.groups], ); + // Reflect the pick instantly on the no-spin `onChange` path (Astryx does not + // advance its own optimistic value there), then defer to the authoritative + // `value` once a read issued after the pick lands — the read barrier lives + // here, in @maka/ui, driven by generation props from the caller. See + // useOptimisticSelection. Optimism requires BOTH generation props; without + // them the barrier could never advance and a pick would be stranded, so the + // trigger simply follows `value` (optimism disabled) — see handleChange. + const { onValueChange, getReadGeneration, committedGeneration } = props; + const optimistic = getReadGeneration !== undefined && committedGeneration !== undefined; + const selection = useOptimisticSelection(props.value, committedGeneration ?? 0); + const { begin, cancel } = selection; + // Monotonic token per pick: a write's late failure rolls back only when no + // newer pick has begun since. A value compare is not enough — two picks of + // the same value (e.g. across a Host generation change) must not cross-cancel. + const pickTokenRef = useRef(0); + const handleChange = useCallback( + (next: string) => { + if (!optimistic || getReadGeneration === undefined) { + // No generation source: no optimistic barrier, just forward the pick. + void onValueChange(next); + return; + } + const token = (pickTokenRef.current += 1); + // Floor the barrier at the reads issued as of the pick (before the write), + // so a read issued afterwards — our refresh or a concurrent external write + // — clears it, while one already in flight cannot. + begin(next, getReadGeneration()); + // Fire-and-forget; roll back only this pick if its write rejects. + Promise.resolve(onValueChange(next)).catch(() => { + if (pickTokenRef.current === token) cancel(); + }); + }, + [optimistic, begin, cancel, onValueChange, getReadGeneration], + ); + const shownValue = optimistic ? selection.value : props.value; + // size=md matches the other settings-row selectors. Settings is the only // production host since the composer footer moved to ghost DropdownMenus, // so the size is a fact of the component, not a prop. @@ -89,15 +139,20 @@ export function ModelPicker(props: ModelPickerProps) { label={props.ariaLabel} isLabelHidden options={options} - value={props.value} + value={shownValue} hasSearch searchPlaceholder={props.searchPlaceholder ?? copy.searchPlaceholder} size="md" placement="above" isDisabled={props.disabled} - isLoading={props.loading} className={props.triggerClassName} - changeAction={props.onValueChange} + // `onChange`, not `changeAction`: the async `changeAction` path wraps + // the caller's save in a transition and spins the trigger (via Astryx's + // built-in optimistic `isBusy`) for the whole round-trip. On the + // fire-and-forget `onChange` path the trigger never enters that busy + // state; `value` is the caller's authoritative value and the optimistic + // pick is applied here (see handleChange) so the label updates at once. + onChange={handleChange} renderOption={(option: SelectorOptionData) => { const providerType = providerTypes.get(option.value); const providerMark = diff --git a/packages/ui/src/use-optimistic-selection.ts b/packages/ui/src/use-optimistic-selection.ts new file mode 100644 index 0000000000..836d3942dc --- /dev/null +++ b/packages/ui/src/use-optimistic-selection.ts @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface OptimisticSelection { + /** + * The value to render: the pending pick while its write is unconfirmed, + * otherwise the authoritative value. + */ + value: string; + /** + * Show a pick immediately and arm the read barrier at `floorGeneration` — the + * read generation as of the pick, i.e. before the write starts. The pick is + * dropped once `committedGeneration` advances *past* this floor: any read + * issued after the pick (including a concurrent external write's read that + * lands mid-write) clears it, while a read already in flight at pick time + * (generation ≤ floor, returning the pre-write value) does not. + */ + begin(next: string, floorGeneration: number): void; + /** Drop the pending pick — e.g. the write threw — falling back to the authoritative value. */ + cancel(): void; +} + +/** + * Reflect a just-picked value instantly while its write is in flight, then + * defer to the authoritative value once a read issued after the pick lands — + * without spinning, stranding a stale value, or reverting prematurely. + * + * The clearing signal is a monotonic read GENERATION captured at the pick (the + * write's start), not a value compare and not a snapshot-reference change. + * Ordering the barrier at the write's start — rather than at "the latest + * generation observed after the write's Promise resolves" — is what makes it + * correct: a read issued after the pick that observed a newer authority (this + * write, or a concurrent external write) has generation > floor and clears the + * pick even if the caller's own explicit refresh never lands; and a read + * already in flight at pick time (generation ≤ floor) can only carry the + * pre-write value, so it never clears. + * + * Consequences, all correct: an in-flight pre-write read never clears the pick; + * the pick converges to whatever an after-the-pick read reports — this pick, a + * concurrent external write, or the prior value restored (A→B→A); a refresh + * that lands no after-the-pick read leaves `committedGeneration` at/under the + * floor, so the pick is kept (the write already persisted it). `cancel` covers + * a thrown write. + * + * Known limitation (bounded, self-healing, never durably wrong): the floor + * proves a read was issued after the pick, not that it observed this write. A + * caller that reloads on ANY authority event — settings-surface reloads on any + * connection event, not only a default-model change — can have an unrelated + * event's read accepted past the floor while it still carries the pre-write + * value, which clears the pick back to that value. If the write then persists + * but the caller's own refresh lands no accepted read (e.g. reloadConnections + * swallows the failure), the trigger shows the prior value until the next + * accepted read corrects it. So the "kept" case above is not "kept against + * every failure path": an accepted unrelated read still clears it. Generation + * alone cannot separate this from a genuine A→B→A (same input to this hook; see + * the A→B→A test). The authoritative fix is for the write itself to return its + * post-write authority so the trigger can compare directly — which would let + * this whole generation barrier be removed. + */ +export function useOptimisticSelection( + authoritative: string, + committedGeneration: number, +): OptimisticSelection { + const [pending, setPending] = useState(null); + // Read-generation floor captured at the pick; null means no pick is pending. + const floorRef = useRef(null); + + useEffect(() => { + if ( + pending !== null && + floorRef.current !== null && + committedGeneration > floorRef.current + ) { + floorRef.current = null; + setPending(null); + } + }, [pending, committedGeneration]); + + const begin = useCallback((next: string, floorGeneration: number) => { + floorRef.current = floorGeneration; + setPending(next); + }, []); + const cancel = useCallback(() => { + floorRef.current = null; + setPending(null); + }, []); + + return { value: pending ?? authoritative, begin, cancel }; +} diff --git a/packages/ui/stories/model-picker.stories.tsx b/packages/ui/stories/model-picker.stories.tsx index b8cd7f7fd9..d7ac2758da 100644 --- a/packages/ui/stories/model-picker.stories.tsx +++ b/packages/ui/stories/model-picker.stories.tsx @@ -26,8 +26,6 @@ import type { SessionSummary } from '@maka/core/session'; 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'; @@ -344,30 +342,6 @@ export const ThinkingLevelSeparate: Story = { }, }; -// 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