222 lines
8.9 KiB
TypeScript
222 lines
8.9 KiB
TypeScript
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<HTMLElement | null>;
|
|
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<HTMLDivElement>(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<DOMRect | null>(
|
|
() => 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<CSSProperties>(() => {
|
|
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 ? (
|
|
<div
|
|
ref={popoverRef}
|
|
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
|
|
style={style}
|
|
>
|
|
<div className="text-xs text-muted-foreground px-2 py-1">
|
|
{query ? `No skill starts with "/${query}"` : 'No skills available'}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={popoverRef}
|
|
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto"
|
|
style={style}
|
|
>
|
|
{filtered.map((skill, i) => (
|
|
<button
|
|
key={skill.name}
|
|
type="button"
|
|
data-highlighted={i === highlightIndex}
|
|
className={cn(
|
|
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
|
i === highlightIndex && 'bg-muted',
|
|
)}
|
|
onMouseEnter={() => setHighlightIndex(i)}
|
|
onMouseDown={(e) => {
|
|
// mousedown not click — click runs after blur/focus shuffles which
|
|
// can race with the textarea's onBlur close path.
|
|
e.preventDefault();
|
|
onSelect(skill.name);
|
|
}}
|
|
>
|
|
<div className="font-mono text-xs font-bold text-foreground">/{skill.name}</div>
|
|
<div
|
|
className="text-xs text-muted-foreground overflow-hidden"
|
|
style={{
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: 'vertical',
|
|
}}
|
|
>
|
|
{skill.description}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// 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);
|
|
}
|