Files
petal/web/src/components/Editor/AskPetal.tsx
prosolis cb8f132d43 Spelling popover: right-click corrections + robust word resolution
Resolve the misspelled word from the click/right-click position and the
checker rather than from the .petal-misspelling DOM span: clicking a word
moves the caret into it, which fires the decoration rebuild that
deliberately un-underlines the caret word, so the span is already gone by
the time the handler runs. Factored into openMisspellAt(pos), which only
opens when the checker actually flags the word, and is gated to clicks
inside .petal-prose so a click on a floating card doesn't resolve a word
hidden behind it.

Right-click now offers spelling corrections first on a misspelled word
(the familiar "did you mean" gesture), falling back to word lookup
otherwise.

AskPetal: focus the input with preventScroll so opening the card doesn't
jump the document to the top.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 20:54:47 -07:00

196 lines
6.5 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. 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 (
<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>
)
}