fix(typecheck): restore AppState hook generics (#1503)

* fix(typecheck): restore AppState hook generics

* test: enforce focused type assertions

* fix: remove unused spinner api metrics prop
This commit is contained in:
chioarub
2026-06-04 05:22:18 +08:00
committed by GitHub
parent 2c755d3da8
commit 343cd1a2c9
11 changed files with 346 additions and 42 deletions
+2 -1
View File
@@ -57,8 +57,9 @@
"security:pr-scan": "bun run scripts/pr-intent-scan.ts",
"test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts",
"typecheck": "tsc --noEmit",
"typecheck:type-tests": "bun run scripts/typecheck-type-tests.ts",
"smoke": "bun run build && node dist/cli.mjs --version",
"check": "bun run smoke && bun run test:full",
"check": "bun run smoke && bun run typecheck:type-tests && bun run test:full",
"verify:privacy": "bun run scripts/verify-no-phone-home.ts",
"build:verified": "bun run build && bun run verify:privacy",
"test:provider": "bun test --max-concurrency=1 src/services/api/*.test.ts src/utils/context.test.ts",
+89
View File
@@ -0,0 +1,89 @@
import path from 'node:path'
import ts from 'typescript'
// Root `tsc --noEmit` has a known failing baseline. This focused check
// enforces diagnostics in type assertion files and covered implementation
// files while quarantining dependency diagnostics until the broader baseline
// is fixed.
function fail(message: string): never {
console.error(message)
process.exit(1)
}
function normalizeFileName(fileName: string): string {
return path.normalize(path.resolve(fileName))
}
const configPath = ts.findConfigFile(
process.cwd(),
ts.sys.fileExists,
'tsconfig.type-tests.json',
)
if (!configPath) {
fail('Could not find tsconfig.type-tests.json')
}
const configFile = ts.readConfigFile(configPath, ts.sys.readFile)
const formatHost: ts.FormatDiagnosticsHost = {
getCanonicalFileName: fileName => fileName,
getCurrentDirectory: ts.sys.getCurrentDirectory,
getNewLine: () => ts.sys.newLine,
}
if (configFile.error) {
console.error(ts.formatDiagnostic(configFile.error, formatHost))
process.exit(1)
}
const parsedConfig = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
path.dirname(configPath),
)
if (parsedConfig.errors.length > 0) {
console.error(
ts.formatDiagnosticsWithColorAndContext(parsedConfig.errors, formatHost),
)
process.exit(1)
}
const rootFileNames = parsedConfig.fileNames.map(normalizeFileName)
const rootFileNameSet = new Set(rootFileNames)
if (rootFileNameSet.size === 0) {
fail('tsconfig.type-tests.json does not include any files')
}
const program = ts.createProgram({
rootNames: parsedConfig.fileNames,
options: parsedConfig.options,
projectReferences: parsedConfig.projectReferences,
})
const diagnostics = ts.getPreEmitDiagnostics(program)
const blockingDiagnostics = diagnostics.filter(diagnostic => {
if (!diagnostic.file) {
return true
}
return rootFileNameSet.has(normalizeFileName(diagnostic.file.fileName))
})
if (blockingDiagnostics.length > 0) {
console.error(
ts.formatDiagnosticsWithColorAndContext(blockingDiagnostics, formatHost),
)
process.exit(1)
}
const ignoredDiagnostics = diagnostics.length - blockingDiagnostics.length
const ignoredSuffix =
ignoredDiagnostics === 0
? ''
: ` (${ignoredDiagnostics} dependency diagnostics ignored)`
console.log(
`Focused typecheck passed: ${rootFileNameSet.size} files checked${ignoredSuffix}.`,
)
+2 -1
View File
@@ -13,6 +13,7 @@ import {
setSessionSettingsCache,
} from '../../utils/settings/settingsCache.js'
import type { ModelOption } from '../../utils/model/modelOptions.js'
import type { ModelSetting } from '../../utils/model/model.js'
import type { SettingsJson } from '../../utils/settings/types.js'
type SettingsModule = typeof import('../../utils/settings/settings.js')
@@ -2582,7 +2583,7 @@ test('interactive model picker rejects models blocked by availableModels before
...getDefaultAppState(),
mainLoopModel: 'allowed-model',
}
let latestMainLoopModel = initialState.mainLoopModel
let latestMainLoopModel: ModelSetting = initialState.mainLoopModel
const instance = await render(
<AppStateProvider
initialState={initialState}
+1 -11
View File
@@ -10,7 +10,7 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growt
import { isEnvTruthy } from '../utils/envUtils.js';
import { count } from '../utils/array.js';
import sample from 'lodash-es/sample.js';
import { formatDuration, formatNumber, formatSecondsShort } from '../utils/format.js';
import { formatDuration, formatNumber } from '../utils/format.js';
import type { Theme } from 'src/utils/theme.js';
import { activityManager } from '../utils/activityManager.js';
import { getSpinnerVerbs } from '../constants/spinnerVerbs.js';
@@ -213,16 +213,6 @@ function SpinnerWithVerbInner({
const messageColor = overrideColor ?? defaultColor;
const shimmerColor = overrideShimmerColor ?? defaultShimmerColor;
// Compute TTFT string here (off the 50ms animation clock) and pass to
// SpinnerAnimationRow so it folds into the `(thought for Ns · ...)` status
// line instead of taking a separate row. apiMetricsRef is a ref so this
// doesn't trigger re-renders; we pick up updates on the parent's ~25x/turn
// re-render cadence, same as the old ApiMetricsLine did.
let ttftText: string | null = null;
if ("external" === 'ant' && apiMetricsRef?.current && apiMetricsRef.current.length > 0) {
ttftText = computeTtftText(apiMetricsRef.current);
}
// When leader is idle but teammates are running (and we're viewing the leader),
// show a static dim idle display instead of the animated spinner — otherwise
// useStalledAnimation detects no new tokens after 3s and turns the spinner red.
+27 -4
View File
@@ -79,11 +79,34 @@ function createAgent(
agentType: string,
source: AgentDefinition['source'] = 'userSettings',
): AgentDefinition {
const whenToUse = `Use ${agentType}`
const getSystemPrompt = () => `You are ${agentType}`
if (source === 'built-in') {
return {
agentType,
whenToUse,
source,
baseDir: 'built-in',
getSystemPrompt,
}
}
if (source === 'plugin') {
return {
agentType,
whenToUse,
source,
plugin: 'test-plugin',
getSystemPrompt,
}
}
return {
agentType,
whenToUse: `Use ${agentType}`,
whenToUse,
source,
getSystemPrompt: () => `You are ${agentType}`,
getSystemPrompt,
}
}
@@ -157,7 +180,7 @@ test('sets a different active session agent from the agent menu', async () => {
patchConsole: false,
})
let callbackAgent: AgentDefinition | undefined
let latestAgent = initialState.agent
let latestAgent: string | undefined = initialState.agent
root.render(
<AppStateProvider
@@ -220,7 +243,7 @@ test('sets the effective agent definition for a shadowed selected row', async ()
patchConsole: false,
})
let callbackAgent: AgentDefinition | undefined
let latestAgent = initialState.agent
let latestAgent: string | undefined = initialState.agent
root.render(
<AppStateProvider
+1 -1
View File
@@ -4759,7 +4759,7 @@ export function REPL({
{"external" === 'ant' && <TungstenLiveMonitor />}
{feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && <WebBrowserPanelModule.WebBrowserPanel /> : null}
<Box flexGrow={1} />
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} apiMetricsRef={apiMetricsRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
{showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />}
{!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && <BriefIdleStatus />}
{isFullscreenEnvEnabled() && <PromptInputQueuedCommands />}
</>} bottom={<Box flexDirection={isBuddyEnabled() && companionNarrow ? 'column' : 'row'} width="100%" alignItems={isBuddyEnabled() && companionNarrow ? undefined : 'flex-end'}>
+30 -19
View File
@@ -26,6 +26,7 @@ import { type AppState, type AppStateStore, getDefaultAppState } from './AppStat
// can incrementally move off the .tsx import and stop pulling React.
export { type AppState, type AppStateStore, type CompletionBoundary, getDefaultAppState, IDLE_SPECULATION_STATE, type SpeculationResult, type SpeculationState } from './AppStateStore.js';
export const AppStoreContext = React.createContext<AppStateStore | null>(null);
type IfAny<T, Y, N> = 0 extends 1 & T ? Y : N;
type Props = {
children: React.ReactNode;
initialState?: AppState;
@@ -35,7 +36,7 @@ type Props = {
}) => void;
};
const HasAppStateContext = React.createContext<boolean>(false);
export function AppStateProvider(t0) {
export function AppStateProvider(t0: Props): React.ReactNode {
const $ = _c(13);
const {
children,
@@ -46,7 +47,7 @@ export function AppStateProvider(t0) {
if (hasAppStateContext) {
throw new Error("AppStateProvider can not be nested within another AppStateProvider");
}
let t1;
let t1: () => AppStateStore;
if ($[0] !== initialState || $[1] !== onChangeAppState) {
t1 = () => createStore(initialState ?? getDefaultAppState(), onChangeAppState);
$[0] = initialState;
@@ -55,8 +56,8 @@ export function AppStateProvider(t0) {
} else {
t1 = $[2];
}
const [store] = useState(t1);
let t2;
const [store] = useState<AppStateStore>(t1);
let t2: () => void;
if ($[3] !== store) {
t2 = () => {
const {
@@ -72,7 +73,7 @@ export function AppStateProvider(t0) {
} else {
t2 = $[4];
}
let t3;
let t3: React.DependencyList;
if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
t3 = [];
$[5] = t3;
@@ -80,9 +81,9 @@ export function AppStateProvider(t0) {
t3 = $[5];
}
useEffect(t2, t3);
let t4;
let t4: (source: SettingSource) => void;
if ($[6] !== store.setState) {
t4 = source => applySettingsChange(source, store.setState);
t4 = (source: SettingSource) => applySettingsChange(source, store.setState);
$[6] = store.setState;
$[7] = t4;
} else {
@@ -109,7 +110,7 @@ export function AppStateProvider(t0) {
}
return t6;
}
function _temp(prev) {
function _temp(prev: AppState): AppState {
return {
...prev,
toolPermissionContext: createDisabledBypassPermissionsContext(prev.toolPermissionContext)
@@ -140,16 +141,18 @@ function useAppStore(): AppStateStore {
* const { text, promptId } = useAppState(s => s.promptSuggestion) // good
* ```
*/
export function useAppState(selector) {
export function useAppState<T>(selector: (state: AppState) => T): T;
export function useAppState<T>(selector: IfAny<T, T, never>): any;
export function useAppState<T>(selector: (state: AppState) => T): T {
const store = useAppStore();
const selectorRef = React.useRef(selector);
const storeRef = React.useRef(store);
const selectorRef = React.useRef<(state: AppState) => T>(selector);
const storeRef = React.useRef<AppStateStore>(store);
// Update refs during render so get() always calls the latest selector/store
// without creating a new function identity that would trigger useSyncExternalStore
// to re-sync and cause re-render loops.
selectorRef.current = selector;
storeRef.current = store;
const get = React.useCallback(() => {
const get = React.useCallback((): T => {
return selectorRef.current(storeRef.current.getState());
}, []);
return useSyncExternalStore(store.subscribe, get, get);
@@ -160,31 +163,39 @@ export function useAppState(selector) {
* Returns a stable reference that never changes -- components using only
* this hook will never re-render from state changes.
*/
export function useSetAppState() {
export function useSetAppState(): AppStateStore['setState'] {
return useAppStore().setState;
}
/**
* Get the store directly (for passing getState/setState to non-React code).
*/
export function useAppStateStore() {
export function useAppStateStore(): AppStateStore {
return useAppStore();
}
const NOOP_SUBSCRIBE = () => () => {};
const NOOP_SUBSCRIBE: AppStateStore['subscribe'] = () => () => {};
/**
* 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.
*/
export function useAppStateMaybeOutsideOfProvider(selector) {
export function useAppStateMaybeOutsideOfProvider<T>(
selector: (state: AppState) => T,
): T | undefined;
export function useAppStateMaybeOutsideOfProvider<T>(
selector: IfAny<T, T, never>,
): any;
export function useAppStateMaybeOutsideOfProvider<T>(
selector: (state: AppState) => T,
): T | undefined {
const store = useContext(AppStoreContext);
const selectorRef = React.useRef(selector);
const storeRef = React.useRef(store);
const selectorRef = React.useRef<(state: AppState) => T>(selector);
const storeRef = React.useRef<AppStateStore | null>(store);
// Update refs during render so get() always calls the latest selector/store
// without creating a new function identity.
selectorRef.current = selector;
storeRef.current = store;
const get = React.useCallback(() => {
const get = React.useCallback((): T | undefined => {
return storeRef.current ? selectorRef.current(storeRef.current.getState()) : undefined;
}, []);
return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, get);
+105
View File
@@ -0,0 +1,105 @@
import type { AppStateStore } from './AppState.js'
import {
useAppState,
useAppStateMaybeOutsideOfProvider,
useAppStateStore,
useSetAppState,
} from './AppState.js'
type Assert<T extends true> = T
type IsAny<T> = 0 extends 1 & T ? true : false
type IsEqual<A, B> = (<T>() => T extends A ? 1 : 2) extends <
T,
>() => T extends B ? 1 : 2
? true
: false
function assertAppStateHookTypes(): void {
const anySelector = ((state: unknown) => state) as any
const verbose = useAppState(state => state.verbose)
type _VerboseIsBoolean = Assert<IsEqual<typeof verbose, boolean>>
type _VerboseIsNotAny = Assert<IsAny<typeof verbose> extends false ? true : false>
const anySelected = useAppState(anySelector)
type _AnySelectorStaysAny = Assert<
IsAny<typeof anySelected> extends true ? true : false
>
// @ts-expect-error Compatibility overload is reserved for compiler-erased `any` selectors.
useAppState((state: { missing: string }) => state.missing)
const maybeVerbose = useAppStateMaybeOutsideOfProvider(
state => state.verbose,
)
type _MaybeVerboseIsOptionalBoolean = Assert<
IsEqual<typeof maybeVerbose, boolean | undefined>
>
type _MaybeVerboseIsNotAny = Assert<
IsAny<typeof maybeVerbose> extends false ? true : false
>
const maybeAnySelected = useAppStateMaybeOutsideOfProvider(anySelector)
type _MaybeAnySelectorStaysAny = Assert<
IsAny<typeof maybeAnySelected> extends true ? true : false
>
// @ts-expect-error Compatibility overload is reserved for compiler-erased `any` selectors.
useAppStateMaybeOutsideOfProvider((state: { missing: string }) => state.missing)
const setAppState = useSetAppState()
type _SetAppStateIsStoreSetter = Assert<
IsEqual<typeof setAppState, AppStateStore['setState']>
>
type _SetAppStateIsNotAny = Assert<
IsAny<typeof setAppState> extends false ? true : false
>
const store = useAppStateStore()
type _StoreIsAppStateStore = Assert<IsEqual<typeof store, AppStateStore>>
type _StoreIsNotAny = Assert<IsAny<typeof store> extends false ? true : false>
const additionalDirectoryKeys = useAppState(state =>
Array.from(state.toolPermissionContext.additionalWorkingDirectories.keys()),
)
type _AdditionalDirectoryKeysAreStrings = Assert<
IsEqual<typeof additionalDirectoryKeys, string[]>
>
type _AdditionalDirectoryKeysAreNotAny = Assert<
IsAny<typeof additionalDirectoryKeys> extends false ? true : false
>
const hasActiveOverlay = useAppState(state =>
state.activeOverlays.has('test-overlay'),
)
type _ActiveOverlayCheckIsBoolean = Assert<
IsEqual<typeof hasActiveOverlay, boolean>
>
type _ActiveOverlayCheckIsNotAny = Assert<
IsAny<typeof hasActiveOverlay> extends false ? true : false
>
const registeredToolNames = useAppState(state =>
state.replContext
? Array.from(state.replContext.registeredTools.keys())
: [],
)
type _RegisteredToolNamesAreStrings = Assert<
IsEqual<typeof registeredToolNames, string[]>
>
type _RegisteredToolNamesAreNotAny = Assert<
IsAny<typeof registeredToolNames> extends false ? true : false
>
void verbose
void anySelected
void maybeVerbose
void maybeAnySelected
void setAppState
void store
void additionalDirectoryKeys
void hasActiveOverlay
void registeredToolNames
}
void assertAppStateHookTypes
+13 -5
View File
@@ -4,11 +4,19 @@
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export type DeepImmutable<T> = T extends any[]
? readonly DeepImmutable<T[number]>[]
: T extends object
? { readonly [K in keyof T]: DeepImmutable<T[K]> }
: T
export type DeepImmutable<T> = T extends (...args: any[]) => any
? T
: T extends ReadonlyMap<infer K, infer V>
? ReadonlyMap<DeepImmutable<K>, DeepImmutable<V>>
: T extends ReadonlySet<infer V>
? ReadonlySet<DeepImmutable<V>>
: T extends readonly unknown[]
? number extends T['length']
? readonly DeepImmutable<T[number]>[]
: { readonly [K in keyof T]: DeepImmutable<T[K]> }
: T extends object
? { readonly [K in keyof T]: DeepImmutable<T[K]> }
: T
export type Permutations<T extends string, U extends string = T> = T extends T
? T | `${T}${Permutations<Exclude<U, T>>}`
+63
View File
@@ -0,0 +1,63 @@
import type { DeepImmutable } from './utils.js'
type Assert<T extends true> = T
type IsEqual<A, B> = (<T>() => T extends A ? 1 : 2) extends <
T,
>() => T extends B ? 1 : 2
? true
: false
type ImmutableReadonlyMap = DeepImmutable<
ReadonlyMap<string, { items: string[] }>
>
type ImmutableReadonlyMapValue = NonNullable<
ReturnType<ImmutableReadonlyMap['get']>
>
type _ReadonlyMapValueIsDeepImmutable = Assert<
IsEqual<ImmutableReadonlyMapValue, { readonly items: readonly string[] }>
>
type ImmutableReadonlySet = DeepImmutable<ReadonlySet<{ items: string[] }>>
type ImmutableReadonlySetValue =
ImmutableReadonlySet extends ReadonlySet<infer Value> ? Value : never
type _ReadonlySetValueIsDeepImmutable = Assert<
IsEqual<ImmutableReadonlySetValue, { readonly items: readonly string[] }>
>
type ImmutableReadonlyTuple = DeepImmutable<
readonly [{ a: string[] }, { b: number[] }]
>
type _ReadonlyTuplePreservesPositions = Assert<
IsEqual<
ImmutableReadonlyTuple,
readonly [
{ readonly a: readonly string[] },
{ readonly b: readonly number[] },
]
>
>
function assertReadonlyCollectionTypes(
readonlyMap: ImmutableReadonlyMap,
readonlySet: ImmutableReadonlySet,
readonlyTuple: ImmutableReadonlyTuple,
): void {
const mapValue = readonlyMap.get('test')
if (mapValue) {
// @ts-expect-error DeepImmutable keeps ReadonlyMap APIs but freezes nested values.
mapValue.items.push('mutates')
}
for (const setValue of readonlySet) {
// @ts-expect-error DeepImmutable keeps ReadonlySet APIs but freezes nested values.
setValue.items.push('mutates')
}
// @ts-expect-error DeepImmutable preserves tuple positions and freezes nested values.
readonlyTuple[0].a.push('mutates')
// @ts-expect-error DeepImmutable preserves readonly tuple arity.
readonlyTuple[2]
}
void assertReadonlyCollectionTypes
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"files": [
"src/state/AppState.types.test.tsx",
"src/state/AppState.tsx",
"src/types/utils.types.test.ts",
"src/types/utils.ts"
],
"include": []
}