Skip to content

Commit d616beb

Browse files
committed
fix(ui): unify chat tooltips and preserve overlay interactions
- Remove duplicate native tooltips from command and message actions - Use styled tooltips and accessible labels for message buttons - Keep interactive tooltips open when entering the overlay - Prevent tooltip clicks from toggling the parent command card - Cover hover persistence, click isolation, and accessible labels
1 parent ae9d6b3 commit d616beb

5 files changed

Lines changed: 60 additions & 14 deletions

File tree

‎design-system/packages/ui/src/components/Tooltip/Tooltip.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,11 @@ export function Tooltip({
357357
const onEnter = (event: MouseEvent) => {
358358
if (trigger === "hover" || trigger === "hover-focus") showTooltip(event);
359359
};
360-
const onLeave = () => {
360+
const onLeave = (event: MouseEvent) => {
361+
// React can deliver the portal's mouse-enter before this native
362+
// mouse-leave. Do not restart the hide timer when entering the tooltip.
363+
if (interactive && event.relatedTarget instanceof Node
364+
&& tooltipRef.current?.contains(event.relatedTarget)) return;
361365
if (trigger === "hover-focus" && element.contains(element.ownerDocument.activeElement)) return;
362366
if (trigger === "hover" || trigger === "hover-focus") scheduleHideTooltip();
363367
};
@@ -384,7 +388,7 @@ export function Tooltip({
384388
element.removeEventListener("focusout", onBlur);
385389
element.removeEventListener("click", onClick);
386390
};
387-
}, [externalTriggerRef, hideTooltip, scheduleHideTooltip, showTooltip, trigger, visible]);
391+
}, [externalTriggerRef, hideTooltip, interactive, scheduleHideTooltip, showTooltip, trigger, visible]);
388392

