import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { SkillCatalogEntry } from '../utils/skillReferences'; /** Which skill root a row came from, shown as the right-aligned source column. */ const ROOT_LABELS: Record = { claude: 'Claude', codex: 'Codex', universal: 'Agents', }; interface SkillReferenceMenuProps { /** Id referenced by the focused textarea's aria-controls attribute. */ id: string; items: SkillCatalogEntry[]; /** Explicitly activated row, or null — the menu opens with nothing active. */ activeIndex: number | null; onSelect: (index: number) => void; /** * The box the menu must stay inside, in viewport coordinates, read at each * measure. Default: the viewport. A host whose composer sits in a dialog * with a footer passes the dialog's area above the footer. */ boundary?: () => { top: number; bottom: number } | null; /** Which side of the anchor the menu tries first. Default: above. */ prefer?: 'above' | 'below'; /** Upper bound on the list's height, in px. Default: 256. */ maxListHeight?: number; } /** Vertical gap between the composer wrapper and the menu (mb-1.5 / mt-1.5 = 6px). */ const MENU_GAP = 6; /** Breathing room kept between the menu and the viewport edge. */ const MENU_VIEWPORT_MARGIN = 8; /** Upper bound on the scrollable list height — the former fixed `max-h-64`. */ const MAX_LIST_HEIGHT = 256; interface MenuPlacement { direction: 'above' | 'below'; maxListHeight: number; } /** * Adaptive placement, the same viewport-measurement idiom CommentPopover's * `computePosition` uses (space vs `window.innerHeight`). The menu PREFERS * opening above the composer — that keeps the text being typed, the action * row, and the human-only notice unobstructed, and matches how chat-composer * autocompletes behave — and flips below when the list does not fit above but * does fit below (the popover near the top of the viewport: the exact case * where the old fixed `bottom-full` menu ran off the top of the screen). * When neither side fits the whole list, the side with more room wins and the * list is clamped to it, so the menu never extends past a viewport edge. */ export function computeMenuPlacement( anchorRect: Pick, naturalListHeight: number, chromeHeight: number, viewportHeight: number, options: { boundaryTop?: number; prefer?: 'above' | 'below'; maxListHeight?: number } = {}, ): MenuPlacement { const boundaryTop = options.boundaryTop ?? 0; const cap = options.maxListHeight ?? MAX_LIST_HEIGHT; const spaceAbove = anchorRect.top - boundaryTop - MENU_GAP - MENU_VIEWPORT_MARGIN - chromeHeight; const spaceBelow = viewportHeight - anchorRect.bottom - MENU_GAP - MENU_VIEWPORT_MARGIN - chromeHeight; const needed = Math.min(naturalListHeight, cap); const [first, second] = options.prefer === 'below' ? (['below', 'above'] as const) : (['above', 'below'] as const); const space = (side: MenuPlacement['direction']) => (side === 'above' ? spaceAbove : spaceBelow); const direction: MenuPlacement['direction'] = needed <= space(first) ? first : needed <= space(second) ? second : space(first) >= space(second) ? first : second; const available = space(direction); return { direction, maxListHeight: Math.max(0, Math.min(cap, Math.round(available))), }; } /** * Dropdown for skill references inside a comment composer. Rendered adjacent * to the textarea (absolute within a relative wrapper), opening above or * below depending on available viewport space (see computeMenuPlacement), and * clamped so it never runs off screen. Each row: icon, bold name, dimmed * inline description (ellipsis-truncated), right-aligned source root. * Human-invocation-only skills render identically to every other row: their * instructions are injected into the exported feedback automatically, so the * distinction needs no surfacing at pick time. (A hover-disclosed warning * used to live here; because it changed the menu's height while the pointer * sat over a row of a bottom-anchored menu, it oscillated the row under the * cursor every frame. Hover must never change any row's rendered size or the * menu's measured geometry — color-only hover styling is fine.) * * Activation is KEYBOARD-ONLY (see useSkillReferenceAutocomplete): the menu * floats directly over the composer, exactly where the mouse rests while * typing, so pointer hover must never arm the "Enter inserts" state. Hover is * a purely visual affordance; a pointer click inserts directly. */ export const SkillReferenceMenu: React.FC = ({ id, items, activeIndex, onSelect, boundary, prefer, maxListHeight, }) => { const menuRef = useRef(null); const listRef = useRef(null); const [placement, setPlacement] = useState({ direction: prefer ?? 'above', maxListHeight: maxListHeight ?? MAX_LIST_HEIGHT, }); // The optional seams, read through a ref so `measure` stays stable for its listeners. const optionsRef = useRef({ boundary, prefer, maxListHeight }); optionsRef.current = { boundary, prefer, maxListHeight }; const measure = useCallback(() => { const menuEl = menuRef.current; const listEl = listRef.current; // The menu is a direct child of the composer's `relative` wrapper — the // same box the `bottom-full` / `top-full` CSS anchors to. const anchor = menuEl?.parentElement; if (!menuEl || !listEl || !anchor) return; const rect = anchor.getBoundingClientRect(); // Non-list height the menu carries (border). This must stay constant // while the pointer moves — hover-dependent chrome is what caused the // jitter loop this component once shipped. const chrome = Math.max(0, menuEl.offsetHeight - listEl.offsetHeight); // scrollHeight reports full content height even while clamped. const { boundary: readBoundary, prefer: side, maxListHeight: cap } = optionsRef.current; const box = readBoundary?.() ?? null; const next = computeMenuPlacement(rect, listEl.scrollHeight, chrome, box ? box.bottom : window.innerHeight, { boundaryTop: box ? box.top : 0, prefer: side, maxListHeight: cap, }); setPlacement((prev) => prev.direction === next.direction && prev.maxListHeight === next.maxListHeight ? prev : next, ); }, []); // Re-measure on EVERY commit: the popover re-renders on drag moves, flips, // and filtering (item-count changes), and each of those can change the // geometry without any window event firing. The setState above is // equality-guarded, so this converges instead of looping. useLayoutEffect(() => { measure(); }); // Same listeners CommentPopover's own position tracking uses: capture-phase // scroll (the document scrolls under the anchored popover) plus resize. useEffect(() => { window.addEventListener('scroll', measure, true); window.addEventListener('resize', measure); return () => { window.removeEventListener('scroll', measure, true); window.removeEventListener('resize', measure); }; }, [measure]); useEffect(() => { if (activeIndex === null) return; const list = listRef.current; const row = list?.children[activeIndex] as HTMLElement | undefined; row?.scrollIntoView({ block: 'nearest' }); }, [activeIndex]); return (
{items.map((item, index) => ( ))}
); }; const SkillIcon: React.FC = () => ( ); interface HumanOnlySkillNoticeProps { skills: SkillCatalogEntry[]; /** * What the instructions go with. Default: the host's (`setSkillInstructionsDestination`), * else "your feedback". The Inbox says "your reply" or "your message". */ destination?: string; } // Module-level seam (see packages/ui/CLAUDE.md): what every composer's // human-only line says the instructions go with, unless the composer says. // Plannotator's comments go with "your feedback"; the Inbox's comments on // attached files ride a reply. const DEFAULT_DESTINATION = 'your feedback'; let defaultDestination = DEFAULT_DESTINATION; export function setSkillInstructionsDestination(destination: string): void { defaultDestination = destination || DEFAULT_DESTINATION; } export function resetSkillInstructionsDestination(): void { defaultDestination = DEFAULT_DESTINATION; } /** * Composer note while human-only skill references are present. Quiet by * design: the user already chose to insert the reference (and saw the menu's * disclosure while choosing), so the resting state is a single muted summary * line rather than a standing explanation. The full plain-language sentence * is one native disclosure away —
/ keeps the expansion * reachable by pointer, keyboard (the summary is focusable; Enter/Space * toggles), and assistive tech alike. It pairs with the dotted-underline * marker the composer overlay paints on human-only tokens. */ export const HumanOnlySkillNotice: React.FC = ({ skills, destination }) => { if (skills.length === 0) return null; destination ??= defaultDestination; const names = skills.map((s) => s.name).join(', '); return (
Includes skill instructions

{skills.length === 1 ? ( <> {names} cannot be invoked by a model, so its instructions will be included with {destination}. ) : ( <> {names} cannot be invoked by a model, so their instructions will be included with {destination}. )}

); }; const DisclosureChevron: React.FC = () => ( );