The tutor prompt said "never mix languages in a single response" and mirrored the language of the question, so asking in English — which she does, because she is practising — returned the one explanation surface that gives nothing in her own language. It now answers in both, pair language first, halves separated by a blank line. Which half is the safety net and which is the lesson depends on who is writing: the pair is (English + X) and Petal is used from both ends, so the prompt asks for both and says it doesn't know which way round. The split is a rendering nicety, never a parse the reply depends on: a half-streamed reply is all one half, a model that ignores the instruction renders as one block, and nothing is ever dropped. For the height, the first attempt clamped the box to the room left below the anchored card so it could never overhang — measured, that gave 176px against a 442px answer, worse than the 220px it replaced. The card's own chrome spends ~290px of an 810px window, so "fits below the word" and "room to read" are not both available. The ceiling is now a flat 50vh and the overhang is made navigable instead, per item 4: the card reports its reach like the rail already does, the column grows, and the page can scroll to the actions below it.
264 lines
9.9 KiB
TypeScript
264 lines
9.9 KiB
TypeScript
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<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])
|
|
|
|
// 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 (
|
|
<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 }}
|
|
>
|
|
{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={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,
|
|
}}
|
|
/>
|
|
<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.
|
|
//
|
|
// 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 (
|
|
<div className={`flex ${isPetal ? 'justify-start' : 'justify-end'}`}>
|
|
<div
|
|
className={`${isPetal ? 'w-full' : 'max-w-[85%]'} rounded-2xl px-3 py-2 leading-snug`}
|
|
style={{
|
|
background: isPetal ? 'var(--color-surface-alt)' : 'var(--color-lavender)',
|
|
color: 'var(--color-plum)',
|
|
fontFamily: CHAT_FONT,
|
|
whiteSpace: 'pre-wrap',
|
|
}}
|
|
>
|
|
{reply ? (
|
|
<>
|
|
<span className="text-[0.8rem]">{reply.native}</span>
|
|
{reply.en !== '' && (
|
|
<span
|
|
className="mt-1.5 block text-xs"
|
|
style={{ color: 'var(--color-muted)' }}
|
|
>
|
|
{reply.en}
|
|
</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="text-xs">{content}</span>
|
|
)}
|
|
{streaming && content === '' && (
|
|
<span className="petal-chat-caret" aria-hidden>
|
|
▍
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|