Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Comment on lines 19 to +21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update stale comment to match NODE_ENV="development" behavior.

Line 19 still says “React production mode”, but Line 21 now sets development. Please align the comment to avoid misleading future changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/dev.ts` around lines 19 - 21, Update the stale comment above the
process.env.NODE_ENV definition in scripts/dev.ts to reflect that NODE_ENV is
set to "development" (not production); locate the comment immediately preceding
the 'process.env.NODE_ENV': JSON.stringify('development') entry and change the
text so it accurately describes the development-mode behavior (e.g., mention
React development mode and that this prevents accumulation of _debugStack Error
objects during long-running sessions).

}

const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
Expand Down
28 changes: 14 additions & 14 deletions src/components/FeedbackSurvey/useFrustrationDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

as any cast on isPolicyAllowed argument indicates a type mismatch that should be resolved properly.

'product_feedback' as any silences a compile error rather than fixing it. Either the accepted policy key union type should include 'product_feedback', or a as unknown as PolicyKey double-cast should be used per guidelines.

♻️ Proposed fix (if `PolicyKey` is the union type)
-  const policyAllowed = isPolicyAllowed('product_feedback' as any)
+  const policyAllowed = isPolicyAllowed('product_feedback' as unknown as PolicyKey)

Alternatively, add 'product_feedback' to the PolicyKey union type if it is a legitimate key.

As per coding guidelines: "Prohibit as any type assertions in production code; use as unknown as SpecificType double assertion."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const policyAllowed = isPolicyAllowed('product_feedback' as any)
const policyAllowed = isPolicyAllowed('product_feedback' as unknown as PolicyKey)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/FeedbackSurvey/useFrustrationDetection.ts` at line 28, The
code uses a banned "as any" cast when calling isPolicyAllowed; replace it by
either adding 'product_feedback' to the PolicyKey union type if it's a valid
policy key, or perform a safe double-cast like `'product_feedback' as unknown as
PolicyKey` when calling isPolicyAllowed (update the call site where
policyAllowed is assigned and ensure isPolicyAllowed's parameter type is
PolicyKey); remove the "as any" cast and run type checks to confirm no remaining
mismatches.

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')
Expand Down
12 changes: 8 additions & 4 deletions src/components/PromptInput/Notifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions src/components/PromptInput/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
22 changes: 14 additions & 8 deletions src/components/PromptInput/PromptInputFooterLeftSide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/components/PromptInput/PromptInputQueuedCommands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 2 additions & 4 deletions src/components/Spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/components/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/components/messages/AttachmentMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 8 additions & 15 deletions src/components/messages/UserPromptMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 10 additions & 11 deletions src/hooks/useGlobalKeybindings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 };
Expand All @@ -121,7 +121,7 @@ export function GlobalKeybindingHandlers({
}, [
screen,
setScreen,
isBriefOnly,
isBriefOnlyState,
showAllInTranscript,
setShowAllInTranscript,
messageCount,
Expand Down Expand Up @@ -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,
Expand All @@ -174,7 +174,7 @@ export function GlobalKeybindingHandlers({
return { ...prev, isBriefOnly: next };
});
}
}, [isBriefOnly, setAppState]);
}, [isBriefOnlyState, setAppState]);

