diff --git a/build.ts b/build.ts index 6cb8e73eca..20d4b1478f 100644 --- a/build.ts +++ b/build.ts @@ -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 diff --git a/scripts/dev.ts b/scripts/dev.ts index 72919caa8c..e572f03878 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -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]) => [ diff --git a/src/components/FeedbackSurvey/useFrustrationDetection.ts b/src/components/FeedbackSurvey/useFrustrationDetection.ts index 0419aa5a0d..d88eb2d569 100644 --- a/src/components/FeedbackSurvey/useFrustrationDetection.ts +++ b/src/components/FeedbackSurvey/useFrustrationDetection.ts @@ -25,24 +25,24 @@ export function useFrustrationDetection( const [state, setState] = useState('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') diff --git a/src/components/PromptInput/Notifications.tsx b/src/components/PromptInput/Notifications.tsx index 65464dcf9e..32af69e441 100644 --- a/src/components/PromptInput/Notifications.tsx +++ b/src/components/PromptInput/Notifications.tsx @@ -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. diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index 5291cde222..a37eaa37ec 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -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); diff --git a/src/components/PromptInput/PromptInputFooterLeftSide.tsx b/src/components/PromptInput/PromptInputFooterLeftSide.tsx index 997fe3e7da..efa2897726 100644 --- a/src/components/PromptInput/PromptInputFooterLeftSide.tsx +++ b/src/components/PromptInput/PromptInputFooterLeftSide.tsx @@ -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; diff --git a/src/components/PromptInput/PromptInputQueuedCommands.tsx b/src/components/PromptInput/PromptInputQueuedCommands.tsx index 1dfec59c75..267635fbf7 100644 --- a/src/components/PromptInput/PromptInputQueuedCommands.tsx +++ b/src/components/PromptInput/PromptInputQueuedCommands.tsx @@ -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. diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index fe5e74fd91..0ef3ab4d5a 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -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 diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx index ce3f1379fb..2a4978d913 100644 --- a/src/components/TextInput.tsx +++ b/src/components/TextInput.tsx @@ -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(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); diff --git a/src/components/messages/AttachmentMessage.tsx b/src/components/messages/AttachmentMessage.tsx index df47aa8fc2..3d3c418a03 100644 --- a/src/components/messages/AttachmentMessage.tsx +++ b/src/components/messages/AttachmentMessage.tsx @@ -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 diff --git a/src/components/messages/UserPromptMessage.tsx b/src/components/messages/UserPromptMessage.tsx index 5853653cef..c19cffadbb 100644 --- a/src/components/messages/UserPromptMessage.tsx +++ b/src/components/messages/UserPromptMessage.tsx @@ -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. diff --git a/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx index 1117de7224..d595ca0bcc 100644 --- a/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx @@ -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. diff --git a/src/hooks/useGlobalKeybindings.tsx b/src/hooks/useGlobalKeybindings.tsx index 36a3fb1027..8b88db0c15 100644 --- a/src/hooks/useGlobalKeybindings.tsx +++ b/src/hooks/useGlobalKeybindings.tsx @@ -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( diff --git a/src/hooks/useIssueFlagBanner.ts b/src/hooks/useIssueFlagBanner.ts index 49161fe952..124d23dde7 100644 --- a/src/hooks/useIssueFlagBanner.ts +++ b/src/hooks/useIssueFlagBanner.ts @@ -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 diff --git a/src/hooks/useReplBridge.tsx b/src/hooks/useReplBridge.tsx index bd535d8b92..d80db942f8 100644 --- a/src/hooks/useReplBridge.tsx +++ b/src/hooks/useReplBridge.tsx @@ -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 diff --git a/src/hooks/useUpdateNotification.ts b/src/hooks/useUpdateNotification.ts index c9a7b2a77e..539471b551 100644 --- a/src/hooks/useUpdateNotification.ts +++ b/src/hooks/useUpdateNotification.ts @@ -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( - () => getSemverPart(initialVersion), - ) + const lastNotifiedRef = useRef(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 } diff --git a/src/hooks/useVoiceIntegration.tsx b/src/hooks/useVoiceIntegration.tsx index 11c172b312..5d83844598 100644 --- a/src/hooks/useVoiceIntegration.tsx +++ b/src/hooks/useVoiceIntegration.tsx @@ -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 diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 83eb7f02c4..72bb7e28b9 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -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