fix: resolve React Compiler i18n caching issues in ChatView

Fixes partial internationalization failures caused by React Compiler
aggressively caching translated values.

Changes:
- TranslationContext: Add i18n.language to translate callback dependency
  to force re-memoization when language changes
- ChatView: Store translation keys instead of translated button text
  in state, compute translations at render time
- ChatView: Remove redundant useState type annotations that may confuse
  React Compiler type inference
- ChatView: Simplify button tooltip rendering by using pre-computed
  tooltip values from key mappings

Closes #11100
This commit is contained in:
Roo Code 2026-01-30 04:49:38 +00:00
parent cc86049f10
commit 64f704ad54
2 changed files with 117 additions and 83 deletions

View file

@ -62,6 +62,50 @@ export interface ChatViewRef {
export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit.
// Button text keys - store translation keys instead of translated values
// This ensures React Compiler doesn't cache stale translations
type PrimaryButtonKey =
| "chat:retry.title"
| "chat:proceedAnyways.title"
| "chat:save.title"
| "chat:completeSubtaskAndReturn"
| "chat:read-batch.approve.title"
| "chat:approve.title"
| "chat:runCommand.title"
| "chat:proceedWhileRunning.title"
| "chat:startNewTask.title"
| "chat:resumeTask.title"
type SecondaryButtonKey =
| "chat:startNewTask.title"
| "chat:reject.title"
| "chat:read-batch.deny.title"
| "chat:terminate.title"
| "chat:killCommand.title"
// Map primary button keys to their tooltip keys
const primaryButtonTooltipMap: Record<PrimaryButtonKey, string | undefined> = {
"chat:retry.title": "chat:retry.tooltip",
"chat:proceedAnyways.title": "chat:proceedAnyways.tooltip",
"chat:save.title": "chat:save.tooltip",
"chat:completeSubtaskAndReturn": undefined,
"chat:read-batch.approve.title": undefined,
"chat:approve.title": "chat:approve.tooltip",
"chat:runCommand.title": "chat:runCommand.tooltip",
"chat:proceedWhileRunning.title": "chat:proceedWhileRunning.tooltip",
"chat:startNewTask.title": "chat:startNewTask.tooltip",
"chat:resumeTask.title": "chat:resumeTask.tooltip",
}
// Map secondary button keys to their tooltip keys
const secondaryButtonTooltipMap: Record<SecondaryButtonKey, string | undefined> = {
"chat:startNewTask.title": "chat:startNewTask.tooltip",
"chat:reject.title": "chat:reject.tooltip",
"chat:read-batch.deny.title": undefined,
"chat:terminate.title": "chat:terminate.tooltip",
"chat:killCommand.title": "chat:killCommand.tooltip",
}
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewProps> = (
@ -142,9 +186,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// handleMessage is called, the last message might not be the ask anymore
// (it could be a say that followed).
const [clineAsk, setClineAsk] = useState<ClineAsk | undefined>(undefined)
const [enableButtons, setEnableButtons] = useState<boolean>(false)
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
// Remove redundant <boolean> type annotation - React Compiler works better with inferred types
const [enableButtons, setEnableButtons] = useState(false)
// Store translation keys instead of translated values to avoid React Compiler caching issues
const [primaryButtonKey, setPrimaryButtonKey] = useState<PrimaryButtonKey | undefined>(undefined)
const [secondaryButtonKey, setSecondaryButtonKey] = useState<SecondaryButtonKey | undefined>(undefined)
const [_didClickCancel, setDidClickCancel] = useState(false)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
@ -154,11 +200,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(false)
const lastTtsRef = useRef<string>("")
const [wasStreaming, setWasStreaming] = useState<boolean>(false)
// Remove redundant <boolean> type annotation - React Compiler works better with inferred types
const [wasStreaming, setWasStreaming] = useState(false)
const [checkpointWarning, setCheckpointWarning] = useState<
{ type: "WAIT_TIMEOUT" | "INIT_TIMEOUT"; timeout: number } | undefined
>(undefined)
const [isCondensing, setIsCondensing] = useState<boolean>(false)
// Remove redundant <boolean> type annotation - React Compiler works better with inferred types
const [isCondensing, setIsCondensing] = useState(false)
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false)
const everVisibleMessagesTsRef = useRef<LRUCache<number, boolean>>(
new LRUCache({
@ -180,6 +228,21 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
>
>(new Map())
// Compute translated button text and tooltips at render time
// This ensures translations update correctly when language changes
const primaryButtonText = primaryButtonKey ? t(primaryButtonKey) : undefined
const secondaryButtonText = secondaryButtonKey ? t(secondaryButtonKey) : undefined
const primaryButtonTooltip = primaryButtonKey
? primaryButtonTooltipMap[primaryButtonKey]
? t(primaryButtonTooltipMap[primaryButtonKey]!)
: undefined
: undefined
const secondaryButtonTooltip = secondaryButtonKey
? secondaryButtonTooltipMap[secondaryButtonKey]
? t(secondaryButtonTooltipMap[secondaryButtonKey]!)
: undefined
: undefined
const clineAskRef = useRef(clineAsk)
useEffect(() => {
clineAskRef.current = clineAsk
@ -280,16 +343,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSendingDisabled(true)
setClineAsk("api_req_failed")
setEnableButtons(true)
setPrimaryButtonText(t("chat:retry.title"))
setSecondaryButtonText(t("chat:startNewTask.title"))
setPrimaryButtonKey("chat:retry.title")
setSecondaryButtonKey("chat:startNewTask.title")
break
case "mistake_limit_reached":
playSound("progress_loop")
setSendingDisabled(false)
setClineAsk("mistake_limit_reached")
setEnableButtons(true)
setPrimaryButtonText(t("chat:proceedAnyways.title"))
setSecondaryButtonText(t("chat:startNewTask.title"))
setPrimaryButtonKey("chat:proceedAnyways.title")
setSecondaryButtonKey("chat:startNewTask.title")
break
case "followup":
setSendingDisabled(isPartial)
@ -299,8 +362,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// We have no buttons for this tool, so no problem having them "enabled"
// to workaround this issue. See #1358.
setEnableButtons(true)
setPrimaryButtonText(undefined)
setSecondaryButtonText(undefined)
setPrimaryButtonKey(undefined)
setSecondaryButtonKey(undefined)
break
case "tool":
setSendingDisabled(isPartial)
@ -312,25 +375,25 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "appliedDiff":
case "newFileCreated":
case "generateImage":
setPrimaryButtonText(t("chat:save.title"))
setSecondaryButtonText(t("chat:reject.title"))
setPrimaryButtonKey("chat:save.title")
setSecondaryButtonKey("chat:reject.title")
break
case "finishTask":
setPrimaryButtonText(t("chat:completeSubtaskAndReturn"))
setSecondaryButtonText(undefined)
setPrimaryButtonKey("chat:completeSubtaskAndReturn")
setSecondaryButtonKey(undefined)
break
case "readFile":
if (tool.batchFiles && Array.isArray(tool.batchFiles)) {
setPrimaryButtonText(t("chat:read-batch.approve.title"))
setSecondaryButtonText(t("chat:read-batch.deny.title"))
setPrimaryButtonKey("chat:read-batch.approve.title")
setSecondaryButtonKey("chat:read-batch.deny.title")
} else {
setPrimaryButtonText(t("chat:approve.title"))
setSecondaryButtonText(t("chat:reject.title"))
setPrimaryButtonKey("chat:approve.title")
setSecondaryButtonKey("chat:reject.title")
}
break
default:
setPrimaryButtonText(t("chat:approve.title"))
setSecondaryButtonText(t("chat:reject.title"))
setPrimaryButtonKey("chat:approve.title")
setSecondaryButtonKey("chat:reject.title")
break
}
break
@ -338,29 +401,29 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSendingDisabled(isPartial)
setClineAsk("browser_action_launch")
setEnableButtons(!isPartial)
setPrimaryButtonText(t("chat:approve.title"))
setSecondaryButtonText(t("chat:reject.title"))
setPrimaryButtonKey("chat:approve.title")
setSecondaryButtonKey("chat:reject.title")
break
case "command":
setSendingDisabled(isPartial)
setClineAsk("command")
setEnableButtons(!isPartial)
setPrimaryButtonText(t("chat:runCommand.title"))
setSecondaryButtonText(t("chat:reject.title"))
setPrimaryButtonKey("chat:runCommand.title")
setSecondaryButtonKey("chat:reject.title")
break
case "command_output":
setSendingDisabled(false)
setClineAsk("command_output")
setEnableButtons(true)
setPrimaryButtonText(t("chat:proceedWhileRunning.title"))
setSecondaryButtonText(t("chat:killCommand.title"))
setPrimaryButtonKey("chat:proceedWhileRunning.title")
setSecondaryButtonKey("chat:killCommand.title")
break
case "use_mcp_server":
setSendingDisabled(isPartial)
setClineAsk("use_mcp_server")
setEnableButtons(!isPartial)
setPrimaryButtonText(t("chat:approve.title"))
setSecondaryButtonText(t("chat:reject.title"))
setPrimaryButtonKey("chat:approve.title")
setSecondaryButtonKey("chat:reject.title")
break
case "completion_result":
// Extension waiting for feedback, but we can just present a new task button.
@ -371,8 +434,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSendingDisabled(isPartial)
setClineAsk("completion_result")
setEnableButtons(!isPartial)
setPrimaryButtonText(t("chat:startNewTask.title"))
setSecondaryButtonText(undefined)
setPrimaryButtonKey("chat:startNewTask.title")
setSecondaryButtonKey(undefined)
break
case "resume_task":
setSendingDisabled(false)
@ -388,11 +451,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
(msg) => msg.ask === "completion_result" || msg.say === "completion_result",
)
if (isCompletedSubtask) {
setPrimaryButtonText(t("chat:startNewTask.title"))
setSecondaryButtonText(undefined)
setPrimaryButtonKey("chat:startNewTask.title")
setSecondaryButtonKey(undefined)
} else {
setPrimaryButtonText(t("chat:resumeTask.title"))
setSecondaryButtonText(t("chat:terminate.title"))
setPrimaryButtonKey("chat:resumeTask.title")
setSecondaryButtonKey("chat:terminate.title")
}
setDidClickCancel(false) // special case where we reset the cancel button state
break
@ -400,8 +463,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSendingDisabled(false)
setClineAsk("resume_completed_task")
setEnableButtons(true)
setPrimaryButtonText(t("chat:startNewTask.title"))
setSecondaryButtonText(undefined)
setPrimaryButtonKey("chat:startNewTask.title")
setSecondaryButtonKey(undefined)
setDidClickCancel(false)
break
}
@ -421,8 +484,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSelectedImages([])
setClineAsk(undefined)
setEnableButtons(false)
setPrimaryButtonText(undefined)
setSecondaryButtonText(undefined)
setPrimaryButtonKey(undefined)
setSecondaryButtonKey(undefined)
break
case "api_req_finished":
case "error":
@ -447,19 +510,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
(msg) => msg.ask === "completion_result" || msg.say === "completion_result",
)
if (hasCompletionResult) {
setPrimaryButtonText(t("chat:startNewTask.title"))
setSecondaryButtonText(undefined)
setPrimaryButtonKey("chat:startNewTask.title")
setSecondaryButtonKey(undefined)
}
}
}, [clineAsk, currentTaskItem?.parentTaskId, messages, t])
}, [clineAsk, currentTaskItem?.parentTaskId, messages])
useEffect(() => {
if (messages.length === 0) {
setSendingDisabled(false)
setClineAsk(undefined)
setEnableButtons(false)
setPrimaryButtonText(undefined)
setSecondaryButtonText(undefined)
setPrimaryButtonKey(undefined)
setSecondaryButtonKey(undefined)
}
}, [messages.length])
@ -594,8 +657,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setClineAsk(undefined)
setEnableButtons(false)
// Do not reset mode here as it should persist.
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
// setPrimaryButtonKey(undefined)
// setSecondaryButtonKey(undefined)
}, [])
/**
@ -780,8 +843,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSendingDisabled(true)
setClineAsk(undefined)
setEnableButtons(false)
setPrimaryButtonText(undefined)
setSecondaryButtonText(undefined)
setPrimaryButtonKey(undefined)
setSecondaryButtonKey(undefined)
},
[clineAsk, startNewTask, currentTaskItem?.parentTaskId],
)
@ -1660,28 +1723,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
) : (
<>
{primaryButtonText && (
<StandardTooltip
content={
primaryButtonText === t("chat:retry.title")
? t("chat:retry.tooltip")
: primaryButtonText === t("chat:save.title")
? t("chat:save.tooltip")
: primaryButtonText === t("chat:approve.title")
? t("chat:approve.tooltip")
: primaryButtonText === t("chat:runCommand.title")
? t("chat:runCommand.tooltip")
: primaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: primaryButtonText === t("chat:resumeTask.title")
? t("chat:resumeTask.tooltip")
: primaryButtonText ===
t("chat:proceedAnyways.title")
? t("chat:proceedAnyways.tooltip")
: primaryButtonText ===
t("chat:proceedWhileRunning.title")
? t("chat:proceedWhileRunning.tooltip")
: undefined
}>
<StandardTooltip content={primaryButtonTooltip}>
<Button
variant="primary"
disabled={!enableButtons}
@ -1692,18 +1734,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
</StandardTooltip>
)}
{secondaryButtonText && (
<StandardTooltip
content={
secondaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: secondaryButtonText === t("chat:reject.title")
? t("chat:reject.tooltip")
: secondaryButtonText === t("chat:terminate.title")
? t("chat:terminate.tooltip")
: secondaryButtonText === t("chat:killCommand.title")
? t("chat:killCommand.tooltip")
: undefined
}>
<StandardTooltip content={secondaryButtonTooltip}>
<Button
variant="secondary"
disabled={!enableButtons}

View file

@ -33,11 +33,14 @@ export const TranslationProvider: React.FC<{ children: ReactNode }> = ({ childre
}, [i18n, extensionState.language])
// Memoize the translation function to prevent unnecessary re-renders
// Include i18n.language in deps so translate is recreated on language change
// This is critical for React Compiler which may cache results aggressively
const translate = useCallback(
(key: string, options?: Record<string, any>) => {
return i18n.t(key, options)
},
[i18n],
// eslint-disable-next-line react-hooks/exhaustive-deps -- i18n.language is intentionally included to force re-memoization on language change (React Compiler fix)
[i18n, i18n.language],
)
return (