Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/web-ui/src/flow_chat/components/UserMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -170,11 +171,14 @@ export const UserMessage: React.FC<UserMessageProps> = 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);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -173,7 +174,11 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
}, [canRollback, sessionId, t, turnIndex, messageContent]);

// Toggle expanded state.
const handleToggleExpand = useCallback(() => {
const handleToggleExpand = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (shouldIgnoreCardToggleClick(event, contentRef.current)) {
return;
}

// Only allow expand/collapse when there is overflow.
if (!hasOverflow && !expanded) {
return;
Expand Down
11 changes: 10 additions & 1 deletion src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -71,6 +72,14 @@ export const BaseToolCard: React.FC<BaseToolCardProps> = ({
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' &&
Expand All @@ -97,7 +106,7 @@ export const BaseToolCard: React.FC<BaseToolCardProps> = ({
>
<div
className={`base-tool-card status-${status} ${isExpanded ? 'expanded' : ''} ${resolvedHeaderExpandAffordance ? 'base-tool-card--header-expandable' : ''}`.trim()}
onClick={onClick}
onClick={handleCardClick}
>
<ToolCardHeaderLayoutContext.Provider value={headerLayoutValue}>
<div className="base-tool-card-header">
Expand Down
9 changes: 6 additions & 3 deletions src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -42,10 +43,12 @@ export const CompactToolCard: React.FC<CompactToolCardProps> = ({
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 =
Expand Down
51 changes: 51 additions & 0 deletions src/web-ui/src/shared/utils/textSelection.test.ts
Original file line number Diff line number Diff line change
@@ -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<Element> {
return {
button: 0,
currentTarget,
defaultPrevented: false,
target,
} as unknown as ReactMouseEvent<Element>;
}

describe('shouldIgnoreCardToggleClick', () => {
let dom: JSDOM;
let root: HTMLDivElement;

beforeEach(() => {
dom = new JSDOM('<!doctype html><html><body><div id="root"><span id="text">selectable text</span><button id="button">Action</button></div></body></html>');
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);
});
});
30 changes: 30 additions & 0 deletions src/web-ui/src/shared/utils/textSelection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@


import type { MouseEvent as ReactMouseEvent } from 'react';
import { createLogger } from '@/shared/utils/logger';

const log = createLogger('TextSelection');
Expand Down Expand Up @@ -40,6 +41,35 @@ export const getSelectedText = (): TextSelection | null => {
};
};

export const shouldIgnoreCardToggleClick = (
event: ReactMouseEvent<Element>,
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();
Expand Down
Loading