From 67ce58798e1e79de6b9c41d185cbda478d5ae2cc Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Fri, 15 May 2026 16:42:59 +0800 Subject: [PATCH] fix(flow-chat): prevent card toggle while selecting text --- .../src/flow_chat/components/UserMessage.tsx | 6 ++- .../components/modern/UserMessageItem.tsx | 7 ++- .../src/flow_chat/tool-cards/BaseToolCard.tsx | 11 +++- .../flow_chat/tool-cards/CompactToolCard.tsx | 9 ++-- .../src/shared/utils/textSelection.test.ts | 51 +++++++++++++++++++ src/web-ui/src/shared/utils/textSelection.ts | 30 +++++++++++ 6 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 src/web-ui/src/shared/utils/textSelection.test.ts diff --git a/src/web-ui/src/flow_chat/components/UserMessage.tsx b/src/web-ui/src/flow_chat/components/UserMessage.tsx index bfca4c024..42781fb38 100644 --- a/src/web-ui/src/flow_chat/components/UserMessage.tsx +++ b/src/web-ui/src/flow_chat/components/UserMessage.tsx @@ -6,6 +6,7 @@ import React, { useMemo, useState, useRef, useEffect } from 'react'; import { File, Folder, Code, Image, Terminal, GitBranch, Link, FileText } from 'lucide-react'; import { Tag } from '@/component-library'; +import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection'; import { SnapshotRollbackButton } from './SnapshotRollbackButton'; import './UserMessage.scss'; @@ -170,11 +171,14 @@ export const UserMessage: React.FC = React.memo(({ }, [messageContent, isExpanded]); const toggleExpand = (e: React.MouseEvent) => { + if (shouldIgnoreCardToggleClick(e, contentRef.current)) { + return; + } + if (!hasOverflow && !isExpanded) { return; } e.stopPropagation(); - e.preventDefault(); setIsExpanded(prev => !prev); }; diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index cddd3337f..b2b8e4ac2 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -13,6 +13,7 @@ import { flowChatStore } from '../../store/FlowChatStore'; import { snapshotAPI } from '@/infrastructure/api'; import { notificationService } from '@/shared/notification-system'; import { globalEventBus } from '@/infrastructure/event-bus'; +import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection'; import { ReproductionStepsBlock, Tooltip, confirmDanger } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; @@ -173,7 +174,11 @@ export const UserMessageItem = React.memo( }, [canRollback, sessionId, t, turnIndex, messageContent]); // Toggle expanded state. - const handleToggleExpand = useCallback(() => { + const handleToggleExpand = useCallback((event: React.MouseEvent) => { + if (shouldIgnoreCardToggleClick(event, contentRef.current)) { + return; + } + // Only allow expand/collapse when there is overflow. if (!hasOverflow && !expanded) { return; diff --git a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx index 12d176721..1803c75c5 100644 --- a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx @@ -3,6 +3,7 @@ * Provides unified card styles and interaction logic */ import React, { ReactNode } from 'react'; +import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection'; import { SmoothHeightCollapse } from '../components/modern/SmoothHeightCollapse'; import { ToolCardHeaderLayoutContext, @@ -71,6 +72,14 @@ export const BaseToolCard: React.FC = ({ headerExpandAffordance: headerExpandAffordanceProp, headerAffordanceKind: headerAffordanceKindProp = 'expand', }) => { + const handleCardClick = (event: React.MouseEvent) => { + if (!onClick || shouldIgnoreCardToggleClick(event)) { + return; + } + + onClick(event); + }; + const hasExpandedContent = isExpanded && expandedContent && !isFailed; const showConfirmationHighlight = requiresConfirmation && status !== 'completed' && @@ -97,7 +106,7 @@ export const BaseToolCard: React.FC = ({ >
diff --git a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx index 8426dfc04..e1dd1d7b0 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx @@ -9,6 +9,7 @@ */ import React, { ReactNode } from 'react'; +import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection'; import { BaseToolCard, type BaseToolCardProps } from './BaseToolCard'; import { SmoothHeightCollapse } from '../components/modern/SmoothHeightCollapse'; import { ToolCardIconSlot } from './ToolCardIconSlot'; @@ -42,10 +43,12 @@ export const CompactToolCard: React.FC = ({ header, expandedContent, }) => { - const handleWrapperClick = (e: React.MouseEvent) => { - if (onClick) { - onClick(e); + const handleWrapperClick = (event: React.MouseEvent) => { + if (!onClick || shouldIgnoreCardToggleClick(event)) { + return; } + + onClick(event); }; const loadingShimmer = diff --git a/src/web-ui/src/shared/utils/textSelection.test.ts b/src/web-ui/src/shared/utils/textSelection.test.ts new file mode 100644 index 000000000..67dedd4f1 --- /dev/null +++ b/src/web-ui/src/shared/utils/textSelection.test.ts @@ -0,0 +1,51 @@ +import type { MouseEvent as ReactMouseEvent } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { shouldIgnoreCardToggleClick } from './textSelection'; + +function createClickEvent(target: Element, currentTarget: Element = target): ReactMouseEvent { + return { + button: 0, + currentTarget, + defaultPrevented: false, + target, + } as unknown as ReactMouseEvent; +} + +describe('shouldIgnoreCardToggleClick', () => { + let dom: JSDOM; + let root: HTMLDivElement; + + beforeEach(() => { + dom = new JSDOM('
selectable text
'); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('Element', dom.window.Element); + vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + + root = dom.window.document.getElementById('root') as HTMLDivElement; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('allows a normal left click with no active text selection', () => { + expect(shouldIgnoreCardToggleClick(createClickEvent(root), root)).toBe(false); + }); + + it('ignores clicks when selected text belongs to the card', () => { + const text = dom.window.document.getElementById('text')!; + const range = dom.window.document.createRange(); + range.selectNodeContents(text); + dom.window.getSelection()?.addRange(range); + + expect(shouldIgnoreCardToggleClick(createClickEvent(text, root), root)).toBe(true); + }); + + it('ignores interactive child clicks', () => { + const button = dom.window.document.getElementById('button')!; + + expect(shouldIgnoreCardToggleClick(createClickEvent(button, root), root)).toBe(true); + }); +}); diff --git a/src/web-ui/src/shared/utils/textSelection.ts b/src/web-ui/src/shared/utils/textSelection.ts index c82609033..ded097dce 100644 --- a/src/web-ui/src/shared/utils/textSelection.ts +++ b/src/web-ui/src/shared/utils/textSelection.ts @@ -1,5 +1,6 @@ +import type { MouseEvent as ReactMouseEvent } from 'react'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('TextSelection'); @@ -40,6 +41,35 @@ export const getSelectedText = (): TextSelection | null => { }; }; +export const shouldIgnoreCardToggleClick = ( + event: ReactMouseEvent, + root: HTMLElement | null = typeof HTMLElement !== 'undefined' && event.currentTarget instanceof HTMLElement + ? event.currentTarget + : null, +): boolean => { + if (event.defaultPrevented || event.button !== 0) { + return true; + } + + const target = typeof Element !== 'undefined' && event.target instanceof Element ? event.target : null; + if (target?.closest('button,a,input,textarea,select,[contenteditable="true"],[data-flow-card-ignore-toggle]')) { + return true; + } + + const selection = window.getSelection?.(); + if (!selection || selection.isCollapsed || !selection.toString().trim()) { + return false; + } + + if (!root) { + return true; + } + + const anchorInside = selection.anchorNode ? root.contains(selection.anchorNode) : false; + const focusInside = selection.focusNode ? root.contains(selection.focusNode) : false; + return anchorInside || focusInside; +}; + export const clearSelection = (): void => { const selection = window.getSelection();