389393
// A measured text slot can mount after focus has already reached its owner.
390394
// Only replay focus/virtual activation on a transition, not on visibility updates.
@@ -504,6 +508,8 @@ export function Tooltip({
504508
data-openbitfun-interactive={interactive ? "true" : "false"}
505509
data-openbitfun-state={isShown ? "visible" : undefined}
506510
data-instant={instantRef.current || undefined}
511+
// Portal clicks still bubble through the React tree to the owning control.
512+
onClick={(event) => event.stopPropagation()}
507513
onMouseEnter={interactive ? () => {
508514
if (hideTimeoutRef.current) {
509515
clearTimeout(hideTimeoutRef.current);

‎design-system/packages/ui/src/flow-chat/tool-cards/CommandToolCard.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,6 @@ export function CommandToolCard({
218218
data-openbitfun-part="command"
219219
data-empty={resolvedCommand ? "false" : "true"}
220220
data-testid={commandTestId}
221-
title={resolvedCommand ?? undefined}
222221
><OverflowText>
223222
{resolvedCommand ?? emptyCommand}
224223
</OverflowText></code>

‎src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -592,9 +592,11 @@ describe('UserMessageItem steering tag', () => {
592592
const editButton = container.querySelector<HTMLButtonElement>('.user-message-item__edit-btn');
593593

594594
expect(rollbackButton?.disabled).toBe(true);
595-
expect(rollbackButton?.title).toContain(`message.rollbackDisabled${reason}`);
595+
expect(rollbackButton?.getAttribute('aria-label')).toContain(`message.rollbackDisabled${reason}`);
596+
expect(rollbackButton?.hasAttribute('title')).toBe(false);
596597
expect(editButton?.disabled).toBe(true);
597-
expect(editButton?.title).toContain(`message.editDisabled${reason}`);
598+
expect(editButton?.getAttribute('aria-label')).toContain(`message.editDisabled${reason}`);
599+
expect(editButton?.hasAttribute('title')).toBe(false);
598600
});
599601

600602
it('hides the edit button when the panel context disables user message editing', () => {

‎src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -669,21 +669,24 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
669669
)}
670670
{!isEditing && (
671671
<div className="user-message-item__actions" data-openbitfun-component="user-message-item" data-openbitfun-part="actions">
672-
<button
673-
className={`user-message-item__copy-btn ${copied ? 'copied' : ''}`}
674-
onClick={handleCopy}
675-
title={copied ? t('message.copyFailed') : t('message.copy')}
676-
>
677-
{copied ? <Icon name="check-line" size="sm" /> : <Icon name="duplicate" size="sm" />}
678-
</button>
672+
<Tooltip content={copied ? t('message.copied') : t('message.copy')}>
673+
<button
674+
type="button"
675+
className={`user-message-item__copy-btn ${copied ? 'copied' : ''}`}
676+
onClick={handleCopy}
677+
aria-label={copied ? t('message.copied') : t('message.copy')}
678+
>
679+
{copied ? <Icon name="check-line" size="sm" /> : <Icon name="duplicate" size="sm" />}
680+
</button>
681+
</Tooltip>
679682
{canShowEditAction && (
680683
<Tooltip content={canEdit ? t('message.edit') : editDisabledReason}>
681684
<button
682685
type="button"
683686
className="user-message-item__edit-btn"
684687
onClick={handleBeginEdit}
685688
disabled={!canEdit}
686-
title={canEdit ? t('message.edit') : editDisabledReason}
689+
aria-label={canEdit ? t('message.edit') : editDisabledReason}
687690
>
688691
<Icon name="edit" size="sm" />
689692
</button>
@@ -704,7 +707,7 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
704707
className="user-message-item__rollback-btn"
705708
onClick={handleRollback}
706709
disabled={!canRollback}
707-
title={rollbackTooltip}
710+
aria-label={rollbackTooltip}
708711
>
709712
{sessionMutation?.kind === 'rollback' && sessionMutation.targetTurnId === turnId ? (
710713
<Loader2 size={14} className="user-message-item__rollback-spinner" />

‎src/web-ui/src/shared/ui/OverflowText.test.tsx‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import React, { act } from 'react';
44
import { createRoot, type Root } from 'react-dom/client';
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
66
import { ListboxOption, OverflowText, Select, Tooltip } from '@openbitfun/ui';
7+
import { CommandToolCard } from '@openbitfun/ui/flow-chat';
78

89
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
910

@@ -74,6 +75,41 @@ describe('overflow text full-content access', () => {
7475
expect(button.getAttribute('aria-describedby')).toBe('help');
7576
});
7677

78+
it('keeps the command tooltip open when portal enter precedes native trigger leave', () => {
79+
render(<CommandToolCard action="Run" command={longLabel} emptyCommand="Empty" isExpanded={false} status="completed" />);
80+
expect(host.querySelector('[title]')).toBeNull();
81+
const label = host.querySelector('[data-openbitfun-part="command"] [data-overflow]')!;
82+
const trigger = label.closest('[data-overflow-trigger]') ?? label;
83+
hover(trigger);
84+
reveal();
85+
const popup = tooltip()!;
86+
expect(popup.textContent).toBe(longLabel);
87+
act(() => {
88+
popup.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, relatedTarget: trigger }));
89+
trigger.dispatchEvent(new MouseEvent('mouseleave', { relatedTarget: popup }));
90+
});
91+
act(() => vi.advanceTimersByTime(1000));
92+
expect(tooltip()).toBe(popup);
93+
act(() => popup.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body })));
94+
act(() => vi.advanceTimersByTime(500));
95+
expect(tooltip()).toBeNull();
96+
});
97+
98+
it('does not toggle the command card when clicking inside its tooltip', () => {
99+
const onToggle = vi.fn();
100+
render(<CommandToolCard action="Run" command={longLabel} emptyCommand="Empty"
101+
isExpanded={false} status="completed" output="Output" onToggle={onToggle} />);
102+
const label = host.querySelector<HTMLElement>('[data-openbitfun-part="command"] [data-overflow]')!;
103+
hover(label.closest('[data-overflow-trigger]') ?? label);
104+
reveal();
105+
const popup = tooltip()!;
106+
act(() => popup.querySelector<HTMLElement>('[data-openbitfun-part="body"]')!.click());
107+
expect(onToggle).not.toHaveBeenCalled();
108+
expect(tooltip()).toBe(popup);
109+
act(() => label.click());
110+
expect(onToggle).toHaveBeenCalledOnce();
111+
});
112+
77113
it('reveals rich labels and plain-text arrays without waiting for a marquee', () => {
78114
render(<button data-overflow-trigger>
79115
<OverflowText>Run <strong>independent tasks</strong> concurrently whenever possible</OverflowText>

0 commit comments

Comments
 (0)