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
193 lines
6.2 KiB
TypeScript
193 lines
6.2 KiB
TypeScript
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<ChatMessage[]>([{ role: 'assistant', content: '' }])
|
|
const [seeding, setSeeding] = useState(true)
|
|
const [input, setInput] = useState('')
|
|
const [streaming, setStreaming] = useState(false)
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
const inputRef = useRef<HTMLInputElement>(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.
|
|
useEffect(() => {
|
|
inputRef.current?.focus()
|
|
}, [])
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="mt-3 flex flex-col"
|
|
style={{
|
|
borderTop: '1px solid var(--color-border)',
|
|
paddingTop: '0.625rem',
|
|
fontFamily: CHAT_FONT,
|
|
}}
|
|
>
|
|
<div
|
|
ref={scrollRef}
|
|
className="flex flex-col gap-2 overflow-y-auto pr-1"
|
|
style={{ maxHeight: 220 }}
|
|
>
|
|
{messages.map((m, i) => (
|
|
<Bubble
|
|
key={i}
|
|
role={m.role}
|
|
content={m.content}
|
|
streaming={(streaming && i === messages.length - 1) || (seeding && i === 0)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<form
|
|
className="mt-2 flex items-center gap-1.5"
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
void send()
|
|
}}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(e) => 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,
|
|
}}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={streaming || input.trim() === ''}
|
|
className="rounded-full px-3 py-1.5 text-xs font-bold text-white disabled:opacity-50"
|
|
style={{ background: 'var(--color-accent)' }}
|
|
>
|
|
Send
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<div className={`flex ${isPetal ? 'justify-start' : 'justify-end'}`}>
|
|
<div
|
|
className="max-w-[85%] rounded-2xl px-3 py-1.5 text-xs leading-snug"
|
|
style={{
|
|
background: isPetal ? 'var(--color-surface-alt)' : 'var(--color-lavender)',
|
|
color: 'var(--color-plum)',
|
|
fontFamily: CHAT_FONT,
|
|
whiteSpace: 'pre-wrap',
|
|
}}
|
|
>
|
|
{content}
|
|
{streaming && content === '' && (
|
|
<span className="petal-chat-caret" aria-hidden>
|
|
▍
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|