mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
fix: 修复条件式 hook 调用导致的 "Rendered fewer hooks than expected" 错误
修复在 dev 模式下按下 Ctrl+O 切换 transcript 视图时 React 抛出
"Rendered fewer hooks than expected" 崩溃的问题。
## 根因分析
项目中有大量 hook(useState / useMemo / useRef / useSyncExternalStore 等)
被包裹在 `feature()` 三元表达式中条件调用,例如:
const value = feature('X') ? useHook() : defaultValue;
在 build 模式下 `feature()` 是编译时常量,死代码消除会移除未使用的分支,
hooks 数量在编译后是确定的。但在 dev 模式下(scripts/dev.ts 注入
--feature 启用全部 31 个 feature),`feature()` 是运行时调用,
但始终返回 true,因此所有 hooks 都会被调用,原本不会出问题。
真正的触发器是 REPL.tsx 第 5381 行的提前返回:
if (screen === 'transcript') { return transcriptReturn; }
当用户按下 Ctrl+O 进入 transcript 模式时,该提前返回之后的所有 hooks
(如 displayedAgentMessages 的 useMemo)都不会被调用,导致 React 在
下一次渲染时检测到 hooks 数量与上次不一致而崩溃。
此外,其他文件中也存在相同的条件式 hook 模式——虽然 dev 模式下
feature() 返回 true,所以这些路径实际上不会被触发,但它们是
潜在的隐患:若将来有人通过环境变量关闭某个 feature,
同样的崩溃会立即出现。
## 修复策略
采用统一模式:**始终无条件调用 hook,将 feature() gate 应用到值上**。
// Before (unsafe — hook count varies by feature flag)
const value = feature('X') ? useHook() : defaultValue;
// After (safe — hook always called, gate on the value)
const rawValue = useHook();
const value = feature('X') ? rawValue : defaultValue;
## 修改清单
### 核心修复(REPL.tsx)
- 将 `displayedAgentMessages` useMemo 及依赖变量(viewedTask /
viewedTeammateTask / viewedAgentTask / usesSyncMessages /
rawAgentMessages / displayedMessages)从 transcript 提前返回
之后移至之前,确保两模式下 hooks 调用顺序一致
- 修复 `disableMessageActions` / `useAssistantHistory` /
`voiceIntegration` 的条件式 hook 调用
### 条件式 hook 修复(11 个文件)
- src/hooks/useGlobalKeybindings.tsx — isBriefOnly / toggleBrief
keybinding 改为 isActive 门控
- src/hooks/useReplBridge.tsx — 5 个 BRIDGE_MODE 选值改为无条件调用
- src/hooks/useVoiceIntegration.tsx — 4 个 VOICE_MODE 选值修复
- src/components/PromptInput/Notifications.tsx — 4 个 feature 选值修复
- src/components/PromptInput/PromptInput.tsx — briefOwnsGap /
companionSpeaking 修复
- src/components/PromptInput/PromptInputFooterLeftSide.tsx — 4 个
VOICE_MODE 选值修复
- src/components/PromptInput/PromptInputQueuedCommands.tsx — isBriefOnly
- src/components/Spinner.tsx — briefEnvEnabled 修复
- src/components/TextInput.tsx — voiceState / audioLevels /
animationFrame 修复
- src/components/messages/AttachmentMessage.tsx — isDemoEnv 修复
- src/components/messages/UserPromptMessage.tsx — isBriefOnly /
viewingAgentTaskId / briefEnvEnabled 修复
- src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx
— isBriefOnly 修复
### 其他修复
- src/components/FeedbackSurvey/useFrustrationDetection.ts — 将 3 个
提前返回合并为 shouldSkip 变量,handleTranscriptSelect 提前 return
- src/hooks/useIssueFlagBanner.ts — useRef 移到 USER_TYPE 检查之前
- src/hooks/useUpdateNotification.ts — useState 改为 useRef,
避免版本号变化触发不必要重渲染
### 构建/开发配置
- build.ts — 添加 `sourcemap: 'linked'`
- scripts/dev.ts — NODE_ENV 从 'production' 改为 'development'
Closes #434
This commit is contained in:
1
build.ts
1
build.ts
@@ -21,6 +21,7 @@ const result = await Bun.build({
|
|||||||
outdir,
|
outdir,
|
||||||
target: 'bun',
|
target: 'bun',
|
||||||
splitting: true,
|
splitting: true,
|
||||||
|
sourcemap: 'linked',
|
||||||
define: {
|
define: {
|
||||||
...getMacroDefines(),
|
...getMacroDefines(),
|
||||||
// React production mode — eliminates _debugStack Error objects
|
// React production mode — eliminates _debugStack Error objects
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const defines = {
|
|||||||
...getMacroDefines(),
|
...getMacroDefines(),
|
||||||
// React production mode — prevents 6,889+ _debugStack Error objects
|
// React production mode — prevents 6,889+ _debugStack Error objects
|
||||||
// (12MB) from accumulating during long-running sessions.
|
// (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]) => [
|
const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
|
||||||
|
|||||||
@@ -25,24 +25,24 @@ export function useFrustrationDetection(
|
|||||||
const [state, setState] = useState<FrustrationState>('closed')
|
const [state, setState] = useState<FrustrationState>('closed')
|
||||||
|
|
||||||
const config = getGlobalConfig() as { transcriptShareDismissed?: boolean }
|
const config = getGlobalConfig() as { transcriptShareDismissed?: boolean }
|
||||||
if (config.transcriptShareDismissed) {
|
const policyAllowed = isPolicyAllowed('product_feedback' as any)
|
||||||
return { state: 'closed', handleTranscriptSelect: () => {} }
|
const shouldSkip =
|
||||||
}
|
config.transcriptShareDismissed ||
|
||||||
|
!policyAllowed ||
|
||||||
if (!isPolicyAllowed('product_feedback' as any)) {
|
isLoading ||
|
||||||
return { state: 'closed', handleTranscriptSelect: () => {} }
|
hasActivePrompt ||
|
||||||
}
|
otherSurveyOpen
|
||||||
|
|
||||||
if (isLoading || hasActivePrompt || otherSurveyOpen) {
|
|
||||||
return { state: 'closed', handleTranscriptSelect: () => {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
const frustrated = detectFrustration(messages)
|
const frustrated = detectFrustration(messages)
|
||||||
|
|
||||||
const effectiveState =
|
const effectiveState = shouldSkip
|
||||||
frustrated && state === 'closed' ? 'transcript_prompt' : state
|
? 'closed'
|
||||||
|
: frustrated && state === 'closed'
|
||||||
|
? 'transcript_prompt'
|
||||||
|
: state
|
||||||
|
|
||||||
function handleTranscriptSelect(choice: string) {
|
const handleTranscriptSelect = (choice: string) => {
|
||||||
|
if (shouldSkip) return
|
||||||
if (choice === 'yes') {
|
if (choice === 'yes') {
|
||||||
void submitTranscriptShare(messages, 'frustration', crypto.randomUUID())
|
void submitTranscriptShare(messages, 'frustration', crypto.randomUUID())
|
||||||
setState('submitted')
|
setState('submitted')
|
||||||
|
|||||||
@@ -204,10 +204,14 @@ function NotificationContent({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
|
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
|
||||||
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
|
const voiceStateRaw = useVoiceState(s => s.voiceState);
|
||||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const);
|
||||||
const voiceError = feature('VOICE_MODE') ? useVoiceState(s => s.voiceError) : null;
|
const voiceEnabledRaw = useVoiceEnabled();
|
||||||
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
|
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
|
// When voice is actively recording or processing, replace all
|
||||||
// notifications with just the voice indicator.
|
// notifications with just the voice indicator.
|
||||||
|
|||||||
@@ -347,8 +347,8 @@ function PromptInput({
|
|||||||
// the input bar. viewingAgentTaskId mirrors the gate on both (Spinner.tsx,
|
// the input bar. viewingAgentTaskId mirrors the gate on both (Spinner.tsx,
|
||||||
// REPL.tsx) — teammate view falls back to SpinnerWithVerbInner which has
|
// REPL.tsx) — teammate view falls back to SpinnerWithVerbInner which has
|
||||||
// its own marginTop, so the gap stays even without ours.
|
// its own marginTop, so the gap stays even without ours.
|
||||||
const briefOwnsGap =
|
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
|
||||||
feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) && !viewingAgentTaskId : false;
|
const briefOwnsGap = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState && !viewingAgentTaskId : false;
|
||||||
const mainLoopModel_ = useAppState(s => s.mainLoopModel);
|
const mainLoopModel_ = useAppState(s => s.mainLoopModel);
|
||||||
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
|
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
|
||||||
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
|
const thinkingEnabled = useAppState(s => s.thinkingEnabled);
|
||||||
@@ -2111,7 +2111,8 @@ function PromptInput({
|
|||||||
|
|
||||||
useBuddyNotification();
|
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 { columns, rows } = useTerminalSize();
|
||||||
const textInputColumns = columns - 3 - companionReservedColumns(columns, companionSpeaking);
|
const textInputColumns = columns - 3 - companionReservedColumns(columns, companionSpeaking);
|
||||||
|
|
||||||
|
|||||||
@@ -230,9 +230,12 @@ function ModeIndicator({
|
|||||||
proactiveModule?.getNextTickAt ?? NULL,
|
proactiveModule?.getNextTickAt ?? NULL,
|
||||||
NULL,
|
NULL,
|
||||||
);
|
);
|
||||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
const voiceEnabledRaw = useVoiceEnabled();
|
||||||
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
|
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
|
||||||
const voiceWarmingUp = feature('VOICE_MODE') ? useVoiceState(s => s.voiceWarmingUp) : 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 hasSelection = useHasSelection();
|
||||||
const selGetState = useSelection().getState;
|
const selGetState = useSelection().getState;
|
||||||
const hasNextTick = nextTickAt !== null;
|
const hasNextTick = nextTickAt !== null;
|
||||||
@@ -250,16 +253,19 @@ function ModeIndicator({
|
|||||||
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
|
const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase();
|
||||||
const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t');
|
const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t');
|
||||||
const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k');
|
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
|
// Captured at mount so the hint doesn't flicker mid-session if another
|
||||||
// CC instance increments the counter. Incremented once via useEffect the
|
// CC instance increments the counter. Incremented once via useEffect the
|
||||||
// first time voice is enabled in this session — approximates "hint was
|
// first time voice is enabled in this session — approximates "hint was
|
||||||
// shown" without tracking the exact render-time condition (which depends
|
// shown" without tracking the exact render-time condition (which depends
|
||||||
// on parts/hintParts computed after the early-return hooks boundary).
|
// on parts/hintParts computed after the early-return hooks boundary).
|
||||||
const [voiceHintUnderCap] = feature('VOICE_MODE')
|
const [voiceHintUnderCapRaw] = useState(
|
||||||
? useState(() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS)
|
() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS,
|
||||||
: [false];
|
);
|
||||||
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null;
|
const voiceHintUnderCap = feature('VOICE_MODE') ? voiceHintUnderCapRaw : false;
|
||||||
|
const voiceHintIncrementedRefRaw = useRef(false);
|
||||||
|
const voiceHintIncrementedRef = feature('VOICE_MODE') ? voiceHintIncrementedRefRaw : null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (feature('VOICE_MODE')) {
|
if (feature('VOICE_MODE')) {
|
||||||
if (!voiceEnabled || !voiceHintUnderCap) return;
|
if (!voiceEnabled || !voiceHintUnderCap) return;
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
|
|||||||
// already indent themselves). Gate mirrors the brief-spinner/message
|
// already indent themselves). Gate mirrors the brief-spinner/message
|
||||||
// check elsewhere — no teammate-view override needed since this
|
// check elsewhere — no teammate-view override needed since this
|
||||||
// component early-returns when viewing a teammate.
|
// 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
|
// createUserMessage mints a fresh UUID per call; without memoization, streaming
|
||||||
// re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker.
|
// re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker.
|
||||||
|
|||||||
@@ -78,10 +78,8 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
|
|||||||
// teammate view needs the real spinner (which shows teammate status).
|
// teammate view needs the real spinner (which shows teammate status).
|
||||||
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
|
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId);
|
||||||
// Hoisted to mount-time — this component re-renders at animation framerate.
|
// Hoisted to mount-time — this component re-renders at animation framerate.
|
||||||
const briefEnvEnabled =
|
const briefEnvEnabledRaw = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []);
|
||||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
const briefEnvEnabled = feature('KAIROS') || feature('KAIROS_BRIEF') ? briefEnvEnabledRaw : false;
|
||||||
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// Runtime gate mirrors isBriefEnabled() but inlined — importing from
|
// Runtime gate mirrors isBriefEnabled() but inlined — importing from
|
||||||
// BriefTool.ts would leak tool-name strings into external builds. Single
|
// BriefTool.ts would leak tool-name strings into external builds. Single
|
||||||
|
|||||||
@@ -44,14 +44,18 @@ export default function TextInput(props: Props): React.ReactNode {
|
|||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const reducedMotion = settings.prefersReducedMotion ?? false;
|
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 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 smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0));
|
||||||
|
|
||||||
const needsAnimation = isVoiceRecording && !reducedMotion;
|
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
|
// Show hint when terminal regains focus and clipboard has an image
|
||||||
useClipboardImageHint(isTerminalFocused, !!props.onImagePaste);
|
useClipboardImageHint(isTerminalFocused, !!props.onImagePaste);
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ type Props = {
|
|||||||
export function AttachmentMessage({ attachment, addMargin, verbose, isTranscriptMode }: Props): React.ReactNode {
|
export function AttachmentMessage({ attachment, addMargin, verbose, isTranscriptMode }: Props): React.ReactNode {
|
||||||
const bg = useSelectedMessageBg();
|
const bg = useSelectedMessageBg();
|
||||||
// Hoisted to mount-time — per-message component, re-renders on every scroll.
|
// 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
|
// Handle teammate_mailbox BEFORE switch
|
||||||
if (isAgentSwarmsEnabled() && attachment.type === 'teammate_mailbox') {
|
if (isAgentSwarmsEnabled() && attachment.type === 'teammate_mailbox') {
|
||||||
// Filter out idle notifications BEFORE counting - they are hidden in the UI
|
// Filter out idle notifications BEFORE counting - they are hidden in the UI
|
||||||
|
|||||||
@@ -38,27 +38,20 @@ export function UserPromptMessage({ addMargin, param: { text }, isTranscriptMode
|
|||||||
// child renders a label-style layout, and Box backgroundColor paints
|
// child renders a label-style layout, and Box backgroundColor paints
|
||||||
// behind children unconditionally (they can't opt out).
|
// behind children unconditionally (they can't opt out).
|
||||||
//
|
//
|
||||||
// Hooks stay INSIDE feature() ternaries so external builds don't pay
|
// Hooks must always be called unconditionally to satisfy React rules.
|
||||||
// the per-scrollback-message store subscription (useSyncExternalStore
|
// The feature gate is applied to the computed value, not the hook call.
|
||||||
// bypasses React.memo). Runtime-gated like isBriefEnabled() but inlined
|
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
|
||||||
// to avoid pulling BriefTool.ts → prompt.ts tool-name strings into
|
const viewingAgentTaskIdState = useAppState(s => s.viewingAgentTaskId);
|
||||||
// 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;
|
|
||||||
// Hoisted to mount-time — per-message component, re-renders on every scroll.
|
// Hoisted to mount-time — per-message component, re-renders on every scroll.
|
||||||
const briefEnvEnabled =
|
const briefEnvEnabledState = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []);
|
||||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
|
||||||
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
|
|
||||||
: false;
|
|
||||||
const useBriefLayout =
|
const useBriefLayout =
|
||||||
feature('KAIROS') || feature('KAIROS_BRIEF')
|
feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||||
? (getKairosActive() ||
|
? (getKairosActive() ||
|
||||||
(getUserMsgOptIn() &&
|
(getUserMsgOptIn() &&
|
||||||
(briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) &&
|
(briefEnvEnabledState || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) &&
|
||||||
isBriefOnly &&
|
isBriefOnlyState &&
|
||||||
!isTranscriptMode &&
|
!isTranscriptMode &&
|
||||||
!viewingAgentTaskId
|
!viewingAgentTaskIdState
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
// Truncate before the early return so the hook order is stable.
|
// Truncate before the early return so the hook order is stable.
|
||||||
|
|||||||
@@ -43,10 +43,9 @@ export function UserToolSuccessMessage({
|
|||||||
shouldCollapseDiffs,
|
shouldCollapseDiffs,
|
||||||
}: Props): React.ReactNode {
|
}: Props): React.ReactNode {
|
||||||
const [theme] = useTheme();
|
const [theme] = useTheme();
|
||||||
// Hook stays inside feature() ternary so external builds don't pay a
|
// Always call hook unconditionally; feature gate applied to the value.
|
||||||
// per-scrollback-message store subscription — same pattern as
|
const isBriefOnlyState = useAppState(s => s.isBriefOnly);
|
||||||
// UserPromptMessage.tsx.
|
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? isBriefOnlyState : false;
|
||||||
const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? useAppState(s => s.isBriefOnly) : false;
|
|
||||||
|
|
||||||
// Capture classifier approval once on mount, then delete from Map to prevent linear growth.
|
// Capture classifier approval once on mount, then delete from Map to prevent linear growth.
|
||||||
// useState lazy initializer ensures the value persists across re-renders.
|
// useState lazy initializer ensures the value persists across re-renders.
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function GlobalKeybindingHandlers({
|
|||||||
|
|
||||||
// Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript.
|
// Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript.
|
||||||
// Brief view has its own dedicated toggle on ctrl+shift+b.
|
// 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(() => {
|
const handleToggleTranscript = useCallback(() => {
|
||||||
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
|
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
|
||||||
// Escape hatch: GB kill-switch while defaultView=chat was persisted
|
// Escape hatch: GB kill-switch while defaultView=chat was persisted
|
||||||
@@ -95,7 +95,7 @@ export function GlobalKeybindingHandlers({
|
|||||||
const { isBriefEnabled } =
|
const { isBriefEnabled } =
|
||||||
require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js');
|
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 */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') {
|
if (!isBriefEnabled() && isBriefOnlyState && screen !== 'transcript') {
|
||||||
setAppState(prev => {
|
setAppState(prev => {
|
||||||
if (!prev.isBriefOnly) return prev;
|
if (!prev.isBriefOnly) return prev;
|
||||||
return { ...prev, isBriefOnly: false };
|
return { ...prev, isBriefOnly: false };
|
||||||
@@ -121,7 +121,7 @@ export function GlobalKeybindingHandlers({
|
|||||||
}, [
|
}, [
|
||||||
screen,
|
screen,
|
||||||
setScreen,
|
setScreen,
|
||||||
isBriefOnly,
|
isBriefOnlyState,
|
||||||
showAllInTranscript,
|
showAllInTranscript,
|
||||||
setShowAllInTranscript,
|
setShowAllInTranscript,
|
||||||
messageCount,
|
messageCount,
|
||||||
@@ -162,8 +162,8 @@ export function GlobalKeybindingHandlers({
|
|||||||
const { isBriefEnabled } =
|
const { isBriefEnabled } =
|
||||||
require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js');
|
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 */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
if (!isBriefEnabled() && !isBriefOnly) return;
|
if (!isBriefEnabled() && !isBriefOnlyState) return;
|
||||||
const next = !isBriefOnly;
|
const next = !isBriefOnlyState;
|
||||||
logEvent('tengu_brief_mode_toggled', {
|
logEvent('tengu_brief_mode_toggled', {
|
||||||
enabled: next,
|
enabled: next,
|
||||||
gated: false,
|
gated: false,
|
||||||
@@ -174,7 +174,7 @@ export function GlobalKeybindingHandlers({
|
|||||||
return { ...prev, isBriefOnly: next };
|
return { ...prev, isBriefOnly: next };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [isBriefOnly, setAppState]);
|
}, [isBriefOnlyState, setAppState]);
|
||||||
|
|
||||||
// Register keybinding handlers
|
// Register keybinding handlers
|
||||||
useKeybinding('app:toggleTodos', handleToggleTodos, {
|
useKeybinding('app:toggleTodos', handleToggleTodos, {
|
||||||
@@ -183,11 +183,10 @@ export function GlobalKeybindingHandlers({
|
|||||||
useKeybinding('app:toggleTranscript', handleToggleTranscript, {
|
useKeybinding('app:toggleTranscript', handleToggleTranscript, {
|
||||||
context: 'Global',
|
context: 'Global',
|
||||||
});
|
});
|
||||||
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
|
useKeybinding('app:toggleBrief', handleToggleBrief, {
|
||||||
useKeybinding('app:toggleBrief', handleToggleBrief, {
|
context: 'Global',
|
||||||
context: 'Global',
|
isActive: feature('KAIROS') ? true : feature('KAIROS_BRIEF') ? true : false,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Register teammate keybinding
|
// Register teammate keybinding
|
||||||
useKeybinding(
|
useKeybinding(
|
||||||
|
|||||||
@@ -93,10 +93,6 @@ export function useIssueFlagBanner(
|
|||||||
messages: Message[],
|
messages: Message[],
|
||||||
submitCount: number,
|
submitCount: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (process.env.USER_TYPE !== 'ant') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastTriggeredAtRef = useRef(0)
|
const lastTriggeredAtRef = useRef(0)
|
||||||
const activeForSubmitRef = useRef(-1)
|
const activeForSubmitRef = useRef(-1)
|
||||||
|
|
||||||
@@ -109,6 +105,11 @@ export function useIssueFlagBanner(
|
|||||||
[messages],
|
[messages],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isAnt = process.env.USER_TYPE === 'ant'
|
||||||
|
if (!isAnt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Keep showing the banner until the user submits another message
|
// Keep showing the banner until the user submits another message
|
||||||
if (activeForSubmitRef.current === submitCount) {
|
if (activeForSubmitRef.current === submitCount) {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -99,11 +99,16 @@ export function useReplBridge(
|
|||||||
messagesRef.current = messages;
|
messagesRef.current = messages;
|
||||||
const store = useAppStateStore();
|
const store = useAppStateStore();
|
||||||
const { addNotification } = useNotifications();
|
const { addNotification } = useNotifications();
|
||||||
const replBridgeEnabled = feature('BRIDGE_MODE') ? useAppState(s => s.replBridgeEnabled) : false;
|
const replBridgeEnabledRaw = useAppState(s => s.replBridgeEnabled);
|
||||||
const replBridgeConnected = feature('BRIDGE_MODE') ? useAppState(s => s.replBridgeConnected) : false;
|
const replBridgeEnabled = feature('BRIDGE_MODE') ? replBridgeEnabledRaw : false;
|
||||||
const replBridgeSessionActive = feature('BRIDGE_MODE') ? useAppState(s => s.replBridgeSessionActive) : false;
|
const replBridgeConnectedRaw = useAppState(s => s.replBridgeConnected);
|
||||||
const replBridgeOutboundOnly = feature('BRIDGE_MODE') ? useAppState(s => s.replBridgeOutboundOnly) : false;
|
const replBridgeConnected = feature('BRIDGE_MODE') ? replBridgeConnectedRaw : false;
|
||||||
const replBridgeInitialName = feature('BRIDGE_MODE') ? useAppState(s => s.replBridgeInitialName) : undefined;
|
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.
|
// Initialize/teardown bridge when enabled state changes.
|
||||||
// Passes current messages as initialMessages so the remote session
|
// Passes current messages as initialMessages so the remote session
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useRef } from 'react'
|
||||||
import { major, minor, patch } from 'semver'
|
import { major, minor, patch } from 'semver'
|
||||||
|
|
||||||
export function getSemverPart(version: string): string {
|
export function getSemverPart(version: string): string {
|
||||||
@@ -17,18 +17,17 @@ export function useUpdateNotification(
|
|||||||
updatedVersion: string | null | undefined,
|
updatedVersion: string | null | undefined,
|
||||||
initialVersion: string = MACRO.VERSION,
|
initialVersion: string = MACRO.VERSION,
|
||||||
): string | null {
|
): string | null {
|
||||||
const [lastNotifiedSemver, setLastNotifiedSemver] = useState<string | null>(
|
const lastNotifiedRef = useRef<string | null>(getSemverPart(initialVersion))
|
||||||
() => getSemverPart(initialVersion),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!updatedVersion) {
|
const updatedSemver = updatedVersion ? getSemverPart(updatedVersion) : null
|
||||||
|
if (!updatedSemver) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedSemver = getSemverPart(updatedVersion)
|
if (updatedSemver !== lastNotifiedRef.current) {
|
||||||
if (updatedSemver !== lastNotifiedSemver) {
|
lastNotifiedRef.current = updatedSemver
|
||||||
setLastNotifiedSemver(updatedSemver)
|
|
||||||
return updatedSemver
|
return updatedSemver
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,9 +214,12 @@ export function useVoiceIntegration({
|
|||||||
// Voice state selectors. useVoiceEnabled = user intent (settings) +
|
// Voice state selectors. useVoiceEnabled = user intent (settings) +
|
||||||
// auth + GB kill-switch, with the auth half memoized on authVersion so
|
// auth + GB kill-switch, with the auth half memoized on authVersion so
|
||||||
// render loops never hit a cold keychain spawn.
|
// render loops never hit a cold keychain spawn.
|
||||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
const voiceEnabledRaw = useVoiceEnabled();
|
||||||
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : ('idle' as const);
|
const voiceEnabled = feature('VOICE_MODE') ? voiceEnabledRaw : false;
|
||||||
const voiceInterimTranscript = feature('VOICE_MODE') ? useVoiceState(s => s.voiceInterimTranscript) : '';
|
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
|
// Set the voice anchor for focus mode (where recording starts via terminal
|
||||||
// focus, not key hold). Key-hold sets the anchor in stripTrailing.
|
// focus, not key hold). Key-hold sets the anchor in stripTrailing.
|
||||||
@@ -377,8 +380,10 @@ export function useVoiceKeybindingHandler({
|
|||||||
const setVoiceState = useSetVoiceState();
|
const setVoiceState = useSetVoiceState();
|
||||||
const keybindingContext = useOptionalKeybindingContext();
|
const keybindingContext = useOptionalKeybindingContext();
|
||||||
const isModalOverlayActive = useIsModalOverlayActive();
|
const isModalOverlayActive = useIsModalOverlayActive();
|
||||||
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false;
|
const voiceEnabledRaw = useVoiceEnabled();
|
||||||
const voiceState = feature('VOICE_MODE') ? useVoiceState(s => s.voiceState) : 'idle';
|
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.
|
// Find the configured key for voice:pushToTalk from keybinding context.
|
||||||
// Forward iteration with last-wins (matching the resolver): if a later
|
// Forward iteration with last-wins (matching the resolver): if a later
|
||||||
|
|||||||
@@ -860,9 +860,8 @@ export function REPL({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []);
|
const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []);
|
||||||
const disableMessageActions = feature('MESSAGE_ACTIONS')
|
const disableMessageActionsRaw = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), []);
|
||||||
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), [])
|
const disableMessageActions = feature('MESSAGE_ACTIONS') ? disableMessageActionsRaw : false;
|
||||||
: false;
|
|
||||||
|
|
||||||
// Log REPL mount/unmount lifecycle
|
// Log REPL mount/unmount lifecycle
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1552,14 +1551,13 @@ export function REPL({
|
|||||||
// KAIROS build + config.viewerOnly. feature() is build-time constant so
|
// KAIROS build + config.viewerOnly. feature() is build-time constant so
|
||||||
// the branch is dead-code-eliminated in non-KAIROS builds (same pattern
|
// the branch is dead-code-eliminated in non-KAIROS builds (same pattern
|
||||||
// as useUnseenDivider above).
|
// as useUnseenDivider above).
|
||||||
const { maybeLoadOlder } = feature('KAIROS')
|
const assistantHistoryResult = useAssistantHistory({
|
||||||
? useAssistantHistory({
|
config: remoteSessionConfig,
|
||||||
config: remoteSessionConfig,
|
setMessages,
|
||||||
setMessages,
|
scrollRef,
|
||||||
scrollRef,
|
onPrepend: shiftDivider,
|
||||||
onPrepend: shiftDivider,
|
});
|
||||||
})
|
const { maybeLoadOlder } = feature('KAIROS') ? assistantHistoryResult : HISTORY_STUB;
|
||||||
: HISTORY_STUB;
|
|
||||||
// Compose useUnseenDivider's callbacks with the lazy-load trigger.
|
// Compose useUnseenDivider's callbacks with the lazy-load trigger.
|
||||||
const composedOnScroll = useCallback(
|
const composedOnScroll = useCallback(
|
||||||
(sticky: boolean, handle: ScrollBoxHandle) => {
|
(sticky: boolean, handle: ScrollBoxHandle) => {
|
||||||
@@ -4941,8 +4939,9 @@ export function REPL({
|
|||||||
const { relayPipeMessage, pipeReturnHadErrorRef } = usePipeRelay();
|
const { relayPipeMessage, pipeReturnHadErrorRef } = usePipeRelay();
|
||||||
|
|
||||||
// Voice input integration (VOICE_MODE builds only)
|
// Voice input integration (VOICE_MODE builds only)
|
||||||
|
const voiceIntegrationResult = useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef });
|
||||||
const voice = feature('VOICE_MODE')
|
const voice = feature('VOICE_MODE')
|
||||||
? useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef })
|
? voiceIntegrationResult
|
||||||
: {
|
: {
|
||||||
stripTrailing: () => 0,
|
stripTrailing: () => 0,
|
||||||
handleKeyEvent: () => {},
|
handleKeyEvent: () => {},
|
||||||
@@ -5379,6 +5378,93 @@ export function REPL({
|
|||||||
// Auto-exit viewing mode when teammate completes or errors
|
// Auto-exit viewing mode when teammate completes or errors
|
||||||
useTeammateViewAutoExit();
|
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') {
|
if (screen === 'transcript') {
|
||||||
// Virtual scroll replaces the 30-message cap: everything is scrollable
|
// Virtual scroll replaces the 30-message cap: everything is scrollable
|
||||||
// and memory is bounded by the viewport. Without it, wrapping transcript
|
// and memory is bounded by the viewport. Without it, wrapping transcript
|
||||||
@@ -5554,92 +5640,6 @@ export function REPL({
|
|||||||
return transcriptReturn;
|
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
|
// Show the placeholder until the real user message appears in
|
||||||
// displayedMessages. userInputOnProcessing stays set for the whole turn
|
// displayedMessages. userInputOnProcessing stays set for the whole turn
|
||||||
// (cleared in resetLoadingState); this length check hides it once
|
// (cleared in resetLoadingState); this length check hides it once
|
||||||
|
|||||||
Reference in New Issue
Block a user