Merge pull request #435 from bonerush/fix/conditional-hooks-ctrlo-error

fix: 修复条件式 hook 调用导致的 "Rendered fewer hooks than expected" 错误
This commit is contained in:
claude-code-best
2026-05-08 19:21:30 +08:00
committed by GitHub
18 changed files with 206 additions and 189 deletions

View File

@@ -21,6 +21,7 @@ const result = await Bun.build({
outdir,
target: 'bun',
splitting: true,
sourcemap: 'linked',
define: {
...getMacroDefines(),
// React production mode — eliminates _debugStack Error objects

View File

@@ -18,7 +18,7 @@ const defines = {
...getMacroDefines(),
// React production mode — prevents 6,889+ _debugStack Error objects
// (12MB) from accumulating during long-running sessions.
'process.env.NODE_ENV': JSON.stringify('production'),
'process.env.NODE_ENV': JSON.stringify('development'),
}
const defineArgs = Object.entries(defines).flatMap(([k, v]) => [

View File

@@ -25,24 +25,24 @@ export function useFrustrationDetection(
const [state, setState] = useState<FrustrationState>('closed')
const config = getGlobalConfig() as { transcriptShareDismissed?: boolean }
if (config.transcriptShareDismissed) {
return { state: 'closed', handleTranscriptSelect: () => {} }
}
if (!isPolicyAllowed('product_feedback' as any)) {
return { state: 'closed', handleTranscriptSelect: () => {} }
}
if (isLoading || hasActivePrompt || otherSurveyOpen) {
return { state: 'closed', handleTranscriptSelect: () => {} }
}
const policyAllowed = isPolicyAllowed('product_feedback' as any)
const shouldSkip =
config.transcriptShareDismissed ||
!policyAllowed ||
isLoading ||
hasActivePrompt ||
otherSurveyOpen
const frustrated = detectFrustration(messages)
const effectiveState =
frustrated && state === 'closed' ? 'transcript_prompt' : state
const effectiveState = shouldSkip
? 'closed'
: frustrated && state === 'closed'
? 'transcript_prompt'
: state
function handleTranscriptSelect(choice: string) {
const handleTranscriptSelect = (choice: string) => {
if (shouldSkip) return
if (choice === 'yes') {
void submitTranscriptShare(messages, 'frustration', crypto.randomUUID())
setState('submitted')

View File

@@ -204,10 +204,14 @@ function NotificationContent({
}, []);
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
const voiceError = feature('VOICE_MODE') ? useVoiceState(s => s.voiceError) : null;
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
const voiceStateRaw = useVoiceState(s => s.voiceState);
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
const voiceEnabledRaw = useVoiceEnabled();
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
const voiceErrorRaw = useVoiceState(s => s.voiceError);
const voiceError = feature('VOICE_MODE') ? voiceErrorRaw : null;
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
// When voice is actively recording or processing, replace all
// notifications with just the voice indicator.

View File

@@ -347,8 +347,8 @@ function PromptInput({
// the input bar. viewingAgentTaskId mirrors the gate on both (Spinner.tsx,
// REPL.tsx) — teammate view falls back to SpinnerWithVerbInner which has
// its own marginTop, so the gap stays even without ours.
const briefOwnsGap =
feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) && !viewingAgentTaskId : false;
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
const briefOwnsGap = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState && !viewingAgentTaskId : false;
const mainLoopModel_ = useAppState(s => s.mainLoopModel);
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
@@ -2111,7 +2111,8 @@ function PromptInput({
useBuddyNotification();
const companionSpeaking = feature('BUDDY') ? useAppState(s => s.companionReaction !== undefined) : false;
const companionReactionState = useAppState(s => s.companionReaction);
const companionSpeaking = feature('BUDDY') ? companionReactionState !== undefined : false;
const { columns, rows } = useTerminalSize();
const textInputColumns = columns - 3 - companionReservedColumns(columns, companionSpeaking);

View File

@@ -230,9 +230,12 @@ function ModeIndicator({
proactiveModule?.getNextTickAt ?? NULL,
NULL,
);
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
const voiceWarmingUp = feature('VOICE_MODE') ? useVoiceState(s => s.voiceWarmingUp) : false;
const voiceEnabledRaw = useVoiceEnabled();
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
const voiceStateRaw = useVoiceState(s => s.voiceState);
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
const voiceWarmingUpRaw = useVoiceState(s => s.voiceWarmingUp);
const voiceWarmingUp = feature('VOICE_MODE') ? voiceWarmingUpRaw : false;
const hasSelection = useHasSelection();
const selGetState = useSelection().getState;
const hasNextTick = nextTickAt !== null;
@@ -250,16 +253,19 @@ function ModeIndicator({
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t');
const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k');
const voiceKeyShortcut = feature('VOICE_MODE') ? useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') : '';
const voiceKeyShortcutRaw = useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space');
const voiceKeyShortcut = feature('VOICE_MODE') ? voiceKeyShortcutRaw : '';
// Captured at mount so the hint doesn't flicker mid-session if another
// CC instance increments the counter. Incremented once via useEffect the
// first time voice is enabled in this session — approximates "hint was
// shown" without tracking the exact render-time condition (which depends
// on parts/hintParts computed after the early-return hooks boundary).
const [voiceHintUnderCap] = feature('VOICE_MODE')
? useState(() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS)
: [false];
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null;
const [voiceHintUnderCapRaw] = useState(
() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS,
);
const voiceHintUnderCap = feature('VOICE_MODE') ? voiceHintUnderCapRaw : false;
const voiceHintIncrementedRefRaw = useRef(false);
const voiceHintIncrementedRef = feature('VOICE_MODE') ? voiceHintIncrementedRefRaw : null;
useEffect(() => {
if (feature('VOICE_MODE')) {
if (!voiceEnabled || !voiceHintUnderCap) return;

View File

@@ -80,7 +80,8 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
// already indent themselves). Gate mirrors the brief-spinner/message
// check elsewhere — no teammate-view override needed since this
// component early-returns when viewing a teammate.
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
// createUserMessage mints a fresh UUID per call; without memoization, streaming
// re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker.

View File

@@ -78,10 +78,8 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
// teammate view needs the real spinner (which shows teammate status).
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
// Hoisted to mount-time — this component re-renders at animation framerate.
const briefEnvEnabled =
feature('KAIROS') || feature('KAIROS_BRIEF')
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
: false;
const briefEnvEnabledRaw = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []);
const briefEnvEnabled = feature('KAIROS') || feature('KAIROS_BRIEF') ? briefEnvEnabledRaw : false;
// Runtime gate mirrors isBriefEnabled() but inlined — importing from
// BriefTool.ts would leak tool-name strings into external builds. Single

View File

@@ -44,14 +44,18 @@ export default function TextInput(props: Props): React.ReactNode {
const settings = useSettings();
const reducedMotion = settings.prefersReducedMotion ?? false;
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
const voiceStateRaw = useVoiceState(s => s.voiceState);
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
const isVoiceRecording = voiceState === 'recording';
const audioLevels = feature('VOICE_MODE') ? useVoiceState(s => s.voiceAudioLevels) : [];
const audioLevelsRaw = useVoiceState(s => s.voiceAudioLevels);
const audioLevels = feature('VOICE_MODE') ? audioLevelsRaw : [];
const smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0));
const needsAnimation = isVoiceRecording && !reducedMotion;
const [animRef, animTime] = feature('VOICE_MODE') ? useAnimationFrame(needsAnimation ? 50 : null) : [() => {}, 0];
const [animRefRaw, animTimeRaw] = useAnimationFrame(needsAnimation ? 50 : null);
const animRef = feature('VOICE_MODE') ? animRefRaw : () => {};
const animTime = feature('VOICE_MODE') ? animTimeRaw : 0;
// Show hint when terminal regains focus and clipboard has an image
useClipboardImageHint(isTerminalFocused, !!props.onImagePaste);

View File

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

View File

@@ -38,27 +38,20 @@ export function UserPromptMessage({ addMargin, param: { text }, isTranscriptMode
// child renders a label-style layout, and Box backgroundColor paints
// behind children unconditionally (they can't opt out).
//
// Hooks stay INSIDE feature() ternaries so external builds don't pay
// the per-scrollback-message store subscription (useSyncExternalStore
// bypasses React.memo). Runtime-gated like isBriefEnabled() but inlined
// to avoid pulling BriefTool.ts → prompt.ts tool-name strings into
// external builds.
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
const viewingAgentTaskId =
feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.viewingAgentTaskId) : null;
// Hooks must always be called unconditionally to satisfy React rules.
// The feature gate is applied to the computed value, not the hook call.
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
const viewingAgentTaskIdState = useAppState(s => s.viewingAgentTaskId);
// Hoisted to mount-time — per-message component, re-renders on every scroll.
const briefEnvEnabled =
feature('KAIROS') || feature('KAIROS_BRIEF')
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
: false;
const briefEnvEnabledState = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []);
const useBriefLayout =
feature('KAIROS') || feature('KAIROS_BRIEF')
? (getKairosActive() ||
(getUserMsgOptIn() &&
(briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) &&
isBriefOnly &&
(briefEnvEnabledState || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) &&
isBriefOnlyState &&
!isTranscriptMode &&
!viewingAgentTaskId
!viewingAgentTaskIdState
: false;
// Truncate before the early return so the hook order is stable.

View File

@@ -43,10 +43,9 @@ export function UserToolSuccessMessage({
shouldCollapseDiffs,
}: Props): React.ReactNode {
const [theme] = useTheme();
// Hook stays inside feature() ternary so external builds don't pay a
// per-scrollback-message store subscription — same pattern as
// UserPromptMessage.tsx.
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
// Always call hook unconditionally; feature gate applied to the value.
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
// Capture classifier approval once on mount, then delete from Map to prevent linear growth.
// useState lazy initializer ensures the value persists across re-renders.

View File

@@ -83,7 +83,7 @@ export function GlobalKeybindingHandlers({
// Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript.
// Brief view has its own dedicated toggle on ctrl+shift+b.
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
const handleToggleTranscript = useCallback(() => {
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
// Escape hatch: GB kill-switch while defaultView=chat was persisted
@@ -95,7 +95,7 @@ export function GlobalKeybindingHandlers({
const { isBriefEnabled } =
require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js');
/* eslint-enable @typescript-eslint/no-require-imports */
if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') {
if (!isBriefEnabled() && isBriefOnlyState && screen !== 'transcript') {
setAppState(prev => {
if (!prev.isBriefOnly) return prev;
return { ...prev, isBriefOnly: false };
@@ -121,7 +121,7 @@ export function GlobalKeybindingHandlers({
}, [
screen,
setScreen,
isBriefOnly,
isBriefOnlyState,
showAllInTranscript,
setShowAllInTranscript,
messageCount,
@@ -162,8 +162,8 @@ export function GlobalKeybindingHandlers({
const { isBriefEnabled } =
require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js');
/* eslint-enable @typescript-eslint/no-require-imports */
if (!isBriefEnabled() && !isBriefOnly) return;
const next = !isBriefOnly;
if (!isBriefEnabled() && !isBriefOnlyState) return;
const next = !isBriefOnlyState;
logEvent('tengu_brief_mode_toggled', {
enabled: next,
gated: false,
@@ -174,7 +174,7 @@ export function GlobalKeybindingHandlers({
return { ...prev, isBriefOnly: next };
});
}
}, [isBriefOnly, setAppState]);
}, [isBriefOnlyState, setAppState]);
// Register keybinding handlers
useKeybinding('app:toggleTodos', handleToggleTodos, {
@@ -183,11 +183,10 @@ export function GlobalKeybindingHandlers({
useKeybinding('app:toggleTranscript', handleToggleTranscript, {
context: 'Global',
});
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
useKeybinding('app:toggleBrief', handleToggleBrief, {
context: 'Global',
});
}
useKeybinding('app:toggleBrief', handleToggleBrief, {
context: 'Global',
isActive: feature('KAIROS') ? true : feature('KAIROS_BRIEF') ? true : false,
});
// Register teammate keybinding
useKeybinding(

View File

@@ -93,10 +93,6 @@ export function useIssueFlagBanner(
messages: Message[],
submitCount: number,
): boolean {
if (process.env.USER_TYPE !== 'ant') {
return false
}
const lastTriggeredAtRef = useRef(0)
const activeForSubmitRef = useRef(-1)
@@ -109,6 +105,11 @@ export function useIssueFlagBanner(
[messages],
)
const isAnt = process.env.USER_TYPE === 'ant'
if (!isAnt) {
return false
}
// Keep showing the banner until the user submits another message
if (activeForSubmitRef.current === submitCount) {
return true

View File

@@ -99,11 +99,16 @@ export function useReplBridge(
messagesRef.current = messages;
const store = useAppStateStore();
const { addNotification } = useNotifications();
const replBridgeEnabled = feature('BRIDGE_MODE') ? useAppState(s => s.replBridgeEnabled) : false;
const replBridgeConnected = feature('BRIDGE_MODE') ? useAppState(s => s.replBridgeConnected) : false;
const replBridgeSessionActive = feature('BRIDGE_MODE') ? useAppState(s => s.replBridgeSessionActive) : false;
const replBridgeOutboundOnly = feature('BRIDGE_MODE') ? useAppState(s => s.replBridgeOutboundOnly) : false;
const replBridgeInitialName = feature('BRIDGE_MODE') ? useAppState(s => s.replBridgeInitialName) : undefined;
const replBridgeEnabledRaw = useAppState(s => s.replBridgeEnabled);
const replBridgeEnabled = feature('BRIDGE_MODE') ? replBridgeEnabledRaw : false;
const replBridgeConnectedRaw = useAppState(s => s.replBridgeConnected);
const replBridgeConnected = feature('BRIDGE_MODE') ? replBridgeConnectedRaw : false;
const replBridgeSessionActiveRaw = useAppState(s => s.replBridgeSessionActive);
const replBridgeSessionActive = feature('BRIDGE_MODE') ? replBridgeSessionActiveRaw : false;
const replBridgeOutboundOnlyRaw = useAppState(s => s.replBridgeOutboundOnly);
const replBridgeOutboundOnly = feature('BRIDGE_MODE') ? replBridgeOutboundOnlyRaw : false;
const replBridgeInitialNameRaw = useAppState(s => s.replBridgeInitialName);
const replBridgeInitialName = feature('BRIDGE_MODE') ? replBridgeInitialNameRaw : undefined;
// Initialize/teardown bridge when enabled state changes.
// Passes current messages as initialMessages so the remote session

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useRef } from 'react'
import { major, minor, patch } from 'semver'
export function getSemverPart(version: string): string {
@@ -17,18 +17,17 @@ export function useUpdateNotification(
updatedVersion: string | null | undefined,
initialVersion: string = MACRO.VERSION,
): string | null {
const [lastNotifiedSemver, setLastNotifiedSemver] = useState<string | null>(
() => getSemverPart(initialVersion),
)
const lastNotifiedRef = useRef<string | null>(getSemverPart(initialVersion))
if (!updatedVersion) {
const updatedSemver = updatedVersion ? getSemverPart(updatedVersion) : null
if (!updatedSemver) {
return null
}
const updatedSemver = getSemverPart(updatedVersion)
if (updatedSemver !== lastNotifiedSemver) {
setLastNotifiedSemver(updatedSemver)
if (updatedSemver !== lastNotifiedRef.current) {
lastNotifiedRef.current = updatedSemver
return updatedSemver
}
return null
}

View File

@@ -214,9 +214,12 @@ export function useVoiceIntegration({
// Voice state selectors. useVoiceEnabled = user intent (settings) +
// auth + GB kill-switch, with the auth half memoized on authVersion so
// render loops never hit a cold keychain spawn.
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
const voiceInterimTranscript = feature('VOICE_MODE') ? useVoiceState(s => s.voiceInterimTranscript) : '';
const voiceEnabledRaw = useVoiceEnabled();
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
const voiceStateRaw = useVoiceState(s => s.voiceState);
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
const voiceInterimTranscriptRaw = useVoiceState(s => s.voiceInterimTranscript);
const voiceInterimTranscript = feature('VOICE_MODE') ? voiceInterimTranscriptRaw : '';
// Set the voice anchor for focus mode (where recording starts via terminal
// focus, not key hold). Key-hold sets the anchor in stripTrailing.
@@ -377,8 +380,10 @@ export function useVoiceKeybindingHandler({
const setVoiceState = useSetVoiceState();
const keybindingContext = useOptionalKeybindingContext();
const isModalOverlayActive = useIsModalOverlayActive();
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : 'idle';
const voiceEnabledRaw = useVoiceEnabled();
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
const voiceStateRaw = useVoiceState(s => s.voiceState);
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : 'idle';
// Find the configured key for voice:pushToTalk from keybinding context.
// Forward iteration with last-wins (matching the resolver): if a later

View File

@@ -860,9 +860,8 @@ export function REPL({
[],
);
const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []);
const disableMessageActions = feature('MESSAGE_ACTIONS')
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), [])
: false;
const disableMessageActionsRaw = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), []);
const disableMessageActions = feature('MESSAGE_ACTIONS') ? disableMessageActionsRaw : false;
// Log REPL mount/unmount lifecycle
useEffect(() => {
@@ -1552,14 +1551,13 @@ export function REPL({
// KAIROS build + config.viewerOnly. feature() is build-time constant so
// the branch is dead-code-eliminated in non-KAIROS builds (same pattern
// as useUnseenDivider above).
const { maybeLoadOlder } = feature('KAIROS')
? useAssistantHistory({
config: remoteSessionConfig,
setMessages,
scrollRef,
onPrepend: shiftDivider,
})
: HISTORY_STUB;
const assistantHistoryResult = useAssistantHistory({
config: remoteSessionConfig,
setMessages,
scrollRef,
onPrepend: shiftDivider,
});
const { maybeLoadOlder } = feature('KAIROS') ? assistantHistoryResult : HISTORY_STUB;
// Compose useUnseenDivider's callbacks with the lazy-load trigger.
const composedOnScroll = useCallback(
(sticky: boolean, handle: ScrollBoxHandle) => {
@@ -4941,8 +4939,9 @@ export function REPL({
const { relayPipeMessage, pipeReturnHadErrorRef } = usePipeRelay();
// Voice input integration (VOICE_MODE builds only)
const voiceIntegrationResult = useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef });
const voice = feature('VOICE_MODE')
? useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef })
? voiceIntegrationResult
: {
stripTrailing: () => 0,
handleKeyEvent: () => {},
@@ -5379,6 +5378,93 @@ export function REPL({
// Auto-exit viewing mode when teammate completes or errors
useTeammateViewAutoExit();
// Get viewed agent task (inlined from selectors for explicit data flow).
// viewedAgentTask: teammate OR local_agent — drives the boolean checks
// below. viewedTeammateTask: teammate-only narrowed, for teammate-specific
// field access (inProgressToolUseIDs).
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined;
const viewedTeammateTask = viewedTask && isInProcessTeammateTask(viewedTask) ? viewedTask : undefined;
const viewedAgentTask = viewedTeammateTask ?? (viewedTask && isLocalAgentTask(viewedTask) ? viewedTask : undefined);
// Bypass useDeferredValue when streaming text is showing so Messages renders
// the final message in the same frame streaming text clears. Also bypass when
// not loading — deferredMessages only matters during streaming (keeps input
// responsive); after the turn ends, showing messages immediately prevents a
// jitter gap where the spinner is gone but the answer hasn't appeared yet.
// Only reducedMotion users keep the deferred path during loading.
const usesSyncMessages = showStreamingText || !isLoading;
// When viewing an agent, never fall through to leader — empty until
// bootstrap/stream fills. Closes the see-leader-type-agent footgun.
const rawAgentMessages = viewedAgentTask?.messages;
// Fork sidechain encodes the user prompt inside a mixed user message alongside
// tool_result blocks; surface the prompt as a standalone bubble and strip the
// boilerplate text from its original carrier while preserving tool_results.
const displayedAgentMessages = useMemo(() => {
if (!viewedAgentTask) return undefined;
const agentMessages = rawAgentMessages ?? [];
if (
!isLocalAgentTask(viewedAgentTask) ||
viewedAgentTask.agentType !== FORK_SUBAGENT_TYPE ||
!viewedAgentTask.prompt
) {
return agentMessages;
}
// Single pass: locate boilerplate carrier, check whether the prompt text is
// already present elsewhere, and find the fallback insertion point (after
// the last parent assistant tool_use).
const trimmedPrompt = viewedAgentTask.prompt.trim();
let boilerplateIndex = -1;
let lastAssistantToolUseIndex = -1;
let promptAlreadyRendered = false;
for (let i = 0; i < agentMessages.length; i++) {
const m = agentMessages[i]!;
if (m.type === 'user' && Array.isArray(m.message?.content)) {
const hasBoilerplate = m.message.content.some(isForkBoilerplateTextBlock);
if (hasBoilerplate) {
boilerplateIndex = i;
} else if (!promptAlreadyRendered) {
const firstText = m.message.content.find(b => b.type === 'text' && typeof b.text === 'string') as
| { type: 'text'; text: string }
| undefined;
if (firstText && firstText.text.trim() === trimmedPrompt) promptAlreadyRendered = true;
}
continue;
}
if (m.type === 'assistant' && Array.isArray(m.message?.content)) {
if (m.message.content.some(b => b.type === 'tool_use')) lastAssistantToolUseIndex = i;
}
}
const stripped =
boilerplateIndex === -1
? agentMessages
: agentMessages.map((m, i) => {
if (i !== boilerplateIndex) return m;
if (!Array.isArray(m.message?.content)) return m;
return {
...m,
message: {
...m.message,
content: m.message.content.filter(b => !isForkBoilerplateTextBlock(b)),
},
};
});
if (promptAlreadyRendered) return stripped;
const insertAt = boilerplateIndex !== -1 ? boilerplateIndex + 1 : lastAssistantToolUseIndex + 1;
const synthetic = createUserMessage({
content: viewedAgentTask.prompt,
timestamp: new Date(viewedAgentTask.startTime).toISOString(),
});
return [...stripped.slice(0, insertAt), synthetic, ...stripped.slice(insertAt)];
}, [viewedAgentTask, rawAgentMessages]);
const displayedMessages = viewedAgentTask
? (displayedAgentMessages ?? [])
: usesSyncMessages
? messages
: deferredMessages;
if (screen === 'transcript') {
// Virtual scroll replaces the 30-message cap: everything is scrollable
// and memory is bounded by the viewport. Without it, wrapping transcript
@@ -5554,92 +5640,6 @@ export function REPL({
return transcriptReturn;
}
// Get viewed agent task (inlined from selectors for explicit data flow).
// viewedAgentTask: teammate OR local_agent — drives the boolean checks
// below. viewedTeammateTask: teammate-only narrowed, for teammate-specific
// field access (inProgressToolUseIDs).
const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined;
const viewedTeammateTask = viewedTask && isInProcessTeammateTask(viewedTask) ? viewedTask : undefined;
const viewedAgentTask = viewedTeammateTask ?? (viewedTask && isLocalAgentTask(viewedTask) ? viewedTask : undefined);
// Bypass useDeferredValue when streaming text is showing so Messages renders
// the final message in the same frame streaming text clears. Also bypass when
// not loading — deferredMessages only matters during streaming (keeps input
// responsive); after the turn ends, showing messages immediately prevents a
// jitter gap where the spinner is gone but the answer hasn't appeared yet.
// Only reducedMotion users keep the deferred path during loading.
const usesSyncMessages = showStreamingText || !isLoading;
// When viewing an agent, never fall through to leader — empty until
// bootstrap/stream fills. Closes the see-leader-type-agent footgun.
const rawAgentMessages = viewedAgentTask?.messages;
// Fork sidechain encodes the user prompt inside a mixed user message alongside
// tool_result blocks; surface the prompt as a standalone bubble and strip the
// boilerplate text from its original carrier while preserving tool_results.
const displayedAgentMessages = useMemo(() => {
if (!viewedAgentTask) return undefined;
const agentMessages = rawAgentMessages ?? [];
if (
!isLocalAgentTask(viewedAgentTask) ||
viewedAgentTask.agentType !== FORK_SUBAGENT_TYPE ||
!viewedAgentTask.prompt
) {
return agentMessages;
}
// Single pass: locate boilerplate carrier, check whether the prompt text is
// already present elsewhere, and find the fallback insertion point (after
// the last parent assistant tool_use).
const trimmedPrompt = viewedAgentTask.prompt.trim();
let boilerplateIndex = -1;
let lastAssistantToolUseIndex = -1;
let promptAlreadyRendered = false;
for (let i = 0; i < agentMessages.length; i++) {
const m = agentMessages[i]!;
if (m.type === 'user' && Array.isArray(m.message?.content)) {
const hasBoilerplate = m.message.content.some(isForkBoilerplateTextBlock);
if (hasBoilerplate) {
boilerplateIndex = i;
} else if (!promptAlreadyRendered) {
const firstText = m.message.content.find(b => b.type === 'text' && typeof b.text === 'string') as
| { type: 'text'; text: string }
| undefined;
if (firstText && firstText.text.trim() === trimmedPrompt) promptAlreadyRendered = true;
}
continue;
}
if (m.type === 'assistant' && Array.isArray(m.message?.content)) {
if (m.message.content.some(b => b.type === 'tool_use')) lastAssistantToolUseIndex = i;
}
}
const stripped =
boilerplateIndex === -1
? agentMessages
: agentMessages.map((m, i) => {
if (i !== boilerplateIndex) return m;
if (!Array.isArray(m.message?.content)) return m;
return {
...m,
message: {
...m.message,
content: m.message.content.filter(b => !isForkBoilerplateTextBlock(b)),
},
};
});
if (promptAlreadyRendered) return stripped;
const insertAt = boilerplateIndex !== -1 ? boilerplateIndex + 1 : lastAssistantToolUseIndex + 1;
const synthetic = createUserMessage({
content: viewedAgentTask.prompt,
timestamp: new Date(viewedAgentTask.startTime).toISOString(),
});
return [...stripped.slice(0, insertAt), synthetic, ...stripped.slice(insertAt)];
}, [viewedAgentTask, rawAgentMessages]);
const displayedMessages = viewedAgentTask
? (displayedAgentMessages ?? [])
: usesSyncMessages
? messages
: deferredMessages;
// Show the placeholder until the real user message appears in
// displayedMessages. userInputOnProcessing stays set for the whole turn
// (cleared in resetLoadingState); this length check hides it once