Ask Petal answers in both languages, with room to read

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.
This commit is contained in:
prosolis
2026-07-28 07:04:16 -07:00
parent f082a930cb
commit 978cb80642
13 changed files with 470 additions and 24 deletions
+80 -14
View File
@@ -1,27 +1,51 @@
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 Simplified-Chinese translation, fetched on open — so the panel doesn't
// just repeat the same English text twice. Falls back to this on failure.
// 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 the user asks questions
// in Mandarin (spec Note #17). Applied to the bubbles specifically, not the
// serif editor body.
// 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 Mandarin
// 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)
@@ -36,6 +60,19 @@ export function AskPetal({ suggestionId, explanation }: Props) {
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
@@ -44,7 +81,8 @@ export function AskPetal({ suggestionId, explanation }: Props) {
inputRef.current?.focus({ preventScroll: true })
}, [])
// Fetch the Chinese translation of the explanation to seed the first bubble.
// 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.
@@ -91,10 +129,10 @@ export function AskPetal({ suggestionId, explanation }: Props) {
} 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. 🌸',
}
// 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)
@@ -116,7 +154,7 @@ export function AskPetal({ suggestionId, explanation }: Props) {
<div
ref={scrollRef}
className="flex flex-col gap-2 overflow-y-auto pr-1"
style={{ maxHeight: 220 }}
style={{ maxHeight }}
>
{messages.map((m, i) => (
<Bubble
@@ -164,6 +202,19 @@ export function AskPetal({ suggestionId, explanation }: Props) {
// 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,
@@ -174,10 +225,11 @@ function Bubble({
streaming: boolean
}) {
const isPetal = role === 'assistant'
const reply = isPetal ? splitBilingual(content) : null
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"
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)',
@@ -185,7 +237,21 @@ function Bubble({
whiteSpace: 'pre-wrap',
}}
>
{content}
{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>
+24 -4
View File
@@ -279,6 +279,24 @@ export function EditorCore({
// without this the column below the last line of text isn't scrollable and any
// card that lands there is unreachable, not merely far from its sentence.
const [railExtent, setRailExtent] = useState(0)
// The same report from the anchored card, which is absolutely positioned for
// the same reason and so has the same problem: an open Ask Petal conversation
// can reach well past the last line of a short document, and its Accept button
// goes with it. 0 whenever no card is open.
const [cardExtent, setCardExtent] = useState(0)
// How far down the column has to reach to cover its floating surfaces. Both
// reach past the prose for the same reason and are answered the same way, so
// they resolve to one number: whichever is lower wins, and 0 means the text
// alone decides the height.
//
// The rail's extent is conditional on the rail being mounted — a stale measure
// from a rail that has since been dismissed would leave a document padded with
// blank scroll. The card's is not: it reports 0 as it unmounts.
const overhang = Math.max(
railEnabled && railExtent > 0 ? railExtent + RAIL_TAIL : 0,
cardExtent > 0 ? cardExtent + RAIL_TAIL : 0,
)
// Sticky offset for the text column, or null when it should sit in normal flow.
// Set only while the stack overhangs the text: scrolling down to reach the lower
// cards would otherwise carry every sentence off the top of the screen.
@@ -1149,10 +1167,11 @@ export function EditorCore({
<div
ref={wrapperRef}
className="relative flex-1"
// Grown to cover the card stack when it overhangs the prose, so the space
// those cards occupy is actually scrollable. `minHeight` never shrinks the
// column, so a rail that fits beside its text changes nothing.
style={railEnabled && railExtent > 0 ? { minHeight: railExtent + RAIL_TAIL } : undefined}
// Grown to cover whichever absolutely-positioned surface reaches lowest —
// the rail's card stack, or an open anchored card — so the space those
// cards occupy is actually scrollable. `minHeight` never shrinks the
// column, so a rail or card that fits beside its text changes nothing.
style={overhang > 0 ? { minHeight: overhang } : undefined}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onMouseMove={handleMouseMove}
@@ -1234,6 +1253,7 @@ export function EditorCore({
onPointerEnter={keepOpen}
onPointerLeave={scheduleClose}
onExpandChange={setPinned}
onExtent={setCardExtent}
/>
)}
{railEnabled && railItems.length > 0 && (
+28 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { Suggestion } from '../../api/client'
import { usePack } from '../../i18n'
import { AskPetal } from './AskPetal'
@@ -14,6 +14,13 @@ interface Props {
// Pins the card open while the Ask Petal panel is expanded, so the chat isn't
// dismissed by the hover-close timer when the pointer drifts away.
onExpandChange: (expanded: boolean) => void
// How far the card reaches below the wrapper's top, in wrapper coordinates —
// the same report the rail makes (item 4). The card is absolutely positioned
// and so adds no layout height of its own; without this, an Ask Petal
// conversation that runs past the last line of text has no scrollable page
// under it and its Accept button simply can't be reached. 0 means "nothing to
// cover", which is what an unmounted card reports on its way out.
onExtent?: (bottom: number) => void
}
// SuggestionCard is the hover panel for a single suggestion: a colored type tag,
@@ -28,12 +35,31 @@ export function SuggestionCard({
onPointerEnter,
onPointerLeave,
onExpandChange,
onExtent,
}: Props) {
const pack = usePack()
const meta = TYPE_META[suggestion.type]
const label = typeLabel(suggestion.type, pack)
const hasReplacement = suggestion.replacement.trim() !== ''
const [asking, setAsking] = useState(false)
const cardRef = useRef<HTMLDivElement>(null)
// Report the card's reach while it is open, and withdraw it on the way out.
// A ResizeObserver rather than a one-shot measure because the card grows
// after it is mounted: the Ask Petal panel opens, and then the reply streams
// into it token by token.
useEffect(() => {
const el = cardRef.current
if (!el || !onExtent) return
const report = () => onExtent(el.offsetTop + el.offsetHeight)
report()
const observer = new ResizeObserver(report)
observer.observe(el)
return () => {
observer.disconnect()
onExtent(0)
}
}, [onExtent])
function toggleAsking() {
setAsking((prev) => {
@@ -45,6 +71,7 @@ export function SuggestionCard({
return (
<div
ref={cardRef}
role="dialog"
aria-label={`${label} suggestion`}
onMouseEnter={onPointerEnter}
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { splitBilingual } from './bilingualReply'
// The contract these tests defend is "never hide an answer", not "parse the
// model". Every case that isn't cleanly two halves must still come back whole.
describe('splitBilingual', () => {
it('splits the pair language from the English at the blank line', () => {
const { native, en } = splitBilingual(
'“by foots” 不是固定说法,正确的是 “on foot”。\n\n"By foots" isnt a set phrase — the idiom is "on foot".',
)
expect(native).toBe('“by foots” 不是固定说法,正确的是 “on foot”。')
expect(en).toBe('"By foots" isnt a set phrase — the idiom is "on foot".')
})
it('works the same for a Latin pair, where both halves are Latin script', () => {
const { native, en } = splitBilingual(
'Dizemos "on foot", não "by foots".\n\nWe say "on foot", not "by foots".',
)
expect(native).toBe('Dizemos "on foot", não "by foots".')
expect(en).toBe('We say "on foot", not "by foots".')
})
it('renders a half-streamed reply as the pair language until the break arrives', () => {
// Mid-stream: the English half hasn't been written yet. The partial text is
// the whole bubble, not an empty one.
expect(splitBilingual('“by foots” 不是固定')).toEqual({
native: '“by foots” 不是固定',
en: '',
})
})
it('keeps a one-language reply whole', () => {
// A model that ignores the instruction costs styling, never content.
const single = 'We say "on foot" because the idiom is fixed.'
expect(splitBilingual(single)).toEqual({ native: single, en: '' })
})
it('treats extra blank lines as part of the English half', () => {
const { native, en } = splitBilingual('中文回答。\n\nFirst English point.\n\nSecond one.')
expect(native).toBe('中文回答。')
expect(en).toBe('First English point.\n\nSecond one.')
})
it('does not split on a blank line with nothing on one side', () => {
// A leading or trailing stray newline is not a separator; styling half of
// this as a translation of nothing would be worse than not splitting.
expect(splitBilingual('\n\nWe say "on foot".')).toEqual({
native: 'We say "on foot".',
en: '',
})
expect(splitBilingual('We say "on foot".\n\n')).toEqual({
native: 'We say "on foot".',
en: '',
})
})
it('accepts a separator line that carries whitespace', () => {
// Models emit "\n \n" often enough that requiring a bare "\n\n" would drop
// the split for a reply that followed the instruction.
const { native, en } = splitBilingual('中文回答。\n \nThe English answer.')
expect(native).toBe('中文回答。')
expect(en).toBe('The English answer.')
})
it('handles an empty reply', () => {
expect(splitBilingual('')).toEqual({ native: '', en: '' })
})
it('leaves single newlines inside a half alone', () => {
const { native, en } = splitBilingual('第一行\n第二行\n\nLine one\nLine two')
expect(native).toBe('第一行\n第二行')
expect(en).toBe('Line one\nLine two')
})
})
@@ -0,0 +1,43 @@
// Splitting Petal's chat reply into the two languages it was asked for.
//
// The Ask Petal prompt (internal/llm/prompts.go) asks for the pair language
// first, then the same answer in English, separated by one blank line. This is
// the reader of that contract — and it is deliberately forgiving, because the
// reply arrives from a small local model, token by token, and a rendering rule
// must never be able to hide an answer the writer could otherwise read.
//
// So there is exactly one failure mode and it is benign: anything that doesn't
// look like two halves is returned as `native` alone, which renders as one
// ordinary block. Nothing is dropped, ever.
export interface BilingualReply {
/** The pair language — or the whole reply, when there is only one half. */
native: string
/** The English half; '' when the reply hasn't reached the blank line yet. */
en: string
}
/**
* splitBilingual divides a reply at its first blank line.
*
* Streaming is the reason this splits at the *first* blank line rather than
* validating the shape: while tokens arrive the text is a native half with no
* separator yet, so it renders as the pair language and the English simply
* appears beneath it when the blank line lands. Any further blank lines stay
* inside the English half rather than starting a third section with nowhere to
* go.
*/
export function splitBilingual(content: string): BilingualReply {
const match = /\n[ \t]*\n/.exec(content)
if (!match) return { native: content, en: '' }
const native = content.slice(0, match.index).trim()
const en = content.slice(match.index + match[0].length).trim()
// A blank line with nothing on one side of it isn't two halves — it's a
// stray newline. Keep the reply whole rather than styling half of it as a
// translation of nothing.
if (native === '' || en === '') return { native: content.trim(), en: '' }
return { native, en }
}