Skip to content
Merged
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 @@ -32,7 +32,8 @@ const NO_MODEL_VALUE = '—';
*/
export function ToolSummaryTranslationSettingsScreen() {
const { organizationId } = useOrganization();
const { enabled, model, hasLoaded, setEnabled, setModel } = useToolSummaryTranslationPreference();
const { enabled, model, hasLoaded, setEnabled, setModel, getRequestedEnabled } =
useToolSummaryTranslationPreference();
const { models, isLoading, isError, isFetching, isFetched, refetch } = useAvailableModels(
organizationId ?? undefined
);
Expand Down Expand Up @@ -106,6 +107,7 @@ export function ToolSummaryTranslationSettingsScreen() {
value={enabled}
disabled={!hasLoaded}
onValueChange={setEnabled}
resolveValue={getRequestedEnabled}
/>
<ConfigureRow
icon={Cpu}
Expand Down
26 changes: 26 additions & 0 deletions apps/mobile/src/components/ui/preference-row.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function renderRow(props: {
value: boolean;
disabled?: boolean;
onValueChange: (next: boolean) => void;
resolveValue?: () => boolean;
}) {
act(() => {
const element = createElement(PreferenceRow, {
Expand Down Expand Up @@ -128,4 +129,29 @@ describe('PreferenceRow', () => {
expect(pressable.props.accessible).toBe(false);
expect(pressable.props.importantForAccessibility).toBe('no-hide-descendants');
});

it('flips the newest requested value, not the rendered one, on either control', () => {
// The rendered value lags a store write. Three presses in a row must each
// flip what the last one asked for, whichever control receives them.
let requested = false;
const seen: boolean[] = [];
const root = renderRow({
value: false,
resolveValue: () => requested,
onValueChange: next => {
seen.push(next);
requested = next;
},
});
const label = root.find(node => Object.is(node.type, 'Pressable'));
const toggle = root.find(node => Object.is(node.type, 'Switch'));

act(() => {
(label.props.onPress as () => void)();
(toggle.props.onValueChange as (next: boolean) => void)(false);
(label.props.onPress as () => void)();
});

expect(seen).toEqual([true, false, true]);
});
});
21 changes: 17 additions & 4 deletions apps/mobile/src/components/ui/preference-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ type PreferenceRowProps = Readonly<{
disabled: boolean;
busy?: boolean;
onValueChange: (next: boolean) => void;
/**
* The value a press should flip. Defaults to the rendered `value`, which is
* the last render, so two presses inside one frame both flip the same state
* and the second one repeats the first. A store that lags its own writes
* passes its newest requested value here, and every press then flips the
* state the person last asked for.
*/
resolveValue?: () => boolean;
}>;

/** Switch row shaped like the Notifications category row. */
Expand All @@ -25,16 +33,21 @@ export function PreferenceRow({
disabled,
busy = false,
onValueChange,
resolveValue,
}: PreferenceRowProps) {
const colors = useThemeColors();
// Both ways of moving the switch flip the same value: a tap on the row and a
// tap on the thumb can arrive in either order, and a burst must not build one
// flip from the rendered value and the next from the native thumb state.
const flip = () => {
onValueChange(!(resolveValue?.() ?? value));
};
return (
<View className="min-h-11 flex-row items-center gap-3 rounded-lg bg-secondary p-3">
<Pressable
className="min-h-11 min-w-0 flex-1 flex-row items-center gap-3 active:opacity-70"
disabled={disabled}
onPress={() => {
onValueChange(!value);
}}
onPress={flip}
accessible={false}
importantForAccessibility="no-hide-descendants"
>
Expand Down Expand Up @@ -69,7 +82,7 @@ export function PreferenceRow({
accessibilityLabel={title}
accessibilityHint={subtitle}
accessibilityState={{ disabled, busy, checked: value }}
onValueChange={onValueChange}
onValueChange={flip}
/>
</View>
);
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/voice-input-settings-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function VoiceInputSettingsScreen() {
gatewayTranscriptionEnabled,
hasLoaded: gatewayTranscriptionLoaded,
setGatewayTranscriptionEnabled,
getRequestedGatewayTranscriptionEnabled,
} = useGatewayTranscriptionPreference();
const { status, model, isFetching, refetch } = useGatewayTranscriptionModelSelection(
organizationId ?? undefined
Expand Down Expand Up @@ -101,6 +102,7 @@ export function VoiceInputSettingsScreen() {
value={gatewayTranscriptionEnabled}
disabled={!gatewayTranscriptionLoaded}
onValueChange={setGatewayTranscriptionEnabled}
resolveValue={getRequestedGatewayTranscriptionEnabled}
/>
<ConfigureRow
icon={Cpu}
Expand Down
8 changes: 4 additions & 4 deletions apps/mobile/src/lib/chat/backend-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ const store = createSecureStorePreference<StoredChatBackend[]>({
});
store.preload();

export const listChatBackends = store.get;
export const listChatBackends = store.getRequested;
export const subscribeChatBackends = store.subscribe;
export const getChatBackendsHasLoaded = store.getHasLoaded;
export const waitForChatBackends = store.whenLoaded;
Expand All @@ -236,7 +236,7 @@ export function addChatBackend(draft: ChatBackendDraft): StoredChatBackend {
id: randomUUID(),
revision: 1,
});
const profiles = [...store.get(), backend];
const profiles = [...store.getRequested(), backend];
Object.freeze(profiles);
store.set(profiles);
return backend;
Expand All @@ -246,7 +246,7 @@ export function updateChatBackend(id: string, draft: ChatBackendDraft): StoredCh
if (!store.getHasLoaded()) {
throw new BackendInputError('notLoaded');
}
const profiles = store.get();
const profiles = store.getRequested();
const previous = profiles.find(profile => profile.id === id);
if (!previous || previous.revision >= Number.MAX_SAFE_INTEGER) {
throw new BackendInputError('staleBackend');
Expand All @@ -266,7 +266,7 @@ export function deleteChatBackend(id: string): void {
if (!store.getHasLoaded()) {
throw new BackendInputError('notLoaded');
}
const profiles = store.get().filter(profile => profile.id !== id);
const profiles = store.getRequested().filter(profile => profile.id !== id);
Object.freeze(profiles);
store.set(profiles);
}
Expand Down
21 changes: 21 additions & 0 deletions apps/mobile/src/lib/chat/remote-mcp-oauth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,27 @@ describe('writing a remote MCP server OAuth state', () => {
});
});

it('reads a write the disk has not taken yet', async () => {
const { store, epoch } = await fresh();
heldDiskRead(null)();
const landing = Promise.withResolvers<undefined>();
setItemAsync.mockReturnValue(landing.promise);

await store.writeRemoteMcpOAuth(
{ ...SERVER, epoch: epoch.currentAuthEpoch() },
{ tokens: TOKENS }
);

// The store holds the choice before the disk does, and the endpoint match
// applies to that choice too.
expect(await store.readRemoteMcpOAuth(SERVER.id, 'https://demo.example/mcp/')).toEqual({
url: SERVER.url,
tokens: TOKENS,
});
expect(await store.readRemoteMcpOAuth(SERVER.id, 'https://other.example/mcp')).toBeUndefined();
landing.resolve(undefined);
});

it('drops a write that a sign-out overtook while it waited on the disk read', async () => {
const { store, epoch } = await fresh();
const answer = heldDiskRead(null);
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/lib/chat/remote-mcp-oauth-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export async function readRemoteMcpOAuth(
url: string
): Promise<RemoteMcpOAuthState | undefined> {
await store.whenLoaded();
const state = store.get()[id];
const state = store.getRequested()[id];
return state != null && sameEndpoint(state.url, url) ? state : undefined;
}

Expand All @@ -115,7 +115,7 @@ export async function writeRemoteMcpOAuth(
return;
}
const { id, url } = owner;
const current = store.get();
const current = store.getRequested();
const saved = current[id];
const existing = saved != null && sameEndpoint(saved.url, url) ? saved : undefined;
store.set({ ...current, [id]: { ...existing, ...patch, url } });
Expand All @@ -124,7 +124,7 @@ export async function writeRemoteMcpOAuth(
/** Drops one server's state, which is what deleting the server or changing its URL does. */
export async function forgetRemoteMcpOAuth(id: string): Promise<void> {
await store.whenLoaded();
const { [id]: forgotten, ...rest } = store.get();
const { [id]: forgotten, ...rest } = store.getRequested();
if (forgotten !== undefined) {
store.set(rest);
}
Expand Down
10 changes: 5 additions & 5 deletions apps/mobile/src/lib/chat/remote-mcp-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ store.preload();

/** The stored servers, in the order they were added. */
export function listRemoteMcpServers(): StoredRemoteMcpServer[] {
return store.get();
return store.getRequested();
}

/**
Expand All @@ -193,7 +193,7 @@ export function listRemoteMcpServers(): StoredRemoteMcpServer[] {
* two rows pointing at one endpoint is a user mistake, not two servers.
*/
export function addRemoteMcpServer(draft: RemoteMcpServerDraft): StoredRemoteMcpServer {
const current = store.get();
const current = store.getRequested();
const url = normalizeRemoteMcpUrl(draft.url);
if (current.some(server => server.url === url)) {
throw new Error('Remote MCP URL is already saved.');
Expand All @@ -219,7 +219,7 @@ export function updateRemoteMcpServer(
id: string,
patch: RemoteMcpServerPatch
): StoredRemoteMcpServer {
const current = store.get();
const current = store.getRequested();
const existing = current.find(server => server.id === id);
if (existing === undefined) {
throw new Error('Remote MCP server not found.');
Expand All @@ -244,7 +244,7 @@ export function updateRemoteMcpServer(

/** Removes one server, and its OAuth tokens with it, and returns it. */
export function deleteRemoteMcpServer(id: string): StoredRemoteMcpServer {
const current = store.get();
const current = store.getRequested();
const removed = current.find(server => server.id === id);
if (removed === undefined) {
throw new Error('Remote MCP server not found.');
Expand All @@ -256,7 +256,7 @@ export function deleteRemoteMcpServer(id: string): StoredRemoteMcpServer {

/** Flips one server's enabled flag and leaves every other server untouched. */
export function setRemoteMcpServerEnabled(id: string, enabled: boolean): StoredRemoteMcpServer {
const current = store.get();
const current = store.getRequested();
const existing = current.find(server => server.id === id);
if (existing === undefined) {
throw new Error('Remote MCP server not found.');
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/lib/chat/settings-tools-switch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,17 @@ describe('settings tools group switch', () => {
expect(setItemAsync).toHaveBeenLastCalledWith('settings-tools-enabled', 'false');
});

it('notifies subscribers when the switch flips', () => {
it('notifies subscribers once the change is stored', async () => {
const listener = vi.fn<() => void>();
const unsubscribe = subscribeSettingsToolsEnabled(listener);

setSettingsToolsEnabled(false);
await flushMicrotasks();
expect(listener).toHaveBeenCalledTimes(1);

unsubscribe();
setSettingsToolsEnabled(true);
await flushMicrotasks();
expect(listener).toHaveBeenCalledTimes(1);
});

Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/chat/settings-tools-switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const store = createSecureStorePreference<boolean>({
store.preload();

export function isSettingsToolsEnabled(): boolean {
return store.get();
return store.getRequested();
}

export function setSettingsToolsEnabled(enabled: boolean): void {
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/lib/chat/sign-out.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ function addServer(): void {
});
}

// A preference becomes visible (and refreshes the snapshots built from it) once
// its write lands, so the tests let the queued write settle before asserting.
async function settlePreferences(): Promise<void> {
await new Promise<void>(resolve => {
setImmediate(resolve);
});
}

beforeEach(() => {
forgetChatPlaces();
storedQuestions.clear();
Expand Down Expand Up @@ -189,6 +197,7 @@ describe('the remote MCP connection and its stored servers', () => {
it('are dropped when the account signs out', async () => {
addServer();
setSettingsToolsEnabled(false);
await settlePreferences();
expect(listRemoteMcpServers()).toHaveLength(1);
expect(remoteMcpState()).toHaveLength(1);
expect(isSettingsToolsEnabled()).toBe(false);
Expand All @@ -205,6 +214,7 @@ describe('the remote MCP connection and its stored servers', () => {
it('are dropped when another account signs in', async () => {
addServer();
setSettingsToolsEnabled(false);
await settlePreferences();
expect(listRemoteMcpServers()).toHaveLength(1);
expect(remoteMcpState()).toHaveLength(1);

Expand Down
Loading
Loading