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:
@@ -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>
|
||||
▍
|
||||
|
||||
Reference in New Issue
Block a user