diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx new file mode 100644 index 00000000000..3bc38ed92b0 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.test.tsx @@ -0,0 +1,30 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { PreviewStatusRow } from './preview-row' + +describe('PreviewStatusRow', () => { + afterEach(() => { + cleanup() + }) + + it('keeps the preview tooltip label inline inside the portaled decoration', async () => { + const view = render( + undefined} + /> + ) + + fireEvent.pointerMove(screen.getByText('preview.html'), { pointerType: 'mouse' }) + await screen.findByRole('tooltip') + + const content = document.querySelector('[data-slot="tooltip-content"]') + const label = content?.firstElementChild?.firstElementChild + + expect(content).not.toBeNull() + expect(view.container.contains(content)).toBe(false) + expect(label?.classList.contains('inline-flex')).toBe(true) + expect(label?.classList.contains('flex')).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx index 5e559365112..cf721d2ae93 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -113,7 +113,9 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss > + // inline-flex (not flex): a block child collapses Tip's decoration + // wrapper geometry and mis-positions the tooltip (#62022). + {item.target} {t.preview.linkHint} diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx new file mode 100644 index 00000000000..60b2113916a --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx @@ -0,0 +1,36 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { $bindings } from '@/store/keybinds' + +import { TerminalRail } from './rail' +import { $activeTerminalId, $terminals } from './terminals' + +describe('TerminalRail', () => { + beforeEach(() => { + $terminals.set([{ auto: true, cwd: 'C:\\repo', id: 'term-1', kind: 'user', title: 'PowerShell' }]) + $activeTerminalId.set('term-1') + $bindings.set({ ...$bindings.get(), 'view.showTerminal': ['ctrl+`'] }) + }) + + afterEach(() => { + cleanup() + $terminals.set([]) + $activeTerminalId.set(null) + }) + + it('keeps a hotkey label inline inside the portaled tooltip decoration', async () => { + const view = render() + + fireEvent.pointerMove(screen.getByRole('tab', { name: '1. PowerShell' }), { pointerType: 'mouse' }) + await screen.findByRole('tooltip') + + const content = document.querySelector('[data-slot="tooltip-content"]') + const label = content?.firstElementChild?.firstElementChild + + expect(content).not.toBeNull() + expect(view.container.contains(content)).toBe(false) + expect(label?.classList.contains('inline-flex')).toBe(true) + expect(label?.classList.contains('flex')).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx index c5a07bb8e6d..8a81a16925b 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx @@ -8,7 +8,7 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu' -import { Tip } from '@/components/ui/tooltip' +import { Tip, TipHintLabel } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' @@ -30,18 +30,6 @@ import { const RAIL_ACTION = 'grid size-6 place-items-center rounded text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring [-webkit-app-region:no-drag]' -/** Tooltip label with a trailing hotkey hint (the user's live binding). */ -function hintLabel(text: string, combo?: string) { - return combo ? ( - - {text} - {formatCombo(combo)} - - ) : ( - text - ) -} - /** Thin icon "bookmark" strip blended into the terminal surface, shown whenever a * terminal exists. Each square is a tab (name + hotkey on hover); close via the * shell's `exit`, middle-click, or the context menu. */ @@ -78,7 +66,10 @@ export function TerminalRail() { /> ))}
  • - + } + side="left" + > - - ) - - fireEvent.pointerMove(screen.getByRole('button', { name: 'target' }), { pointerType: 'mouse' }) - const tip = await screen.findByRole('tooltip') - // Role lives on the visually-hidden a11y node; the portaled content root - // is the data-slot wrapper that must stay click-through. - const content = tip.closest('[data-slot="tooltip-content"]') ?? tip.parentElement - expect(content?.className).toMatch(/pointer-events-none/) - }) - it('renders the child alone when label is empty', () => { render( @@ -53,4 +36,39 @@ describe('Tip', () => { expect(screen.getByRole('button', { name: 'bare' })).toBeTruthy() expect(screen.queryByRole('tooltip')).toBeNull() }) + + it('guards a block-level label child via the decoration wrapper class', async () => { + render( + broken label}> + + + ) + + fireEvent.pointerMove(screen.getByRole('button', { name: 'trigger' }), { pointerType: 'mouse' }) + await screen.findByRole('tooltip') + + // jsdom applies no real Tailwind, so assert the guarding class is present on + // the decoration wrapper — that's what forces any direct child inline-flex + // in a browser (#62022). + const decoration = document.querySelector('[data-slot="tooltip-content"]')?.firstElementChild + + expect(decoration?.className).toMatch(/\[&>\*\]:!inline-flex/) + }) +}) + +describe('TipHintLabel', () => { + afterEach(() => { + cleanup() + }) + + it('renders inline-flex with a hint and plain text without one', () => { + const { rerender } = render() + const withHint = screen.getByText('PowerShell').parentElement + + expect(withHint?.classList.contains('inline-flex')).toBe(true) + expect(withHint?.classList.contains('flex')).toBe(false) + + rerender() + expect(screen.getByText('PowerShell').tagName).not.toBe('SPAN') + }) }) diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index d1053585869..669a0eada7e 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -53,7 +53,11 @@ function TooltipContent({ {/* bg-foreground/text-background auto-inverts per theme. leading-normal keeps lines readable; py-1 makes the cloned line-boxes overlap just enough to read as one continuous fill (no gaps between lines). */} - + {/* [&>*]:!inline-flex: a block-level label child (e.g. `flex`) collapses + this inline decoration's geometry, so Radix measures a zero-size chip + and parks an empty rectangle in the corner (#62022). Force any direct + child inline-flex so every call site stays safe. */} + {children} @@ -86,4 +90,25 @@ function Tip({ label, children, delayDuration = 0, ...props }: TipProps) { ) } -export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } +interface TipHintLabelProps { + text: string + hint?: string +} + +/** Tooltip label with an optional trailing hotkey hint. Uses `inline-flex` so it + * stays safe inside Tip's decoration wrapper — prefer this over a bespoke + * flex/gap span at the call site (see #62022). */ +function TipHintLabel({ text, hint }: TipHintLabelProps) { + if (!hint) { + return <>{text} + } + + return ( + + {text} + {hint} + + ) +} + +export { Tip, TipHintLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }