Skip to content

Commit 02dd796

Browse files
Merge pull request #435 from bonerush/fix/conditional-hooks-ctrlo-error
fix: 修复条件式 hook 调用导致的 "Rendered fewer hooks than expected" 错误
2 parents 73e54d4 + 8ba51ed commit 02dd796

18 files changed

Lines changed: 206 additions & 189 deletions

build.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const result = await Bun.build({
2121
outdir,
2222
target: 'bun',
2323
splitting: true,
24+
sourcemap: 'linked',
2425
define: {
2526
...getMacroDefines(),
2627
// React production mode — eliminates _debugStack Error objects

scripts/dev.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const defines = {
1818
...getMacroDefines(),
1919
// React production mode — prevents 6,889+ _debugStack Error objects
2020
// (12MB) from accumulating during long-running sessions.
21-
'process.env.NODE_ENV': JSON.stringify('production'),
21+
'process.env.NODE_ENV': JSON.stringify('development'),
2222
}
2323

2424
const defineArgs = Object.entries(defines).flatMap(([k, v]) => [

src/components/FeedbackSurvey/useFrustrationDetection.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,24 @@ export function useFrustrationDetection(
2525
const [state, setState] = useState<FrustrationState>('closed')
2626

2727
const config = getGlobalConfig() as { transcriptShareDismissed?: boolean }
28-
if (config.transcriptShareDismissed) {
29-
return { state: 'closed', handleTranscriptSelect: () => {} }
30-
}
31-
32-
if (!isPolicyAllowed('product_feedback' as any)) {
33-
return { state: 'closed', handleTranscriptSelect: () => {} }
34-
}
35-
36-
if (isLoading || hasActivePrompt || otherSurveyOpen) {
37-
return { state: 'closed', handleTranscriptSelect: () => {} }
38-
}
28+
const policyAllowed = isPolicyAllowed('product_feedback' as any)
29+
const shouldSkip =
30+
config.transcriptShareDismissed ||
31+
!policyAllowed ||
32+
isLoading ||
33+
hasActivePrompt ||
34+
otherSurveyOpen
3935

4036
const frustrated = detectFrustration(messages)
4137

42-
const effectiveState =
43-
frustrated && state === 'closed' ? 'transcript_prompt' : state
38+
const effectiveState = shouldSkip
39+
? 'closed'
40+
: frustrated && state === 'closed'
41+
? 'transcript_prompt'
42+
: state
4443

45-
function handleTranscriptSelect(choice: string) {
44+
const handleTranscriptSelect = (choice: string) => {
45+
if (shouldSkip) return
4646
if (choice === 'yes') {
4747
void submitTranscriptShare(messages, 'frustration', crypto.randomUUID())
4848
setState('submitted')

src/components/PromptInput/Notifications.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,14 @@ function NotificationContent({
204204
}, []);
205205

206206
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
207-
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
208-
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
209-
const voiceError = feature('VOICE_MODE') ? useVoiceState(s => s.voiceError) : null;
210-
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
207+
const voiceStateRaw = useVoiceState(s => s.voiceState);
208+
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
209+
const voiceEnabledRaw = useVoiceEnabled();
210+
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
211+
const voiceErrorRaw = useVoiceState(s => s.voiceError);
212+
const voiceError = feature('VOICE_MODE') ? voiceErrorRaw : null;
213+
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
214+
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
211215

212216
// When voice is actively recording or processing, replace all
213217
// notifications with just the voice indicator.

src/components/PromptInput/PromptInput.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,8 @@ function PromptInput({
347347
// the input bar. viewingAgentTaskId mirrors the gate on both (Spinner.tsx,
348348
// REPL.tsx) — teammate view falls back to SpinnerWithVerbInner which has
349349
// its own marginTop, so the gap stays even without ours.
350-
const briefOwnsGap =
351-
feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) && !viewingAgentTaskId : false;
350+
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
351+
const briefOwnsGap = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState && !viewingAgentTaskId : false;
352352
const mainLoopModel_ = useAppState(s => s.mainLoopModel);
353353
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
354354
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
@@ -2111,7 +2111,8 @@ function PromptInput({
21112111

21122112
useBuddyNotification();
21132113

2114-
const companionSpeaking = feature('BUDDY') ? useAppState(s => s.companionReaction !== undefined) : false;
2114+
const companionReactionState = useAppState(s => s.companionReaction);
2115+
const companionSpeaking = feature('BUDDY') ? companionReactionState !== undefined : false;
21152116
const { columns, rows } = useTerminalSize();
21162117
const textInputColumns = columns - 3 - companionReservedColumns(columns, companionSpeaking);
21172118

src/components/PromptInput/PromptInputFooterLeftSide.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,12 @@ function ModeIndicator({
230230
proactiveModule?.getNextTickAt ?? NULL,
231231
NULL,
232232
);
233-
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
234-
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
235-
const voiceWarmingUp = feature('VOICE_MODE') ? useVoiceState(s => s.voiceWarmingUp) : false;
233+
const voiceEnabledRaw = useVoiceEnabled();
234+
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
235+
const voiceStateRaw = useVoiceState(s => s.voiceState);
236+
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
237+
const voiceWarmingUpRaw = useVoiceState(s => s.voiceWarmingUp);
238+
const voiceWarmingUp = feature('VOICE_MODE') ? voiceWarmingUpRaw : false;
236239
const hasSelection = useHasSelection();
237240
const selGetState = useSelection().getState;
238241
const hasNextTick = nextTickAt !== null;
@@ -250,16 +253,19 @@ function ModeIndicator({
250253
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
251254
const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t');
252255
const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k');
253-
const voiceKeyShortcut = feature('VOICE_MODE') ? useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') : '';
256+
const voiceKeyShortcutRaw = useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space');
257+
const voiceKeyShortcut = feature('VOICE_MODE') ? voiceKeyShortcutRaw : '';
254258
// Captured at mount so the hint doesn't flicker mid-session if another
255259
// CC instance increments the counter. Incremented once via useEffect the
256260
// first time voice is enabled in this session — approximates "hint was
257261
// shown" without tracking the exact render-time condition (which depends
258262
// on parts/hintParts computed after the early-return hooks boundary).
259-
const [voiceHintUnderCap] = feature('VOICE_MODE')
260-
? useState(() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS)
261-
: [false];
262-
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null;
263+
const [voiceHintUnderCapRaw] = useState(
264+
() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS,
265+
);
266+
const voiceHintUnderCap = feature('VOICE_MODE') ? voiceHintUnderCapRaw : false;
267+
const voiceHintIncrementedRefRaw = useRef(false);
268+
const voiceHintIncrementedRef = feature('VOICE_MODE') ? voiceHintIncrementedRefRaw : null;
263269
useEffect(() => {
264270
if (feature('VOICE_MODE')) {
265271
if (!voiceEnabled || !voiceHintUnderCap) return;

src/components/PromptInput/PromptInputQueuedCommands.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
8080
// already indent themselves). Gate mirrors the brief-spinner/message
8181
// check elsewhere — no teammate-view override needed since this
8282
// component early-returns when viewing a teammate.
83-
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
83+
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
84+
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
8485

8586
// createUserMessage mints a fresh UUID per call; without memoization, streaming
8687
// re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker.

src/components/Spinner.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,8 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
7878
// teammate view needs the real spinner (which shows teammate status).
7979
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
8080
// Hoisted to mount-time — this component re-renders at animation framerate.
81-
const briefEnvEnabled =
82-
feature('KAIROS') || feature('KAIROS_BRIEF')
83-
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
84-
: false;
81+
const briefEnvEnabledRaw = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []);
82+
const briefEnvEnabled = feature('KAIROS') || feature('KAIROS_BRIEF') ? briefEnvEnabledRaw : false;
8583

8684
// Runtime gate mirrors isBriefEnabled() but inlined — importing from
8785
// BriefTool.ts would leak tool-name strings into external builds. Single

src/components/TextInput.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,18 @@ export default function TextInput(props: Props): React.ReactNode {
4444
const settings = useSettings();
4545
const reducedMotion = settings.prefersReducedMotion ?? false;
4646

47-
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
47+
const voiceStateRaw = useVoiceState(s => s.voiceState);
48+
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
4849
const isVoiceRecording = voiceState === 'recording';
4950

50-
const audioLevels = feature('VOICE_MODE') ? useVoiceState(s => s.voiceAudioLevels) : [];
51+
const audioLevelsRaw = useVoiceState(s => s.voiceAudioLevels);
52+
const audioLevels = feature('VOICE_MODE') ? audioLevelsRaw : [];
5153
const smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0));
5254

5355
const needsAnimation = isVoiceRecording && !reducedMotion;
54-
const [animRef, animTime] = feature('VOICE_MODE') ? useAnimationFrame(needsAnimation ? 50 : null) : [() => {}, 0];
56+
const [animRefRaw, animTimeRaw] = useAnimationFrame(needsAnimation ? 50 : null);
57+
const animRef = feature('VOICE_MODE') ? animRefRaw : () => {};
58+
const animTime = feature('VOICE_MODE') ? animTimeRaw : 0;
5559

5660
// Show hint when terminal regains focus and clipboard has an image
5761
useClipboardImageHint(isTerminalFocused, !!props.onImagePaste);

src/components/messages/AttachmentMessage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ type Props = {
3939
export function AttachmentMessage({ attachment, addMargin, verbose, isTranscriptMode }: Props): React.ReactNode {
4040
const bg = useSelectedMessageBg();
4141
// Hoisted to mount-time — per-message component, re-renders on every scroll.
42-
const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH') ? useMemo(() => isEnvTruthy(process.env.IS_DEMO), []) : false;
42+
const isDemoEnvRaw = useMemo(() => isEnvTruthy(process.env.IS_DEMO), []);
43+
const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH') ? isDemoEnvRaw : false;
4344
// Handle teammate_mailbox BEFORE switch
4445
if (isAgentSwarmsEnabled() && attachment.type === 'teammate_mailbox') {
4546
// Filter out idle notifications BEFORE counting - they are hidden in the UI

0 commit comments

Comments
 (0)