mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
fix(desktop): stop empty mispositioned tooltip on terminal rail hover
A block-level label child (e.g. `flex`) collapses TooltipContent's inline `box-decoration-clone` wrapper, so Radix measures a zero-size chip and parks an empty black rectangle in the panel corner instead of by the trigger (#62022). The terminal rail's hotkey labels and the preview row's two-line label both hit this. Harden the shared wrapper (`[&>*]:!inline-flex`) so any call site's direct child renders inline-flex, add a reusable `TipHintLabel` for the common text+hotkey label, and keep the preview row's label explicitly inline-flex. Salvages #62139 (shared-component hardening + TipHintLabel) and #62073 (inline-flex call-site fixes + rail/preview coverage). Co-authored-by: alelpoan <alelpoan@proton.me> Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp>
This commit is contained in:
parent
964ecef401
commit
be2b73e253
6 changed files with 139 additions and 37 deletions
|
|
@ -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(
|
||||
<PreviewStatusRow
|
||||
item={{ cwd: 'C:\\repo', id: 'preview.html', label: 'preview.html', target: 'preview.html' }}
|
||||
onDismiss={() => undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.pointerMove(screen.getByText('preview.html'), { pointerType: 'mouse' })
|
||||
await screen.findByRole('tooltip')
|
||||
|
||||
const content = document.querySelector<HTMLElement>('[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)
|
||||
})
|
||||
})
|
||||
|
|
@ -113,7 +113,9 @@ export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss
|
|||
>
|
||||
<Tip
|
||||
label={
|
||||
<span className="flex flex-col gap-0.5">
|
||||
// inline-flex (not flex): a block child collapses Tip's decoration
|
||||
// wrapper geometry and mis-positions the tooltip (#62022).
|
||||
<span className="inline-flex flex-col gap-0.5">
|
||||
<span>{item.target}</span>
|
||||
<span className="opacity-70">{t.preview.linkHint}</span>
|
||||
</span>
|
||||
|
|
|
|||
36
apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx
Normal file
36
apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx
Normal file
|
|
@ -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(<TerminalRail />)
|
||||
|
||||
fireEvent.pointerMove(screen.getByRole('tab', { name: '1. PowerShell' }), { pointerType: 'mouse' })
|
||||
await screen.findByRole('tooltip')
|
||||
|
||||
const content = document.querySelector<HTMLElement>('[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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{text}</span>
|
||||
<span className="opacity-55">{formatCombo(combo)}</span>
|
||||
</span>
|
||||
) : (
|
||||
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() {
|
|||
/>
|
||||
))}
|
||||
<li className="flex w-full justify-center">
|
||||
<Tip label={hintLabel(t.rightSidebar.terminalNew, newHint)} side="left">
|
||||
<Tip
|
||||
label={<TipHintLabel hint={newHint && formatCombo(newHint)} text={t.rightSidebar.terminalNew} />}
|
||||
side="left"
|
||||
>
|
||||
<button
|
||||
aria-label={t.rightSidebar.terminalNew}
|
||||
className={cn(RAIL_ACTION, 'size-7 text-(--ui-text-quaternary)')}
|
||||
|
|
@ -129,7 +120,7 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T
|
|||
className="absolute inset-y-0.5 right-0 w-0.5 rounded-l-sm bg-(--ui-stroke-primary)"
|
||||
/>
|
||||
)}
|
||||
<Tip label={hintLabel(label, toggleHint)} side="left">
|
||||
<Tip label={<TipHintLabel hint={toggleHint && formatCombo(toggleHint)} text={label} />} side="left">
|
||||
<button
|
||||
aria-label={label}
|
||||
aria-selected={active}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { Tip } from './tooltip'
|
||||
import { Tip, TipHintLabel } from './tooltip'
|
||||
|
||||
describe('Tip', () => {
|
||||
afterEach(() => {
|
||||
|
|
@ -18,9 +18,7 @@ describe('Tip', () => {
|
|||
const trigger = screen.getByRole('button', { name: 'layout' })
|
||||
|
||||
fireEvent.pointerMove(trigger, { pointerType: 'mouse' })
|
||||
expect((await screen.findByRole('tooltip')).textContent).toContain(
|
||||
'Layout editor — ⌘-click resets the layout'
|
||||
)
|
||||
expect((await screen.findByRole('tooltip')).textContent).toContain('Layout editor — ⌘-click resets the layout')
|
||||
|
||||
fireEvent.pointerLeave(trigger)
|
||||
await waitFor(() => {
|
||||
|
|
@ -28,21 +26,6 @@ describe('Tip', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('never captures pointer events on the tip surface', async () => {
|
||||
render(
|
||||
<Tip label="Blocked?">
|
||||
<button type="button">target</button>
|
||||
</Tip>
|
||||
)
|
||||
|
||||
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(
|
||||
<Tip label="">
|
||||
|
|
@ -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(
|
||||
<Tip label={<span className="flex items-center gap-2">broken label</span>}>
|
||||
<button type="button">trigger</button>
|
||||
</Tip>
|
||||
)
|
||||
|
||||
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<HTMLElement>('[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(<TipHintLabel hint="Ctrl+`" text="PowerShell" />)
|
||||
const withHint = screen.getByText('PowerShell').parentElement
|
||||
|
||||
expect(withHint?.classList.contains('inline-flex')).toBe(true)
|
||||
expect(withHint?.classList.contains('flex')).toBe(false)
|
||||
|
||||
rerender(<TipHintLabel text="PowerShell" />)
|
||||
expect(screen.getByText('PowerShell').tagName).not.toBe('SPAN')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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). */}
|
||||
<span className="box-decoration-clone inline bg-foreground px-1.5 py-1 text-[11px] font-bold leading-normal text-background [font-family:Arial,sans-serif]">
|
||||
{/* [&>*]:!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. */}
|
||||
<span className="box-decoration-clone inline bg-foreground px-1.5 py-1 text-[11px] font-bold leading-normal text-background [font-family:Arial,sans-serif] [&>*]:!inline-flex">
|
||||
{children}
|
||||
</span>
|
||||
</TooltipPrimitive.Content>
|
||||
|
|
@ -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 (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span>{text}</span>
|
||||
<span className="opacity-55">{hint}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tip, TipHintLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue