diff --git a/UX_REVIEW_2026-07-27.md b/UX_REVIEW_2026-07-27.md index dda27fc..42b4ad2 100644 --- a/UX_REVIEW_2026-07-27.md +++ b/UX_REVIEW_2026-07-27.md @@ -513,6 +513,119 @@ sane max-height (~50vh) before scrolling. **Acceptance:** an Ask Petal answer shows 中文 + English; a 3-paragraph answer is readable without scrolling a ~100 px box. +### 6 — DONE (eighth session). The answer was English-only on purpose, and the box was the smaller half of the item. + +**The first half was one sentence in a prompt.** `askPetalSystemTemplate` said +*"Detect the language of the user's message and respond in that same language… +Never mix languages in a single response."* Self-consistent, and it made the +English-only answer inevitable: ask in English — which she does, because she is +practising — and the explanation that goes deepest into the "why" is the one +surface that gives her nothing in her own language. It now asks for both halves +every time, pair language first, and the old sentence is gone (a model handed +both instructions picks one at random). + +**Which half is the lesson is not Petal's to assume.** The first draft of this +justified the change as "her language is the safety net, English is what she's +learning" — wrong, and wrong in a way the code would have carried for good. The +pair is (English + X) and Petal is used from both ends: an English speaker +learning French needs the French half for exactly the reason a Mandarin speaker +learning English needs the English one. So the prompt asks for both and says it +does not know which way round, and nothing in the wording, the rendering or the +comments assigns the halves a role. The ordering still holds either way — the +pair language leads, English follows, which is the pack's order everywhere else. + +**The blank line between the halves is a contract, and a soft one.** +`bilingualReply.ts` splits on the first blank line to render the two halves the +way the companion renders its two lines. It is deliberately forgiving because +the reply streams in token by token from a small local model: a half-arrived +reply is all "native" and the English simply appears beneath it when the break +lands; a model that ignores the instruction renders as one ordinary block. The +one thing it will never do is drop text. A separator with nothing on one side of +it is a stray newline, not a split, and is kept whole. + +- Petal's bubbles now take the card's full width. Two languages in the 85% a + chat reserves to show who is talking wrapped a sentence into a paragraph, and + the tint and alignment already say who is talking. +- `chatFailed` moved into the packs. It is the only message the panel writes + without the model, and it was English-only — telling the half of the pair that + can't read English nothing at all, in the one situation where nothing else is + on screen. It is written blank-line separated, so it renders through the same + two-half bubble as a real reply. + +**The height half was the larger one, and the first fix was wrong.** The item +asks for ~50vh. Because the anchored card opens under the flagged word and never +flips above it, the first version took the *smaller* of 50vh and the room left +below the card, so it could never overhang the screen. Measured on the running +build, that gave **176 px against a 442 px answer** — worse than the 220 px it +replaced. The card's own pill, diff, explanation and action row already spend +~290 px of an 810 px window: "fits below the word" and "room to read" are not +both available, and the clamp silently chose the wrong one. + +So the ceiling is flat 50vh and the overhang is made navigable instead — item 4's +answer to the same conflict, in its own words: *make the overhang navigable, not +shrink what each card says*. Both surfaces that host the panel now report their +reach, so the column grows and the page can scroll to what hangs below: + +- `SuggestionCard.tsx` — `onExtent`, a ResizeObserver rather than a one-shot + measure, because the card grows twice after it mounts: the panel opens, and + then the reply streams into it. It reports 0 as it unmounts. +- `EditorCore.tsx` — `cardExtent` beside `railExtent`, resolved to one + `overhang` (whichever reaches lower) that feeds the wrapper's `minHeight`. The + rail's contribution stays conditional on the rail being mounted; the card's + does not, because it withdraws its own. +- The rail needed nothing: it already re-measures on `expandedId` and a + per-card ResizeObserver, so a rail card whose conversation grows reports it. + +**Verified in a real browser at the review's own 1517×810**, against a stand-in +model server (no VPN, no GPU) returning a deliberately long three-paragraph +bilingual reply: + +- *Anchored card.* Box **176 → 362 px** (50vh), card overhangs by 165 px, the + column gained 209 px of scroll where the same card previously had 20, and + Accept is fully on screen after scrolling to it. No horizontal overflow. +- *Rail card.* Box 334 px, overhang 104 px, 204 px of scroll room, Accept + reachable. The rail's own extent pipeline covered it, as read. +- *On a taller window* the whole 494 px answer fits with no scrollbar at all. + +**Honest limit:** at 810 px a genuinely long answer still scrolls — 362 px of +442. The item's "readable without scrolling a ~100 px box" is met in the sense +that mattered (the box is no longer a peephole and the rest is a short scroll, +not a hunt), but a three-paragraph *bilingual* answer is roughly twice the text +the item imagined, and half a small screen does not hold it. + +**A trap worth recording, because it cost two false measurements.** The restart +script used `pkill -f 'scratchpad/petal$'`, which never matched: the process was +started as `./petal` after a `cd`, so its command line doesn't contain the path. +Every "restart" after the first therefore failed to bind the port and died +quietly, while the *original* binary kept serving the *original* bundle — and +the page loaded fine, the app worked, and the numbers looked plausible. Two +rounds of "the fix didn't take" were measurements of code that was never +running. What caught it was the inline `max-height` reading `176.417px`, a value +the new code cannot produce. **Check the served bundle hash, not that the page +loads** (`curl -s localhost:PORT/ | grep -o 'index-[A-Za-z0-9_-]*\.js'` against +`web/dist/index.html`). + +**Deliberately not done:** + +- No auto-scroll to the card when the panel opens. The conversation starts short + and grows; scrolling the page out from under her the moment she clicks Ask + Petal would move the sentence she is reading about, to solve a problem she + does not have yet. +- The seed bubble stays single-language. It is the pair-language rendering of + the English explanation printed directly above it in the same card — the card + is already bilingual across those two lines, and repeating the English inside + the bubble would be the "same text twice" the seed exists to avoid. + +Coverage: `bilingualReply.test.ts` (both scripts, mid-stream, one-language, +extra blank lines, whitespace-only separator, empty, single newlines inside a +half), `lang_test.go`'s `TestAskPetalAnswersInBothLanguages` (all four pairs ask +for both languages, name the pair language first, keep the separator, and no +longer carry the sentence forbidding it), and an `i18n.test.ts` case that every +pack's `chatFailed` has two non-empty halves and keeps its English one in +English. **The extent wiring has no unit test**, for the reason item 7 recorded: +jsdom has no layout, every rect is zero, and a test there would pass whatever +the code did. It is browser-verified only, and is written down as such. + ## 7. Inline popover at the underline (verify, then strengthen) Grammarly's core gesture is click-the-word → popup at the word. @@ -714,6 +827,16 @@ recorded: **a fixed-width column that gets re-centred changes position without changing size**, and neither a ResizeObserver on it nor a window resize will say so. Untouched: 6, 8, item 3's incremental half.)* +*(Eighth session: item 6 done — see the subsection under it. Two things to carry +forward. First, **the pair is symmetric**: Petal is used from both ends, so +"her language" and "the language being learned" are not interchangeable terms, +and any copy or prompt that assigns the two halves a role is wrong for half the +users — the wording here was corrected mid-session for exactly that. Second, +**check the served bundle hash before believing a browser measurement**: a +restart that silently failed left an old binary serving an old bundle through +two rounds of measurement, and nothing about the running app looked wrong. +Untouched: 8, item 3's incremental half.)* + **Migration 0015 on the live database.** It rebuilds the suggestions table, so unlike 0014 it could have dropped her rows. Backed up first — and the backup had to be the whole WAL set (`petal.db`, `-wal`, `-shm` in @@ -728,7 +851,15 @@ CHECK, and every existing row still carrying the label she has already read (the seven `clarity` rows include the mislabelled Chinese one — by design, only new findings get the new type; if you want that card relabelled, edit the sentence). -**Suggested next (seventh session onward):** **item 6** is the obvious pick — +**Suggested next (eighth session onward):** only **item 8**'s four small ones +and item 3's incremental half remain. **Dismissal persistence** is still the one +with real value now that item 2 gives suggestions stable identity across checks; +the status-bar summary is the cheapest. Item 3's incremental surfacing needs +streaming, which the current `/check` response shape doesn't do — it remains the +largest of what's left. + +*(Superseded, kept for the reading list: the seventh session recommended item 6, +which is now done.)* **item 6** was the obvious pick — it is the last small one, it is self-contained (prompt the answer path to reply in both pair languages, then let the answer area grow to ~50vh instead of a ~100px scrollbox), and item 7 just made the surface it lives on more prominent: diff --git a/internal/llm/lang_test.go b/internal/llm/lang_test.go index eab541e..ac5b215 100644 --- a/internal/llm/lang_test.go +++ b/internal/llm/lang_test.go @@ -83,3 +83,40 @@ func TestDefaultPairStillReadsAsBefore(t *testing.T) { t.Fatalf("zh ask-petal lost its Mandarin \"why\":\n%s", got) } } + +// UX item 6: the Ask Petal answer is bilingual, pair language first, halves +// separated by one blank line. That separator is not a stylistic preference — +// AskPetal.tsx splits on it to render the two halves the way the companion +// renders its two lines — so the instruction has to survive prompt edits. +// +// The direction the writer is learning in is deliberately not encoded: the pair +// is (English + X), and an English speaker learning French needs the same two +// halves a Mandarin speaker learning English does. The prompt asks for both and +// lets the reader choose, so there is nothing here that names one half the +// answer and the other a courtesy. +func TestAskPetalAnswersInBothLanguages(t *testing.T) { + for _, code := range []string{"zh", "pt-PT", "fr", "es"} { + lang := LangFor(code) + ask := AskPetalSystemPrompt("a", "b", "grammar", "d", "e", lang) + + if !strings.Contains(ask, "BOTH languages") { + t.Fatalf("%s: ask-petal no longer asks for both languages:\n%s", code, ask) + } + if !strings.Contains(ask, "single blank line") { + t.Fatalf("%s: ask-petal lost the blank-line separator the client splits on:\n%s", code, ask) + } + // Order matters to the rendering: the pair language is the prominent + // half, English the muted one beneath it. + if !strings.Contains(ask, "first the whole answer in "+lang.Name) { + t.Fatalf("%s: ask-petal doesn't put %s first:\n%s", code, lang.Name, ask) + } + // The instruction it replaced. Left in place it directly contradicts the + // new one, and a model given both will pick one at random. + if strings.Contains(ask, "Never mix languages") { + t.Fatalf("%s: ask-petal still forbids the bilingual reply it now asks for:\n%s", code, ask) + } + if strings.Contains(ask, "%!") { + t.Fatalf("%s: ask-petal prompt has a formatting error:\n%s", code, ask) + } + } +} diff --git a/internal/llm/prompts.go b/internal/llm/prompts.go index eb68706..20df3f3 100644 --- a/internal/llm/prompts.go +++ b/internal/llm/prompts.go @@ -144,6 +144,25 @@ func CollocationMessages(contentText, tone string, lang Lang) []Message { // askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context // is interpolated in; the user's own messages are appended after this system // turn by the caller. +// +// The reply is bilingual, the pair language first. Until UX item 6 it mirrored +// the language of the question instead — self-consistent, but it meant asking in +// one language cost you the other, and the writer doesn't always know which one +// the answer will be clearer in. Which half is the safety net and which is the +// lesson depends on who is writing: the pair is (English + X) either way, and an +// English speaker learning French wants the French half for the same reason a +// Mandarin speaker learning English wants the English one. Petal cannot tell +// them apart from a chat message, and doesn't need to — every other explanation +// surface already gives both (the card's English body, the seeded bubble in the +// pair language). The answer that goes deepest into the "why" was the one place +// that didn't. +// +// The blank line between the halves is a contract with the client: AskPetal.tsx +// splits on the first one to render her language prominently and the English +// beneath it, mirroring the companion's bubble. A model that ignores the +// instruction and writes one language degrades to a single plain block — the +// answer is still readable, which is why the split is a rendering nicety and +// never a parse the reply depends on. const askPetalSystemTemplate = `You are Petal, a warm and patient English writing tutor helping someone who is learning English ` + `as a second language. You are currently discussing a specific writing suggestion. @@ -154,15 +173,21 @@ Suggestion context: - Initial explanation: "%[4]s" - Surrounding paragraph: "%[5]s" -The user wants to understand this suggestion better. Detect the language of the user's message ` + - `and respond in that same language. If they write in %[6]s, respond entirely in ` + - `%[6]s. If they write in English, respond in English. Never mix languages in a single response. +The user wants to understand this suggestion better. Answer in BOTH languages, every time, ` + + `whichever language they asked their question in: first the whole answer in %[6]s, then the ` + + `same answer again in English. Separate the two with a single blank line. Do not label them, ` + + `do not use a blank line anywhere else, and do not mix the two languages within one half — ` + + `each half is complete on its own. + +One of those two languages is the one they are surest in and the other is the one they are ` + + `working in — you do not know which way round, so give both and let them choose. Both halves ` + + `say the same thing: do not put a point in one that is missing from the other. Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples ` + `when helpful. If they ask "why" (or "%[7]s"), explain the grammar rule or idiom behind it. ` + `If they suggest an alternative phrasing, evaluate it honestly. -Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encouraging — ` + +Keep each half concise (2-3 sentences). This is a chat, not an essay. Be encouraging — ` + `learning a language is hard and they're doing great.` // AskPetalSystemPrompt fills the tutor prompt with one suggestion's context and diff --git a/web/src/components/Editor/AskPetal.tsx b/web/src/components/Editor/AskPetal.tsx index 88bf64a..382dbc3 100644 --- a/web/src/components/Editor/AskPetal.tsx +++ b/web/src/components/Editor/AskPetal.tsx @@ -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([{ 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) {
{messages.map((m, i) => (
- {content} + {reply ? ( + <> + {reply.native} + {reply.en !== '' && ( + + {reply.en} + + )} + + ) : ( + {content} + )} {streaming && content === '' && ( ▍ diff --git a/web/src/components/Editor/EditorCore.tsx b/web/src/components/Editor/EditorCore.tsx index 26448bb..04de890 100644 --- a/web/src/components/Editor/EditorCore.tsx +++ b/web/src/components/Editor/EditorCore.tsx @@ -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({
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 && ( diff --git a/web/src/components/Editor/SuggestionCard.tsx b/web/src/components/Editor/SuggestionCard.tsx index 8651ab0..6f21e77 100644 --- a/web/src/components/Editor/SuggestionCard.tsx +++ b/web/src/components/Editor/SuggestionCard.tsx @@ -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(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 (
{ + it('splits the pair language from the English at the blank line', () => { + const { native, en } = splitBilingual( + '“by foots” 不是固定说法,正确的是 “on foot”。\n\n"By foots" isn’t a set phrase — the idiom is "on foot".', + ) + expect(native).toBe('“by foots” 不是固定说法,正确的是 “on foot”。') + expect(en).toBe('"By foots" isn’t 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') + }) +}) diff --git a/web/src/components/Editor/bilingualReply.ts b/web/src/components/Editor/bilingualReply.ts new file mode 100644 index 0000000..ffa3f9c --- /dev/null +++ b/web/src/components/Editor/bilingualReply.ts @@ -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 } +} diff --git a/web/src/i18n/i18n.test.ts b/web/src/i18n/i18n.test.ts index 1aead9f..c1cea2c 100644 --- a/web/src/i18n/i18n.test.ts +++ b/web/src/i18n/i18n.test.ts @@ -164,6 +164,22 @@ describe('the zh pack', () => { if (p.code === 'pt-PT') expect(p.locale).toBe('pt-PT') // never pt-BR }) + // The chat-failure line is the only message the Ask Petal panel writes without + // the model, and it renders through the same bilingual bubble as a real reply + // (splitBilingual, blank line between the halves). A pack that writes it as + // one language gets a bubble with a muted empty half — and, worse, tells the + // half of the pair that can't read that language nothing at all. + it.each(PACKS)('says the chat-failure line in both halves of the pair ($code)', async (p) => { + const { splitBilingual } = await import('../components/Editor/bilingualReply') + const { native, en } = splitBilingual(p.editor.chatFailed) + expect(native, `${p.code} chatFailed has no pair-language half`).not.toBe('') + expect(en, `${p.code} chatFailed has no English half`).not.toBe('') + // The English half is the one every reader of every pack shares, so it is + // the one worth pinning: a pack that translated it has lost the point. + expect(en).toMatch(/ask me again/i) + expect(native).not.toBe(en) + }) + it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => { const { COMPANIONS } = await import('../components/Companion/companions') for (const c of COMPANIONS) { diff --git a/web/src/i18n/packs/fr.ts b/web/src/i18n/packs/fr.ts index 90fde26..702031a 100644 --- a/web/src/i18n/packs/fr.ts +++ b/web/src/i18n/packs/fr.ts @@ -307,6 +307,7 @@ export const fr: Pack = { editor: { askPlaceholder: 'Ask why… / Demande pourquoi…', + chatFailed: 'Je n’ai pas réussi à répondre — repose-moi la question, s’il te plaît. 🌸\n\nI had trouble answering just now — please ask me again. 🌸', findPlaceholder: 'Rechercher · Find', findNone: 'Rien · 0', matchCase: 'Match case · Respecter la casse', diff --git a/web/src/i18n/packs/pt-PT.ts b/web/src/i18n/packs/pt-PT.ts index a1f43a0..2f517c8 100644 --- a/web/src/i18n/packs/pt-PT.ts +++ b/web/src/i18n/packs/pt-PT.ts @@ -285,6 +285,7 @@ export const ptPT: Pack = { editor: { askPlaceholder: 'Ask why… / Pergunta porquê…', + chatFailed: 'Não consegui responder agora — pergunta-me outra vez, se faz favor. 🌸\n\nI had trouble answering just now — please ask me again. 🌸', findPlaceholder: 'Localizar · Find', findNone: 'Nada · 0', matchCase: 'Match case · Maiúsculas/minúsculas', diff --git a/web/src/i18n/packs/zh.ts b/web/src/i18n/packs/zh.ts index 1fd0e0e..480d321 100644 --- a/web/src/i18n/packs/zh.ts +++ b/web/src/i18n/packs/zh.ts @@ -185,6 +185,7 @@ export const zh: Pack = { editor: { askPlaceholder: 'Ask why… / 问为什么…', + chatFailed: '我这会儿没答上来,再问我一次好吗?🌸\n\nI had trouble answering just now — please ask me again. 🌸', findPlaceholder: '查找 · Find', findNone: '无 · 0', matchCase: 'Match case · 区分大小写', diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index a66ca19..8a06126 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -155,6 +155,10 @@ export interface Pack { editor: { askPlaceholder: string + // Shown in Petal's own chat bubble when the reply never arrives. It is + // the one line in that panel Petal writes without the model, so the pack + // owns it — and it is bilingual like every answer beside it. + chatFailed: string findPlaceholder: string findNone: string matchCase: string