Compare commits

...

1 Commits

Author SHA1 Message Date
ethernet
13196da0d3 fix(desktop): slash command keyboard selection snaps back + scroll into view
The / menu arrow-key selection was instantly resetting to the first
item because refreshTrigger() unconditionally called
setTriggerActive(0) on every keyup. Now only resets when the trigger
kind or query string actually changed.

Also adds scroll-into-view for keyboard navigation (only, not on
mouse hover) via an imperative handle on the trigger popover, and
splits the row transition so keyboard highlight is instant while
mouse hover remains smooth.
2026-06-03 01:23:13 -04:00
3 changed files with 63 additions and 15 deletions

View File

@@ -24,7 +24,12 @@ export const COMPLETION_DRAWER_BELOW_CLASS = [
export const COMPLETION_DRAWER_ROW_CLASS = [
'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1',
'w-full min-w-0 text-left text-xs outline-hidden transition-colors',
'w-full min-w-0 text-left text-xs outline-hidden',
// Keyboard selection (data-highlighted / activeIndex) should feel instant —
// no transition on background so the highlight snaps to the new row.
// Mouse hover keeps a smooth 200ms color transition for that buttery feel.
'transition-[color,border-color,text-decoration-color] duration-0',
'hover:transition-colors hover:duration-200',
'hover:bg-(--ui-bg-tertiary)',
'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground'
].join(' ')

View File

@@ -65,7 +65,7 @@ import {
} from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import { ComposerTriggerPopover, type ComposerTriggerPopoverHandle } from './trigger-popover'
import type { ChatBarProps } from './types'
import { UrlDialog } from './url-dialog'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
@@ -125,6 +125,7 @@ export function ChatBar({
const previousBusyRef = useRef(busy)
const drainingQueueRef = useRef(false)
const urlInputRef = useRef<HTMLInputElement | null>(null)
const triggerPopoverRef = useRef<ComposerTriggerPopoverHandle>(null)
const [urlOpen, setUrlOpen] = useState(false)
const [urlValue, setUrlValue] = useState('')
@@ -441,8 +442,19 @@ export function ChatBar({
const before = textBeforeCaret(editor)
const detected = detectTrigger(before ?? composerPlainText(editor))
// Only reset the active index when the trigger state actually changed
// (new trigger opened, trigger closed, or query text changed). When the
// kind + query are the same (e.g. the user just pressed ArrowUp/Down),
// preserve the selection so keyboard navigation isn't wiped out by the
// keyup→refreshTrigger round-trip.
const queryChanged =
trigger?.kind !== detected?.kind || trigger?.query !== detected?.query
setTrigger(detected)
setTriggerActive(0)
if (queryChanged) {
setTriggerActive(0)
}
}, [trigger])
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
@@ -559,6 +571,7 @@ export function ChatBar({
if (event.key === 'ArrowDown') {
event.preventDefault()
setTriggerActive(idx => (idx + 1) % triggerItems.length)
requestAnimationFrame(() => triggerPopoverRef.current?.scrollActiveIntoView())
return
}
@@ -566,6 +579,7 @@ export function ChatBar({
if (event.key === 'ArrowUp') {
event.preventDefault()
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
requestAnimationFrame(() => triggerPopoverRef.current?.scrollActiveIntoView())
return
}
@@ -1092,6 +1106,7 @@ export function ChatBar({
{showHelpHint && <HelpHint />}
{trigger && (
<ComposerTriggerPopover
ref={triggerPopoverRef}
activeIndex={triggerActive}
items={triggerItems}
kind={trigger.kind}

View File

@@ -1,4 +1,5 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { forwardRef, useImperativeHandle, useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
@@ -51,21 +52,47 @@ interface ComposerTriggerPopoverProps {
placement?: 'bottom' | 'top'
}
export function ComposerTriggerPopover({
activeIndex,
items,
kind,
loading,
onHover,
onPick,
placement = 'top'
}: ComposerTriggerPopoverProps) {
export interface ComposerTriggerPopoverHandle {
scrollActiveIntoView: () => void
}
export const ComposerTriggerPopover = forwardRef<
ComposerTriggerPopoverHandle,
ComposerTriggerPopoverProps
>(function ComposerTriggerPopover(
{ activeIndex, items, kind, loading, onHover, onPick, placement = 'top' },
ref
) {
const containerRef = useRef<HTMLDivElement | null>(null)
// Expose scrollActiveIntoView so the keyboard handler in the parent can
// trigger a scroll only on arrow-key events — mouse hover never calls this.
useImperativeHandle(ref, () => ({
scrollActiveIntoView() {
const container = containerRef.current
if (!container) return
const highlighted = container.querySelector<HTMLElement>('[data-highlighted]')
if (!highlighted) return
const buttonRect = highlighted.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
if (buttonRect.top < containerRect.top) {
container.scrollTop -= containerRect.top - buttonRect.top
} else if (buttonRect.bottom > containerRect.bottom) {
container.scrollTop += buttonRect.bottom - containerRect.bottom
}
}
}))
return (
<div
className={placement === 'bottom' ? COMPLETION_DRAWER_BELOW_CLASS : COMPLETION_DRAWER_CLASS}
data-slot="composer-completion-drawer"
data-state="open"
onMouseDown={event => event.preventDefault()}
ref={containerRef}
role="listbox"
>
{items.length === 0 ? (
@@ -86,11 +113,12 @@ export function ComposerTriggerPopover({
const meta = item.metadata as { display?: string; meta?: string } | undefined
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
const description = meta?.meta || item.description
const isActive = index === activeIndex
return (
<button
className={cn(COMPLETION_DRAWER_ROW_CLASS, index === activeIndex && 'bg-(--ui-bg-tertiary)')}
data-highlighted={index === activeIndex ? '' : undefined}
className={cn(COMPLETION_DRAWER_ROW_CLASS, isActive && 'bg-(--ui-bg-tertiary)')}
data-highlighted={isActive ? '' : undefined}
key={item.id}
onClick={() => onPick(item)}
onMouseEnter={() => onHover(index)}
@@ -109,4 +137,4 @@ export function ComposerTriggerPopover({
)}
</div>
)
}
})