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
17 changes: 17 additions & 0 deletions apps/app/src/hooks/cache-owners/system-cache-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from "../queries/query-keys";
import { allThreadDefaultExecutionOptionsQueryKeyPrefix } from "../queries/thread-default-execution-options-query";
import type { QueryClientArg } from "../cache-effect-types";
import { clearCachedModelCatalogs } from "@/lib/model-catalog-cache";
import { bumpAllDiffPatchEvictionGenerations } from "./environment-diff-patch-cache-owner";
import { invalidateSystemVersion } from "./system-version-cache-owner";
import {
Expand Down Expand Up @@ -167,6 +168,22 @@ export function invalidateGeneralSettingsDependencies({
});
}

/**
* Forget every model catalog after streamer mode flips. An invalidation would
* keep showing the previous catalog, and the localStorage preload would replay
* it on the next mount, until a refetch succeeds; both can still name a model
* the server now hides. A reset drops the data first, so open pickers show a
* loading state and refetch instead of the stale list.
*/
export function resetModelCatalogsAfterStreamerModeChange({
queryClient,
}: QueryClientArg): Promise<void> {
clearCachedModelCatalogs();
return queryClient.resetQueries({
queryKey: allSystemExecutionOptionsQueryKeyPrefix(),
});
}

function getServerReconnectInvalidationQueryKeys(): QueryKey[] {
return [
hostsQueryKey(),
Expand Down
11 changes: 11 additions & 0 deletions apps/app/src/hooks/cache-owners/system-config-cache-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,14 @@ export function rollbackKeyboardSettingsCacheTransaction({
if (transaction?.previous === undefined) return;
queryClient.setQueryData(systemConfigQueryKey(), transaction.previous);
}

/**
* The streamer mode value the cache last saw from the server, or undefined
* when `/system/config` has not resolved in this window.
*/
export function readCachedStreamerMode(
queryClient: QueryClient,
): boolean | undefined {
return queryClient.getQueryData<SystemConfigResponse>(systemConfigQueryKey())
?.generalSettings.streamerMode;
}
59 changes: 58 additions & 1 deletion apps/app/src/hooks/mutations/settings-mutations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ import {
type AppKeybindings,
} from "@bb/domain";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
modelCatalogCacheKey,
readCachedModelCatalog,
writeCachedModelCatalog,
} from "@/lib/model-catalog-cache";
import { sdk } from "@/lib/sdk";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import {
systemConfigQueryKey,
systemExecutionOptionsQueryKey,
threadTimelineQueryKey,
threadTimelineTurnSummaryDetailsQueryKey,
} from "../queries/query-keys";
Expand Down Expand Up @@ -72,11 +78,12 @@ function systemConfig(): SystemConfigResponse {

afterEach(() => {
cleanup();
window.localStorage.clear();
vi.clearAllMocks();
});

describe("general settings mutation", () => {
it("invalidates config and timeline projections after visibility changes", async () => {
it("invalidates config and timeline projections and leaves model catalogs alone for a non-streamer write", async () => {
const { queryClient, wrapper } = createQueryClientTestHarness();
const configKey = systemConfigQueryKey();
const timelineKey = threadTimelineQueryKey("thread-1");
Expand All @@ -86,9 +93,15 @@ describe("general settings mutation", () => {
sourceSeqStart: 1,
sourceSeqEnd: 2,
});
const executionOptionsKey = systemExecutionOptionsQueryKey({
environmentId: null,
hostId: "host-1",
providerId: "claude-code",
});
queryClient.setQueryData(configKey, systemConfig());
queryClient.setQueryData(timelineKey, {});
queryClient.setQueryData(summaryKey, {});
queryClient.setQueryData(executionOptionsKey, { models: ["cached"] });
const nextSettings = {
...defaultAppSettings,
showUnhandledProviderEvents: true,
Expand All @@ -104,6 +117,50 @@ describe("general settings mutation", () => {
expect(queryClient.getQueryState(configKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(summaryKey)?.isInvalidated).toBe(true);
// The server's `config-changed` broadcast already refreshes catalogs; an
// unrelated preference must not add a second picker refetch here.
expect(queryClient.getQueryState(executionOptionsKey)?.isInvalidated).toBe(
false,
);
expect(queryClient.getQueryData(executionOptionsKey)).toEqual({
models: ["cached"],
});
});

// A stale catalog can still name a model the server now hides, both in the
// active query and in the localStorage preload, until a refetch succeeds.
it("drops cached model catalogs when streamer mode flips", async () => {
const { queryClient, wrapper } = createQueryClientTestHarness();
const executionOptionsKey = systemExecutionOptionsQueryKey({
environmentId: null,
hostId: "host-1",
providerId: "claude-code",
});
const catalogCacheKey = modelCatalogCacheKey({
environmentId: null,
hostId: "host-1",
providerId: "claude-code",
});
queryClient.setQueryData(systemConfigQueryKey(), systemConfig());
queryClient.setQueryData(executionOptionsKey, { models: ["secret"] });
writeCachedModelCatalog(catalogCacheKey, {
models: [],
selectedOnlyModels: [],
});
expect(readCachedModelCatalog(catalogCacheKey)).not.toBeNull();
const nextSettings = { ...defaultAppSettings, streamerMode: true };
vi.mocked(sdk.system.updateGeneralSettings).mockResolvedValue(nextSettings);
const { result } = renderHook(() => useUpdateGeneralSettings(), {
wrapper,
});

act(() => result.current.mutate(nextSettings));
await waitFor(() => expect(result.current.isSuccess).toBe(true));

await waitFor(() =>
expect(queryClient.getQueryData(executionOptionsKey)).toBeUndefined(),
);
expect(readCachedModelCatalog(catalogCacheKey)).toBeNull();
});
});

Expand Down
13 changes: 10 additions & 3 deletions apps/app/src/hooks/mutations/settings-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import { sdk } from "@/lib/sdk";
import {
invalidateGeneralSettingsDependencies,
invalidateSystemConfig,
resetModelCatalogsAfterStreamerModeChange,
} from "../cache-owners/system-cache-effects";
import {
beginKeyboardSettingsCacheTransaction,
readCachedStreamerMode,
rollbackKeyboardSettingsCacheTransaction,
} from "../cache-owners/system-config-cache-owner";

Expand Down Expand Up @@ -50,8 +52,14 @@ export function useUpdateGeneralSettings() {
},
mutationFn: (settings: AppSettings) =>
sdk.system.updateGeneralSettings(settings),
onSuccess: () => {
onSuccess: (_settings, written) => {
// Read the previous value before the config invalidation replaces it.
const previous = readCachedStreamerMode(queryClient);
invalidateGeneralSettingsDependencies({ queryClient });
// An unknown previous value also resets: a stale preload is the risk.
if (previous !== written.streamerMode) {
void resetModelCatalogsAfterStreamerModeChange({ queryClient });
}
},
});
}
Expand Down Expand Up @@ -107,8 +115,7 @@ export function useUpdateAppearance() {
meta: {
errorMessage: "Failed to update appearance.",
},
mutationFn: (selection: AppThemeSelection) =>
sdk.theme.set(selection),
mutationFn: (selection: AppThemeSelection) => sdk.theme.set(selection),
onSuccess: () => {
invalidateSystemConfig({ queryClient });
},
Expand Down
22 changes: 22 additions & 0 deletions apps/app/src/lib/last-known-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export interface LastKnownCache<T> {
read(key: string): T | null;
/** Best-effort: storage failures (quota, privacy modes) are swallowed. */
write(key: string, value: T): void;
/**
* Forget every scope of this cache's current version. Use it when a policy
* change makes every remembered answer wrong to replay, not merely stale.
*/
clear(): void;
}

/**
Expand Down Expand Up @@ -96,5 +101,22 @@ export function createLastKnownCache<T>({
// Best-effort by contract; see above.
}
},
clear: () => {
try {
const owned: string[] = [];
for (let index = 0; index < window.localStorage.length; index += 1) {
const stored = window.localStorage.key(index);
if (
stored !== null &&
(stored === zeroScopeKey || stored.startsWith(versionPrefix))
) {
owned.push(stored);
}
}
for (const key of owned) window.localStorage.removeItem(key);
} catch {
// No storage, or none we may enumerate: nothing to clear.
}
},
};
}
5 changes: 5 additions & 0 deletions apps/app/src/lib/model-catalog-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ export function modelCatalogCacheKey({

export const readCachedModelCatalog = modelCatalogCache.read;
export const writeCachedModelCatalog = modelCatalogCache.write;
/**
* Drop every remembered catalog. Streamer mode changes which models the server
* lists, so a catalog cached before the toggle must not preload the picker.
*/
export const clearCachedModelCatalogs = modelCatalogCache.clear;
6 changes: 6 additions & 0 deletions apps/app/src/views/SettingsView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ function useSettingsStoryState() {
const [richTextEditing, setRichTextEditing] = useState(false);
const [steerActiveThreadOnEnter, setSteerActiveThreadOnEnter] =
useState(false);
const [streamerMode, setStreamerMode] = useState(false);
const [showUnhandledProviderEvents, setShowUnhandledProviderEvents] =
useState(false);
const [preferredAudioInputDeviceId, setPreferredAudioInputDeviceId] =
Expand All @@ -226,6 +227,7 @@ function useSettingsStoryState() {
rewriteLocalhostLinks,
richTextEditing,
steerActiveThreadOnEnter,
streamerMode,
showUnhandledProviderEvents,
setAppearance,
setDirectoryTargetId,
Expand All @@ -237,6 +239,7 @@ function useSettingsStoryState() {
setRewriteLocalhostLinks,
setRichTextEditing,
setSteerActiveThreadOnEnter,
setStreamerMode,
setShowUnhandledProviderEvents,
setThemePreference,
themePreference,
Expand Down Expand Up @@ -278,11 +281,14 @@ function GeneralSettingsStory({
onRewriteLocalhostLinksChange={state.setRewriteLocalhostLinks}
onRichTextEditingChange={state.setRichTextEditing}
onSteerActiveThreadOnEnterChange={state.setSteerActiveThreadOnEnter}
onStreamerModeChange={state.setStreamerMode}
openLinksInAppBrowser={state.openLinksInAppBrowser}
rewriteLocalhostLinks={state.rewriteLocalhostLinks}
richTextEditing={state.richTextEditing}
steerActiveThreadOnEnter={state.steerActiveThreadOnEnter}
steerActiveThreadOnEnterDisabled={false}
streamerMode={state.streamerMode}
streamerModeDisabled={false}
/>
<DebugSettingsSection
disabled={false}
Expand Down
50 changes: 50 additions & 0 deletions apps/app/src/views/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ export interface SteerActiveThreadOnEnterSettingsControlProps {
onEnabledChange: (enabled: boolean) => void;
}

export interface StreamerModeSettingsControlProps {
disabled: boolean;
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
}

export interface RichTextEditingSettingsControlProps {
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
Expand Down Expand Up @@ -184,11 +190,14 @@ export interface GeneralSettingsSectionProps {
onRewriteLocalhostLinksChange: (enabled: boolean) => void;
onRichTextEditingChange: (enabled: boolean) => void;
onSteerActiveThreadOnEnterChange: (enabled: boolean) => void;
onStreamerModeChange: (enabled: boolean) => void;
openLinksInAppBrowser: boolean;
rewriteLocalhostLinks: boolean;
richTextEditing: boolean;
steerActiveThreadOnEnter: boolean;
steerActiveThreadOnEnterDisabled: boolean;
streamerMode: boolean;
streamerModeDisabled: boolean;
}

export type DebugSettingsSectionProps =
Expand Down Expand Up @@ -564,6 +573,7 @@ const UNHANDLED_PROVIDER_EVENTS_SETTING_LABEL =
"Show unhandled provider events";
const STEER_ACTIVE_THREAD_ON_ENTER_SETTING_LABEL =
"Steer running threads on Enter";
const STREAMER_MODE_SETTING_LABEL = "Streamer mode";

export function RootComposeBehaviorSettingsControl({
navigateToThreadAfterCreate,
Expand Down Expand Up @@ -600,6 +610,26 @@ export function SteerActiveThreadOnEnterSettingsControl({
);
}

export function StreamerModeSettingsControl({
disabled,
enabled,
onEnabledChange,
}: StreamerModeSettingsControlProps) {
return (
<SettingsWithControl
label={STREAMER_MODE_SETTING_LABEL}
description="Hide the custom models from config.json in every model picker, so a screen share does not show them."
>
<Switch
checked={enabled}
disabled={disabled}
onCheckedChange={onEnabledChange}
aria-label={STREAMER_MODE_SETTING_LABEL}
/>
</SettingsWithControl>
);
}

export function InAppBrowserLinkSettingsControl({
enabled,
onEnabledChange,
Expand Down Expand Up @@ -833,11 +863,14 @@ export function GeneralSettingsSection({
onRewriteLocalhostLinksChange,
onRichTextEditingChange,
onSteerActiveThreadOnEnterChange,
onStreamerModeChange,
openLinksInAppBrowser,
rewriteLocalhostLinks,
richTextEditing,
steerActiveThreadOnEnter,
steerActiveThreadOnEnterDisabled,
streamerMode,
streamerModeDisabled,
}: GeneralSettingsSectionProps) {
return (
<SettingsSection title="General">
Expand Down Expand Up @@ -871,6 +904,12 @@ export function GeneralSettingsSection({
enabled={rewriteLocalhostLinks}
onEnabledChange={onRewriteLocalhostLinksChange}
/>

<StreamerModeSettingsControl
disabled={streamerModeDisabled}
enabled={streamerMode}
onEnabledChange={onStreamerModeChange}
/>
</div>
</SettingsSection>
);
Expand Down Expand Up @@ -1275,6 +1314,17 @@ export function SettingsView() {
steerActiveThreadOnEnter: enabled,
})
}
streamerMode={generalSettings.streamerMode}
streamerModeDisabled={
systemConfigQuery.data === undefined ||
updateGeneralSettingsMutation.isPending
}
onStreamerModeChange={(enabled) =>
updateGeneralSettingsMutation.mutate({
...generalSettings,
streamerMode: enabled,
})
}
/>
<CliSkillsSettingsSection />
<VoiceInputSettingsSection />
Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/src/screens/settings/GeneralSettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ function ConnectedGeneralSettingsScreen() {
/>
</SettingsSection>

<SettingsSection title="Privacy">
<SettingsSwitchRow
label="Streamer mode"
description="Hide the custom models from config.json in every model picker, so a screen share does not show them."
checked={settings.streamerMode}
disabled={serverDisabled}
onCheckedChange={(value) =>
updateGeneral.mutate({
...settings,
streamerMode: value,
})
}
testID="general-streamer-mode"
/>
</SettingsSection>

<SettingsSection title="Debug">
<SettingsSwitchRow
label="Show unhandled provider events"
Expand Down
Loading
Loading