// Register keybinding handlers
useKeybinding('app:toggleTodos', handleToggleTodos, {
Expand All @@ -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(
Expand Down
9 changes: 5 additions & 4 deletions src/hooks/useIssueFlagBanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
15 changes: 10 additions & 5 deletions src/hooks/useReplBridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 7 additions & 8 deletions src/hooks/useUpdateNotification.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useRef } from 'react'
import { major, minor, patch } from 'semver'

export function getSemverPart(version: string): string {
Expand All @@ -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) {
Comment on lines +22 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In semver v7, what do major()/minor()/patch() do when passed an invalid version string? Do they throw or return null?

💡 Result:

In semver v7 (npm/node-semver package version 7.x), the major, minor, and patch functions throw a TypeError with message "Invalid Version: {version}" when passed an invalid version string. They do not return null.[1][2][3]

Citations:


🏁 Script executed:

cat -n src/hooks/useUpdateNotification.ts

Repository: claude-code-best/claude-code

Length of output: 1270


🌐 Web query:

semver v7 loose option behavior major minor patch invalid version

💡 Result:

In npm's node-semver library version 7.x (latest 7.7.4 as of 2026), the 'loose' option makes parsing more forgiving for not-quite-valid semver strings, primarily by allowing prerelease tags without the required '-' separator (e.g., semver.valid('1.2.3beta', {loose: true}) returns '1.2.3-beta' [1][2][3]). Output is always strict-compliant semver. For major, minor, patch extraction: Functions like major(v), minor(v), patch(v) first parse/validate v using loose rules if specified, then extract the respective component from the parsed SemVer object [4][2]. If invalid even loosely, they return undefined/null. Invalid versions: semver.valid('a.b.c') returns null even with loose [4]. Loose does not accept non-numeric components in major/minor/patch or malformed structures like missing parts ('2.0' likely null in loose [5]). It may misparse extras like '1.2.34.5' as '1.2.3-4.5' by treating extra as prerelease [5]. No major changes to loose behavior in v7; recent fixes ensure CLI passes loose to valid [1][6]. Use coerce for aggressive non-semver to semver conversion (e.g., '42.6.7.9.3-alpha' -> '42.6.7'), independent of loose [4][7].

Citations:


🌐 Web query:

semver.major() with loose:true throws or returns null invalid version

💡 Result:

In the npm/node-semver library, semver.major(v) returns a major number (it’s not documented to return null for invalid versions), and loose:true is only relevant when parsing/validating options are actually used by the underlying logic. The public semver docs state that valid(v) returns null if it’s not valid, while major(v) “Return the major version number” (no null/loose behavior described for major specifically) [1]. So the behavior “semver.major with { loose:true } throws or returns null for an invalid version” is not a specific/known “bug” in the major API itself. What can happen is: 1) If the input string is not valid semver, the code path may throw a TypeError “Invalid Version: …” (common across semver operations when invalid inputs are passed to logic that expects a valid SemVer object) [2][3]. 2) If you use the CLI or other helper code that calls semver.valid(v, options), loose:true can change validation results (for example, the npm CLI had a loose-flag validation issue because it called semver.valid(v) without passing the loose option, and it was fixed to call semver.valid(v, options)) [4]. Practical takeaway: don’t rely on semver.major(v, { loose:true }) returning null for invalid input. If you need “null instead of throw,” call semver.valid(v, { loose: true }) first; docs explicitly say valid(v) “Return the parsed version, or null if it’s not valid” [1]. Minimal example pattern (node-semver): - if (!semver.valid(input, { loose: true })) return null; - else return semver.major(input); Citations: semver.valid returns parsed version or null [1]; semver.major is described as returning the major version number [1]; npm CLI loose validation bug/fix indicates passing loose into semver.valid is what changes loose behavior [4]; invalid version commonly throws TypeError in semver operations [2][3].

Citations:


Guard semver parsing before calling getSemverPart to avoid render-time crashes.

A truthy but invalid updatedVersion will throw via major()/minor()/patch() at line 22 during render, even with { loose: true }. The loose option only affects validation; it does not prevent exceptions when parsing malformed versions. Validate first using semver.valid() with the same loose option, then parse only if valid.

Proposed fix
-import { major, minor, patch } from 'semver'
+import { major, minor, patch, valid } from 'semver'
@@
-  const updatedSemver = updatedVersion ? getSemverPart(updatedVersion) : null
+  const normalizedUpdatedVersion =
+    updatedVersion && valid(updatedVersion, { loose: true })
+      ? updatedVersion
+      : null
+  const updatedSemver = normalizedUpdatedVersion ? getSemverPart(normalizedUpdatedVersion) : null
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const updatedSemver = updatedVersion ? getSemverPart(updatedVersion) : null
if (!updatedSemver) {
const normalizedUpdatedVersion =
updatedVersion && valid(updatedVersion, { loose: true })
? updatedVersion
: null
const updatedSemver = normalizedUpdatedVersion ? getSemverPart(normalizedUpdatedVersion) : null
if (!updatedSemver) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useUpdateNotification.ts` around lines 22 - 23, Validate
updatedVersion with semver.valid(...) using the same loose option before calling
getSemverPart to avoid exceptions; in the useUpdateNotification flow replace the
direct call const updatedSemver = updatedVersion ? getSemverPart(updatedVersion)
: null with a validation step (e.g., const isValid =
semver.valid(updatedVersion, { loose: true })) and only call
getSemverPart(updatedVersion) when isValid is true, otherwise set updatedSemver
to null so the subsequent if (!updatedSemver) branch is safe.

return null
}

const updatedSemver = getSemverPart(updatedVersion)
if (updatedSemver !== lastNotifiedSemver) {
setLastNotifiedSemver(updatedSemver)
if (updatedSemver !== lastNotifiedRef.current) {
lastNotifiedRef.current = updatedSemver
return updatedSemver
}

return null
}
Loading