import { useEffect, useRef, useState } from 'react' import type { Suggestion, SuggestionType } from '../../api/client' import { usePack } from '../../i18n' import { AskPetal } from './AskPetal' import { TYPE_META, batchLabel, typeLabel } from './suggestionMeta' interface Props { suggestion: Suggestion style: React.CSSProperties // How many pending suggestions share this card's type (including this one). // Two or more offers to take the whole category in one step. batchCount: number onAccept: (s: Suggestion) => void onAcceptAll: (type: SuggestionType) => void onDismiss: (s: Suggestion) => void onPointerEnter: () => void onPointerLeave: () => void // Pins the card open while the Ask Petal panel is expanded, so the chat isn't // dismissed by the hover-close timer when the pointer drifts away. onExpandChange: (expanded: boolean) => void // How far the card reaches below the wrapper's top, in wrapper coordinates — // the same report the rail makes (item 4). The card is absolutely positioned // and so adds no layout height of its own; without this, an Ask Petal // conversation that runs past the last line of text has no scrollable page // under it and its Accept button simply can't be reached. 0 means "nothing to // cover", which is what an unmounted card reports on its way out. onExtent?: (bottom: number) => void } // SuggestionCard is the hover panel for a single suggestion: a colored type tag, // the original → replacement diff, the friendly explanation, and accept/dismiss // actions. Voice flags carry no replacement, so the diff row is hidden and only // Dismiss is offered (awareness-only). export function SuggestionCard({ suggestion, style, batchCount, onAccept, onAcceptAll, onDismiss, onPointerEnter, onPointerLeave, onExpandChange, onExtent, }: Props) { const pack = usePack() const meta = TYPE_META[suggestion.type] const label = typeLabel(suggestion.type, pack) const hasReplacement = suggestion.replacement.trim() !== '' const [asking, setAsking] = useState(false) const cardRef = useRef(null) // Report the card's reach while it is open, and withdraw it on the way out. // A ResizeObserver rather than a one-shot measure because the card grows // after it is mounted: the Ask Petal panel opens, and then the reply streams // into it token by token. useEffect(() => { const el = cardRef.current if (!el || !onExtent) return const report = () => onExtent(el.offsetTop + el.offsetHeight) report() const observer = new ResizeObserver(report) observer.observe(el) return () => { observer.disconnect() onExtent(0) } }, [onExtent]) function toggleAsking() { setAsking((prev) => { const next = !prev onExpandChange(next) return next }) } return (
{label} {hasReplacement && (
{suggestion.original} {suggestion.replacement}
)}

{suggestion.explanation}

{asking && }
{hasReplacement && ( )}
{hasReplacement && batchCount > 1 && ( )}
) }