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
+132 -1
View File
@@ -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: