Files
petal/web/src/components/Editor/SuggestionRail.tsx
prosolis 82e2bcc777 Suggestions: right-margin comment rail + Mandarin explanations
Surface every outstanding suggestion as a card in the right-hand
whitespace, vertically aligned to the text it flags — so the writer sees
the whole queue at once instead of hovering each highlight. Cards stack
with collision avoidance, link both ways with their highlight (hover/click
↔ soft text wash, driven through the decoration plugin so it survives
edit repaints), and carry the same Accept / Dismiss / Ask Petal actions.
The rail is a progressive enhancement: it mounts only when there's room
beside the editor, otherwise the existing inline hover card is unchanged.
Stacked cards that reach the bottom-right corner tuck behind the
companion mascot (z-order).

When a card is expanded, the Ask Petal bubble now opens with the
Simplified-Chinese translation of the explanation (the English stays in
the card body) instead of repeating the same text twice — a new
POST /api/suggestions/{id}/translate one-shot LLM endpoint, loaded
lazily on open with an English fallback.

Verified live against the local LLM via the uitest harness: rail
stacking, hover↔text wash, expand/Ask Petal, accept-from-rail, narrow
fallback, and the Mandarin bubble.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 12:23:02 -07:00

212 lines
8.0 KiB
TypeScript

import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
import type { Suggestion } from '../../api/client'
import { AskPetal } from './AskPetal'
import { TYPE_META } from './suggestionMeta'
// Vertical breathing room kept between stacked cards when their natural anchors
// would otherwise collide.
const CARD_GAP = 12
export interface RailItem {
suggestion: Suggestion
// Vertical offset (px) of the suggestion's highlight, relative to the editor
// wrapper — the position the card wants to sit beside.
anchorTop: number
}
interface Props {
items: RailItem[]
// The suggestion currently emphasized (its highlight hovered, or this card
// hovered) — gets a lifted, ring-accented treatment so the text↔card link reads.
activeId: string | null
// The card expanded to show the full explanation + Ask Petal, or null.
expandedId: string | null
onAccept: (s: Suggestion) => void
onDismiss: (s: Suggestion) => void
// Pointer entering/leaving a card, so the matching highlight can light up.
onHover: (id: string | null) => void
// A card's body was clicked — scroll its highlight into view and toggle expand.
onActivate: (id: string) => void
onToggleExpand: (id: string) => void
}
// SuggestionRail is the right-margin "comment column": every outstanding
// suggestion as a card, vertically aligned to the text it flags, so the writer
// sees the whole queue at once instead of hunting highlight by highlight. Cards
// are anchored to their highlight's vertical position and pushed down only as far
// as needed to avoid overlapping their neighbor above (a la doc-editor comments).
export function SuggestionRail({
items,
activeId,
expandedId,
onAccept,
onDismiss,
onHover,
onActivate,
onToggleExpand,
}: Props) {
// Measured resolved tops keyed by suggestion id (after collision avoidance).
const [tops, setTops] = useState<Record<string, number>>({})
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// Bumped whenever a card's own height changes (Ask Petal streaming in, wrapping
// text) so the stack re-packs and cards below don't get overlapped.
const [measureTick, setMeasureTick] = useState(0)
const observerRef = useRef<ResizeObserver | null>(null)
if (!observerRef.current && typeof ResizeObserver !== 'undefined') {
observerRef.current = new ResizeObserver(() => setMeasureTick((t) => t + 1))
}
useLayoutEffect(() => () => observerRef.current?.disconnect(), [])
// Sort by anchor, then greedily stack: each card sits at its anchor unless that
// would overlap the previous card, in which case it slides down just enough.
// Re-runs whenever the anchors or the expanded card (which changes a height)
// shift. Reads live offsetHeight so variable-length explanations pack tightly.
const ordered = [...items].sort((a, b) => a.anchorTop - b.anchorTop)
const layoutKey = ordered.map((i) => `${i.suggestion.id}:${Math.round(i.anchorTop)}`).join('|')
useLayoutEffect(() => {
let cursor = -Infinity
const next: Record<string, number> = {}
for (const { suggestion, anchorTop } of ordered) {
const h = cardRefs.current.get(suggestion.id)?.offsetHeight ?? 96
const top = Math.max(anchorTop, cursor)
next[suggestion.id] = top
cursor = top + h + CARD_GAP
}
setTops((prev) => {
const ids = Object.keys(next)
if (ids.length === Object.keys(prev).length && ids.every((id) => prev[id] === next[id])) return prev
return next
})
// layoutKey captures anchor positions; expandedId/measureTick capture height
// changes (toggling detail, Ask Petal streaming in).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutKey, expandedId, measureTick])
return (
<div className="petal-rail petal-no-print" aria-label="Suggestions">
{ordered.map(({ suggestion }) => (
<RailCard
key={suggestion.id}
ref={(el) => {
const prev = cardRefs.current.get(suggestion.id)
if (prev && prev !== el) observerRef.current?.unobserve(prev)
if (el) {
cardRefs.current.set(suggestion.id, el)
observerRef.current?.observe(el)
} else {
cardRefs.current.delete(suggestion.id)
}
}}
suggestion={suggestion}
top={tops[suggestion.id] ?? 0}
active={activeId === suggestion.id}
expanded={expandedId === suggestion.id}
onAccept={onAccept}
onDismiss={onDismiss}
onHover={onHover}
onActivate={onActivate}
onToggleExpand={onToggleExpand}
/>
))}
</div>
)
}
interface CardProps {
suggestion: Suggestion
top: number
active: boolean
expanded: boolean
onAccept: (s: Suggestion) => void
onDismiss: (s: Suggestion) => void
onHover: (id: string | null) => void
onActivate: (id: string) => void
onToggleExpand: (id: string) => void
}
const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
{ suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand },
ref,
) {
const meta = TYPE_META[suggestion.type]
const hasReplacement = suggestion.replacement.trim() !== ''
return (
<div
ref={ref}
role="group"
aria-label={`${meta.label} suggestion`}
onMouseEnter={() => onHover(suggestion.id)}
onMouseLeave={() => onHover(null)}
className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`}
style={{ top, borderLeftColor: meta.color }}
>
<div className="flex items-center justify-between">
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[0.68rem] font-bold"
style={{ background: meta.color, color: 'var(--color-plum)' }}
>
{meta.label}
</span>
<button
type="button"
aria-label="Dismiss suggestion"
onClick={() => onDismiss(suggestion)}
className="petal-rail-x"
>
</button>
</div>
{/* The body toggles the expanded explanation + Ask Petal and points the
editor at the flagged text. */}
<button type="button" onClick={() => onActivate(suggestion.id)} className="mt-2 block w-full text-left">
{hasReplacement && (
<span className="flex flex-col gap-0.5" style={{ fontFamily: 'var(--font-body)' }}>
<span className="truncate text-[0.9rem] line-through" style={{ color: 'var(--color-muted)' }}>
{suggestion.original}
</span>
<span className="truncate text-[0.9rem] font-medium" style={{ color: 'var(--color-plum)' }}>
{suggestion.replacement}
</span>
</span>
)}
<span
className={`mt-1.5 block text-[0.82rem] leading-snug${expanded ? '' : ' line-clamp-2'}`}
style={{ color: 'var(--color-plum)' }}
>
{suggestion.explanation}
</span>
</button>
{expanded && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
<div className="mt-2.5 flex items-center gap-2">
{hasReplacement && (
<button
type="button"
onClick={() => onAccept(suggestion)}
className="rounded-full px-3 py-1 text-xs font-bold text-white"
style={{ background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
Accept
</button>
)}
<button
type="button"
onClick={() => onToggleExpand(suggestion.id)}
className="rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
style={{
background: expanded ? 'var(--color-surface-alt)' : 'transparent',
color: 'var(--color-accent-hover)',
}}
>
{expanded ? 'Hide Petal' : 'Ask Petal ✨'}
</button>
</div>
</div>
)
})