import { useEffect, useRef, useState } from 'react' import { api, streamSuggestionChat, type ChatMessage } from '../../api/client' interface Props { suggestionId: string // The English explanation (shown in the card body). Petal's opening bubble is // its Simplified-Chinese translation, 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 the user asks questions // in Mandarin (spec Note #17). Applied to the bubbles specifically, not the // serif editor body. const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif" // 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) { // Opening bubble starts empty (caret-only) and fills with the Mandarin // 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]) // 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 Chinese 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() next[next.length - 1] = { role: 'assistant', content: 'Sorry, I had trouble responding just now. Please try again. 🌸', } 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="Ask why… / 问为什么…" 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. function Bubble({ role, content, streaming, }: { role: ChatMessage['role'] content: string streaming: boolean }) { const isPetal = role === 'assistant' return (
{content} {streaming && content === '' && ( )}
) }