🐛 fix(intervention): resolve InterventionBar context errors, rendering, and topic transition issues (#13420)

* 🐛 fix: resolve InterventionBar context errors and rendering issues

- Replace useMessageAggregationContext with prop drilling for assistantGroupId,
  fixing crash when ApprovalActions renders outside MessageAggregationContext
- Filter out tmp_ message IDs from pending interventions to prevent
  disabled buttons during message creation
- Portal ApprovalActions outside scroll container in InterventionBar
  so buttons are always accessible for long content
- Clear stale displayMessages synchronously on topic change to prevent
  old interventions from persisting during transitions

* 🐛 fix: use useLayoutEffect to clear stale interventions on topic switch

Replace render-phase side effect with useLayoutEffect to properly clear
displayMessages before browser paint when context changes, preventing
old topic interventions from flashing during transitions.

* 🐛 fix: synchronously reset store on context change to prevent stale data flash

Use React's "setState during render" pattern instead of useLayoutEffect.
When contextKey changes, React bails out and re-renders StoreUpdater
before rendering sibling components (ChatList/ChatInput), ensuring they
read fresh store state with no visible flash of old topic data.

* 🐛 fix: remount store on context change to eliminate stale data flash

Add key={contextKey} to zustand Provider so the store is recreated on
topic switch. Seed the new store with initialMessages in createStore to
render correct data on first mount — no intermediate skeleton or stale
flash. Remove render-phase reset hack from StoreUpdater as it's no
longer needed.

* 🐛 fix: revert Provider key approach, use useLayoutEffect for context reset

Provider key={contextKey} caused ChatHydration to remount and reset
activeTopicId from URL query, preventing topic switches entirely.

Reverted to stable Provider. Instead, use useLayoutEffect in StoreUpdater
to atomically reset displayMessages + messagesInit when contextKey changes.
This fires after commit but before paint, and React processes store updates
from layout effects synchronously, ensuring subscribers re-render with
correct state before the browser paints.
This commit is contained in:
Innei 2026-03-31 02:57:56 +08:00 committed by GitHub
parent 7097167613
commit c59c066330
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 105 additions and 39 deletions

View file

@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useState } from 'react';
import Intervention from '../Messages/AssistantGroup/Tool/Detail/Intervention';
import { type PendingIntervention } from '../store/slices/data/pendingInterventions';
@ -10,17 +10,23 @@ interface InterventionContentProps {
const InterventionContent = memo<InterventionContentProps>(({ intervention }) => {
const { styles } = useStyles();
const [actionsContainer, setActionsContainer] = useState<HTMLDivElement | null>(null);
return (
<div className={styles.content}>
<Intervention
apiName={intervention.apiName}
id={intervention.toolMessageId}
identifier={intervention.identifier}
requestArgs={intervention.requestArgs}
toolCallId={intervention.toolCallId}
/>
</div>
<>
<div className={styles.content}>
<Intervention
actionsPortalTarget={actionsContainer}
apiName={intervention.apiName}
assistantGroupId={intervention.assistantGroupId}
id={intervention.toolMessageId}
identifier={intervention.identifier}
requestArgs={intervention.requestArgs}
toolCallId={intervention.toolCallId}
/>
</div>
<div className={styles.actions} ref={setActionsContainer} />
</>
);
});

View file

@ -1,6 +1,11 @@
import { createStyles } from 'antd-style';
export const useStyles = createStyles(({ css, token }) => ({
actions: css`
padding-block: 8px;
padding-inline: 16px;
border-block-start: 1px solid ${token.colorBorderSecondary};
`,
container: css`
overflow: hidden;
display: flex;

View file

@ -7,12 +7,12 @@ import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
import { useConversationStore } from '../../../../../store';
import { useMessageAggregationContext } from '../../../../Contexts/MessageAggregationContext';
import { type ApprovalMode } from './index';
interface ApprovalActionsProps {
apiName: string;
approvalMode: ApprovalMode;
assistantGroupId?: string;
identifier: string;
messageId: string;
/**
@ -24,7 +24,7 @@ interface ApprovalActionsProps {
}
const ApprovalActions = memo<ApprovalActionsProps>(
({ approvalMode, messageId, identifier, apiName, onBeforeApprove }) => {
({ approvalMode, messageId, identifier, apiName, onBeforeApprove, assistantGroupId }) => {
const { t } = useTranslation(['chat', 'common']);
const [rejectReason, setRejectReason] = useState('');
const [rejectPopoverOpen, setRejectPopoverOpen] = useState(false);
@ -34,7 +34,6 @@ const ApprovalActions = memo<ApprovalActionsProps>(
// Disable actions while message is still being created (temp ID)
const isMessageCreating = messageId.startsWith('tmp_');
const { assistantGroupId } = useMessageAggregationContext();
const [approveToolCall, rejectToolCall, rejectAndContinueToolCall] = useConversationStore(
(s) => [s.approveToolCall, s.rejectToolCall, s.rejectAndContinueToolCall],
);
@ -49,7 +48,7 @@ const ApprovalActions = memo<ApprovalActionsProps>(
}
// 1. Update intervention status
await approveToolCall(messageId, assistantGroupId);
await approveToolCall(messageId, assistantGroupId ?? '');
// 2. If remembered, add to allowList
if (remember) {

View file

@ -2,6 +2,7 @@ import { safeParseJSON } from '@lobechat/utils';
import { ActionIcon, Flexbox } from '@lobehub/ui';
import { Edit3Icon } from 'lucide-react';
import { memo, Suspense, useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
@ -13,7 +14,9 @@ import ApprovalActions from './ApprovalActions';
import KeyValueEditor from './KeyValueEditor';
interface FallbackInterventionProps {
actionsPortalTarget?: HTMLDivElement | null;
apiName: string;
assistantGroupId?: string;
id: string;
identifier: string;
requestArgs: string;
@ -21,7 +24,7 @@ interface FallbackInterventionProps {
}
const FallbackIntervention = memo<FallbackInterventionProps>(
({ requestArgs, id, identifier, apiName, toolCallId }) => {
({ requestArgs, id, identifier, apiName, toolCallId, assistantGroupId, actionsPortalTarget }) => {
const { t } = useTranslation('chat');
const approvalMode = useUserStore(toolInterventionSelectors.approvalMode);
const [isEditing, setIsEditing] = useState(false);
@ -76,15 +79,21 @@ const FallbackIntervention = memo<FallbackInterventionProps>(
}
/>
<Flexbox horizontal justify={'flex-end'}>
<ApprovalActions
apiName={apiName}
approvalMode={approvalMode}
identifier={identifier}
messageId={id}
toolCallId={toolCallId}
/>
</Flexbox>
{(() => {
const actions = (
<Flexbox horizontal justify={'flex-end'}>
<ApprovalActions
apiName={apiName}
approvalMode={approvalMode}
assistantGroupId={assistantGroupId}
identifier={identifier}
messageId={id}
toolCallId={toolCallId}
/>
</Flexbox>
);
return actionsPortalTarget ? createPortal(actions, actionsPortalTarget) : actions;
})()}
</Flexbox>
);
},

View file

@ -3,6 +3,7 @@ import { getBuiltinIntervention } from '@lobechat/builtin-tools/interventions';
import { safeParseJSON } from '@lobechat/utils';
import { Flexbox } from '@lobehub/ui';
import { memo, Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useUserStore } from '@/store/user';
import { toolInterventionSelectors } from '@/store/user/selectors';
@ -17,7 +18,9 @@ import SecurityBlacklistWarning from './SecurityBlacklistWarning';
export type { ApprovalMode } from '@/store/user/slices/settings/selectors';
interface InterventionProps {
actionsPortalTarget?: HTMLDivElement | null;
apiName: string;
assistantGroupId?: string;
id: string;
identifier: string;
requestArgs: string;
@ -25,7 +28,7 @@ interface InterventionProps {
}
const Intervention = memo<InterventionProps>(
({ requestArgs, id, identifier, apiName, toolCallId }) => {
({ requestArgs, id, identifier, apiName, toolCallId, assistantGroupId, actionsPortalTarget }) => {
const approvalMode = useUserStore(toolInterventionSelectors.approvalMode);
const [isEditing, setIsEditing] = useState(false);
const updatePluginArguments = useConversationStore((s) => s.updatePluginArguments);
@ -147,6 +150,20 @@ const Intervention = memo<InterventionProps>(
);
}
const actions = (
<Flexbox horizontal justify={'flex-end'}>
<ApprovalActions
apiName={apiName}
approvalMode={approvalMode}
assistantGroupId={assistantGroupId}
identifier={identifier}
messageId={id}
toolCallId={toolCallId}
onBeforeApprove={handleBeforeApprove}
/>
</Flexbox>
);
return (
<Flexbox gap={12}>
<SecurityBlacklistWarning args={parsedArgs} />
@ -158,16 +175,7 @@ const Intervention = memo<InterventionProps>(
registerBeforeApprove={registerBeforeApprove}
onArgsChange={handleArgsChange}
/>
<Flexbox horizontal justify={'flex-end'}>
<ApprovalActions
apiName={apiName}
approvalMode={approvalMode}
identifier={identifier}
messageId={id}
toolCallId={toolCallId}
onBeforeApprove={handleBeforeApprove}
/>
</Flexbox>
{actionsPortalTarget ? createPortal(actions, actionsPortalTarget) : actions}
</Flexbox>
);
}
@ -176,7 +184,9 @@ const Intervention = memo<InterventionProps>(
<Flexbox gap={12}>
<SecurityBlacklistWarning args={parsedArgs} />
<Fallback
actionsPortalTarget={actionsPortalTarget}
apiName={apiName}
assistantGroupId={assistantGroupId}
id={id}
identifier={identifier}
requestArgs={requestArgs}

View file

@ -2,7 +2,7 @@
import { type UIChatMessage } from '@lobechat/types';
import debug from 'debug';
import { memo, useEffect, useRef } from 'react';
import { memo, useEffect, useLayoutEffect, useRef } from 'react';
import { createStoreUpdater } from 'zustand-utils';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
@ -72,6 +72,31 @@ const StoreUpdater = memo<StoreUpdaterProps>(
// When external messages are provided, mark as initialized
useStoreUpdater('messagesInit', skipFetch ? true : (hasInitMessages ?? false));
// Reset store state before paint when context changes.
// useLayoutEffect fires after commit but before browser paint, and React processes
// store updates triggered here synchronously — so subscribers re-render before paint.
const prevContextKeyRef = useRef(contextKey);
useLayoutEffect(() => {
if (prevContextKeyRef.current !== contextKey) {
prevContextKeyRef.current = contextKey;
prevMessagesRef.current = undefined;
// Atomically reset all stale data so ChatList shows skeleton (messagesInit=false)
// instead of old topic messages during the transition
storeApi.setState({
dbMessages: messages ?? [],
displayMessages: [],
messagesInit: false,
});
// If messages are already available, sync them immediately
if (messages) {
storeApi.getState().replaceMessages(messages);
storeApi.setState({ messagesInit: true });
}
}
}, [contextKey]); // eslint-disable-line react-hooks/exhaustive-deps
// Sync external messages into store
useEffect(() => {
if (messages) {

View file

@ -2,6 +2,7 @@ import type { ChatToolPayloadWithResult, ToolIntervention, UIChatMessage } from
export interface PendingIntervention {
apiName: string;
assistantGroupId?: string;
identifier: string;
intervention: ToolIntervention & { status: 'pending' };
requestArgs: string;
@ -16,7 +17,12 @@ export const getPendingInterventions = (
for (const msg of displayMessages) {
// Standalone tool messages with pluginIntervention pending
if (msg.role === 'tool' && msg.pluginIntervention?.status === 'pending' && msg.plugin) {
if (
msg.role === 'tool' &&
msg.pluginIntervention?.status === 'pending' &&
msg.plugin &&
!msg.id.startsWith('tmp_')
) {
pending.push({
apiName: msg.plugin.apiName,
identifier: msg.plugin.identifier,
@ -31,7 +37,7 @@ export const getPendingInterventions = (
if (msg.children) {
for (const block of msg.children) {
if (!block.tools) continue;
collectPendingTools(block.tools, pending);
collectPendingTools(block.tools, pending, msg.id);
}
}
}
@ -42,11 +48,17 @@ export const getPendingInterventions = (
const collectPendingTools = (
tools: ChatToolPayloadWithResult[],
pending: PendingIntervention[],
assistantGroupId?: string,
) => {
for (const tool of tools) {
if (tool.intervention?.status === 'pending' && tool.result_msg_id) {
if (
tool.intervention?.status === 'pending' &&
tool.result_msg_id &&
!tool.result_msg_id.startsWith('tmp_')
) {
pending.push({
apiName: tool.apiName,
assistantGroupId,
identifier: tool.identifier,
intervention: tool.intervention as ToolIntervention & { status: 'pending' },
requestArgs: tool.arguments || '',