feat(desktop): full command description hover on slash autocomplete rows (#104729)

This commit is contained in:
Konstantin Khlopkov
2026-09-07 07:51:29 +03:00
committed by Teknium
parent 02aa4b536f
commit 00bb169d1b
4 changed files with 122 additions and 32 deletions
@@ -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>
)
})
+2 -3
View File
@@ -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:
+1 -1
View File
@@ -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: