import { useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties, RefObject } from 'react'; import { createPortal } from 'react-dom'; import { cn } from '@/lib/utils'; import type { Skill } from '@/api/types'; interface Props { query: string; skills: Skill[]; // v1.12 CP7.5: was `anchorRect: {top, left}` (snapshot at open time). Now a // live ref so the dropdown can re-stat the input on visualViewport events — // critical on iOS where the keyboard shifts the visual viewport and the // dropdown would otherwise sit in the wrong place (often hidden). inputRef: RefObject; onSelect: (skillName: string) => void; onClose: () => void; } // max-h-[320px] on the popover — use as the height budget for above/below // fit decisions. Slightly under-estimates when the list is short, but the // only consequence is we sometimes flip below when we'd fit above; no UX // breakage either way. const DROPDOWN_HEIGHT_BUDGET = 320; // Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern — // fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn // `Command` (cmdk) isn't installed in this project; per the addendum we use // a plain div + Tailwind instead of pulling a new primitive autonomously. // // v1.12 CP7.5: portalled to document.body (escapes transformed/will-change // ancestor stacking contexts that hid the popover inside ChatInput on iOS) // + visualViewport-aware positioning (handles keyboard open/close + the iOS // "shift layout to keep input visible" auto-scroll). // Case-insensitive prefix match on `name` only. Description is display-only // in v1 (substring search across description is deferred to a polish batch). function filterByPrefix(skills: Skill[], query: string): Skill[] { const q = query.toLowerCase(); const filtered = q ? skills.filter((s) => s.name.toLowerCase().startsWith(q)) : skills; // Stable alphabetical ordering matches the server's cache order (skills.ts // sorts on name asc) but we re-sort here so a stale client cache doesn't // surprise the user. return [...filtered].sort((a, b) => a.name.localeCompare(b.name)); } export function SkillSlashCommand({ query, skills, inputRef, onSelect, onClose }: Props) { const [highlightIndex, setHighlightIndex] = useState(0); const popoverRef = useRef(null); const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]); // Anchor + viewport tracking. `rect` is the input's bounding rect in layout // viewport coords. `vvTick` forces a re-render whenever visualViewport // changes even if the rect itself didn't (e.g. user scrolled the visual // viewport without the input moving in layout space). const [rect, setRect] = useState( () => inputRef.current?.getBoundingClientRect() ?? null, ); const [vvTick, setVvTick] = useState(0); useEffect(() => { setHighlightIndex(0); }, [query]); // v1.12 CP7.5: recalc on viewport changes. iOS Safari fires // visualViewport.resize when the soft keyboard opens/closes; .scroll fires // when the page is shifted to keep the focused input visible above the // keyboard. Both events should trigger a position recompute. useEffect(() => { function recalc() { setRect(inputRef.current?.getBoundingClientRect() ?? null); setVvTick((t) => t + 1); } recalc(); const vv = window.visualViewport; vv?.addEventListener('resize', recalc); vv?.addEventListener('scroll', recalc); window.addEventListener('resize', recalc); return () => { vv?.removeEventListener('resize', recalc); vv?.removeEventListener('scroll', recalc); window.removeEventListener('resize', recalc); }; }, [inputRef]); // Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the // textarea reach the popover even though focus stays in the textarea. useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); } else if (e.key === 'Enter' || e.key === 'Tab') { if (filtered.length === 0) return; e.preventDefault(); const target = filtered[highlightIndex] ?? filtered[0]; if (target) onSelect(target.name); } else if (e.key === 'Escape') { e.preventDefault(); onClose(); } } document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [filtered, highlightIndex, onSelect, onClose]); useEffect(() => { function handleMouseDown(e: MouseEvent) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { onClose(); } } document.addEventListener('mousedown', handleMouseDown); return () => document.removeEventListener('mousedown', handleMouseDown); }, [onClose]); useEffect(() => { const el = popoverRef.current?.querySelector('[data-highlighted="true"]'); if (el) el.scrollIntoView({ block: 'nearest' }); }, [highlightIndex]); // v1.12 CP7.5: visualViewport-corrected positioning. getBoundingClientRect // returns layout-viewport coords; iOS Safari's `position: fixed` positions // relative to the layout viewport too — but the visible area can be offset // (vv.offsetTop/offsetLeft) when iOS scrolls the input above the keyboard. // Subtracting the vv offsets keeps the dropdown locked to the input's // visual position. vvTick is in the dep list to force recompute on // visualViewport events even when the rect itself didn't change. // // Default: position above the input (matches original UX). Flip below if // above doesn't fit (input too close to top of visible viewport). When // below would overlap the keyboard, cap top so the dropdown stays visible. const style = useMemo(() => { if (!rect) return { display: 'none' }; const vv = window.visualViewport; const vvOffsetTop = vv?.offsetTop ?? 0; const vvOffsetLeft = vv?.offsetLeft ?? 0; const vvHeight = vv?.height ?? window.innerHeight; const anchorTop = rect.top - vvOffsetTop; const anchorBottom = rect.bottom - vvOffsetTop; const left = rect.left - vvOffsetLeft; const fitsAbove = anchorTop >= DROPDOWN_HEIGHT_BUDGET; if (fitsAbove) { // translate(-100%) on Y so the dropdown grows upward from anchorTop. return { position: 'fixed', top: anchorTop, left, transform: 'translateY(-100%)', }; } // Render below; clamp so the bottom edge stays inside the visible viewport. const maxTop = Math.max(0, vvHeight - DROPDOWN_HEIGHT_BUDGET); return { position: 'fixed', top: Math.min(anchorBottom, maxTop), left, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [rect, vvTick]); const popover = filtered.length === 0 ? (
{query ? `No skill starts with "/${query}"` : 'No skills available'}
) : (
{filtered.map((skill, i) => ( ))}
); // v1.12 CP7.5: portal to document.body to escape ChatInput's stacking // context. The original render-in-place rendered the dropdown inside the // composer's transformed/will-change ancestor tree, which on iOS Safari + // Vivaldi caused the popover to either disappear or sit at z-index 0 // behind the autofill toolbar. document.body has no transform ancestor. return createPortal(popover, document.body); }