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
31 changes: 28 additions & 3 deletions apps/desktop/src/renderer/settings/general-settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
} from "@maka/ui";
import { ProviderBrandMark } from "./provider-brand-marks";
import { PasswordInput } from "./password-input";
import { getConversationCopy } from '@maka/ui';
import { getConversationCopy, useOptimisticSelection } from '@maka/ui';
import { settingsActionErrorMessage } from "./settings-error-copy";
import { useActionGuard, useKeyedActionGuard } from "./use-action-guard";
import { useOptimisticSettingsDraft } from "./use-optimistic-settings-draft";
Expand Down Expand Up @@ -87,6 +87,8 @@ export function GeneralSettingsPage(props: {
patch: Parameters<typeof window.maka.settings.update>[0],
): Promise<UpdateAppSettingsResult>;
onRefreshConnections(): Promise<void>;
connectionsReadGeneration: number;
getConnectionsReadGeneration(): number;
onRetryRuntimeHost(): Promise<void>;
}) {
const host = useOptionalRuntimeHostSettingsTarget();
Expand Down Expand Up @@ -295,6 +297,8 @@ export function GeneralSettingsPage(props: {
settingsInteractive={runtimeHostSettingsInteractive}
showSettingsPlaceholder={showRuntimeHostSettingsPlaceholder}
onRefresh={props.onRefreshConnections}
connectionsReadGeneration={props.connectionsReadGeneration}
getConnectionsReadGeneration={props.getConnectionsReadGeneration}
permissionMode={props.settings.chatDefaults.permissionMode}
thinkingLevel={props.settings.chatDefaults.thinkingLevel}
onUpdate={props.onUpdate}
Expand Down Expand Up @@ -492,6 +496,8 @@ function GeneralDefaultsCard(props: {
settingsInteractive: boolean;
showSettingsPlaceholder: boolean;
onRefresh(): Promise<void>;
connectionsReadGeneration: number;
getConnectionsReadGeneration(): number;
permissionMode: ChatDefaultPermissionMode;
thinkingLevel?: ThinkingLevel;
onUpdate(
Expand Down Expand Up @@ -537,11 +543,24 @@ function GeneralDefaultsCard(props: {
? value
: "";
}, [modelChoices, props.connections, props.defaultSlug]);
// Reflect the pick the instant it is chosen — the trigger uses the no-spin
// `onChange` path, so Astryx never advances its own optimistic value, and the
// sibling row would otherwise lag on the old model until the save round-trip.
// The pick is dropped by a read barrier keyed on the connections read
// GENERATION (not a value or snapshot-reference compare, both of which a
// read already in flight at pick time would trip): begin() shows it, settle()
// arms the barrier once the write is durable, and only a read issued after
// that (our own refresh) clears it. See useOptimisticSelection.
const modelSelection = useOptimisticSelection(
selectedValue,
props.connectionsReadGeneration,
);
async function persistDefault(nextValue: string) {
if (!props.connectionsBridge || !props.connectionsInteractive) return;
const releaseSave = persistGuard.begin("default-model");
if (!releaseSave) return;
setSaving(true);
modelSelection.begin(nextValue);
try {
const parsed = parseModelChoiceValue(nextValue);
await props.connectionsBridge.setDefaultModel(
Expand All @@ -553,9 +572,16 @@ function GeneralDefaultsCard(props: {
: null,
);
if (!mountedRef.current) return;
// The write is durable; arm the barrier at the reads issued so far. Our
// refresh below issues a later read whose accepted snapshot clears the
// pick — a read already in flight (returning the pre-write value) cannot.
modelSelection.settle(props.getConnectionsReadGeneration());
await props.onRefresh();
} catch (error) {
if (mountedRef.current) {
// The save threw: drop the optimistic pick so the trigger snaps back to
// the model that is actually persisted.
modelSelection.cancel();
toast.error(
copy.saveDefaultModelFailed,
settingsActionErrorMessage(error, locale),
Expand Down Expand Up @@ -654,12 +680,11 @@ function GeneralDefaultsCard(props: {
end={
<ModelPicker
groups={modelGroups}
value={selectedValue}
value={modelSelection.value}
leadingOption={{ value: "", label: copy.notSet }}
renderProviderMark={(type) => <ProviderBrandMark type={type} />}
ariaLabel={copy.defaultModel}
disabled={saving || !props.connectionsInteractive}
loading={saving}
triggerClassName="settingsModelPickerTrigger"
onValueChange={persistDefault}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ 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;
},

acceptsConnectionsRead(candidate: SettingsRequestTicket): boolean {
return isCurrentTarget(candidate) &&
candidate.requestGeneration === connectionsReadGeneration;
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/renderer/settings/settings-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,12 @@ export function SettingsSurface(props: {
? snapshotCache.readRuntimeHostConnections(initialRuntimeHostKey)
: undefined,
));
// The read generation that produced the connections snapshot currently shown.
// It advances only on an accepted read (see reloadConnections), so an
// optimistic default-model pick can be cleared strictly by a read issued
// after its write — see useOptimisticSelection / GeneralDefaultsCard.
const [committedConnectionsReadGeneration, setCommittedConnectionsReadGeneration] =
useState(0);
const [runtimeHostCatalog, setRuntimeHostCatalog] = useState<
SettingsResourceState<DesktopRuntimeHostProfileSnapshot>
>(() => createSettingsResourceState(
Expand Down Expand Up @@ -525,6 +531,10 @@ export function SettingsSurface(props: {
};
snapshotCache.commitRuntimeHostConnectionsRead(key, next);
setRuntimeHostConnections(completeSettingsResourceLoad(key, next));
// Publish the generation of the read that produced this snapshot so an
// optimistic default-model pick clears only on a read issued after its
// write (ticket is the accepted read here).
setCommittedConnectionsReadGeneration(ticket.requestGeneration);
} catch (error) {
if (
settingsModalMountedRef.current &&
Expand Down Expand Up @@ -979,6 +989,10 @@ export function SettingsSurface(props: {
themePref={props.themePref}
themePalette={props.themePalette}
onRefreshConnections={reloadConnections}
connectionsReadGeneration={committedConnectionsReadGeneration}
getConnectionsReadGeneration={
runtimeHostRequestAuthority.currentConnectionsReadGeneration
}
onUpdateSettings={updateSettings}
onReloadSettings={reloadRuntimeHostSettings}
onReloadClientSettings={reloadClientSettings}
Expand Down Expand Up @@ -1034,6 +1048,8 @@ function SettingsPageBody(props: {
themePref: ThemePreference;
themePalette: ThemePalette;
onRefreshConnections(): Promise<void>;
connectionsReadGeneration: number;
getConnectionsReadGeneration(): number;
onUpdateSettings(patch: Parameters<typeof window.maka.settings.update>[0]): Promise<UpdateAppSettingsResult>;
onReloadSettings(): Promise<void>;
onReloadClientSettings(): Promise<void>;
Expand Down Expand Up @@ -1121,6 +1137,8 @@ function SettingsPageBody(props: {
: undefined}
onUpdate={props.onUpdateSettings}
onRefreshConnections={props.onRefreshConnections}
connectionsReadGeneration={props.connectionsReadGeneration}
getConnectionsReadGeneration={props.getConnectionsReadGeneration}
onRetryRuntimeHost={props.onRetryRuntimeHost}
/>
);
Expand Down
195 changes: 195 additions & 0 deletions packages/ui/src/__tests__/use-optimistic-selection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*
* 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, and these pin why: a read
* already in flight when the user picks (returning the pre-write value) must
* NOT clear the pick, even though it commits a fresh snapshot afterwards. The
* hook separates begin (show) from settle (arm the barrier at the reads issued
* so far, once the write is durable); only a read issued past that floor — the
* caller's own post-write refresh — clears it. Covered here: instant show,
* in-flight read before settle, in-flight read at/under the floor, convergence
* to this pick / an external write / the prior value restored (A→B→A),
* refresh-that-lands-nothing keeping the pick, and cancel on a thrown write.
*/

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<typeof createRoot> | 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<void>;
begin(next: string): Promise<void>;
settle(floor: number): Promise<void>;
cancel(): Promise<void>;
value(): string | null;
}

async function mount(): Promise<Harness> {
const { document, window } = parseHTML('<main id="root"></main>');
const root = document.querySelector<HTMLElement>('#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 <span data-value={selection.value} />;
}

mountedRoot = createRoot(root);
const el = () => root.querySelector('span');
return {
async render(authoritative, committedGeneration) {
await act(() =>
mountedRoot?.render(<Probe authoritative={authoritative} committedGeneration={committedGeneration} />),
);
},
async begin(next) {
await act(() => api.current?.begin(next));
},
async settle(floor) {
await act(() => api.current?.settle(floor));
},
async cancel() {
await act(() => api.current?.cancel());
},
value: () => el()?.getAttribute('data-value') ?? null,
};
}

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

await h.begin('B');
assert.equal(h.value(), 'B');
});

test('an in-flight read that commits BEFORE settle does not clear the pick', async () => {
const h = await mount();
await h.render('A', 5);
await h.begin('B');

// A read that was in flight at pick time completes, committing the pre-write
// value A on a new generation. The barrier is not armed yet, so B stands.
await h.render('A', 6);
assert.equal(h.value(), 'B');
});

test('an in-flight read at/under the floor does not clear after settle', async () => {
const h = await mount();
await h.render('A', 5);
await h.begin('B');
// Write is durable; the latest read issued so far is generation 6.
await h.settle(6);
// That same in-flight read commits A at generation 6 (== floor) — pre-write, must not clear.
await h.render('A', 6);
assert.equal(h.value(), 'B');
});

test("the post-write refresh (generation past the floor) clears to this pick", async () => {
const h = await mount();
await h.render('A', 5);
await h.begin('B');
await h.settle(6);
// Our refresh issued read 7 (> floor) and it accepted B.
await h.render('B', 7);
assert.equal(h.value(), 'B');
});

test('a concurrent external write past the floor wins', async () => {
const h = await mount();
await h.render('A', 5);
await h.begin('B');
await h.settle(6);
// The post-write read observed an external write to C.
await h.render('C', 7);
assert.equal(h.value(), 'C');
});

test('A→B→A: authority restored to the pre-pick value still clears the pick', async () => {
const h = await mount();
await h.render('A', 5);
await h.begin('B');
await h.settle(6);
// A post-write read (gen 7 > floor) reports the value is back to A.
await h.render('A', 7);
assert.equal(h.value(), 'A');
});

test('a refresh that lands no new read keeps the pick (does not revert to stale)', async () => {
const h = await mount();
await h.render('A', 5);
await h.begin('B');
await h.settle(6);
// Refresh failed/invalidated: no accepted read, generation unchanged. The
// write persisted B, so B must remain shown.
await h.render('A', 6);
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');
assert.equal(h.value(), 'B');

await h.cancel();
assert.equal(h.value(), 'A');
});
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export * from './assistant-stream.js';
export * from './chat-empty-hero.js';
export * from './chat-model-helpers.js';
export * from './use-mounted-ref.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';
Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/model-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ export interface ModelPickerProps {
onValueChange(value: string): void | Promise<void>;
renderProviderMark?(type: ProviderType): ReactNode;
disabled?: boolean;
loading?: boolean;
/**
* 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 @@ -95,9 +94,14 @@ export function ModelPicker(props: ModelPickerProps) {
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; the caller controls `value` from its own state (e.g. reflecting
// the pick optimistically) and the trigger simply follows it.
onChange={props.onValueChange}
renderOption={(option: SelectorOptionData) => {
const providerType = providerTypes.get(option.value);
const providerMark =
Expand Down
Loading