import { useEffect, useRef, useState } from 'react' import { api, streamSuggestionChat, type ChatMessage } from '../../api/client' import { usePack } from '../../i18n' import { splitBilingual } from './bilingualReply' interface Props { suggestionId: string // The English explanation (shown in the card body). Petal's opening bubble is // its translation into the pair language, fetched on open — so the panel // doesn't just repeat the same English text twice. Falls back to this on // failure. explanation: string } // CJK fallback stack — Nunito has no Chinese glyphs, and on the zh pair both // the questions and half of every answer are in Mandarin (spec Note #17). The // Latin pairs fall through to Nunito as before. Applied to the bubbles // specifically, not the serif editor body. const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif" // How tall the conversation may grow (UX item 6: "room to read"). A bilingual // three-paragraph answer in a 220px box was a scrollbar with a sentence in it. // // An earlier version of this took the smaller of half the viewport and the room // left below the card, so the card could never overhang the screen. Measured, it // gave 176px against a 442px answer — the card's own pill, diff, explanation and // action row already spend ~290px of an 810px screen, so "fits below the word" // and "room to read" are simply not both available. // // So this is the flat ceiling, and the overhang is made navigable instead — // item 4's answer to the same conflict, and its words for it: "the answer is to // make the overhang navigable, not to shrink what each card says". Both surfaces // that host this panel report their reach to the editor wrapper (SuggestionCard // via onExtent, the rail via its own measureTick), which grows the column, so a // conversation that runs past the fold has real page under it and the Accept // button below it can be scrolled to. const CHAT_MAX_FRACTION = 0.5 // Below this a max-height stops being a reading area and becomes a peephole — // the floor for a very short window, where half of it is not worth having. const CHAT_MIN_PX = 160 // AskPetal is the mini chat panel inside an expanded SuggestionCard. The whole // conversation lives in this component's state — nothing is persisted; closing // the card (unmounting) clears it. Each send streams Petal's reply token-by- // token into the latest assistant bubble. export function AskPetal({ suggestionId, explanation }: Props) { const t = usePack() // Opening bubble starts empty (caret-only) and fills with the pair-language // translation once it lands; `seeding` drives that loading caret. const [messages, setMessages] = useState([{ role: 'assistant', content: '' }]) const [seeding, setSeeding] = useState(true) const [input, setInput] = useState('') const [streaming, setStreaming] = useState(false) const scrollRef = useRef(null) const inputRef = useRef(null) // Keep the latest bubble in view as tokens arrive. useEffect(() => { const el = scrollRef.current if (el) el.scrollTop = el.scrollHeight }, [messages]) // How tall the conversation may grow. A share of the window, so a laptop and a // large monitor both give the answer a sensible amount of themselves — and a // window she resizes mid-conversation is answered live. const [maxHeight, setMaxHeight] = useState(() => Math.max(CHAT_MIN_PX, window.innerHeight * CHAT_MAX_FRACTION), ) useEffect(() => { const onResize = () => setMaxHeight(Math.max(CHAT_MIN_PX, window.innerHeight * CHAT_MAX_FRACTION)) window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, []) // Focus the input when the panel opens. preventScroll: the card is already on // screen as an absolutely-positioned overlay, and a default focus() would make // the browser scroll its ancestor to "reveal" the input — jumping the document // to the top. useEffect(() => { inputRef.current?.focus({ preventScroll: true }) }, []) // Fetch the pair-language translation of the explanation to seed the first // bubble. // Only replaces the seed bubble if the user hasn't started chatting yet (the // conversation always opens with this one assistant turn). Falls back to the // English explanation if the translation can't be fetched. useEffect(() => { let cancelled = false api .translateSuggestion(suggestionId) .then((res) => { if (cancelled) return const text = res.translation.trim() || explanation setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: text }] : prev)) }) .catch(() => { if (cancelled) return setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: explanation }] : prev)) }) .finally(() => { if (!cancelled) setSeeding(false) }) return () => { cancelled = true } }, [suggestionId, explanation]) async function send() { const text = input.trim() if (!text || streaming) return setInput('') // Append the user turn plus an empty assistant bubble to stream into. const history: ChatMessage[] = [...messages, { role: 'user', content: text }] setMessages([...history, { role: 'assistant', content: '' }]) setStreaming(true) try { await streamSuggestionChat(suggestionId, history, (token) => { setMessages((prev) => { const next = prev.slice() const last = next[next.length - 1] next[next.length - 1] = { ...last, content: last.content + token } return next }) }) } catch (err) { setMessages((prev) => { const next = prev.slice() // Bilingual, from the pack, and blank-line separated like a real reply — // so the one message Petal writes without the model still renders // through the same two-half bubble as every message with it. next[next.length - 1] = { role: 'assistant', content: t.editor.chatFailed } return next }) console.error('Ask Petal chat failed:', err) } finally { setStreaming(false) inputRef.current?.focus({ preventScroll: true }) } } return (
{messages.map((m, i) => ( ))}
{ e.preventDefault() void send() }} > setInput(e.target.value)} placeholder={t.editor.askPlaceholder} className="min-w-0 flex-1 rounded-full px-3 py-1.5 text-xs focus:outline-none" style={{ background: 'var(--color-surface-alt)', border: '1px solid var(--color-border)', color: 'var(--color-plum)', fontFamily: CHAT_FONT, }} />
) } // Bubble renders one chat turn: Petal rose-tinted and left-aligned, the user // lavender and right-aligned. A trailing caret marks the actively streaming // reply until its first token lands. // // Petal's turns are bilingual (see bilingualReply.ts) and are laid out the way // the companion lays out its own two lines: the pair language first and plainly // readable, the English beneath it in the muted tone. That order is the pack's // order everywhere else in the UI, and it holds whichever direction the writer // is learning in — the muted half is the one they can already read, and which // half that is isn't Petal's to decide. The writer's own turns are their own // words in whichever language they typed them, so they are never split. // // Petal's bubble also takes the full width the card offers rather than the 85% // a chat normally reserves to show who is talking — the alignment and the // tint already say that, and two languages in a 4/5-width column wraps a // sentence-length answer into a paragraph-shaped one. function Bubble({ role, content, streaming, }: { role: ChatMessage['role'] content: string streaming: boolean }) { const isPetal = role === 'assistant' const reply = isPetal ? splitBilingual(content) : null return (
{reply ? ( <> {reply.native} {reply.en !== '' && ( {reply.en} )} ) : ( {content} )} {streaming && content === '' && ( )}
) }