fix: 修复 voice provider 的问题

This commit is contained in:
claude-code-best 2026-05-18 22:54:11 +08:00 committed by James Feng
parent be76720400
commit 1dfe909301

View File

@ -1,30 +1,36 @@
import { feature } from 'bun:bundle' import { feature } from 'bun:bundle';
import React, { import React, { useContext, useEffect, useEffectEvent, useState, useSyncExternalStore } from 'react';
useContext, import { MailboxProvider } from '../context/mailbox.js';
useEffect, import { VoiceProvider as VoiceProviderValue } from '../context/voice.js';
useEffectEvent, import { useSettingsChange } from '../hooks/useSettingsChange.js';
useState, import { logForDebugging } from '../utils/debug.js';
useSyncExternalStore,
} from 'react'
import { MailboxProvider } from '../context/mailbox.js'
import { VoiceProvider as VoiceProviderValue } from '../context/voice.js'
import { useSettingsChange } from '../hooks/useSettingsChange.js'
import { logForDebugging } from '../utils/debug.js'
import { import {
createDisabledBypassPermissionsContext, createDisabledBypassPermissionsContext,
isBypassPermissionsModeDisabled, isBypassPermissionsModeDisabled,
} from '../utils/permissions/permissionSetup.js' } from '../utils/permissions/permissionSetup.js';
import { applySettingsChange } from '../utils/settings/applySettingsChange.js' import { applySettingsChange } from '../utils/settings/applySettingsChange.js';
import type { SettingSource } from '../utils/settings/constants.js' import type { SettingSource } from '../utils/settings/constants.js';
import { createStore } from './store.js' import { createStore } from './store.js';
const VoiceProvider: (props: { children: React.ReactNode }) => React.ReactNode = // DCE: voice context is ant-only. External builds get a noop provider that
feature('VOICE_MODE') ? VoiceProviderValue : ({ children }) => children // still wraps children in VoiceContext so useVoiceState never throws.
import { const VoiceProvider: (props: { children: React.ReactNode }) => React.ReactNode = feature('VOICE_MODE')
type AppState, ? VoiceProviderValue
type AppStateStore, : (() => {
getDefaultAppState, const { VoiceContext } = require('../context/voice.js');
} from './AppStateStore.js' const noopStore = createStore({
voiceState: 'idle' as const,
voiceError: null as string | null,
voiceInterimTranscript: '',
voiceAudioLevels: [] as number[],
voiceWarmingUp: false,
});
return ({ children }: { children: React.ReactNode }) => (
<VoiceContext.Provider value={noopStore}>{children}</VoiceContext.Provider>
);
})();
import { type AppState, type AppStateStore, getDefaultAppState } from './AppStateStore.js';
// TODO: Remove these re-exports once all callers import directly from // TODO: Remove these re-exports once all callers import directly from
// ./AppStateStore.js. Kept for back-compat during migration so .ts callers // ./AppStateStore.js. Kept for back-compat during migration so .ts callers
@ -37,40 +43,29 @@ export {
IDLE_SPECULATION_STATE, IDLE_SPECULATION_STATE,
type SpeculationResult, type SpeculationResult,
type SpeculationState, type SpeculationState,
} from './AppStateStore.js' } from './AppStateStore.js';
export const AppStoreContext = React.createContext<AppStateStore | null>(null) export const AppStoreContext = React.createContext<AppStateStore | null>(null);
type Props = { type Props = {
children: React.ReactNode children: React.ReactNode;
initialState?: AppState initialState?: AppState;
onChangeAppState?: (args: { newState: AppState; oldState: AppState }) => void onChangeAppState?: (args: { newState: AppState; oldState: AppState }) => void;
} };
const HasAppStateContext = React.createContext<boolean>(false) const HasAppStateContext = React.createContext<boolean>(false);
export function AppStateProvider({ export function AppStateProvider({ children, initialState, onChangeAppState }: Props): React.ReactNode {
children,
initialState,
onChangeAppState,
}: Props): React.ReactNode {
// Don't allow nested AppStateProviders. // Don't allow nested AppStateProviders.
const hasAppStateContext = useContext(HasAppStateContext) const hasAppStateContext = useContext(HasAppStateContext);
if (hasAppStateContext) { if (hasAppStateContext) {
throw new Error( throw new Error('AppStateProvider can not be nested within another AppStateProvider');
'AppStateProvider can not be nested within another AppStateProvider',
)
} }
// Store is created once and never changes -- stable context value means // Store is created once and never changes -- stable context value means
// the provider never triggers re-renders. Consumers subscribe to slices // the provider never triggers re-renders. Consumers subscribe to slices
// via useSyncExternalStore in useAppState(selector). // via useSyncExternalStore in useAppState(selector).
const [store] = useState(() => const [store] = useState(() => createStore<AppState>(initialState ?? getDefaultAppState(), onChangeAppState));
createStore<AppState>(
initialState ?? getDefaultAppState(),
onChangeAppState,
),
)
// Check on mount if bypass mode should be disabled // Check on mount if bypass mode should be disabled
// This handles the race condition where remote settings load BEFORE this component mounts, // This handles the race condition where remote settings load BEFORE this component mounts,
@ -78,31 +73,22 @@ export function AppStateProvider({
// On subsequent sessions, the cached remote-settings.json is read during initial setup, // On subsequent sessions, the cached remote-settings.json is read during initial setup,
// but on the first session the remote fetch may complete before React mounts. // but on the first session the remote fetch may complete before React mounts.
useEffect(() => { useEffect(() => {
const { toolPermissionContext } = store.getState() const { toolPermissionContext } = store.getState();
if ( if (toolPermissionContext.isBypassPermissionsModeAvailable && isBypassPermissionsModeDisabled()) {
toolPermissionContext.isBypassPermissionsModeAvailable && logForDebugging('Disabling bypass permissions mode on mount (remote settings loaded before mount)');
isBypassPermissionsModeDisabled()
) {
logForDebugging(
'Disabling bypass permissions mode on mount (remote settings loaded before mount)',
)
store.setState(prev => ({ store.setState(prev => ({
...prev, ...prev,
toolPermissionContext: createDisabledBypassPermissionsContext( toolPermissionContext: createDisabledBypassPermissionsContext(prev.toolPermissionContext),
prev.toolPermissionContext, }));
),
}))
} }
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only effect // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only effect
}, []) }, []);
// Listen for external settings changes and sync to AppState. // Listen for external settings changes and sync to AppState.
// This ensures file watcher changes propagate through the app -- // This ensures file watcher changes propagate through the app --
// shared with the headless/SDK path via applySettingsChange. // shared with the headless/SDK path via applySettingsChange.
const onSettingsChange = useEffectEvent((source: SettingSource) => const onSettingsChange = useEffectEvent((source: SettingSource) => applySettingsChange(source, store.setState));
applySettingsChange(source, store.setState), useSettingsChange(onSettingsChange);
)
useSettingsChange(onSettingsChange)
return ( return (
<HasAppStateContext.Provider value={true}> <HasAppStateContext.Provider value={true}>
@ -112,18 +98,16 @@ export function AppStateProvider({
</MailboxProvider> </MailboxProvider>
</AppStoreContext.Provider> </AppStoreContext.Provider>
</HasAppStateContext.Provider> </HasAppStateContext.Provider>
) );
} }
function useAppStore(): AppStateStore { function useAppStore(): AppStateStore {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const store = useContext(AppStoreContext) const store = useContext(AppStoreContext);
if (!store) { if (!store) {
throw new ReferenceError( throw new ReferenceError('useAppState/useSetAppState cannot be called outside of an <AppStateProvider />');
'useAppState/useSetAppState cannot be called outside of an <AppStateProvider />',
)
} }
return store return store;
} }
/** /**
@ -143,22 +127,22 @@ function useAppStore(): AppStateStore {
* ``` * ```
*/ */
export function useAppState<T>(selector: (state: AppState) => T): T { export function useAppState<T>(selector: (state: AppState) => T): T {
const store = useAppStore() const store = useAppStore();
const get = () => { const get = () => {
const state = store.getState() const state = store.getState();
const selected = selector(state) const selected = selector(state);
if (process.env.USER_TYPE === 'ant' && state === selected) { if (process.env.USER_TYPE === 'ant' && state === selected) {
throw new Error( throw new Error(
`Your selector in \`useAppState(${selector.toString()})\` returned the original state, which is not allowed. You must instead return a property for optimised rendering.`, `Your selector in \`useAppState(${selector.toString()})\` returned the original state, which is not allowed. You must instead return a property for optimised rendering.`,
) );
} }
return selected return selected;
} };
return useSyncExternalStore(store.subscribe, get, get) return useSyncExternalStore(store.subscribe, get, get);
} }
/** /**
@ -166,30 +150,26 @@ export function useAppState<T>(selector: (state: AppState) => T): T {
* Returns a stable reference that never changes -- components using only * Returns a stable reference that never changes -- components using only
* this hook will never re-render from state changes. * this hook will never re-render from state changes.
*/ */
export function useSetAppState(): ( export function useSetAppState(): (updater: (prev: AppState) => AppState) => void {
updater: (prev: AppState) => AppState, return useAppStore().setState;
) => void {
return useAppStore().setState
} }
/** /**
* Get the store directly (for passing getState/setState to non-React code). * Get the store directly (for passing getState/setState to non-React code).
*/ */
export function useAppStateStore(): AppStateStore { export function useAppStateStore(): AppStateStore {
return useAppStore() return useAppStore();
} }
const NOOP_SUBSCRIBE = () => () => {} const NOOP_SUBSCRIBE = () => () => {};
/** /**
* Safe version of useAppState that returns undefined if called outside of AppStateProvider. * Safe version of useAppState that returns undefined if called outside of AppStateProvider.
* Useful for components that may be rendered in contexts where AppStateProvider isn't available. * Useful for components that may be rendered in contexts where AppStateProvider isn't available.
*/ */
export function useAppStateMaybeOutsideOfProvider<T>( export function useAppStateMaybeOutsideOfProvider<T>(selector: (state: AppState) => T): T | undefined {
selector: (state: AppState) => T, const store = useContext(AppStoreContext);
): T | undefined {
const store = useContext(AppStoreContext)
return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, () => return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, () =>
store ? selector(store.getState()) : undefined, store ? selector(store.getState()) : undefined,
) );
} }