A dismissed card stays dismissed, even offline
The server already suppressed every span she had accepted or dismissed, on
both the LLM reconcile and the mechanics pass, with tests either side. What
had no memory was the half that never asks it: item 3b's rule pack renders
250 ms after a keystroke with no network, and its record of "she already
answered this" was a set cleared on every document switch and added to only
for cards dismissed while still provisional.
So dismissing a persisted rule-pack card recorded nothing client-side and the
next keystroke put it straight back until the server's reply removed it again;
and after a reload the client knew nothing at all — permanently so with the
server unreachable, which is the case the rule pack exists for.
GET /docs/{id}/settled hands over the normalized originals of the document's
actioned rows, scoped through documents because an original quotes her
sentence. The client seeds a SettledSpans from it on open and adds to it for
every card that leaves, keyed on the original alone the way the server keys
it. The load adds rather than assigns, so a dismissal made while it is in
flight survives it.
normalizeForDedup now exists in both languages, compared across a network
boundary, so the same nine cases are asserted on both sides and each test
names the other.
Also: the status-bar count — "🌸 5片花瓣待打磨 · 5 petals to polish" beside the
word count, from the packs, hidden at zero. An empty rail already says nothing
is waiting; a badge announcing it after every check is a verdict, which the
review's non-goals rule out.
Verified in Chrome at 1517x810 with the server killed: a new violation was
detected, underlined and counted with no network, while the dismissed span
stayed gone.
This commit is contained in:
@@ -589,6 +589,7 @@ export default function App() {
|
||||
voicing={voicing}
|
||||
collocating={collocating}
|
||||
llmDown={llmDown}
|
||||
suggestionCount={suggestions.length}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -298,6 +298,12 @@ export const api = {
|
||||
}),
|
||||
// Pending suggestions for a doc, loaded when the editor opens it.
|
||||
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
||||
// The spans she has already accepted or dismissed on this doc, normalized. The
|
||||
// server suppresses these itself; the client needs them so the instant rule-pack
|
||||
// pass doesn't hand back a dismissed card before the server can say otherwise —
|
||||
// or, with the server unreachable, at all. See lib/settled.ts.
|
||||
listSettled: (id: string) =>
|
||||
req<{ originals: string[] }>(`/docs/${id}/settled`),
|
||||
acceptSuggestion: (id: string) =>
|
||||
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
||||
dismissSuggestion: (id: string) =>
|
||||
|
||||
@@ -19,6 +19,9 @@ interface Props {
|
||||
// True when Petal can't reach its LLM helper — shows a gentle, reassuring note
|
||||
// (the writing still saves locally, so this is awareness, not an error).
|
||||
llmDown: boolean
|
||||
// How many suggestions the rail is holding. Shown as a soft bilingual line
|
||||
// beside the word count; hidden at zero.
|
||||
suggestionCount: number
|
||||
}
|
||||
|
||||
// Save-state labels. English except for the lapsed-session case, which is the
|
||||
@@ -48,9 +51,23 @@ interface Indicator {
|
||||
label: string
|
||||
}
|
||||
|
||||
export function StatusBar({ wordCount, text, saveStatus, checking, voicing, collocating, llmDown }: Props) {
|
||||
export function StatusBar({
|
||||
wordCount,
|
||||
text,
|
||||
saveStatus,
|
||||
checking,
|
||||
voicing,
|
||||
collocating,
|
||||
llmDown,
|
||||
suggestionCount,
|
||||
}: Props) {
|
||||
const t = usePack()
|
||||
const label = saveLabel(saveStatus, t)
|
||||
// The gentle counterpart of Grammarly's score: how much is waiting, never how
|
||||
// well she wrote. Nothing waiting says itself — an empty rail — so a zero here
|
||||
// would only be a verdict delivered after every check, which is the pressure
|
||||
// this app's non-goals rule out.
|
||||
const petals = suggestionCount > 0 ? t.status.petalsToPolish(suggestionCount) : null
|
||||
|
||||
const indicators: Indicator[] = [
|
||||
{
|
||||
@@ -109,6 +126,18 @@ export function StatusBar({ wordCount, text, saveStatus, checking, voicing, coll
|
||||
</button>
|
||||
{statsOpen && <StatsPanel text={text} wordCount={wordCount} />}
|
||||
</div>
|
||||
{petals && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
{/* Native half first, as everywhere else in Petal. (The review wrote it
|
||||
English-first; the pack's order is the one she reads all day.) */}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span aria-hidden>🌸</span>
|
||||
<span>{petals.native}</span>
|
||||
<span style={{ opacity: 0.75 }}>· {petals.en}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{indicators
|
||||
.filter((i) => i.active)
|
||||
.map((i) => (
|
||||
|
||||
Binary file not shown.
@@ -180,6 +180,26 @@ describe('the zh pack', () => {
|
||||
expect(native).not.toBe(en)
|
||||
})
|
||||
|
||||
// The status-bar count is the one line in Petal that grows a number, so it is
|
||||
// the one that can be quietly ungrammatical in three languages at once — and
|
||||
// the count itself has to survive translation, since it is the whole content.
|
||||
it.each(PACKS)('counts petals to polish in both halves, and agrees on the number ($code)', (p) => {
|
||||
for (const n of [1, 2, 5, 21]) {
|
||||
const { native, en } = p.status.petalsToPolish(n)
|
||||
expect(native, `${p.code} has no pair-language half for n=${n}`).toBeTruthy()
|
||||
expect(en, `${p.code} has no English half for n=${n}`).toBeTruthy()
|
||||
expect(native, `${p.code} drops the count from its native half`).toContain(String(n))
|
||||
expect(en).toContain(String(n))
|
||||
// English is the half every pack shares; a pack that translated it has lost
|
||||
// the point, exactly as with chatFailed above.
|
||||
expect(en).toMatch(/petals? to polish/)
|
||||
}
|
||||
// One is not many, in every language Petal ships.
|
||||
expect(p.status.petalsToPolish(1).native).not.toBe(p.status.petalsToPolish(2).native)
|
||||
expect(p.status.petalsToPolish(1).en).toContain('petal to polish')
|
||||
expect(p.status.petalsToPolish(2).en).toContain('petals to polish')
|
||||
})
|
||||
|
||||
it.each(PACKS)('labels every companion, tone and style ($code)', async (p) => {
|
||||
const { COMPANIONS } = await import('../components/Companion/companions')
|
||||
for (const c of COMPANIONS) {
|
||||
|
||||
@@ -450,6 +450,10 @@ export const fr: Pack = {
|
||||
savedLocally: 'Gardé sur cet appareil · Kept on this device',
|
||||
helperRestingNative: 'L’assistant se repose',
|
||||
helperRestingEn: "· Petal's helper is resting · ton texte est enregistré",
|
||||
petalsToPolish: (n) => ({
|
||||
native: `${n} ${n === 1 ? 'pétale' : 'pétales'} à polir`,
|
||||
en: `${n} ${n === 1 ? 'petal' : 'petals'} to polish`,
|
||||
}),
|
||||
soundsOn: 'Sons activés · Sounds on',
|
||||
soundsOff: 'Sons coupés · Sounds off',
|
||||
petalsOn: 'Pétales activés · Petals on',
|
||||
|
||||
@@ -427,6 +427,10 @@ export const ptPT: Pack = {
|
||||
savedLocally: 'Guardado neste dispositivo · Kept on this device',
|
||||
helperRestingNative: 'O ajudante está a descansar',
|
||||
helperRestingEn: "· Petal's helper is resting · o teu texto está guardado",
|
||||
petalsToPolish: (n) => ({
|
||||
native: `${n} ${n === 1 ? 'pétala' : 'pétalas'} para polir`,
|
||||
en: `${n} ${n === 1 ? 'petal' : 'petals'} to polish`,
|
||||
}),
|
||||
soundsOn: 'Som ligado · Sounds on',
|
||||
soundsOff: 'Som desligado · Sounds off',
|
||||
petalsOn: 'Pétalas ligadas · Petals on',
|
||||
|
||||
@@ -327,6 +327,12 @@ export const zh: Pack = {
|
||||
savedLocally: '已保存在本机 · Kept on this device',
|
||||
helperRestingNative: '小助手在休息',
|
||||
helperRestingEn: "· Petal's helper is resting · 文字已保存",
|
||||
// 片 is the measure word for petals; a digit reads perfectly naturally in
|
||||
// Chinese and spares every pack a numeral table.
|
||||
petalsToPolish: (n) => ({
|
||||
native: `${n}片花瓣待打磨`,
|
||||
en: `${n} ${n === 1 ? 'petal' : 'petals'} to polish`,
|
||||
}),
|
||||
soundsOn: '声音开 · Sounds on',
|
||||
soundsOff: '声音关 · Sounds off',
|
||||
petalsOn: '花瓣开 · Petals on',
|
||||
|
||||
@@ -297,6 +297,11 @@ export interface Pack {
|
||||
savedLocally: string
|
||||
helperRestingNative: string
|
||||
helperRestingEn: string
|
||||
// How many suggestions are waiting, said gently — the status-bar counterpart
|
||||
// of the rail. Never a score: it counts what is there, and says nothing about
|
||||
// how well she is writing. Only ever called with n >= 1 (nothing waiting is
|
||||
// said by the rail being empty, not by a badge announcing it).
|
||||
petalsToPolish: (n: number) => Line
|
||||
soundsOn: string
|
||||
soundsOff: string
|
||||
petalsOn: string
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SettledSpans, normalizeForDedup } from './settled'
|
||||
|
||||
describe('normalizeForDedup', () => {
|
||||
// These cases are the client half of a pair: every one of them is also asserted
|
||||
// against the Go implementation in TestNormalizeMatchesTheClient. The server
|
||||
// normalizes the spans it sends and the client normalizes the findings it
|
||||
// checks against them, so a divergence would be silent — a dismissed card
|
||||
// quietly coming back. Add a case to both or neither.
|
||||
it('folds every quote variant onto one character', () => {
|
||||
expect(normalizeForDedup('She said “hello”')).toBe("She said 'hello'")
|
||||
expect(normalizeForDedup('She said "hello"')).toBe("She said 'hello'")
|
||||
expect(normalizeForDedup('it‘s')).toBe("it's")
|
||||
expect(normalizeForDedup('it’s')).toBe("it's")
|
||||
expect(normalizeForDedup('`code´')).toBe("'code'")
|
||||
})
|
||||
|
||||
it('collapses runs of whitespace and trims', () => {
|
||||
expect(normalizeForDedup(' a apple\n here ')).toBe('a apple here')
|
||||
expect(normalizeForDedup('a\tapple')).toBe('a apple')
|
||||
})
|
||||
|
||||
it('is empty for whitespace only, so it can never settle everything', () => {
|
||||
expect(normalizeForDedup(' \n ')).toBe('')
|
||||
})
|
||||
|
||||
it('leaves her Chinese alone', () => {
|
||||
expect(normalizeForDedup('我想说这句话')).toBe('我想说这句话')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettledSpans', () => {
|
||||
it('recognises a span it was told about, in any spelling', () => {
|
||||
const settled = new SettledSpans()
|
||||
settled.add('a apple')
|
||||
expect(settled.has('a apple')).toBe(true)
|
||||
expect(settled.has('a apple')).toBe(true)
|
||||
expect(settled.has('an apple')).toBe(false)
|
||||
})
|
||||
|
||||
it('takes the document record and later dismissals through the same door', () => {
|
||||
const settled = new SettledSpans()
|
||||
settled.add('the the', 'a apple') // as loaded on open
|
||||
settled.add('two apple') // as dismissed just now
|
||||
expect(settled.has('the the')).toBe(true)
|
||||
expect(settled.has('two apple')).toBe(true)
|
||||
})
|
||||
|
||||
// The load is async and she can dismiss a card while it is in flight. Adding
|
||||
// rather than assigning is what keeps that dismissal — an assignment here would
|
||||
// hand the card straight back, which is the bug this whole file is about.
|
||||
it('keeps a dismissal made before the document record lands', () => {
|
||||
const settled = new SettledSpans()
|
||||
settled.add('two apple') // she dismisses
|
||||
settled.add('the the') // the fetch lands afterwards
|
||||
expect(settled.has('two apple')).toBe(true)
|
||||
expect(settled.has('the the')).toBe(true)
|
||||
})
|
||||
|
||||
it('forgets everything on reset, because the record is per document', () => {
|
||||
const settled = new SettledSpans()
|
||||
settled.add('a apple')
|
||||
settled.reset()
|
||||
expect(settled.has('a apple')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores an empty span rather than storing one nothing can match', () => {
|
||||
const settled = new SettledSpans()
|
||||
settled.add(' ')
|
||||
expect(settled.has('')).toBe(false)
|
||||
expect(settled.has('anything')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
// Whether a span is one she has already settled — accepted or dismissed.
|
||||
//
|
||||
// The rule pack (Companion/prose.ts) detects from the document text alone and has
|
||||
// no memory between runs, so something has to tell it "she has already answered
|
||||
// this one". The server keeps that record and applies it to everything it stores
|
||||
// (see buildSuppressor in internal/suggestions/handlers.go); this is the same
|
||||
// question asked locally, for the 250 ms pass that renders before — and, offline,
|
||||
// instead of — the server's reply.
|
||||
//
|
||||
// Keyed on the original alone, deliberately, because that is how the server keys
|
||||
// it: dismissing an edit settles the span, not one particular rewrite of it.
|
||||
|
||||
// normalizeForDedup mirrors the Go function of the same name: quote variants
|
||||
// folded onto one character, runs of whitespace collapsed. The editor rewrites
|
||||
// quotes as she types and a reflowed paragraph changes its line breaks, so
|
||||
// comparing raw text would miss spans that are plainly the same.
|
||||
//
|
||||
// The two implementations must agree, because the server normalizes the strings
|
||||
// it sends and the client normalizes the findings it compares against them. They
|
||||
// differ only on characters neither her writing nor the model produces (JS folds
|
||||
// a BOM as whitespace, Go folds U+0085); a mismatch there costs one redundant
|
||||
// card, not a wrong one.
|
||||
export function normalizeForDedup(s: string): string {
|
||||
return s.replace(/[‘’‚‛“”„″"`´]/g, "'").trim().split(/\s+/).join(' ')
|
||||
}
|
||||
|
||||
// SettledSpans answers "has she already dealt with this?" for a rule-pack
|
||||
// finding. Built from the server's record when the document opens, and added to
|
||||
// as she accepts or dismisses, so the answer is right for cards the server has
|
||||
// never heard of — the provisional ones.
|
||||
export class SettledSpans {
|
||||
private spans = new Set<string>()
|
||||
|
||||
// Forget everything (on switching documents — the record is per document).
|
||||
reset(): void {
|
||||
this.spans = new Set()
|
||||
}
|
||||
|
||||
// Remember a span she has just actioned. Called for every card that leaves the
|
||||
// rail, not only the provisional ones: a persisted card is suppressed by the
|
||||
// server, but its reply arrives a round-trip after the local pass has already
|
||||
// put the card back on screen.
|
||||
//
|
||||
// The document's stored record is loaded through this same door rather than by
|
||||
// replacing the set, so a card she dismisses while that fetch is in flight isn't
|
||||
// forgotten when it lands.
|
||||
add(...originals: string[]): void {
|
||||
for (const original of originals) {
|
||||
const norm = normalizeForDedup(original)
|
||||
if (norm) this.spans.add(norm)
|
||||
}
|
||||
}
|
||||
|
||||
has(original: string): boolean {
|
||||
return this.spans.has(normalizeForDedup(original))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user