Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,6 @@ function GeneralDefaultsCard(props: {
renderProviderMark={(type) => <ProviderBrandMark type={type} />}
ariaLabel={copy.defaultModel}
disabled={saving || !props.connectionsInteractive}
loading={saving}
triggerClassName="settingsModelPickerTrigger"
onValueChange={persistDefault}
/>
Expand Down
141 changes: 141 additions & 0 deletions packages/ui/src/__tests__/use-pending-selection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* 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 assert from 'node:assert/strict';
import test from 'node:test';
import { act, createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { parseHTML } from 'linkedom';
import { usePendingSelection } from '../use-pending-selection.js';

interface Deferred {
resolve(): void;
reject(): void;
}

interface Harness {
value(): string;
pick(next: string): Promise<void>;
render(authoritative: string): Promise<void>;
settle(index?: number): Promise<void>;
reject(index?: number): Promise<void>;
}

const flush = () => new Promise((r) => setTimeout(r, 0));

async function mount(initial: string): Promise<Harness> {
const { document, window } = parseHTML('<div id="root"></div>');
Object.assign(globalThis, {
document,
window,
IS_REACT_ACT_ENVIRONMENT: true,
});
const container = document.querySelector('#root');
assert.ok(container);

const writes: Deferred[] = [];
let handle: { value: string; onChange: (n: string) => void } | null = null;
const onValueChange = (_next: string) =>
new Promise<void>((resolve, reject) => {
writes.push({ resolve, reject });
});

function Host({ authoritative }: { authoritative: string }) {
handle = usePendingSelection(authoritative, onValueChange);
return null;
}

const root = createRoot(container as unknown as Element);
await act(() => {
root.render(createElement(Host, { authoritative: initial }));
});

return {
value: () => handle!.value,
pick: async (next) => {
await act(async () => {
handle!.onChange(next);
await flush();
});
},
render: async (authoritative) => {
await act(() => {
root.render(createElement(Host, { authoritative }));
});
},
settle: async (index = writes.length - 1) => {
await act(async () => {
writes[index]!.resolve();
await flush();
});
},
reject: async (index = writes.length - 1) => {
await act(async () => {
writes[index]!.reject();
await flush();
});
},
};
}

test('a pick shows immediately, before the write settles', async () => {
const h = await mount('A');
assert.equal(h.value(), 'A');
await h.pick('B');
assert.equal(h.value(), 'B');
});

test('the pick clears to authority once the write resolves and value catches up', async () => {
const h = await mount('A');
await h.pick('B');
await h.render('B'); // caller's refresh lands the new authority
await h.settle();
assert.equal(h.value(), 'B');
});

test('a rejected write rolls back to the authoritative value', async () => {
const h = await mount('A');
await h.pick('B');
assert.equal(h.value(), 'B');
await h.reject();
assert.equal(h.value(), 'A');
});

test('the pick holds across an unrelated authority change while the write is in flight', async () => {
const h = await mount('A');
await h.pick('B');
// An unrelated event pushes a different authoritative value mid-write; the
// user's pick still shows until their own write settles.
await h.render('C');
assert.equal(h.value(), 'B');
await h.settle();
assert.equal(h.value(), 'C');
});

test('latest pick wins: a slower earlier write settling does not wipe a newer pick', async () => {
const h = await mount('A');
await h.pick('B'); // write #0
await h.pick('C'); // write #1 (newer)
assert.equal(h.value(), 'C');
await h.settle(0); // the older B write resolves late
assert.equal(h.value(), 'C'); // still C, not cleared
await h.render('C');
await h.settle(1);
assert.equal(h.value(), 'C');
});
17 changes: 13 additions & 4 deletions packages/ui/src/model-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ import {
} from './model-picker-internals.js';
import { useUiLocale } from './locale-context.js';
import { getSharedUiCopy } from './shared-ui-copy.js';
import { usePendingSelection } from './use-pending-selection.js';

export interface ModelPickerProps {
groups: readonly ModelMenuGroup[];
value: string;
onValueChange(value: string): void | Promise<void>;
renderProviderMark?(type: ProviderType): ReactNode;
disabled?: boolean;
loading?: boolean;
Comment thread
Astro-Han marked this conversation as resolved.
/**
* 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
Expand Down Expand Up @@ -80,6 +80,10 @@ export function ModelPicker(props: ModelPickerProps) {
[locale, props.groups],
);

// Reflect the pick immediately and hold it until the caller's write settles,
// then defer to the authoritative `value`. See usePendingSelection.
const selection = usePendingSelection(props.value, props.onValueChange);

// 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.
Expand All @@ -89,15 +93,20 @@ export function ModelPicker(props: ModelPickerProps) {
label={props.ariaLabel}
isLabelHidden
options={options}
value={props.value}
value={selection.value}
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 (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; usePendingSelection shows the pick at once and settles it when
// the write finishes.
onChange={selection.onChange}
renderOption={(option: SelectorOptionData) => {
const providerType = providerTypes.get(option.value);
const providerMark =
Expand Down
76 changes: 76 additions & 0 deletions packages/ui/src/use-pending-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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, useRef, useState } from 'react';

export interface PendingSelection {
/**
* The value to render: the just-picked value while its write is unsettled,
* otherwise the authoritative value.
*/
value: string;
/**
* Show `next` at once and fire the write; the pick clears when that write
* settles — by when the authoritative `value` has caught up on success, or
* falling back to it on failure.
*/
onChange(next: string): void;
}

/**
* Reflect a just-picked value immediately and hold it until the caller's write
* settles, then defer to the authoritative `value`. No spinner and no lag: the
* pick shows from the click and clears the moment `onValueChange` resolves (by
* when `value` has caught up) or rejects (rolling back to `value`).
*
* A monotonic token makes the latest pick win, so a slower earlier write's
* settle cannot wipe a newer pick. The state is deliberately local and
* write-scoped: it carries no cross-read generation and no state that outlives
* the component, so reopening the surface always starts clean.
*
* Limitation: the pick clears when the write settles, not when `authoritative`
* is confirmed to carry it — so if the write resolves but the caller never
* lands the new value into `authoritative` (e.g. its refresh is silently
* dropped), the trigger falls back to the prior `authoritative` until the
* caller next updates it, self-healing on that next update and never wrong
* durably.
*/
export function usePendingSelection(
authoritative: string,
onValueChange: (next: string) => void | Promise<void>,
): PendingSelection {
const [pending, setPending] = useState<string | null>(null);
const tokenRef = useRef(0);
const onChange = useCallback(
(next: string) => {
const token = (tokenRef.current += 1);
setPending(next);
// Clear on either outcome — success (authoritative caught up) or failure
// (roll back to authoritative) — and only if this is still the latest
// pick. Two-arg `then` (not `finally`) so a rejected write is consumed
// here rather than surfacing as an unhandled rejection.
const settle = () => {
if (tokenRef.current === token) setPending(null);
};
Promise.resolve(onValueChange(next)).then(settle, settle);
},
[onValueChange],
);
return { value: pending ?? authoritative, onChange };
}
26 changes: 0 additions & 26 deletions packages/ui/stories/model-picker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: () => (
<div style={{ width: 260 }}>
<ModelPicker
groups={modelMenuGroups(CHOICES, useUiLocale())}
value={modelChoiceValue(CHOICES[4]!.connectionSlug, CHOICES[4]!.model)}
leadingOption={{ value: '', label: '未设置' }}
renderProviderMark={providerMark}
ariaLabel="默认模型"
disabled
loading
triggerClassName="settingsModelPickerTrigger"
onValueChange={async () => {}}
/>
</div>
),
};

// 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
Expand Down