mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
🐛 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:
parent
7097167613
commit
c59c066330
7 changed files with 105 additions and 39 deletions
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
Loading…
Reference in a new issue