Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aa3a14030 | ||
|
|
978cb80642 | ||
|
|
f082a930cb |
+248
-8
@@ -513,6 +513,119 @@ sane max-height (~50vh) before scrolling.
|
|||||||
**Acceptance:** an Ask Petal answer shows 中文 + English; a 3-paragraph
|
**Acceptance:** an Ask Petal answer shows 中文 + English; a 3-paragraph
|
||||||
answer is readable without scrolling a ~100 px box.
|
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)
|
## 7. Inline popover at the underline (verify, then strengthen)
|
||||||
|
|
||||||
Grammarly's core gesture is click-the-word → popup at the word.
|
Grammarly's core gesture is click-the-word → popup at the word.
|
||||||
@@ -531,6 +644,99 @@ whichever half is missing.
|
|||||||
Accept, without needing the rail; hovering a rail card glows its span and
|
Accept, without needing the rail; hovering a rail card glows its span and
|
||||||
vice versa.
|
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)
|
## 8. Smaller items (each small, do opportunistically)
|
||||||
|
|
||||||
- **Accept All per category.** Five tense fixes = five clicks today. Add
|
- **Accept All per category.** Five tense fixes = five clicks today. Add
|
||||||
@@ -611,6 +817,26 @@ 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,
|
hover card is what she sees, which inverts item 7's premise. Untouched: 6, 7, 8,
|
||||||
item 3's incremental half.)*
|
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.)*
|
||||||
|
|
||||||
|
*(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
|
**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
|
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
|
had to be the whole WAL set (`petal.db`, `-wal`, `-shm` in
|
||||||
@@ -625,14 +851,28 @@ 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
|
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).
|
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
|
**Suggested next (eighth session onward):** only **item 8**'s four small ones
|
||||||
and all small; item 5's re-scoped Translate card type is the cheapest
|
and item 3's incremental half remain. **Dismissal persistence** is still the one
|
||||||
*visible* win (see its Status note — the span is already detected and
|
with real value now that item 2 gives suggestions stable identity across checks;
|
||||||
already rendered in English, it's only mislabeled as Clarity), and
|
the status-bar summary is the cheapest. Item 3's incremental surfacing needs
|
||||||
`internal/suggestions/translate.go` already exists — read it before
|
streaming, which the current `/check` response shape doesn't do — it remains the
|
||||||
designing a new type. Item 3's incremental-surfacing half now has the
|
largest of what's left.
|
||||||
chunking it was waiting on, but still needs streaming, which the current
|
|
||||||
`/check` response shape doesn't do.
|
*(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:
|
||||||
|
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
|
*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
|
reading list, not its recommendation: item 3b is done and deployed.* It was
|
||||||
|
|||||||
@@ -83,3 +83,40 @@ func TestDefaultPairStillReadsAsBefore(t *testing.T) {
|
|||||||
t.Fatalf("zh ask-petal lost its Mandarin \"why\":\n%s", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+29
-4
@@ -144,6 +144,25 @@ func CollocationMessages(contentText, tone string, lang Lang) []Message {
|
|||||||
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
|
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
|
||||||
// is interpolated in; the user's own messages are appended after this system
|
// is interpolated in; the user's own messages are appended after this system
|
||||||
// turn by the caller.
|
// 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 ` +
|
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.
|
`as a second language. You are currently discussing a specific writing suggestion.
|
||||||
|
|
||||||
@@ -154,15 +173,21 @@ Suggestion context:
|
|||||||
- Initial explanation: "%[4]s"
|
- Initial explanation: "%[4]s"
|
||||||
- Surrounding paragraph: "%[5]s"
|
- Surrounding paragraph: "%[5]s"
|
||||||
|
|
||||||
The user wants to understand this suggestion better. Detect the language of the user's message ` +
|
The user wants to understand this suggestion better. Answer in BOTH languages, every time, ` +
|
||||||
`and respond in that same language. If they write in %[6]s, respond entirely in ` +
|
`whichever language they asked their question in: first the whole answer in %[6]s, then the ` +
|
||||||
`%[6]s. If they write in English, respond in English. Never mix languages in a single response.
|
`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 ` +
|
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. ` +
|
`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.
|
`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.`
|
`learning a language is hard and they're doing great.`
|
||||||
|
|
||||||
// AskPetalSystemPrompt fills the tutor prompt with one suggestion's context and
|
// AskPetalSystemPrompt fills the tutor prompt with one suggestion's context and
|
||||||
|
|||||||
@@ -1,27 +1,51 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
|
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
|
import { splitBilingual } from './bilingualReply'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
suggestionId: string
|
suggestionId: string
|
||||||
// The English explanation (shown in the card body). Petal's opening bubble is
|
// 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
|
// its translation into the pair language, fetched on open — so the panel
|
||||||
// just repeat the same English text twice. Falls back to this on failure.
|
// doesn't just repeat the same English text twice. Falls back to this on
|
||||||
|
// failure.
|
||||||
explanation: string
|
explanation: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CJK fallback stack — Nunito has no Chinese glyphs, and the user asks questions
|
// CJK fallback stack — Nunito has no Chinese glyphs, and on the zh pair both
|
||||||
// in Mandarin (spec Note #17). Applied to the bubbles specifically, not the
|
// the questions and half of every answer are in Mandarin (spec Note #17). The
|
||||||
// serif editor body.
|
// 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"
|
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
|
// AskPetal is the mini chat panel inside an expanded SuggestionCard. The whole
|
||||||
// conversation lives in this component's state — nothing is persisted; closing
|
// conversation lives in this component's state — nothing is persisted; closing
|
||||||
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
|
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
|
||||||
// token into the latest assistant bubble.
|
// token into the latest assistant bubble.
|
||||||
export function AskPetal({ suggestionId, explanation }: Props) {
|
export function AskPetal({ suggestionId, explanation }: Props) {
|
||||||
const t = usePack()
|
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.
|
// translation once it lands; `seeding` drives that loading caret.
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
|
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
|
||||||
const [seeding, setSeeding] = useState(true)
|
const [seeding, setSeeding] = useState(true)
|
||||||
@@ -36,6 +60,19 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
|||||||
if (el) el.scrollTop = el.scrollHeight
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
}, [messages])
|
}, [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
|
// 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
|
// screen as an absolutely-positioned overlay, and a default focus() would make
|
||||||
// the browser scroll its ancestor to "reveal" the input — jumping the document
|
// 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 })
|
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
|
// 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
|
// conversation always opens with this one assistant turn). Falls back to the
|
||||||
// English explanation if the translation can't be fetched.
|
// English explanation if the translation can't be fetched.
|
||||||
@@ -91,10 +129,10 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const next = prev.slice()
|
const next = prev.slice()
|
||||||
next[next.length - 1] = {
|
// Bilingual, from the pack, and blank-line separated like a real reply —
|
||||||
role: 'assistant',
|
// so the one message Petal writes without the model still renders
|
||||||
content: 'Sorry, I had trouble responding just now. Please try again. 🌸',
|
// through the same two-half bubble as every message with it.
|
||||||
}
|
next[next.length - 1] = { role: 'assistant', content: t.editor.chatFailed }
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
console.error('Ask Petal chat failed:', err)
|
console.error('Ask Petal chat failed:', err)
|
||||||
@@ -116,7 +154,7 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="flex flex-col gap-2 overflow-y-auto pr-1"
|
className="flex flex-col gap-2 overflow-y-auto pr-1"
|
||||||
style={{ maxHeight: 220 }}
|
style={{ maxHeight }}
|
||||||
>
|
>
|
||||||
{messages.map((m, i) => (
|
{messages.map((m, i) => (
|
||||||
<Bubble
|
<Bubble
|
||||||
@@ -164,6 +202,19 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
|||||||
// Bubble renders one chat turn: Petal rose-tinted and left-aligned, the user
|
// Bubble renders one chat turn: Petal rose-tinted and left-aligned, the user
|
||||||
// lavender and right-aligned. A trailing caret marks the actively streaming
|
// lavender and right-aligned. A trailing caret marks the actively streaming
|
||||||
// reply until its first token lands.
|
// reply until its first token lands.
|
||||||
|
//
|
||||||
|
// Petal's turns are bilingual (see bilingualReply.ts) and are laid out the way
|
||||||
|
// the companion lays out its own two lines: the pair language first and plainly
|
||||||
|
// readable, the English beneath it in the muted tone. That order is the pack's
|
||||||
|
// order everywhere else in the UI, and it holds whichever direction the writer
|
||||||
|
// is learning in — the muted half is the one they can already read, and which
|
||||||
|
// half that is isn't Petal's to decide. The writer's own turns are their own
|
||||||
|
// words in whichever language they typed them, so they are never split.
|
||||||
|
//
|
||||||
|
// Petal's bubble also takes the full width the card offers rather than the 85%
|
||||||
|
// a chat normally reserves to show who is talking — the alignment and the
|
||||||
|
// tint already say that, and two languages in a 4/5-width column wraps a
|
||||||
|
// sentence-length answer into a paragraph-shaped one.
|
||||||
function Bubble({
|
function Bubble({
|
||||||
role,
|
role,
|
||||||
content,
|
content,
|
||||||
@@ -174,10 +225,11 @@ function Bubble({
|
|||||||
streaming: boolean
|
streaming: boolean
|
||||||
}) {
|
}) {
|
||||||
const isPetal = role === 'assistant'
|
const isPetal = role === 'assistant'
|
||||||
|
const reply = isPetal ? splitBilingual(content) : null
|
||||||
return (
|
return (
|
||||||
<div className={`flex ${isPetal ? 'justify-start' : 'justify-end'}`}>
|
<div className={`flex ${isPetal ? 'justify-start' : 'justify-end'}`}>
|
||||||
<div
|
<div
|
||||||
className="max-w-[85%] rounded-2xl px-3 py-1.5 text-xs leading-snug"
|
className={`${isPetal ? 'w-full' : 'max-w-[85%]'} rounded-2xl px-3 py-2 leading-snug`}
|
||||||
style={{
|
style={{
|
||||||
background: isPetal ? 'var(--color-surface-alt)' : 'var(--color-lavender)',
|
background: isPetal ? 'var(--color-surface-alt)' : 'var(--color-lavender)',
|
||||||
color: 'var(--color-plum)',
|
color: 'var(--color-plum)',
|
||||||
@@ -185,7 +237,21 @@ function Bubble({
|
|||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{reply ? (
|
||||||
|
<>
|
||||||
|
<span className="text-[0.8rem]">{reply.native}</span>
|
||||||
|
{reply.en !== '' && (
|
||||||
|
<span
|
||||||
|
className="mt-1.5 block text-xs"
|
||||||
|
style={{ color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
{reply.en}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs">{content}</span>
|
||||||
|
)}
|
||||||
{streaming && content === '' && (
|
{streaming && content === '' && (
|
||||||
<span className="petal-chat-caret" aria-hidden>
|
<span className="petal-chat-caret" aria-hidden>
|
||||||
▍
|
▍
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
|
|||||||
import { Toolbar } from '../Toolbar/Toolbar'
|
import { Toolbar } from '../Toolbar/Toolbar'
|
||||||
import { SuggestionCard } from './SuggestionCard'
|
import { SuggestionCard } from './SuggestionCard'
|
||||||
import { SuggestionRail, type RailItem } from './SuggestionRail'
|
import { SuggestionRail, type RailItem } from './SuggestionRail'
|
||||||
|
import { railFitsBeside } from './railFit'
|
||||||
import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight'
|
import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight'
|
||||||
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||||
import { MisspellCard } from './MisspellCard'
|
import { MisspellCard } from './MisspellCard'
|
||||||
@@ -278,6 +279,24 @@ export function EditorCore({
|
|||||||
// without this the column below the last line of text isn't scrollable and any
|
// 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.
|
// card that lands there is unreachable, not merely far from its sentence.
|
||||||
const [railExtent, setRailExtent] = useState(0)
|
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.
|
// 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
|
// 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.
|
// cards would otherwise carry every sentence off the top of the screen.
|
||||||
@@ -425,9 +444,7 @@ export function EditorCore({
|
|||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
if (!wrapper) return
|
if (!wrapper) return
|
||||||
const wrapRect = wrapper.getBoundingClientRect()
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
// Need room for the 300px column + its 32px gutter (see .petal-rail), plus
|
setRailEnabled(railFitsBeside(window.innerWidth, wrapRect.right))
|
||||||
// a little breathing space to the viewport edge.
|
|
||||||
setRailEnabled(window.innerWidth - wrapRect.right >= 348)
|
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const items: RailItem[] = []
|
const items: RailItem[] = []
|
||||||
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
|
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
|
||||||
@@ -448,11 +465,24 @@ export function EditorCore({
|
|||||||
|
|
||||||
// Re-anchor when the suggestion set changes (after the decorations repaint),
|
// Re-anchor when the suggestion set changes (after the decorations repaint),
|
||||||
// and keep the rail in sync with viewport/editor width changes (room + reflow).
|
// 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(() => {
|
useEffect(() => {
|
||||||
recomputeRail()
|
recomputeRail()
|
||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
|
const port = wrapper?.closest('.petal-scrollport')
|
||||||
const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null
|
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)
|
window.addEventListener('resize', recomputeRail)
|
||||||
return () => {
|
return () => {
|
||||||
ro?.disconnect()
|
ro?.disconnect()
|
||||||
@@ -590,12 +620,15 @@ export function EditorCore({
|
|||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (!(e.target as HTMLElement).closest('.petal-suggestion')) return
|
if (!(e.target as HTMLElement).closest('.petal-suggestion')) return
|
||||||
if (railEnabled) {
|
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
|
return
|
||||||
}
|
}
|
||||||
scheduleClose()
|
scheduleClose()
|
||||||
},
|
},
|
||||||
[scheduleClose, railEnabled],
|
[scheduleClose, railEnabled, hover],
|
||||||
)
|
)
|
||||||
|
|
||||||
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
||||||
@@ -685,15 +718,15 @@ export function EditorCore({
|
|||||||
if (suggestionEl) {
|
if (suggestionEl) {
|
||||||
const id = suggestionEl.getAttribute('data-suggestion-id')
|
const id = suggestionEl.getAttribute('data-suggestion-id')
|
||||||
if (id) {
|
if (id) {
|
||||||
// With the rail open, a tap emphasizes and expands its margin card
|
// Clicking a highlight always opens the card at the word. Hover still
|
||||||
// instead of opening a floating one.
|
// defers to the rail (see handleMouseOver) — an unbidden floating card
|
||||||
if (railEnabled) {
|
// 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)
|
setActiveId(id)
|
||||||
setRailExpandedId(id)
|
|
||||||
} else {
|
|
||||||
openCardFor(id, suggestionEl)
|
openCardFor(id, suggestionEl)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
@@ -1134,10 +1167,11 @@ export function EditorCore({
|
|||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
className="relative flex-1"
|
className="relative flex-1"
|
||||||
// Grown to cover the card stack when it overhangs the prose, so the space
|
// Grown to cover whichever absolutely-positioned surface reaches lowest —
|
||||||
// those cards occupy is actually scrollable. `minHeight` never shrinks the
|
// the rail's card stack, or an open anchored card — so the space those
|
||||||
// column, so a rail that fits beside its text changes nothing.
|
// cards occupy is actually scrollable. `minHeight` never shrinks the
|
||||||
style={railEnabled && railExtent > 0 ? { minHeight: railExtent + RAIL_TAIL } : undefined}
|
// column, so a rail or card that fits beside its text changes nothing.
|
||||||
|
style={overhang > 0 ? { minHeight: overhang } : undefined}
|
||||||
onMouseOver={handleMouseOver}
|
onMouseOver={handleMouseOver}
|
||||||
onMouseOut={handleMouseOut}
|
onMouseOut={handleMouseOut}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
@@ -1210,7 +1244,7 @@ export function EditorCore({
|
|||||||
onAdd={addMisspellingToDict}
|
onAdd={addMisspellingToDict}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hover && !railEnabled && (
|
{hover && (
|
||||||
<SuggestionCard
|
<SuggestionCard
|
||||||
suggestion={hover.suggestion}
|
suggestion={hover.suggestion}
|
||||||
style={{ top: hover.top, left: hover.left }}
|
style={{ top: hover.top, left: hover.left }}
|
||||||
@@ -1219,6 +1253,7 @@ export function EditorCore({
|
|||||||
onPointerEnter={keepOpen}
|
onPointerEnter={keepOpen}
|
||||||
onPointerLeave={scheduleClose}
|
onPointerLeave={scheduleClose}
|
||||||
onExpandChange={setPinned}
|
onExpandChange={setPinned}
|
||||||
|
onExtent={setCardExtent}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{railEnabled && railItems.length > 0 && (
|
{railEnabled && railItems.length > 0 && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { Suggestion } from '../../api/client'
|
import type { Suggestion } from '../../api/client'
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
import { AskPetal } from './AskPetal'
|
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
|
// 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.
|
// dismissed by the hover-close timer when the pointer drifts away.
|
||||||
onExpandChange: (expanded: boolean) => void
|
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,
|
// SuggestionCard is the hover panel for a single suggestion: a colored type tag,
|
||||||
@@ -28,12 +35,31 @@ export function SuggestionCard({
|
|||||||
onPointerEnter,
|
onPointerEnter,
|
||||||
onPointerLeave,
|
onPointerLeave,
|
||||||
onExpandChange,
|
onExpandChange,
|
||||||
|
onExtent,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const pack = usePack()
|
const pack = usePack()
|
||||||
const meta = TYPE_META[suggestion.type]
|
const meta = TYPE_META[suggestion.type]
|
||||||
const label = typeLabel(suggestion.type, pack)
|
const label = typeLabel(suggestion.type, pack)
|
||||||
const hasReplacement = suggestion.replacement.trim() !== ''
|
const hasReplacement = suggestion.replacement.trim() !== ''
|
||||||
const [asking, setAsking] = useState(false)
|
const [asking, setAsking] = useState(false)
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Report the card's reach while it is open, and withdraw it on the way out.
|
||||||
|
// A ResizeObserver rather than a one-shot measure because the card grows
|
||||||
|
// after it is mounted: the Ask Petal panel opens, and then the reply streams
|
||||||
|
// into it token by token.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = cardRef.current
|
||||||
|
if (!el || !onExtent) return
|
||||||
|
const report = () => onExtent(el.offsetTop + el.offsetHeight)
|
||||||
|
report()
|
||||||
|
const observer = new ResizeObserver(report)
|
||||||
|
observer.observe(el)
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
onExtent(0)
|
||||||
|
}
|
||||||
|
}, [onExtent])
|
||||||
|
|
||||||
function toggleAsking() {
|
function toggleAsking() {
|
||||||
setAsking((prev) => {
|
setAsking((prev) => {
|
||||||
@@ -45,6 +71,7 @@ export function SuggestionCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={cardRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={`${label} suggestion`}
|
aria-label={`${label} suggestion`}
|
||||||
onMouseEnter={onPointerEnter}
|
onMouseEnter={onPointerEnter}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { splitBilingual } from './bilingualReply'
|
||||||
|
|
||||||
|
// The contract these tests defend is "never hide an answer", not "parse the
|
||||||
|
// model". Every case that isn't cleanly two halves must still come back whole.
|
||||||
|
describe('splitBilingual', () => {
|
||||||
|
it('splits the pair language from the English at the blank line', () => {
|
||||||
|
const { native, en } = splitBilingual(
|
||||||
|
'“by foots” 不是固定说法,正确的是 “on foot”。\n\n"By foots" 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { RAIL_MIN_MARGIN, railFitsBeside } from './railFit'
|
||||||
|
|
||||||
|
// The numbers below are not invented: they were measured in Chrome at the UX
|
||||||
|
// review's own 1517x810 viewport, with the 280px sidebar open and closed. They
|
||||||
|
// are here so the two layouts stay distinguishable if the constant is ever tuned.
|
||||||
|
describe('railFitsBeside', () => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -164,6 +164,22 @@ describe('the zh pack', () => {
|
|||||||
if (p.code === 'pt-PT') expect(p.locale).toBe('pt-PT') // never pt-BR
|
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) => {
|
it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => {
|
||||||
const { COMPANIONS } = await import('../components/Companion/companions')
|
const { COMPANIONS } = await import('../components/Companion/companions')
|
||||||
for (const c of COMPANIONS) {
|
for (const c of COMPANIONS) {
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ export const fr: Pack = {
|
|||||||
|
|
||||||
editor: {
|
editor: {
|
||||||
askPlaceholder: 'Ask why… / Demande pourquoi…',
|
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',
|
findPlaceholder: 'Rechercher · Find',
|
||||||
findNone: 'Rien · 0',
|
findNone: 'Rien · 0',
|
||||||
matchCase: 'Match case · Respecter la casse',
|
matchCase: 'Match case · Respecter la casse',
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ export const ptPT: Pack = {
|
|||||||
|
|
||||||
editor: {
|
editor: {
|
||||||
askPlaceholder: 'Ask why… / Pergunta porquê…',
|
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',
|
findPlaceholder: 'Localizar · Find',
|
||||||
findNone: 'Nada · 0',
|
findNone: 'Nada · 0',
|
||||||
matchCase: 'Match case · Maiúsculas/minúsculas',
|
matchCase: 'Match case · Maiúsculas/minúsculas',
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export const zh: Pack = {
|
|||||||
|
|
||||||
editor: {
|
editor: {
|
||||||
askPlaceholder: 'Ask why… / 问为什么…',
|
askPlaceholder: 'Ask why… / 问为什么…',
|
||||||
|
chatFailed: '我这会儿没答上来,再问我一次好吗?🌸\n\nI had trouble answering just now — please ask me again. 🌸',
|
||||||
findPlaceholder: '查找 · Find',
|
findPlaceholder: '查找 · Find',
|
||||||
findNone: '无 · 0',
|
findNone: '无 · 0',
|
||||||
matchCase: 'Match case · 区分大小写',
|
matchCase: 'Match case · 区分大小写',
|
||||||
|
|||||||
@@ -155,6 +155,10 @@ export interface Pack {
|
|||||||
|
|
||||||
editor: {
|
editor: {
|
||||||
askPlaceholder: string
|
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
|
findPlaceholder: string
|
||||||
findNone: string
|
findNone: string
|
||||||
matchCase: string
|
matchCase: string
|
||||||
|
|||||||
Reference in New Issue
Block a user