From f082a930cb4fd7c832951c00490a882b3799b78a Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:34:27 -0700 Subject: [PATCH 1/2] The rail is a mode, not a screen size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 7 said to confirm before building, and confirming is what mattered. The rail's 348px threshold is measured against a fixed 720px column centred in the pane. The doc-list sidebar is 280px, so at her 1517px viewport the right margin is 258 with it open and 406 without — either side of the threshold. What moves between them is distraction-free mode, which engages on its own when the editor takes focus. The rail therefore appears when she starts writing and disappears when she stops; items 4 and 5 disagreed about whether it exists at 1517px only because they caught it in different states. Re-centring a fixed-width column changes its position and not its size, so the wrapper's ResizeObserver reported nothing and no window resize fired. railEnabled kept whatever value it last had. Leaving distraction-free with the rail up left a 300px column in a 266px margin: overhanging the viewport by 66px, cards clipped mid-sentence, the page scrolling sideways. Entering it with the rail down opened 406px of margin and put nothing in it. Both persisted until something else happened to resize the window. Observe the scrollport too — it spans the pane, so it resizes whenever the chrome around the editor does. That covers any future chrome that moves the editor, which threading focusMode down as a prop would not. Clicking a highlight now opens the anchored card even when the rail is up. That is the item's own acceptance criterion and was previously false by design; the measured distance from the first flagged span to its rail card is 651px, not the ~400 the review guessed. Hover still defers to the rail, since the reasoning against an unbidden second card was about hover and still holds — but a click is her asking to deal with that word. The rail card glows instead of expanding, so nothing is ever open twice. Verified in Chrome at the review's own 1517x810, driving the rule pack from item 3b so no model was involved: the rail follows the mode in both directions with no resize event anywhere; the popover lands 6px under the word with the full explanation, Ask Petal, Accept and Dismiss; accepting from it applied the edit and took the rail 6 cards to 5, leaving the rest with their ids, positions and wording intact. railFit.test.ts pins the threshold to the margins actually measured. The observer wiring has no unit test and can't have a useful one: jsdom has no layout, so every rect is zero and the rail branch is unreachable there. That half is browser-verified only, and the doc says so. --- UX_REVIEW_2026-07-27.md | 125 ++++++++++++++++++++-- web/src/components/Editor/EditorCore.tsx | 45 +++++--- web/src/components/Editor/railFit.test.ts | 42 ++++++++ web/src/components/Editor/railFit.ts | 19 ++++ 4 files changed, 208 insertions(+), 23 deletions(-) create mode 100644 web/src/components/Editor/railFit.test.ts create mode 100644 web/src/components/Editor/railFit.ts diff --git a/UX_REVIEW_2026-07-27.md b/UX_REVIEW_2026-07-27.md index 12a82a8..dda27fc 100644 --- a/UX_REVIEW_2026-07-27.md +++ b/UX_REVIEW_2026-07-27.md @@ -531,6 +531,99 @@ whichever half is missing. Accept, without needing the rail; hovering a rail card glows its span and vice versa. +### 7 — DONE (seventh session). The rail isn't a screen-size fact; it's a mode. + +The item says to confirm first and fix whichever half is missing. Confirming +first is what mattered, because the interesting defect wasn't either half. + +**Settled first: the contradiction items 4 and 5 left behind.** The fifth +session measured rail cards at 1517px; the sixth found no rail at all at the +same width and wrote down a margin of 258. Both were right. The editor is a +fixed 720px column centred in the pane, and the doc-list sidebar is 280px, so +at 1517px the right margin is **258 with the sidebar open and 406 without** — +either side of the rail's 348 threshold. What moves between them is +distraction-free mode, which engages *on its own* the moment the editor takes +focus. So the rail is not a property of her screen. It appears when she starts +writing and disappears when she stops, and both sessions had simply caught it +in different states. + +**The bug that fell out of that.** `recomputeRail` was triggered by a +ResizeObserver on the wrapper, a window `resize`, or a change to the suggestion +set. Entering or leaving distraction-free mode is none of the three: the +wrapper is a fixed 720px column, so re-centring it changes its *position* and +never its *size*, and a ResizeObserver reports only size. `railEnabled` therefore +kept whatever value it last had. + +Leaving distraction-free with the rail up is the bad direction, and it is not +subtle — measured in Chrome at 1517×810: the 300px column stayed mounted in the +266px margin the restored sidebar left behind, **overhanging the viewport by +66px**, cards clipped mid-sentence ("use "an": "a…"), and the page grew a +horizontal scrollbar it never has otherwise. The other direction is only a loss: +she starts typing, the margin opens to 406, and no rail arrives. Both persisted +indefinitely — dispatching a lone `resize` event was enough to correct either, +which is what proved the measurement was the only thing missing. + +**Implemented:** + +- `EditorCore.tsx` — the ResizeObserver now watches `.petal-scrollport` as well + as the wrapper. The scrollport spans the pane, so it resizes whenever the + chrome around the editor does; the wrapper, being fixed-width, never does. It + is the element the sticky-pin code already reaches for, so it needed no new + handle, and unlike threading `focusMode` down as a prop it also covers any + future chrome that moves the editor. +- `railFit.ts` — `RAIL_MIN_MARGIN` and `railFitsBeside` lifted out of the + measurement callback. A bare `>=` doesn't need a name; this one earns it, + because the number picks between two entirely different suggestion surfaces + and the margin it reads moves for reasons unrelated to window size. +- `EditorCore.tsx` — **clicking a highlight now opens the anchored card even + when the rail is up**, which is the item's own acceptance criterion and was + previously false by design. Hover still defers to the rail: an unbidden + floating card next to a margin card saying the same thing is noise, and that + earlier reasoning was about hover and still holds. A click isn't. The rail card + glows instead of expanding, so the suggestion is never open in two places, and + a click-opened card keeps its glow after the pointer leaves (it closes on a + click away) so the margin and the open card don't disagree about what she's + reading. + +**Measured, not estimated.** The item guessed ~400px of eye travel from +underline to rail card. At 1517px in distraction-free mode the real distance +from the first flagged span's right edge to its card is **651px**. After the +change the card lands 6px under the word. + +**Verified in a real browser at the review's own 1517×810**, driving the local +build with the rule pack from item 3b so no model or VPN was involved: + +- *Rail follows the mode, with no resize event anywhere.* Click into the prose → + sidebar collapses, margin 406, rail mounts with its cards, no overflow. Escape + → sidebar restores, margin 258, rail unmounts, no overflow, no horizontal + scroll. Re-focus → it comes back. Re-run after the `railFit` extraction. +- *Click with the rail up.* Popover opens flush under "a apple" (6px gap, left + edges aligned), carrying the type pill, the diff, the full explanation, Ask + Petal, Accept and Dismiss; it fits the viewport; exactly one rail card glows + and **none is expanded**. +- *Accept from that popover.* Text became "an apple", the popover closed, the + rail went 6 cards → 5, and the other four kept their id, position and wording + — item 2's stability holding under a path it hadn't been exercised on. +- *The two halves the item asked about were already fine.* Span hover lights its + rail card, card hover lights its span (both directions, checked via the + `-active` classes). And with the rail off, clicking an underline already gave + an anchored popover — richer than the item's "one-line reason + more", since + it carries the whole explanation and Ask Petal. Nothing to build there. + +**Deliberately not done:** no "more" affordance linking the popover to a rail +card. The item imagined the popover as a teaser for the rail's fuller version; +there is no fuller version — both surfaces render the same explanation, and the +popover has Ask Petal too. Adding a control that expands a second copy of what +she is already reading would be the redundancy the hover rule exists to avoid. + +Coverage: `railFit.test.ts` pins the threshold to the margins actually measured +in Chrome — 406 fits, 258 and the mid-animation 266 don't, the bound is +inclusive, 1920-with-sidebar fits, narrow windows never do. **The observer wiring +itself has no unit test and can't have a useful one**: jsdom has no layout, so +every `getBoundingClientRect()` is zero, `railFitsBeside(0, 0)` is false, and the +rail branch is unreachable there. That half is browser-verified only, and is +written down as such rather than covered by a test that would pass regardless. + ## 8. Smaller items (each small, do opportunistically) - **Accept All per category.** Five tense fixes = five clicks today. Add @@ -611,6 +704,16 @@ renders invisibly, and at her actual viewport the rail is disabled — the inlin hover card is what she sees, which inverts item 7's premise. Untouched: 6, 7, 8, item 3's incremental half.)* +*(Seventh session: item 7 done — see the subsection under it. Two things there +are worth carrying forward. First, **the rail is a mode, not a screen size**: +distraction-free engages by itself on editor focus and moves the margin across +the rail's threshold, so "does she see the rail?" has no fixed answer at a given +width — items 4 and 5 disagreed only because they caught it in different states. +Second, the layout invariant that bit here is the same shape as the one item 4 +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.)* + **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 @@ -625,14 +728,20 @@ 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 (sixth session onward):** items 6, 7, 8 are all untouched -and all small; item 5's re-scoped Translate card type is the cheapest -*visible* win (see its Status note — the span is already detected and -already rendered in English, it's only mislabeled as Clarity), and -`internal/suggestions/translate.go` already exists — read it before -designing a new type. Item 3's incremental-surfacing half now has the -chunking it was waiting on, but still needs streaming, which the current -`/check` response shape doesn't do. +**Suggested next (seventh session onward):** **item 6** is 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: +Ask Petal now opens inside a card anchored at the word in *both* layouts, so a +cramped English-only answer is more visible than when the review was written. +Item 8's four are all still small and independent; **dismissal persistence** is +the one with real value now that item 2 gives suggestions stable identity across +checks. Item 3's incremental-surfacing half still 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 sixth session recommended item 5's +Translate card type, which is now done and live.)* *The advice below was written for the second session and is kept for its reading list, not its recommendation: item 3b is done and deployed.* It was diff --git a/web/src/components/Editor/EditorCore.tsx b/web/src/components/Editor/EditorCore.tsx index 0a5e317..26448bb 100644 --- a/web/src/components/Editor/EditorCore.tsx +++ b/web/src/components/Editor/EditorCore.tsx @@ -19,6 +19,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import { Toolbar } from '../Toolbar/Toolbar' import { SuggestionCard } from './SuggestionCard' import { SuggestionRail, type RailItem } from './SuggestionRail' +import { railFitsBeside } from './railFit' import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight' import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck' import { MisspellCard } from './MisspellCard' @@ -425,9 +426,7 @@ export function EditorCore({ const wrapper = wrapperRef.current if (!wrapper) return const wrapRect = wrapper.getBoundingClientRect() - // Need room for the 300px column + its 32px gutter (see .petal-rail), plus - // a little breathing space to the viewport edge. - setRailEnabled(window.innerWidth - wrapRect.right >= 348) + setRailEnabled(railFitsBeside(window.innerWidth, wrapRect.right)) const seen = new Set() const items: RailItem[] = [] wrapper.querySelectorAll('.petal-suggestion[data-suggestion-id]').forEach((el) => { @@ -448,11 +447,24 @@ export function EditorCore({ // Re-anchor when the suggestion set changes (after the decorations repaint), // and keep the rail in sync with viewport/editor width changes (room + reflow). + // + // The scrollport is observed as well as the wrapper, and it is not redundant: + // the wrapper is a fixed 720px column, so entering or leaving distraction-free + // mode *moves* it (the pane re-centres) without ever changing its size. A + // ResizeObserver on the wrapper alone reports nothing, no window resize fires, + // and `railEnabled` keeps whatever value it had — leaving the 300px rail + // rendered into the 266px margin a restored sidebar leaves behind, cards + // clipped mid-sentence and the page scrolling sideways. The scrollport spans + // the pane, so it resizes whenever the chrome around the editor does. useEffect(() => { recomputeRail() const wrapper = wrapperRef.current + const port = wrapper?.closest('.petal-scrollport') const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null - if (wrapper && ro) ro.observe(wrapper) + if (wrapper && ro) { + ro.observe(wrapper) + if (port) ro.observe(port) + } window.addEventListener('resize', recomputeRail) return () => { ro?.disconnect() @@ -590,12 +602,15 @@ export function EditorCore({ (e: React.MouseEvent) => { if (!(e.target as HTMLElement).closest('.petal-suggestion')) return if (railEnabled) { - setActiveId(null) + // A click-opened card outlives the pointer (it closes on a click away), + // so its rail card keeps the glow — otherwise the open card and the + // margin stop agreeing about which suggestion is being read. + if (!hover) setActiveId(null) return } scheduleClose() }, - [scheduleClose, railEnabled], + [scheduleClose, railEnabled, hover], ) const keepOpen = useCallback(() => clearTimeout(closeTimer.current), []) @@ -685,14 +700,14 @@ export function EditorCore({ if (suggestionEl) { const id = suggestionEl.getAttribute('data-suggestion-id') if (id) { - // With the rail open, a tap emphasizes and expands its margin card - // instead of opening a floating one. - if (railEnabled) { - setActiveId(id) - setRailExpandedId(id) - } else { - openCardFor(id, suggestionEl) - } + // Clicking a highlight always opens the card at the word. Hover still + // defers to the rail (see handleMouseOver) — an unbidden floating card + // beside a margin card that already says the same thing is noise. But a + // click is her asking to deal with *this* word, and answering it 650px + // away in the periphery is the gesture item 7 is about. The rail card + // glows rather than expanding, so the suggestion is never open twice. + setActiveId(id) + openCardFor(id, suggestionEl) } return } @@ -1210,7 +1225,7 @@ export function EditorCore({ onAdd={addMisspellingToDict} /> )} - {hover && !railEnabled && ( + {hover && ( { + it('fits in distraction-free mode, where the pane spans the window', () => { + // 1517px window, sidebar collapsed: the 720px column centres at left 391, + // so its right edge is 1111 and 406px of margin remain. + expect(railFitsBeside(1517, 1111)).toBe(true) + }) + + it('does not fit with the document list open at the same window size', () => { + // Same window, 280px sidebar in flow: the column re-centres to right 1259 and + // the margin falls to 258 — the measurement item 5 reported. This is the case + // that must return false; rendering the rail here overhangs the viewport. + expect(railFitsBeside(1517, 1259)).toBe(false) + }) + + it('rejects the mid-animation width too, not just the settled one', () => { + // The sidebar animates over 280ms, so the recompute can land on an + // intermediate margin (266 was observed one frame in). Anything under the + // threshold has to read as "no rail", or the column flickers back in. + expect(railFitsBeside(1517, 1251)).toBe(false) + }) + + it('treats the threshold as inclusive', () => { + expect(railFitsBeside(1000, 1000 - RAIL_MIN_MARGIN)).toBe(true) + expect(railFitsBeside(1000, 1000 - RAIL_MIN_MARGIN + 1)).toBe(false) + }) + + it('has room to spare on a wide desktop', () => { + // 1920px maximised, sidebar open: margin 468. + expect(railFitsBeside(1920, 1452)).toBe(true) + }) + + it('never fits on a narrow window, whatever the column does', () => { + expect(railFitsBeside(900, 810)).toBe(false) + expect(railFitsBeside(768, 744)).toBe(false) + }) +}) diff --git a/web/src/components/Editor/railFit.ts b/web/src/components/Editor/railFit.ts new file mode 100644 index 0000000..f0ef424 --- /dev/null +++ b/web/src/components/Editor/railFit.ts @@ -0,0 +1,19 @@ +// Whether the margin rail has room to sit beside the editor. +// +// The rail is a 300px column with a 32px gutter (see `.petal-rail` in index.css); +// RAIL_MIN_MARGIN adds a little breathing space to the viewport edge. Below it the +// editor falls back to the inline card anchored under the word. +// +// This is a bare comparison, but it earns a name: the number decides which of two +// entirely different suggestion surfaces she gets, and the margin it measures moves +// for reasons that have nothing to do with the window size. The editor is a fixed +// 720px column centred in the pane, so collapsing the 280px sidebar (distraction-free +// mode) re-centres it and changes this margin by 140px without resizing anything. +// See the ResizeObserver in EditorCore for the other half of that story. +export const RAIL_MIN_MARGIN = 348 + +// `wrapperRight` and `innerWidth` are both viewport coordinates — i.e. exactly +// `wrapper.getBoundingClientRect().right` and `window.innerWidth`. +export function railFitsBeside(innerWidth: number, wrapperRight: number): boolean { + return innerWidth - wrapperRight >= RAIL_MIN_MARGIN +} From 978cb806423bb7346368b1e38eaf674d3733d692 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:04:16 -0700 Subject: [PATCH 2/2] Ask Petal answers in both languages, with room to read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- UX_REVIEW_2026-07-27.md | 133 +++++++++++++++++- internal/llm/lang_test.go | 37 +++++ internal/llm/prompts.go | 33 ++++- web/src/components/Editor/AskPetal.tsx | 94 +++++++++++-- web/src/components/Editor/EditorCore.tsx | 28 +++- web/src/components/Editor/SuggestionCard.tsx | 29 +++- .../components/Editor/bilingualReply.test.ts | 74 ++++++++++ web/src/components/Editor/bilingualReply.ts | 43 ++++++ web/src/i18n/i18n.test.ts | 16 +++ web/src/i18n/packs/fr.ts | 1 + web/src/i18n/packs/pt-PT.ts | 1 + web/src/i18n/packs/zh.ts | 1 + web/src/i18n/types.ts | 4 + 13 files changed, 470 insertions(+), 24 deletions(-) create mode 100644 web/src/components/Editor/bilingualReply.test.ts create mode 100644 web/src/components/Editor/bilingualReply.ts 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