Ask Petal answers in both languages, with room to read
The tutor prompt said "never mix languages in a single response" and mirrored the language of the question, so asking in English — which she does, because she is practising — returned the one explanation surface that gives nothing in her own language. It now answers in both, pair language first, halves separated by a blank line. Which half is the safety net and which is the lesson depends on who is writing: the pair is (English + X) and Petal is used from both ends, so the prompt asks for both and says it doesn't know which way round. The split is a rendering nicety, never a parse the reply depends on: a half-streamed reply is all one half, a model that ignores the instruction renders as one block, and nothing is ever dropped. For the height, the first attempt clamped the box to the room left below the anchored card so it could never overhang — measured, that gave 176px against a 442px answer, worse than the 220px it replaced. The card's own chrome spends ~290px of an 810px window, so "fits below the word" and "room to read" are not both available. The ceiling is now a flat 50vh and the overhang is made navigable instead, per item 4: the card reports its reach like the rail already does, the column grows, and the page can scroll to the actions below it.
This commit is contained in:
+132
-1
@@ -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.
|
||||||
@@ -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
|
changing size**, and neither a ResizeObserver on it nor a window resize will say
|
||||||
so. Untouched: 6, 8, item 3's incremental half.)*
|
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
|
||||||
@@ -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
|
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 (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
|
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
|
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:
|
~100px scrollbox), and item 7 just made the surface it lives on more prominent:
|
||||||
|
|||||||
@@ -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>
|
||||||
▍
|
▍
|
||||||
|
|||||||
@@ -279,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.
|
||||||
@@ -1149,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}
|
||||||
@@ -1234,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 }
|
||||||
|
}
|
||||||
@@ -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