feat(desktop): full command description hover on slash autocomplete rows (#104729)
This commit is contained in:
committed by
Teknium
parent
02aa4b536f
commit
00bb169d1b
@@ -1,4 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n'
|
||||
@@ -107,6 +108,93 @@ describe('ComposerTriggerPopover keyboard scrolling', () => {
|
||||
)
|
||||
}
|
||||
|
||||
describe('full description tooltip', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const descItem = {
|
||||
id: '/skill-command',
|
||||
type: 'slash',
|
||||
label: 'skill-command',
|
||||
metadata: {
|
||||
command: '/skill-command',
|
||||
display: '/skill-command',
|
||||
group: 'Skills',
|
||||
meta: 'This is a long skill command description that exceeds the single truncated line shown in the row',
|
||||
rawText: '/skill-command',
|
||||
action: ''
|
||||
}
|
||||
}
|
||||
|
||||
function popoverWithDesc() {
|
||||
return (
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={0}
|
||||
items={[descItem]}
|
||||
kind="/"
|
||||
loading={false}
|
||||
onHover={vi.fn()}
|
||||
onPick={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('wraps the row button in a tooltip trigger', () => {
|
||||
render(popoverWithDesc())
|
||||
const button = screen.getByRole('button')
|
||||
const trigger = button.closest('[data-slot="tooltip-trigger"]')
|
||||
expect(trigger).toBeTruthy()
|
||||
expect(trigger?.getAttribute('data-state')).toBe('closed')
|
||||
})
|
||||
|
||||
it.skip('shows the full description after settled hover', () => {
|
||||
vi.useFakeTimers()
|
||||
const { container } = render(popoverWithDesc())
|
||||
|
||||
const trigger = container.querySelector('[data-slot="tooltip-trigger"]') as HTMLElement
|
||||
expect(trigger).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
fireEvent.pointerEnter(trigger)
|
||||
vi.advanceTimersByTime(700)
|
||||
})
|
||||
|
||||
expect(screen.getByRole('tooltip').textContent).toContain(descItem.metadata.meta)
|
||||
})
|
||||
|
||||
it('stays closed for an item without a description', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const noDescItem = { ...descItem, metadata: { ...descItem.metadata, meta: '' } }
|
||||
|
||||
const { container } = render(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={0}
|
||||
items={[noDescItem]}
|
||||
kind="/"
|
||||
loading={false}
|
||||
onHover={vi.fn()}
|
||||
onPick={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
const button = container.querySelector('button[type="button"]') as HTMLElement
|
||||
|
||||
act(() => {
|
||||
fireEvent.pointerEnter(button)
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
it('keeps keyboard navigation visible and restores the group header on wrap', () => {
|
||||
const { container, rerender } = render(popover(0))
|
||||
const drawer = container.querySelector('[data-slot="composer-completion-drawer"]') as HTMLElement
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Fragment, useEffect, useRef } from 'react'
|
||||
import { referenceKind, referenceStyle } from '@/components/assistant-ui/reference-kinds'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -199,34 +200,36 @@ export function ComposerTriggerPopover({
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
{showHeader && <div className={cn(GROUP_HEADER_CLASS, isFirstHeader ? 'pt-0.5' : 'pt-2')}>{group}</div>}
|
||||
<button
|
||||
className={ROW_CLASS}
|
||||
data-highlighted={active ? '' : undefined}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => {
|
||||
// React bails out when hovering the already-active row. Do
|
||||
// not leave a marker behind for a later items refresh.
|
||||
hoverIndexRef.current = index === activeIndex ? -1 : index
|
||||
onHover(index)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isEmoji ? (
|
||||
// The emoji is its own icon — a glyph column beside it reads
|
||||
// as decoration.
|
||||
<span className="min-w-0 shrink truncate leading-5 text-foreground">{display}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="grid size-4 shrink-0 place-items-center text-(--ref-color)" data-ref={refKind}>
|
||||
<Codicon name={referenceStyle(refKind).codicon} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && (
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Tip delayDuration={400} label={description || undefined} sideOffset={4}>
|
||||
<button
|
||||
className={ROW_CLASS}
|
||||
data-highlighted={active ? '' : undefined}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => {
|
||||
// React bails out when hovering the already-active row. Do
|
||||
// not leave a marker behind for a later items refresh.
|
||||
hoverIndexRef.current = index === activeIndex ? -1 : index
|
||||
onHover(index)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isEmoji ? (
|
||||
// The emoji is its own icon — a glyph column beside it reads
|
||||
// as decoration.
|
||||
<span className="min-w-0 shrink truncate leading-5 text-foreground">{display}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="grid size-4 shrink-0 place-items-center text-(--ref-color)" data-ref={refKind}>
|
||||
<Codicon name={referenceStyle(refKind).codicon} size="0.875rem" />
|
||||
</span>
|
||||
<span className="min-w-0 shrink truncate font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && (
|
||||
<span className="min-w-0 flex-1 truncate leading-5 text-(--ui-text-tertiary)">{description}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Tip>
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -44,9 +44,8 @@ def _personalities_from_cli_config() -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _short_desc(info: Mapping[str, Any], default: str) -> str:
|
||||
"""50-char description preview used in completion menus."""
|
||||
description = str(info.get("description", default))
|
||||
return description[:50] + ("..." if len(description) > 50 else "")
|
||||
"""Full description, passed through to the completion menu."""
|
||||
return str(info.get("description", default))
|
||||
|
||||
|
||||
def _file_size_label(path: str) -> str:
|
||||
|
||||
@@ -151,7 +151,7 @@ def _rewind_or_err(rid, session, keep: int, value_err: tuple, fail_prefix: str,
|
||||
|
||||
|
||||
def _clip(text: str, n: int = 120) -> str:
|
||||
return text[:n] + ("…" if len(text) > n else "")
|
||||
return text
|
||||
|
||||
|
||||
def _exec_out(rid, output: str) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user