diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md
index 6b838180ef..b43b1db6b0 100644
--- a/design-system/packages/ui/README.md
+++ b/design-system/packages/ui/README.md
@@ -25,6 +25,9 @@ Use `OverflowText` for single-line, non-editable labels instead of local
defaults to **fade-out truncation with an interaction marquee**: a background-independent
gradient mask at the inline end, followed by scrolling on hover or keyboard focus.
Both effects apply only when the text actually overflows. Short labels remain untouched.
+Overflowing labels also open a wrapping, selectable tooltip on hover or keyboard
+focus, including when motion is reduced. The tooltip uses the owning
+`data-overflow-trigger` control and groups its clipped text slots into one popup.
Standard button, menu, navigation, card, selection, and disclosure text slots
already use this primitive; consumers should not wrap those slots a second time.
@@ -55,10 +58,11 @@ Rich children default to fade to preserve the label's existing inline compositio
Composite containers keep their icons/actions fixed and give each text slot its
own `OverflowText`. Marquee measures and
translates one inline text span; keep icons, badges, and action buttons outside
-it. Complete text stays in the accessibility tree. Clipped string/number labels
-get a native title unless the caller supplies one; rich content should use its
-own full-text tooltip or detail view. Do not use marquee as the sole way to
-access information on touch surfaces.
+it. Complete text stays in the accessibility tree. Plain-text arrays and rich
+labels use their complete rendered text in the tooltip. A supplied `title`
+overrides that text; `title=""` opts out when a surrounding native title owns the
+content. An explicit enclosing `Tooltip` suppresses automatic nested tooltips.
+Do not use marquee as the sole way to access information on touch surfaces.
Multi-line descriptions should normally wrap. Editable fields, source code,
structured paths that need to preserve their suffix, and native controls keep
@@ -66,6 +70,11 @@ their appropriate text treatment instead of receiving a blanket fade rule.
Mobile sheet/page titles and row descriptions wrap for touch access. Tooltips
also wrap: a full-text fallback must not truncate its own content.
+For a compact multiline preview, use `` (or `div`
+to preserve the existing semantics). It measures vertical clipping as well as
+horizontal overflow and exposes the same full-text tooltip. Keep existing
+click-to-open details or expansion controls available on touch surfaces.
+
The Web UI uses this contract in shell/navigation and search, workspace/session
lists, model and context pickers, file/Git lists, settings, tool-card summaries,
usage reports, and the Canvas SDK's truncating text/file labels. Remaining local
diff --git a/design-system/packages/ui/src/components/Listbox/Listbox.tsx b/design-system/packages/ui/src/components/Listbox/Listbox.tsx
index 5d404356a4..3732f58337 100644
--- a/design-system/packages/ui/src/components/Listbox/Listbox.tsx
+++ b/design-system/packages/ui/src/components/Listbox/Listbox.tsx
@@ -242,7 +242,7 @@ export const ListboxOption = forwardRef(
)}
- {children}
+ {children}
{description !== undefined && description !== null && (
{description}
@@ -250,7 +250,7 @@ export const ListboxOption = forwardRef(
)}
{metadata !== undefined && metadata !== null && (
- {metadata}
+ {metadata}
)}
{indicator ?? (selected ? : null)}
diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.module.css b/design-system/packages/ui/src/components/Tooltip/Tooltip.module.css
index e54199b499..c7d4f529a0 100644
--- a/design-system/packages/ui/src/components/Tooltip/Tooltip.module.css
+++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.module.css
@@ -32,7 +32,7 @@
.content {
position: relative;
z-index: 1;
- max-inline-size: var(--openbitfun-overlay-tooltip-max-inline-size);
+ max-inline-size: min(var(--openbitfun-overlay-tooltip-max-inline-size), calc(100vw - 16px));
max-block-size: min(var(--openbitfun-overlay-tooltip-max-block-size), calc(100vh - 24px));
overflow-y: auto;
overscroll-behavior: contain;
@@ -46,7 +46,8 @@
font-weight: var(--openbitfun-type-support-font-weight);
line-height: var(--openbitfun-type-support-line-height);
letter-spacing: var(--openbitfun-type-support-letter-spacing);
- overflow-wrap: break-word;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
user-select: text;
box-shadow: var(--openbitfun-shadow-sm);
backdrop-filter: var(--openbitfun-effect-blur-base);
diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx
index 4f9302fff2..5e5d9d00f2 100644
--- a/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx
+++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.tsx
@@ -10,14 +10,16 @@ import {
type ReactElement,
type ReactNode,
type Ref,
+ type RefObject,
} from "react";
import { classNames } from "../../internal/classNames";
+import { TooltipTriggerContext } from "../../internal/tooltipTriggerContext";
import { Portal } from "../../overlay/Portal";
import { useDesignSystem } from "../../overlay/useDesignSystem";
import styles from "./Tooltip.module.css";
export type TooltipPlacement = "top" | "bottom" | "left" | "right";
-export type TooltipTrigger = "hover" | "click" | "focus";
+export type TooltipTrigger = "hover" | "click" | "focus" | "hover-focus";
const DEFAULT_TOOLTIP_DELAY_MS = 450;
const INTERACTIVE_HIDE_DELAY_MS = 400;
@@ -27,6 +29,7 @@ const INTERACTIVE_HIDE_DELAY_MS = 400;
*/
const WARM_WINDOW_MS = 300;
let tooltipWarmUntil = 0;
+const activeTooltips = new WeakMap void }>();
/** Cursor offset when followCursor: right and down so the tooltip never covers the cursor. */
const CURSOR_OFFSET_X = 12;
@@ -36,7 +39,7 @@ const VIEWPORT_PADDING = 8;
export interface TooltipProps {
/** Single focusable trigger element the tooltip describes. */
- children: ReactElement;
+ children?: ReactElement;
className?: string;
content: ReactNode;
/** Open delay in milliseconds. Falls back to the provider value, then 450ms. */
@@ -49,6 +52,12 @@ export interface TooltipProps {
/** Preferred side of the trigger; flips to the opposite side when space runs out. */
placement?: TooltipPlacement;
trigger?: TooltipTrigger;
+ /** Bind to an existing control without adding a wrapper or another tab stop. */
+ triggerRef?: RefObject;
+ /** Reveal a virtually focused option, for example in an aria-activedescendant listbox. */
+ active?: boolean;
+ /** Refresh lazy content or decline opening when the trigger no longer needs a tooltip. */
+ onBeforeShow?: () => boolean;
}
function assignRef(ref: Ref | undefined, value: T | null): void {
@@ -166,6 +175,9 @@ export function Tooltip({
interactive = false,
placement = "top",
trigger = "hover",
+ triggerRef: externalTriggerRef,
+ active = false,
+ onBeforeShow,
}: TooltipProps) {
const designSystem = useDesignSystem();
const resolvedDelayMs = delay ?? designSystem.tooltipDelay ?? DEFAULT_TOOLTIP_DELAY_MS;
@@ -181,13 +193,15 @@ export function Tooltip({
ready: false,
});
const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null);
- const triggerRef = useRef(null);
+ const internalTriggerRef = useRef(null);
+ const triggerRef = externalTriggerRef ?? internalTriggerRef;
const tooltipRef = useRef(null);
const showTimeoutRef = useRef | null>(null);
const hideTimeoutRef = useRef | null>(null);
const latestMousePositionRef = useRef<{ x: number; y: number } | null>(null);
const recalcFrameRef = useRef(null);
const instantRef = useRef(false);
+ const hideCurrentRef = useRef<() => void>(() => {});
const calculatePosition = useCallback(() => {
if (!tooltipRef.current) return;
@@ -226,8 +240,11 @@ export function Tooltip({
});
}, [calculatePosition]);
- const showTooltip = useCallback((event?: ReactMouseEvent) => {
+ const showTooltip = useCallback((event?: Pick) => {
if (disabled) return;
+ const element = triggerRef.current;
+ if (!element || element.closest('[hidden], [aria-hidden="true"]')) return;
+ if (onBeforeShow && !onBeforeShow()) return;
if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
@@ -236,19 +253,24 @@ export function Tooltip({
if (followCursor && event) {
latestMousePositionRef.current = { x: event.clientX, y: event.clientY };
}
- const openDelay = trigger === "hover" && Date.now() < tooltipWarmUntil
+ const openDelay = (trigger === "hover" || trigger === "hover-focus") && Date.now() < tooltipWarmUntil
? 0
: resolvedDelayMs;
instantRef.current = openDelay === 0;
showTimeoutRef.current = setTimeout(() => {
showTimeoutRef.current = null;
+ if (!element.isConnected || element.closest('[hidden], [aria-hidden="true"]')) return;
+ if (onBeforeShow && !onBeforeShow()) return;
+ const previous = activeTooltips.get(element.ownerDocument);
+ if (previous && previous.id !== tooltipId) previous.hide();
+ activeTooltips.set(element.ownerDocument, { id: tooltipId, hide: () => hideCurrentRef.current() });
if (followCursor) {
setMousePosition(latestMousePositionRef.current);
}
setLayout((prev) => (prev.ready ? { ...prev, ready: false } : prev));
setVisible(true);
}, openDelay);
- }, [disabled, followCursor, resolvedDelayMs, trigger]);
+ }, [disabled, followCursor, onBeforeShow, resolvedDelayMs, tooltipId, trigger, triggerRef]);
const hideTooltip = useCallback(() => {
if (showTimeoutRef.current) {
@@ -262,16 +284,20 @@ export function Tooltip({
if (visible) {
tooltipWarmUntil = Date.now() + WARM_WINDOW_MS;
}
+ const ownerDocument = triggerRef.current?.ownerDocument;
+ if (ownerDocument && activeTooltips.get(ownerDocument)?.id === tooltipId) activeTooltips.delete(ownerDocument);
setVisible(false);
setLayout((prev) => (prev.ready ? { ...prev, ready: false } : prev));
if (followCursor) {
latestMousePositionRef.current = null;
setMousePosition(null);
}
- }, [followCursor, visible]);
+ }, [followCursor, tooltipId, triggerRef, visible]);
+
+ useEffect(() => { hideCurrentRef.current = hideTooltip; }, [hideTooltip]);
const scheduleHideTooltip = useCallback(() => {
- if (!interactive) {
+ if (!interactive || !visible) {
hideTooltip();
return;
}
@@ -281,7 +307,7 @@ export function Tooltip({
hideTimeoutRef.current = null;
hideTooltip();
}, INTERACTIVE_HIDE_DELAY_MS);
- }, [hideTooltip, interactive]);
+ }, [hideTooltip, interactive, visible]);
useEffect(() => {
setLayout((prev) => (prev.placement === placement ? prev : { ...prev, placement }));
@@ -314,26 +340,89 @@ export function Tooltip({
};
}, [visible, followCursor, scheduleCalculatePosition]);
- useEffect(() => () => {
- if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
- if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
- }, []);
+ useEffect(() => {
+ const ownerDocument = triggerRef.current?.ownerDocument;
+ return () => {
+ if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
+ if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
+ if (ownerDocument && activeTooltips.get(ownerDocument)?.id === tooltipId) activeTooltips.delete(ownerDocument);
+ };
+ }, [tooltipId, triggerRef]);
+
+ // Delegated text slots use the owning button/row for hover and keyboard focus.
+ // Keep the actual label in place so this also works inside portalled listboxes.
+ useEffect(() => {
+ const element = externalTriggerRef?.current;
+ if (!element) return;
+ const onEnter = (event: MouseEvent) => {
+ if (trigger === "hover" || trigger === "hover-focus") showTooltip(event);
+ };
+ const onLeave = () => {
+ if (trigger === "hover-focus" && element.contains(element.ownerDocument.activeElement)) return;
+ if (trigger === "hover" || trigger === "hover-focus") scheduleHideTooltip();
+ };
+ const onFocus = () => {
+ if (trigger === "focus" || trigger === "hover-focus") showTooltip();
+ };
+ const onBlur = (event: FocusEvent) => {
+ if (event.relatedTarget && element.contains(event.relatedTarget as Node)) return;
+ if (trigger === "focus" || trigger === "hover-focus") hideTooltip();
+ };
+ const onClick = () => {
+ if (trigger === "click" && !visible) showTooltip();
+ else hideTooltip();
+ };
+ element.addEventListener("mouseenter", onEnter);
+ element.addEventListener("mouseleave", onLeave);
+ element.addEventListener("focusin", onFocus);
+ element.addEventListener("focusout", onBlur);
+ element.addEventListener("click", onClick);
+ return () => {
+ element.removeEventListener("mouseenter", onEnter);
+ element.removeEventListener("mouseleave", onLeave);
+ element.removeEventListener("focusin", onFocus);
+ element.removeEventListener("focusout", onBlur);
+ element.removeEventListener("click", onClick);
+ };
+ }, [externalTriggerRef, hideTooltip, scheduleHideTooltip, showTooltip, trigger, visible]);
+
+ // A measured text slot can mount after focus has already reached its owner.
+ // Only replay focus/virtual activation on a transition, not on visibility updates.
+ const activationRef = useRef(false);
+ useEffect(() => {
+ const element = triggerRef.current;
+ const activated = !disabled && (active || Boolean(externalTriggerRef
+ && element?.contains(element.ownerDocument.activeElement)));
+ if (activated === activationRef.current) return;
+ activationRef.current = activated;
+ if (activated) showTooltip();
+ else hideTooltip();
+ }, [active, disabled, externalTriggerRef, hideTooltip, showTooltip, triggerRef]);
+
+ useEffect(() => {
+ const ownerDocument = triggerRef.current?.ownerDocument;
+ const onEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") hideTooltip();
+ };
+ ownerDocument?.addEventListener("keydown", onEscape, true);
+ return () => ownerDocument?.removeEventListener("keydown", onEscape, true);
+ }, [hideTooltip, triggerRef]);
- const childProps = children.props as Record;
- const childRef = (children as ReactElement & { ref?: Ref }).ref;
+ const childProps = (children?.props ?? {}) as Record;
+ const childRef = (children as (ReactElement & { ref?: Ref }) | undefined)?.ref;
const handleTriggerRef = useCallback((node: HTMLElement | null) => {
- triggerRef.current = node;
+ internalTriggerRef.current = node;
assignRef(childRef, node);
}, [childRef]);
const handleMouseEnter = (event: ReactMouseEvent) => {
- if (trigger === "hover") showTooltip(event);
+ if (trigger === "hover" || trigger === "hover-focus") showTooltip(event);
(childProps.onMouseEnter as ((event: ReactMouseEvent) => void) | undefined)?.(event);
};
const handleMouseLeave = (event: ReactMouseEvent) => {
- if (trigger === "hover") scheduleHideTooltip();
+ if (trigger === "hover" || (trigger === "hover-focus" && !event.currentTarget.contains(event.currentTarget.ownerDocument.activeElement))) scheduleHideTooltip();
(childProps.onMouseLeave as ((event: ReactMouseEvent) => void) | undefined)?.(event);
};
@@ -361,18 +450,31 @@ export function Tooltip({
};
const handleFocus = (event: ReactFocusEvent) => {
- if (trigger === "focus") showTooltip();
+ if (trigger === "focus" || trigger === "hover-focus") showTooltip();
(childProps.onFocus as ((event: ReactFocusEvent) => void) | undefined)?.(event);
};
const handleBlur = (event: ReactFocusEvent) => {
- if (trigger === "focus") hideTooltip();
+ if (trigger === "focus" || trigger === "hover-focus") hideTooltip();
(childProps.onBlur as ((event: ReactFocusEvent) => void) | undefined)?.(event);
};
const isShown = visible && layout.ready;
- const triggerElement = cloneElement(children as ReactElement>, {
+ useEffect(() => {
+ const element = externalTriggerRef?.current;
+ if (!element || !isShown) return;
+ const descriptions = new Set(element.getAttribute("aria-describedby")?.split(/\s+/).filter(Boolean));
+ descriptions.add(tooltipId);
+ element.setAttribute("aria-describedby", [...descriptions].join(" "));
+ return () => {
+ const remaining = element.getAttribute("aria-describedby")?.split(/\s+/).filter(id => id && id !== tooltipId) ?? [];
+ if (remaining.length) element.setAttribute("aria-describedby", remaining.join(" "));
+ else element.removeAttribute("aria-describedby");
+ };
+ }, [externalTriggerRef, isShown, tooltipId]);
+
+ const triggerElement = children ? cloneElement(children as ReactElement>, {
ref: handleTriggerRef,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
@@ -383,11 +485,13 @@ export function Tooltip({
"aria-describedby": isShown
? [childProps["aria-describedby"], tooltipId].filter(Boolean).join(" ")
: childProps["aria-describedby"],
- } as Record);
+ } as Record) : null;
return (
<>
- {triggerElement}
+
+ {triggerElement}
+
{visible && (
{
+export interface OverflowTextProps extends HTMLAttributes
{
+ /** Preserve paragraph/div semantics when adopting the shared text behavior. */
+ as?: "span" | "p" | "div";
+ /** Clamp a multiline preview; its full text uses the same hover/focus tooltip. */
+ lines?: number;
/** Plain text defaults to marquee; rich composition defaults to a static fade. */
behavior?: OverflowTextBehavior;
/** Runs an overflowing marquee while its owning control is virtually active. */
@@ -39,11 +46,13 @@ interface OverflowMeasurement {
isOverflowing: boolean;
}
-export const OverflowText = forwardRef(
+export const OverflowText = forwardRef(
function OverflowText({
+ as: Tag = "span",
behavior: requestedBehavior,
children,
className,
+ lines,
marqueeActive = false,
style,
title,
@@ -53,17 +62,21 @@ export const OverflowText = forwardRef(
const textOnly = Children.toArray(children).every(
child => typeof child === "string" || typeof child === "number",
);
- const behavior = requestedBehavior ?? (textOnly ? "marquee" : "fade");
- const elementRef = useRef(null);
+ const behavior = lines ? "fade" : requestedBehavior ?? (textOnly ? "marquee" : "fade");
+ const elementRef = useRef(null);
const contentRef = useRef(null);
+ const triggerRef = useRef(null);
+ const hasExplicitTooltip = useContext(TooltipTriggerContext);
+ const [tooltipText, setTooltipText] = useState("");
const measurementRef = useRef({
distance: 0,
isOverflowing: false,
});
const [measurement, setMeasurement] = useState(measurementRef.current);
- const setElementRef = useCallback((element: HTMLSpanElement | null) => {
+ const setElementRef = useCallback((element: HTMLElement | null) => {
elementRef.current = element;
+ triggerRef.current = element?.closest("[data-overflow-trigger]") ?? element;
assignRef(forwardedRef, element);
}, [forwardedRef]);
@@ -73,7 +86,8 @@ export const OverflowText = forwardRef(
if (!element || !content) return;
const distance = Math.max(0, content.scrollWidth - element.clientWidth);
- const isOverflowing = element.clientWidth > 0 && distance > 0;
+ const isOverflowing = element.clientWidth > 0 && (distance > 0
+ || (element.clientHeight > 0 && element.scrollHeight > element.clientHeight));
const current = measurementRef.current;
if (current.distance === distance && current.isOverflowing === isOverflowing) return;
@@ -82,9 +96,33 @@ export const OverflowText = forwardRef(
setMeasurement(next);
}, []);
+ const prepareTooltip = useCallback(() => {
+ const element = elementRef.current;
+ const trigger = triggerRef.current;
+ if (!element || !trigger) return false;
+
+ // One tooltip per control, even when its label and metadata both overflow.
+ // Nested text slots contribute only their innermost full-text content.
+ const slots = trigger === element
+ ? [element]
+ : Array.from(trigger.querySelectorAll('[data-overflow-tooltip="true"]'))
+ .filter(slot => slot.closest("[data-overflow-trigger]") === trigger
+ && !slot.querySelector('[data-overflow-tooltip="true"]'));
+ if (slots[0] !== element) return false;
+ const text = slots.map(slot => slot.getAttribute("data-overflow-text") ?? slot.textContent ?? "")
+ .filter(Boolean).join("\n");
+ if (!text) return false;
+ setTooltipText(text);
+ return true;
+ }, []);
+
useIsomorphicLayoutEffect(() => {
updateOverflow();
- }, [behavior, children, updateOverflow]);
+ }, [behavior, children, lines, updateOverflow]);
+
+ useEffect(() => {
+ if (measurementRef.current.isOverflowing) prepareTooltip();
+ }, [children, title, prepareTooltip]);
useEffect(() => {
const element = elementRef.current;
@@ -98,6 +136,11 @@ export const OverflowText = forwardRef(
const fontSet = element.ownerDocument.fonts;
fontSet?.addEventListener("loadingdone", updateOverflow);
+ // Rich labels may update their own descendants without changing this slot's props.
+ const mutationObserver = textOnly || typeof MutationObserver === "undefined"
+ ? null
+ : new MutationObserver(() => { updateOverflow(); prepareTooltip(); });
+ mutationObserver?.observe(element, { childList: true, characterData: true, subtree: true });
if (!resizeObserver) {
element.ownerDocument.defaultView?.addEventListener("resize", updateOverflow);
@@ -105,45 +148,61 @@ export const OverflowText = forwardRef(
return () => {
resizeObserver?.disconnect();
+ mutationObserver?.disconnect();
fontSet?.removeEventListener("loadingdone", updateOverflow);
if (!resizeObserver) {
element.ownerDocument.defaultView?.removeEventListener("resize", updateOverflow);
}
};
- }, [behavior, updateOverflow]);
+ }, [behavior, prepareTooltip, textOnly, updateOverflow]);
const marqueeDuration = Math.max(
MARQUEE_MIN_DURATION_MS,
Math.round((measurement.distance / MARQUEE_PIXELS_PER_SECOND) * 1000),
);
- const resolvedStyle = behavior === "marquee"
- ? ({
- ...style,
+ const resolvedStyle = {
+ ...style,
+ ...(lines ? { "--_overflow-text-lines": lines } : {}),
+ ...(behavior === "marquee" ? {
"--_overflow-text-marquee-distance": `${measurement.distance}px`,
"--_overflow-text-marquee-duration": `${marqueeDuration}ms`,
- } as CSSProperties)
- : style;
+ } : {}),
+ } as CSSProperties;
+
+ const hasOverflowTooltip = measurement.isOverflowing && !hasExplicitTooltip && title !== "";
return (
-
+
{behavior === "marquee" ? (
{children}
) : children}
-
+
+ {hasOverflowTooltip && (
+
+ )}
+ >
);
},
);
diff --git a/design-system/packages/ui/tests/overflow-text.test.mjs b/design-system/packages/ui/tests/overflow-text.test.mjs
index 2e4e72f62b..116da0a9fd 100644
--- a/design-system/packages/ui/tests/overflow-text.test.mjs
+++ b/design-system/packages/ui/tests/overflow-text.test.mjs
@@ -63,7 +63,7 @@ test("OverflowText measures real clipping for fade and marquee treatments", asyn
assert.match(source, /measurementRef\.current/);
assert.match(
source,
- /useIsomorphicLayoutEffect\(\(\) => \{\s*updateOverflow\(\);\s*\}, \[behavior, children, updateOverflow\]\);/s,
+ /useIsomorphicLayoutEffect\(\(\) => \{\s*updateOverflow\(\);\s*\}, \[behavior, children, lines, updateOverflow\]\);/s,
);
assert.match(source, /new ResizeObserver\(updateOverflow\)/);
assert.match(source, /resizeObserver\?\.observe\(contentRef\.current\)/);
diff --git a/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.tsx b/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.tsx
index 733b2e5ee6..7ca96e1607 100644
--- a/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.tsx
+++ b/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.tsx
@@ -196,7 +196,7 @@ export const ThumbnailCard: React.FC = ({
{isFileType ? (
{previewLines.map((line, index) => (
-
+
{line || ' '}
))}
diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.terminal.test.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.terminal.test.ts
index 1822162aa4..d17cc3ca69 100644
--- a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.terminal.test.ts
+++ b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.terminal.test.ts
@@ -22,4 +22,20 @@ describe('canvasStore terminal lifecycle', () => {
expect(next.primaryGroup.tabs).toEqual([]);
expect(next.closedTabs).toEqual([]);
});
+
+ it('keeps full terminal titles when opening old payloads and renaming a session', () => {
+ const fullName = 'A terminal session with a name longer than twenty characters';
+ const store = useAgentCanvasStore.getState();
+ store.addTab({
+ type: 'terminal', title: 'A terminal session w...',
+ data: { sessionId: 'terminal-1', sessionName: fullName },
+ metadata: { sessionId: 'terminal-1' },
+ }, 'active', 'primary');
+ expect(useAgentCanvasStore.getState().primaryGroup.tabs[0].title).toBe(fullName);
+ const renamed = `${fullName} after renaming`;
+ store.renameTerminalTabBySessionId('terminal-1', renamed);
+ const tab = useAgentCanvasStore.getState().primaryGroup.tabs[0];
+ expect(tab.title).toBe(renamed);
+ expect(tab.content.title).toBe(renamed);
+ });
});
diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts
index a0d8006a11..871183a30c 100644
--- a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts
+++ b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts
@@ -492,9 +492,8 @@ const createCanvasStoreHook = () => create
()(
const group = getGroup(draft, result.groupId);
const tab = group.tabs.find(t => t.id === result.tab.id);
if (tab) {
- const displayTitle = newName.length > 20 ? `${newName.slice(0, 20)}...` : newName;
- tab.title = displayTitle;
- tab.content.title = displayTitle;
+ tab.title = newName;
+ tab.content.title = newName;
tab.content.data = { ...tab.content.data, sessionName: newName };
}
});
diff --git a/src/web-ui/src/app/components/panels/content-canvas/types/tab.ts b/src/web-ui/src/app/components/panels/content-canvas/types/tab.ts
index c5387770ac..4219810967 100644
--- a/src/web-ui/src/app/components/panels/content-canvas/types/tab.ts
+++ b/src/web-ui/src/app/components/panels/content-canvas/types/tab.ts
@@ -109,7 +109,10 @@ export const createTab = (
const now = Date.now();
return {
id: generateTabId(),
- title: content.title,
+ // Older terminal tab payloads shortened title but retained the full session name.
+ title: content.type === 'terminal' && typeof content.data?.sessionName === 'string'
+ ? content.data.sessionName
+ : content.title,
content,
state,
isDirty: false,
diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx b/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx
index 022b0f7f4e..f4d4885171 100644
--- a/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx
+++ b/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx
@@ -87,9 +87,9 @@ const AgentCard: React.FC = ({
-
+
{getAgentDescription(t, agent)}
-
+
diff --git a/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.tsx b/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.tsx
index 607ab728d5..ecc7aef203 100644
--- a/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.tsx
+++ b/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.tsx
@@ -92,9 +92,9 @@ const CoreAgentCard: React.FC = ({
-
+
{getAgentDescription(t, agent)}
-
+
diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.tsx
index 8baef2c5cb..a272930a5c 100644
--- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.tsx
+++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.tsx
@@ -121,7 +121,7 @@ const MiniAppCard: React.FC
= ({
{localizedDescription ? (
- {localizedDescription}
+ {localizedDescription}
) : null}
diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppDetailModal.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppDetailModal.tsx
index 6bbe137eae..22be22aff9 100644
--- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppDetailModal.tsx
+++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppDetailModal.tsx
@@ -264,7 +264,7 @@ const MiniAppDetailModal: React.FC = ({
{copy.title}
- {copy.description}
+ {copy.description}
);
diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx
index 91c56d531f..f3d767bdce 100644
--- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx
+++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx
@@ -391,7 +391,7 @@ const MiniAppMarketView: React.FC = ({ tabs }) => {
v{item.latestRelease}
{name}
- {description}
+ {description}
{item.ratingAverage.toFixed(1)}
{formatNumber(item.downloadCount)}
diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx
index 77c6d08060..b1a11418de 100644
--- a/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx
+++ b/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx
@@ -79,11 +79,11 @@ const AssistantCard: React.FC
= ({
)}
{vibe ? (
- {vibe}
+ {vibe}
) : (
-
+
{t('nursery.card.noVibe')}
-
+
)}
{creature ? (
diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx
index 05f55d8559..edefe1775c 100644
--- a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx
+++ b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx
@@ -502,7 +502,7 @@ const SkillsScene: React.FC = () => {
{skill.name}
{skill.description?.trim() && (
- {skill.description}
+ {skill.description}
)}
{skill.isBuiltin && (
diff --git a/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx b/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx
index 2d8b99e443..84714054ec 100644
--- a/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx
+++ b/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx
@@ -82,7 +82,7 @@ const SkillCard: React.FC = ({
{description?.trim() && (
- {description.trim()}
+ {description.trim()}
)}
diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx
index bc8494560c..f03662604b 100644
--- a/src/web-ui/src/flow_chat/components/ChatInput.tsx
+++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx
@@ -6085,7 +6085,7 @@ export const ChatInput: React.FC = ({
onMouseEnter={() => setSlashCommandState(prev => ({ ...prev, selectedIndex: index }))}
>
{action.command}
- {action.label}
+ {action.label}
))
) : (
diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss
index 11b41b25b3..910ba58e77 100644
--- a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss
+++ b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss
@@ -111,16 +111,13 @@
}
&__tooltip-message {
- display: -webkit-box;
- overflow: hidden;
+ display: block;
color: var(--openbitfun-color-content-primary);
font-size: var(--openbitfun-type-flow-control-font-size);
line-height: var(--openbitfun-type-flow-support-line-height);
letter-spacing: var(--openbitfun-type-body-sm-letter-spacing);
overflow-wrap: anywhere;
white-space: pre-wrap;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 5;
}
}
diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx
index 8cfa98b847..4d2ec20694 100644
--- a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx
@@ -285,6 +285,8 @@ export const FlowChatTurnRail: React.FC = ({
key={turn.itemKey}
placement="right"
delay={250}
+ interactive
+ trigger="hover-focus"
className="flowchat-turn-rail__tooltip"
content={(
diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx
index f7832e82f3..a4701cbf4a 100644
--- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx
+++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx
@@ -87,7 +87,7 @@ export const ToolbarMode: React.FC = () => {
}
// Fallback to the user's latest message.
- return lastTurn.userMessage?.content?.slice(0, 100) || null;
+ return lastTurn.userMessage?.content || null;
}, [activeSession]);
// Derive current streaming state from session data.
@@ -120,7 +120,7 @@ export const ToolbarMode: React.FC = () => {
toolName = effectiveItem.toolName;
if (effectiveItem.toolCall?.input && typeof effectiveItem.toolCall.input === 'object') {
const input = effectiveItem.toolCall.input;
- content = input.path || input.command || input.query || input.content?.slice(0, 50) || t('toolCards.toolbar.executing');
+ content = input.path || input.command || input.query || input.content || t('toolCards.toolbar.executing');
} else {
content = t('toolCards.toolbar.executing');
}
diff --git a/src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.tsx b/src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.tsx
index 77f76aa6b4..17362ae652 100644
--- a/src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.tsx
+++ b/src/web-ui/src/flow_chat/deep-review/action-bar/RemediationSelectionPanel.tsx
@@ -183,7 +183,7 @@ export const RemediationSelectionPanel: React.FC
/>
{isCompleted && (
diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx
index d1af082183..c0012e0a78 100644
--- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx
+++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx
@@ -636,7 +636,7 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial
>
{item.name}
{item.author || item.owner.login} · v{item.packageVersion}
- {item.description}
+ {item.description}
{local && (
vi.fn());
const selectAppearanceMock = vi.hoisted(() => vi.fn());
const appearanceStateMock = vi.hoisted(() => ({
@@ -143,6 +145,7 @@ describe('AppearancePackageConfigSection', () => {
it('reuses the built-in high-density artwork in the hover preview without requesting a package asset', async () => {
appearanceStateMock.selectedAppearanceId = 'system';
const container = document.createElement('div');
+ document.body.appendChild(container);
const root = createRoot(container);
try {
@@ -163,11 +166,13 @@ describe('AppearancePackageConfigSection', () => {
expect(getPreviewAssetMock).not.toHaveBeenCalledWith('system');
} finally {
act(() => root.unmount());
+ container.remove();
}
});
it('renders stored preview assets and releases their object URLs', async () => {
const container = document.createElement('div');
+ document.body.appendChild(container);
const root = createRoot(container);
const createObjectURL = vi.fn(() => 'blob:appearance-preview');
const revokeObjectURL = vi.fn();
@@ -180,32 +185,35 @@ describe('AppearancePackageConfigSection', () => {
height: 9,
});
- await act(async () => {
- root.render();
- await Promise.resolve();
- });
-
- const preview = container.querySelector(
- '[data-appearance-id="sample.appearance"] .appearance-package-config__card-preview img',
- );
- expect(preview?.src).toBe('blob:appearance-preview');
- expect(preview?.alt).toBe('');
- expect(preview?.closest('article')?.getAttribute('aria-label')).toBe('Sample Appearance');
- expect(container.querySelectorAll('.appearance-package-config__selected-mark')).toHaveLength(1);
- expect(container.querySelector('.appearance-package-config__card-preview--builtin')).not.toBeNull();
- expect(createObjectURL).toHaveBeenCalledOnce();
+ try {
+ await act(async () => {
+ root.render();
+ await Promise.resolve();
+ });
- await act(async () => {
- preview?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
- await new Promise(resolve => setTimeout(resolve, 220));
- });
+ const preview = container.querySelector(
+ '[data-appearance-id="sample.appearance"] .appearance-package-config__card-preview img',
+ );
+ expect(preview?.src).toBe('blob:appearance-preview');
+ expect(preview?.alt).toBe('');
+ expect(preview?.closest('article')?.getAttribute('aria-label')).toBe('Sample Appearance');
+ expect(container.querySelectorAll('.appearance-package-config__selected-mark')).toHaveLength(1);
+ expect(container.querySelector('.appearance-package-config__card-preview--builtin')).not.toBeNull();
+ expect(createObjectURL).toHaveBeenCalledOnce();
- const largerPreview = document.querySelector(
- '[data-testid="appearance-package-preview-popover"] img',
- );
- expect(largerPreview?.src).toBe('blob:appearance-preview');
+ await act(async () => {
+ preview?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
+ await new Promise(resolve => setTimeout(resolve, 220));
+ });
- act(() => root.unmount());
+ const largerPreview = document.querySelector(
+ '[data-testid="appearance-package-preview-popover"] img',
+ );
+ expect(largerPreview?.src).toBe('blob:appearance-preview');
+ } finally {
+ act(() => root.unmount());
+ container.remove();
+ }
expect(revokeObjectURL).toHaveBeenCalledWith('blob:appearance-preview');
});
diff --git a/src/web-ui/src/infrastructure/config/components/MCPResourceBrowser.tsx b/src/web-ui/src/infrastructure/config/components/MCPResourceBrowser.tsx
index 758dcd2d62..145f548e26 100644
--- a/src/web-ui/src/infrastructure/config/components/MCPResourceBrowser.tsx
+++ b/src/web-ui/src/infrastructure/config/components/MCPResourceBrowser.tsx
@@ -183,7 +183,7 @@ export const MCPResourceBrowser: React.FC = ({ serverId
{resource.name}
{resource.description && (
-
{resource.description}
+
{resource.description}
)}
{resource.uri}
diff --git a/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx b/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx
index 91567d80b6..7deec36760 100644
--- a/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx
+++ b/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx
@@ -464,9 +464,9 @@ const SkillsConfig: React.FC = () => {
{t('market.item.installs', { count: skill.installs })}
-
+
{skill.description?.trim() || t('market.item.noDescription')}
-
+
{skill.source ? (
sourceLabel !== skill.source ? (
diff --git a/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx b/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx
index 385f468333..d2309a5959 100644
--- a/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx
+++ b/src/web-ui/src/shared/announcement-system/components/AnnouncementToastItem.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
-import { Button, Icon } from '@openbitfun/ui';
+import { Button, Icon, OverflowText } from '@openbitfun/ui';
import type { AnnouncementCard } from '../types';
import { useAnnouncementStore } from '../store/announcementStore';
import { useAnnouncementI18n } from '../hooks/useAnnouncementI18n';
@@ -71,7 +71,7 @@ const AnnouncementToastItem: React.FC
= ({ card }) => {
>
{/* Row 1: title + close (with optional countdown ring) */}
-
{resolve(toast.title)}
+
{resolve(toast.title)}
{toast.dismissible && (
{autoDismissMs && (
@@ -100,7 +100,7 @@ const AnnouncementToastItem: React.FC
= ({ card }) => {
{/* Row 2: description */}
-
{resolve(toast.description)}
+
{resolve(toast.description)}
{/* Row 3: action buttons */}
diff --git a/src/web-ui/src/shared/context-system/core/types/WebElementContextImpl.tsx b/src/web-ui/src/shared/context-system/core/types/WebElementContextImpl.tsx
index 620b92b817..5811f7a3f3 100644
--- a/src/web-ui/src/shared/context-system/core/types/WebElementContextImpl.tsx
+++ b/src/web-ui/src/shared/context-system/core/types/WebElementContextImpl.tsx
@@ -100,7 +100,7 @@ export class WebElementCardRenderer implements ContextCardRenderer<'web-element'
{v && (
<>
=
-
+
"{v.length > 20 ? `${v.slice(0, 20)}…` : v}"
>
@@ -110,7 +110,7 @@ export class WebElementCardRenderer implements ContextCardRenderer<'web-element'
)}
{context.textContent && (
-
+
{context.textContent.length > 80
? `${context.textContent.slice(0, 80)}…`
: context.textContent}
diff --git a/src/web-ui/src/shared/ui/OverflowText.test.tsx b/src/web-ui/src/shared/ui/OverflowText.test.tsx
new file mode 100644
index 0000000000..e9f693cf2b
--- /dev/null
+++ b/src/web-ui/src/shared/ui/OverflowText.test.tsx
@@ -0,0 +1,222 @@
+// @vitest-environment jsdom
+
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { ListboxOption, OverflowText, Select, Tooltip } from '@openbitfun/ui';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+describe('overflow text full-content access', () => {
+ let host: HTMLDivElement;
+ let root: Root;
+ let availableWidth: number;
+ const resizeCallbacks = new Set<() => void>();
+ const longLabel = 'Run independent tasks concurrently whenever possible';
+
+ const render = (content: React.ReactNode) => act(() => root.render(content));
+ const hover = (element: Element) => act(() => {
+ element.dispatchEvent(new MouseEvent('mouseenter'));
+ });
+ const reveal = () => {
+ act(() => vi.advanceTimersByTime(500));
+ act(() => vi.advanceTimersByTime(20));
+ };
+ const tooltip = () => document.querySelector('[role="tooltip"]');
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ availableWidth = 100;
+ vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) {
+ return this.hasAttribute('data-overflow') ? availableWidth : 0;
+ });
+ vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
+ return this.hasAttribute('data-overflow') || this.hasAttribute('data-overflow-content')
+ ? (this.textContent?.length ?? 0) * 8
+ : 0;
+ });
+ vi.stubGlobal('ResizeObserver', class {
+ constructor(private callback: () => void) { resizeCallbacks.add(callback); }
+ observe() {}
+ disconnect() { resizeCallbacks.delete(this.callback); }
+ });
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0));
+ vi.stubGlobal('cancelAnimationFrame', clearTimeout);
+ host = document.createElement('div');
+ document.body.append(host);
+ root = createRoot(host);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ resizeCallbacks.clear();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ });
+
+ it('shows the complete label from the entire owning control and keeps its click behavior', () => {
+ const onClick = vi.fn();
+ render();
+ const button = host.querySelector('button')!;
+ hover(button);
+ reveal();
+ expect(tooltip()?.textContent).toBe(longLabel);
+ expect(button.getAttribute('aria-describedby')).toContain(tooltip()!.id);
+ expect(host.querySelector('[title]')).toBeNull();
+ expect(host.querySelector('[tabindex]')).toBeNull();
+ act(() => button.click());
+ expect(onClick).toHaveBeenCalledOnce();
+ expect(tooltip()).toBeNull();
+ expect(button.getAttribute('aria-describedby')).toBe('help');
+ });
+
+ it('reveals rich labels and plain-text arrays without waiting for a marquee', () => {
+ render();
+ hover(host.querySelector('button')!);
+ reveal();
+ expect(tooltip()?.textContent).toBe(longLabel);
+ render();
+ expect(tooltip()?.textContent).toBe(longLabel);
+ });
+
+ it('opens on keyboard focus, dismisses with Escape, and clears pending opens on blur', () => {
+ render();
+ const button = host.querySelector('button')!;
+ act(() => button.focus());
+ reveal();
+ expect(tooltip()?.textContent).toBe(longLabel);
+ act(() => button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
+ reveal();
+ expect(tooltip()).toBeNull();
+ act(() => { button.blur(); button.focus(); button.blur(); });
+ reveal();
+ expect(tooltip()).toBeNull();
+ });
+
+ it('combines overflowing label and metadata into one tooltip per control', () => {
+ const metadata = 'A second long metadata string';
+ render();
+ hover(host.querySelector('button')!);
+ reveal();
+ expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(1);
+ expect(tooltip()?.textContent).toBe(`${longLabel}\n${metadata}`);
+ });
+
+ it('cancels a delayed tooltip when the pointer leaves or Escape is pressed before opening', () => {
+ render();
+ const button = host.querySelector('button')!;
+ hover(button);
+ act(() => button.dispatchEvent(new MouseEvent('mouseleave')));
+ reveal();
+ expect(tooltip()).toBeNull();
+ act(() => button.focus());
+ act(() => button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
+ reveal();
+ expect(tooltip()).toBeNull();
+ });
+
+ it('uses an explicit full title for shortened text and preserves the empty-title opt-out', () => {
+ render(Run independent tasks...);
+ hover(host.querySelector('[data-overflow]')!);
+ reveal();
+ expect(tooltip()?.textContent).toBe(longLabel);
+ render({longLabel});
+ hover(host.querySelector('[data-overflow]')!);
+ reveal();
+ expect(tooltip()).toBeNull();
+ });
+
+ it('updates the open text and stops showing a tooltip when it fits after resizing', () => {
+ render({longLabel});
+ hover(host.querySelector('[data-overflow]')!);
+ reveal();
+ const updated = `${longLabel} on the selected host`;
+ render({updated});
+ expect(tooltip()?.textContent).toBe(updated);
+ availableWidth = 1000;
+ act(() => resizeCallbacks.forEach(callback => callback()));
+ expect(tooltip()).toBeNull();
+ hover(host.querySelector('[data-overflow]')!);
+ reveal();
+ expect(tooltip()).toBeNull();
+ });
+
+ it('keeps an explicit tooltip as the sole owner of the control', () => {
+ render(
+
+ );
+ act(() => host.querySelector('button')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })));
+ reveal();
+ expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(1);
+ expect(tooltip()?.textContent).toBe('Existing full description');
+ });
+
+ it('detects vertical clipping and preserves multiline paragraph semantics', () => {
+ availableWidth = 1000;
+ vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(40);
+ vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(120);
+ render();
+ expect(host.querySelector('p')?.getAttribute('data-overflow')).toBe('true');
+ expect(host.querySelector('p')?.getAttribute('data-overflow-behavior')).toBe('fade');
+ expect(host.querySelector('[data-overflow-content]')).toBeNull();
+ hover(host.querySelector('button')!);
+ reveal();
+ expect(tooltip()?.textContent).toBe(longLabel);
+ });
+
+ it('leaves short, fully visible labels without a tooltip', () => {
+ render();
+ hover(host.querySelector('button')!);
+ act(() => host.querySelector('button')!.focus());
+ reveal();
+ expect(tooltip()).toBeNull();
+ });
+
+ it('reveals a virtually focused listbox option without adding a tab stop', () => {
+ render({longLabel});
+ reveal();
+ expect(tooltip()?.textContent).toBe(longLabel);
+ render({longLabel});
+ reveal();
+ expect(tooltip()).toBeNull();
+ expect(host.querySelector('button')?.tabIndex).toBe(-1);
+ });
+
+ it('shows only the hovered option when another option still has keyboard focus', () => {
+ const other = 'Another long option with a complete description';
+ render(<>{longLabel}{other}>);
+ const [first, second] = host.querySelectorAll('button');
+ act(() => first.focus());
+ reveal();
+ hover(second);
+ reveal();
+ expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(1);
+ expect(tooltip()?.textContent).toBe(other);
+ });
+
+ it('covers both the settings Select value and its portalled options', () => {
+ render();
+ const trigger = host.querySelector('[data-openbitfun-part="trigger"]')!;
+ hover(trigger);
+ reveal();
+ expect(tooltip()?.textContent).toBe(longLabel);
+ act(() => trigger.click());
+ const option = document.querySelector('[role="option"]')!;
+ expect(host.contains(option)).toBe(false);
+ hover(option);
+ reveal();
+ expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(1);
+ expect(tooltip()?.textContent).toBe(longLabel);
+ act(() => option.click());
+ expect(document.querySelector('[role="listbox"]')).toBeNull();
+ expect(tooltip()).toBeNull();
+ });
+});
diff --git a/src/web-ui/src/shared/utils/tabUtils.ts b/src/web-ui/src/shared/utils/tabUtils.ts
index 3d27717298..b1f93cf217 100644
--- a/src/web-ui/src/shared/utils/tabUtils.ts
+++ b/src/web-ui/src/shared/utils/tabUtils.ts
@@ -449,13 +449,9 @@ export function createTerminalTab(
mode: 'agent' | 'project' | 'bottom-terminal' = 'agent',
options: CreateTerminalTabOptions = {}
): void {
- const title = sessionName.length > 20
- ? `${sessionName.slice(0, 20)}...`
- : sessionName;
-
const detail = {
type: 'terminal',
- title: `${title}`,
+ title: sessionName,
data: { sessionId, sessionName },
metadata: {
isTerminal: true,
diff --git a/src/web-ui/src/tools/file-system/components/FileSearchResults.tsx b/src/web-ui/src/tools/file-system/components/FileSearchResults.tsx
index 5fd792f198..07e6b128c4 100644
--- a/src/web-ui/src/tools/file-system/components/FileSearchResults.tsx
+++ b/src/web-ui/src/tools/file-system/components/FileSearchResults.tsx
@@ -251,7 +251,7 @@ const FileGroup = memo(({
-
+
{group.path}
diff --git a/src/web-ui/src/tools/openbitfun-canvas/runtime/sdk/charts.tsx b/src/web-ui/src/tools/openbitfun-canvas/runtime/sdk/charts.tsx
index 25ffef4478..2d4d41a387 100644
--- a/src/web-ui/src/tools/openbitfun-canvas/runtime/sdk/charts.tsx
+++ b/src/web-ui/src/tools/openbitfun-canvas/runtime/sdk/charts.tsx
@@ -195,6 +195,7 @@ export function BarChart(props: CanvasChartProps = {}) {
)}
{labels.map((label, index) => (
+ {String(label)}
{String(label).slice(0, 14)}
))}
@@ -233,6 +234,7 @@ export function LineChart(props: CanvasChartProps = {}) {
)}
{labels.map((label, index) => (
+ {String(label)}
{String(label).slice(0, 14)}
))}
@@ -302,6 +304,7 @@ export function PieChart(props: CanvasChartProps = {}) {
{paths}
{slices.slice(0, 6).map((item, index) => (
+ {String(item.label)}
{String(item.label).slice(0, 20)}
diff --git a/src/web-ui/src/tools/openbitfun-canvas/runtime/sdk/diagrams.tsx b/src/web-ui/src/tools/openbitfun-canvas/runtime/sdk/diagrams.tsx
index 9a19c49f0f..c8215bb4bd 100644
--- a/src/web-ui/src/tools/openbitfun-canvas/runtime/sdk/diagrams.tsx
+++ b/src/web-ui/src/tools/openbitfun-canvas/runtime/sdk/diagrams.tsx
@@ -110,6 +110,7 @@ function renderGraphSvg({
fill="var(--openbitfun-color-content-muted)"
fontSize="var(--openbitfun-type-micro-font-size)"
>
+ {String(meta.label)}
{String(meta.label).slice(0, 18)}
) : null}
@@ -124,6 +125,7 @@ function renderGraphSvg({
const color = toneColor(node?.tone);
return (
+ {[label, description].filter(Boolean).join('\n')}