4 Commits

Author SHA1 Message Date
prosolis
46db0a3e16 Checkpoint: auto-retry after a failed pass; release rate-limit slot on failure
A paste fires exactly one grammar checkpoint, and a failed one never retried
until the next keystroke — stranding the writer on "Petal's helper is resting"
after a paste. Long docs make it worse: their 15-25s checks have a wide window
to catch a transient 502 from the shared Ollama (co-tenant apps load other
models and evict the 9B). A failed pass also burned the per-document rate-limit
slot, so a retry within 30s hit the throttle path and got an empty set back.

- llm.RateLimiter.Release rolls back a slot when its pass fails; Allow now
  returns the recorded timestamp so Release only frees its own slot.
- suggestions.runPass releases the slot on LLM failure before returning 502.
- useCheckpoint auto-retries a failed checkpoint with backoff (3/12/35s),
  keeping the breathing dot up and only flagging "resting" once retries exhaust.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 13:09:12 -07:00
prosolis
1bd38b20e8 Merge feat/suggestion-rail: right-margin comment rail + Mandarin explanations
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 12:23:22 -07:00
prosolis
82e2bcc777 Suggestions: right-margin comment rail + Mandarin explanations
Surface every outstanding suggestion as a card in the right-hand
whitespace, vertically aligned to the text it flags — so the writer sees
the whole queue at once instead of hovering each highlight. Cards stack
with collision avoidance, link both ways with their highlight (hover/click
↔ soft text wash, driven through the decoration plugin so it survives
edit repaints), and carry the same Accept / Dismiss / Ask Petal actions.
The rail is a progressive enhancement: it mounts only when there's room
beside the editor, otherwise the existing inline hover card is unchanged.
Stacked cards that reach the bottom-right corner tuck behind the
companion mascot (z-order).

When a card is expanded, the Ask Petal bubble now opens with the
Simplified-Chinese translation of the explanation (the English stays in
the card body) instead of repeating the same text twice — a new
POST /api/suggestions/{id}/translate one-shot LLM endpoint, loaded
lazily on open with an English fallback.

Verified live against the local LLM via the uitest harness: rail
stacking, hover↔text wash, expand/Ask Petal, accept-from-rail, narrow
fallback, and the Mandarin bubble.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 12:23:02 -07:00
prosolis
035dcf5d25 Merge feat/tts-piper: natural read-aloud via local Piper TTS (EN+ZH)
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 11:35:07 -07:00
15 changed files with 709 additions and 55 deletions

View File

@@ -127,17 +127,33 @@ func NewRateLimiter(interval time.Duration) *RateLimiter {
}
// Allow reports whether a checkpoint may run for docID now. When allowed it
// records the time and returns (true, 0); when throttled it returns
// (false, retryAfter) where retryAfter is the wait until the next allowed run.
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration) {
// records the time and returns (true, 0, recordedAt); when throttled it returns
// (false, retryAfter, zero) where retryAfter is the wait until the next allowed
// run. recordedAt lets a caller whose pass fails hand the exact timestamp to
// Release so it rolls back only its own slot.
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration, time.Time) {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
if last, ok := rl.last[docID]; ok {
if elapsed := now.Sub(last); elapsed < rl.interval {
return false, rl.interval - elapsed
return false, rl.interval - elapsed, time.Time{}
}
}
rl.last[docID] = now
return true, 0
return true, 0, now
}
// Release rolls back the slot a prior Allow recorded for docID, but only if no
// newer Allow has since claimed it. Called when an LLM pass fails: Allow runs
// before the model call, so without this a failed checkpoint would hold the
// per-document slot for the full interval and a retry (or the frontend's
// auto-retry) would hit the throttle path and get the stale/empty set back
// instead of re-running. `at` is the time the failed Allow returned.
func (rl *RateLimiter) Release(docID string, at time.Time) {
rl.mu.Lock()
defer rl.mu.Unlock()
if last, ok := rl.last[docID]; ok && last.Equal(at) {
delete(rl.last, docID)
}
}

View File

@@ -65,16 +65,31 @@ func TestParseCheckpoint(t *testing.T) {
func TestRateLimiter(t *testing.T) {
rl := NewRateLimiter(30 * time.Second)
if ok, _ := rl.Allow("doc1"); !ok {
ok, _, at := rl.Allow("doc1")
if !ok {
t.Fatal("first call should be allowed")
}
if ok, retry := rl.Allow("doc1"); ok || retry <= 0 {
if ok, retry, _ := rl.Allow("doc1"); ok || retry <= 0 {
t.Fatalf("immediate second call should be throttled, got ok=%v retry=%v", ok, retry)
}
// A different document is independent.
if ok, _ := rl.Allow("doc2"); !ok {
if ok, _, _ := rl.Allow("doc2"); !ok {
t.Fatal("different doc should be allowed")
}
// Releasing doc1's slot (as a failed pass does) lets the next check re-run
// immediately instead of waiting out the interval.
rl.Release("doc1", at)
if ok, _, _ := rl.Allow("doc1"); !ok {
t.Fatal("after Release the next call should be allowed again")
}
// Release only rolls back its own slot: a stale timestamp must not evict the
// slot a newer Allow holds (so a late failure can't cancel a fresh in-flight
// check). doc2 still holds its slot from above; a zero-time Release is a no-op.
rl.Release("doc2", time.Time{})
if ok, _, _ := rl.Allow("doc2"); ok {
t.Fatal("stale Release must not free doc2's current slot")
}
}
func TestTruncateDoc(t *testing.T) {

View File

@@ -165,3 +165,24 @@ func RewriteMessages(text, style string) []Message {
{Role: "user", Content: text},
}
}
// translateSystemPrompt drives the explanation translator: it renders a
// suggestion's English explanation into Simplified Chinese so an ESL reader sees
// the "why" in her first language. Strict about returning ONLY the translation
// (no quotes, no pinyin, no English echo) so it can drop straight into the chat
// bubble. Kept warm and plain — these are short, friendly one-liners.
const translateSystemPrompt = `You are Petal, a warm writing assistant. Translate the English text the user ` +
`sends into natural, friendly Simplified Chinese (Mandarin). It is a short explanation of a writing ` +
`suggestion, written for a native Chinese speaker learning English.
Respond with ONLY the Simplified Chinese translation. No quotation marks, no pinyin, no English, no preamble — ` +
`just the translated sentence.`
// TranslateMessages builds the message array for translating one short English
// explanation into Simplified Chinese.
func TranslateMessages(text string) []Message {
return []Message{
{Role: "system", Content: translateSystemPrompt},
{Role: "user", Content: text},
}
}

23
internal/llm/translate.go Normal file
View File

@@ -0,0 +1,23 @@
package llm
import (
"context"
)
// RunTranslate renders a short English explanation into Simplified Chinese. It
// is a one-shot Complete (the result seeds the Ask Petal bubble), kept at a low
// temperature so the translation is faithful rather than creative. Output is
// trimmed of any stray surrounding quotes the model may add.
func RunTranslate(ctx context.Context, client LLMClient, text string) (string, error) {
out, err := client.Complete(ctx, CompletionRequest{
Messages: TranslateMessages(text),
MaxTokens: 512,
Temperature: 0.2,
TopP: 0.9,
RepetitionPenalty: 1.1,
})
if err != nil {
return "", err
}
return cleanRewrite(out), nil
}

View File

@@ -55,6 +55,7 @@ func (h *Handler) Routes() chi.Router {
r.Post("/{id}/accept", h.accept)
r.Post("/{id}/dismiss", h.dismiss)
r.Post("/{id}/chat", h.chat)
r.Post("/{id}/translate", h.translate)
return r
}
@@ -101,7 +102,8 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
return
}
if ok, _ := limiter.Allow(docID); !ok {
ok, _, slotAt := limiter.Allow(docID)
if !ok {
// Throttled: return the existing pending set unchanged rather than an
// error, so the frontend keeps showing current suggestions.
existing, err := h.fetchPending(docID)
@@ -115,6 +117,10 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R
raw, err := run(r.Context(), h.Client, contentText, tone)
if err != nil {
// Allow ran before the model call, so a failed pass would otherwise hold
// the per-document slot for the full interval — stranding the frontend's
// auto-retry on the throttle path. Release it so a retry can re-run.
limiter.Release(docID, slotAt)
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
return
}

View File

@@ -0,0 +1,57 @@
package suggestions
import (
"database/sql"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
type translateResponse struct {
Translation string `json:"translation"`
}
// translate renders a suggestion's English explanation into Simplified Chinese
// for the Ask Petal bubble, so the ESL reader sees the "why" in her first
// language instead of a second copy of the same English text. The explanation is
// loaded server-side from the suggestion id (scoped to the local user) and never
// trusted from the client, mirroring chat (spec Note #10).
func (h *Handler) translate(w http.ResponseWriter, r *http.Request) {
sugID := chi.URLParam(r, "id")
var explanation string
err := h.DB.QueryRow(
`SELECT s.explanation
FROM suggestions s
JOIN documents d ON d.id = s.doc_id
WHERE s.id = ? AND d.user_id = ?`,
sugID, db.LocalUserID,
).Scan(&explanation)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "suggestion not found")
return
}
if err != nil {
serverError(w, err)
return
}
explanation = strings.TrimSpace(explanation)
if explanation == "" {
writeJSON(w, http.StatusOK, translateResponse{Translation: ""})
return
}
out, err := llm.RunTranslate(r.Context(), h.Client, explanation)
if err != nil {
errorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error())
return
}
writeJSON(w, http.StatusOK, translateResponse{Translation: out})
}

View File

@@ -147,6 +147,10 @@ export const api = {
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
dismissSuggestion: (id: string) =>
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
// Simplified-Chinese rendering of a suggestion's explanation, for the Ask Petal
// opening bubble (the explanation itself stays English in the card body).
translateSuggestion: (id: string) =>
req<{ translation: string }>(`/suggestions/${id}/translate`, { method: 'POST' }),
// Version history. listVersions returns metadata only (no bodies); getVersion
// loads one full snapshot for preview; snapshotDoc takes an explicit restore

View File

@@ -1,9 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import { streamSuggestionChat, type ChatMessage } from '../../api/client'
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
interface Props {
suggestionId: string
// Pre-populates Petal's first bubble so the conversation opens with context.
// The English explanation (shown in the card body). Petal's opening bubble is
// its Simplified-Chinese translation, fetched on open — so the panel doesn't
// just repeat the same English text twice. Falls back to this on failure.
explanation: string
}
@@ -17,9 +19,10 @@ const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC
// the card (unmounting) clears it. Each send streams Petal's reply token-by-
// token into the latest assistant bubble.
export function AskPetal({ suggestionId, explanation }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'assistant', content: explanation },
])
// Opening bubble starts empty (caret-only) and fills with the Mandarin
// translation once it lands; `seeding` drives that loading caret.
const [messages, setMessages] = useState<ChatMessage[]>([{ role: 'assistant', content: '' }])
const [seeding, setSeeding] = useState(true)
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
@@ -36,6 +39,31 @@ export function AskPetal({ suggestionId, explanation }: Props) {
inputRef.current?.focus()
}, [])
// Fetch the Chinese translation of the explanation to seed the first bubble.
// Only replaces the seed bubble if the user hasn't started chatting yet (the
// conversation always opens with this one assistant turn). Falls back to the
// English explanation if the translation can't be fetched.
useEffect(() => {
let cancelled = false
api
.translateSuggestion(suggestionId)
.then((res) => {
if (cancelled) return
const text = res.translation.trim() || explanation
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: text }] : prev))
})
.catch(() => {
if (cancelled) return
setMessages((prev) => (prev.length === 1 ? [{ role: 'assistant', content: explanation }] : prev))
})
.finally(() => {
if (!cancelled) setSeeding(false)
})
return () => {
cancelled = true
}
}, [suggestionId, explanation])
async function send() {
const text = input.trim()
if (!text || streaming) return
@@ -86,7 +114,12 @@ export function AskPetal({ suggestionId, explanation }: Props) {
style={{ maxHeight: 220 }}
>
{messages.map((m, i) => (
<Bubble key={i} role={m.role} content={m.content} streaming={streaming && i === messages.length - 1} />
<Bubble
key={i}
role={m.role}
content={m.content}
streaming={(streaming && i === messages.length - 1) || (seeding && i === 0)}
/>
))}
</div>

View File

@@ -18,7 +18,8 @@ import type { EditorView } from '@tiptap/pm/view'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Toolbar } from '../Toolbar/Toolbar'
import { SuggestionCard } from './SuggestionCard'
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
import { SuggestionRail, type RailItem } from './SuggestionRail'
import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight'
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
import { MisspellCard } from './MisspellCard'
import { WordCard } from './WordCard'
@@ -219,6 +220,18 @@ export function EditorCore({
const rewriteReqRef = useRef(0)
// The in-document Find & Replace bar (Ctrl/Cmd+F).
const [findOpen, setFindOpen] = useState(false)
// The right-margin comment rail. `railItems` carries each anchored suggestion's
// vertical offset; `railEnabled` is true only when the viewport has room for the
// column beside the editor (otherwise we fall back to the inline hover cards).
// `railExpandedId` is the card showing its full explanation + Ask Petal, and
// `activeId` is the suggestion currently emphasized (hovered text or card).
const [railItems, setRailItems] = useState<RailItem[]>([])
const [railEnabled, setRailEnabled] = useState(false)
const [railExpandedId, setRailExpandedId] = useState<string | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
// A stable handle to the latest recompute so the editor's onUpdate (captured
// once at construction) can trigger a re-measure without stale closures.
const recomputeRailRef = useRef<() => void>(() => {})
const editor = useEditor({
extensions: [
@@ -282,6 +295,8 @@ export function EditorCore({
content_text: editor.getText(),
word_count: editor.storage.characterCount.words(),
})
// Edits reflow the text, so the rail anchors need re-measuring.
recomputeRailRef.current()
},
onSelectionUpdate: ({ editor }) => {
// A selection supersedes the hover gloss; an empty one clears the bubble.
@@ -333,8 +348,87 @@ export function EditorCore({
setSuggestions(editor.state, editor.view.dispatch, suggestions)
// Close the card if its suggestion is gone.
setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null))
// Drop any rail expand/emphasis that points at a now-removed suggestion.
setRailExpandedId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
setActiveId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
}, [editor, suggestions])
// Re-measure the margin rail: whether there's room for the column beside the
// editor, and where each suggestion's highlight sits vertically. Anchors are
// taken from the live decoration DOM (keyed by data-suggestion-id) relative to
// the wrapper — stable under scroll since text and wrapper scroll together.
// Deferred a frame so decoration DOM and reflow have settled.
const recomputeRail = useCallback(() => {
requestAnimationFrame(() => {
const wrapper = wrapperRef.current
if (!wrapper) return
const wrapRect = wrapper.getBoundingClientRect()
// Need room for the 300px column + its 32px gutter (see .petal-rail), plus
// a little breathing space to the viewport edge.
setRailEnabled(window.innerWidth - wrapRect.right >= 348)
const seen = new Set<string>()
const items: RailItem[] = []
wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
const id = el.getAttribute('data-suggestion-id')
if (!id || seen.has(id)) return
const s = suggestions.find((x) => x.id === id)
if (!s) return
seen.add(id)
items.push({ suggestion: s, anchorTop: el.getBoundingClientRect().top - wrapRect.top })
})
setRailItems(items)
})
}, [suggestions])
useEffect(() => {
recomputeRailRef.current = recomputeRail
}, [recomputeRail])
// Re-anchor when the suggestion set changes (after the decorations repaint),
// and keep the rail in sync with viewport/editor width changes (room + reflow).
useEffect(() => {
recomputeRail()
const wrapper = wrapperRef.current
const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null
if (wrapper && ro) ro.observe(wrapper)
window.addEventListener('resize', recomputeRail)
return () => {
ro?.disconnect()
window.removeEventListener('resize', recomputeRail)
}
}, [recomputeRail])
// Emphasize the flagged text for the active suggestion, mirroring the rail
// card ↔ text link both ways. Driven through the decoration plugin (not an
// imperative DOM class) so it survives the repaints that fire on every edit.
useEffect(() => {
if (!editor) return
setActiveSuggestion(editor.state, editor.view.dispatch, railEnabled ? activeId : null)
}, [editor, activeId, railEnabled])
// Scroll a suggestion's highlight to the middle of the viewport (clicking its
// rail card jumps the editor to the flagged text).
const scrollToSuggestion = useCallback((id: string) => {
const el = wrapperRef.current?.querySelector(
`.petal-suggestion[data-suggestion-id="${CSS.escape(id)}"]`,
)
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, [])
// Clicking a rail card's body jumps to the text and toggles its detail panel.
const activateRailCard = useCallback(
(id: string) => {
setActiveId(id)
scrollToSuggestion(id)
setRailExpandedId((cur) => (cur === id ? null : id))
},
[scrollToSuggestion],
)
const toggleRailExpand = useCallback((id: string) => {
setRailExpandedId((cur) => (cur === id ? null : id))
}, [])
const openCardFor = useCallback(
(id: string, el: HTMLElement) => {
const wrapper = wrapperRef.current
@@ -366,10 +460,17 @@ export function EditorCore({
if (!target) return
const id = target.getAttribute('data-suggestion-id')
if (!id) return
// With the rail open the card already lives in the margin — hovering the
// text just emphasizes its card (and the highlight) rather than popping a
// second, redundant floating card.
if (railEnabled) {
setActiveId(id)
return
}
clearTimeout(closeTimer.current)
openCardFor(id, target)
},
[openCardFor],
[openCardFor, railEnabled],
)
const scheduleClose = useCallback(() => {
@@ -389,35 +490,53 @@ export function EditorCore({
// pointer can bridge the small gap from text to card.
const handleMouseOut = useCallback(
(e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('.petal-suggestion')) scheduleClose()
if (!(e.target as HTMLElement).closest('.petal-suggestion')) return
if (railEnabled) {
setActiveId(null)
return
}
scheduleClose()
},
[scheduleClose],
[scheduleClose, railEnabled],
)
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
// Accept applies the replacement to the document, plays a little confetti
// burst where the card sat, then notifies the parent.
// burst over the flagged text, then notifies the parent. The confetti is
// anchored to the highlight itself (captured before the replacement removes it),
// so it fires in the right spot whether the accept came from the hover card or
// the margin rail.
const handleAccept = useCallback(
(s: Suggestion) => {
const wrapper = wrapperRef.current
const el = wrapper?.querySelector(
`.petal-suggestion[data-suggestion-id="${CSS.escape(s.id)}"]`,
) as HTMLElement | null
let burst: { top: number; left: number } | null = null
if (wrapper && el) {
const wrapRect = wrapper.getBoundingClientRect()
const elRect = el.getBoundingClientRect()
burst = { top: elRect.top - wrapRect.top, left: elRect.right - wrapRect.left }
}
if (editor && s.replacement.trim() !== '') {
const range = findRange(editor.state.doc, s.original)
if (range) {
editor.chain().focus().insertContentAt(range, s.replacement).run()
}
}
setHover((h) => {
if (h) {
setConfetti({ top: h.top, left: h.left + 16 })
clearTimeout(confettiTimer.current)
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
}
return h
})
// Fall back to the hover card's position if the highlight wasn't found.
if (!burst && hover) burst = { top: hover.top, left: hover.left + 16 }
if (burst) {
setConfetti(burst)
clearTimeout(confettiTimer.current)
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
}
closeCard()
setRailExpandedId(null)
onAccept(s)
},
[editor, onAccept, closeCard],
[editor, onAccept, closeCard, hover],
)
const handleDismiss = useCallback(
@@ -439,7 +558,16 @@ export function EditorCore({
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
if (suggestionEl) {
const id = suggestionEl.getAttribute('data-suggestion-id')
if (id) openCardFor(id, suggestionEl)
if (id) {
// With the rail open, a tap emphasizes and expands its margin card
// instead of opening a floating one.
if (railEnabled) {
setActiveId(id)
setRailExpandedId(id)
} else {
openCardFor(id, suggestionEl)
}
}
return
}
if (!editor || !spellChecker) return
@@ -461,7 +589,7 @@ export function EditorCore({
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
},
[editor, spellChecker, closeCard, openCardFor],
[editor, spellChecker, closeCard, openCardFor, railEnabled],
)
const replaceMisspelling = useCallback(
@@ -854,7 +982,7 @@ export function EditorCore({
onAdd={addMisspellingToDict}
/>
)}
{hover && (
{hover && !railEnabled && (
<SuggestionCard
suggestion={hover.suggestion}
style={{ top: hover.top, left: hover.left }}
@@ -865,6 +993,18 @@ export function EditorCore({
onExpandChange={setPinned}
/>
)}
{railEnabled && railItems.length > 0 && (
<SuggestionRail
items={railItems}
activeId={activeId}
expandedId={railExpandedId}
onAccept={handleAccept}
onDismiss={handleDismiss}
onHover={setActiveId}
onActivate={activateRailCard}
onToggleExpand={toggleRailExpand}
/>
)}
</div>
</div>
)

View File

@@ -1,15 +1,7 @@
import { useState } from 'react'
import type { Suggestion, SuggestionType } from '../../api/client'
import type { Suggestion } from '../../api/client'
import { AskPetal } from './AskPetal'
// Per-type accent color + human label, mirroring the design tokens.
const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
voice: { color: 'var(--color-honey)', label: 'Voice' },
}
import { TYPE_META } from './suggestionMeta'
interface Props {
suggestion: Suggestion

View File

@@ -16,9 +16,17 @@ export const suggestionPluginKey = new PluginKey<PluginState>('petalSuggestions'
interface PluginState {
suggestions: Suggestion[]
// The suggestion currently emphasized from its margin card / a hover, or null.
// Carried in plugin state (not an imperative DOM class) so it survives the
// decoration repaints that fire on every document change.
activeId: string | null
decorations: DecorationSet
}
// Meta carried on a transaction to update the plugin: either a fresh suggestion
// list or a change to which suggestion is emphasized.
type SuggestionMeta = { suggestions: Suggestion[] } | { activeId: string | null }
// mapOffset converts a character offset within a textblock's flattened text into
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
// breaks) that occupy a position but contribute no text.
@@ -130,14 +138,15 @@ export function findRange(doc: PMNode, search: string): { from: number; to: numb
return result
}
function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet {
function buildDecorations(doc: PMNode, suggestions: Suggestion[], activeId: string | null): DecorationSet {
const decos: Decoration[] = []
for (const s of suggestions) {
const range = findRange(doc, s.original)
if (!range) continue
const active = s.id === activeId ? ' petal-suggestion-active' : ''
decos.push(
Decoration.inline(range.from, range.to, {
class: `petal-suggestion petal-suggestion-${s.type}`,
class: `petal-suggestion petal-suggestion-${s.type}${active}`,
'data-suggestion-id': s.id,
}),
)
@@ -148,10 +157,22 @@ function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet
// setSuggestions pushes a new suggestion list into the plugin. Decorations are
// rebuilt against the current document immediately.
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) {
const tr = state.tr.setMeta(suggestionPluginKey, suggestions)
const tr = state.tr.setMeta(suggestionPluginKey, { suggestions } satisfies SuggestionMeta)
dispatch(tr)
}
// setActiveSuggestion marks one suggestion (or none) as emphasized, rebuilding
// the decorations so the flagged text gets the active wash. Driven by the margin
// rail's hover/selection so the card↔text link reads both ways.
export function setActiveSuggestion(
state: EditorState,
dispatch: (tr: Transaction) => void,
activeId: string | null,
) {
if (suggestionPluginKey.getState(state)?.activeId === activeId) return
dispatch(state.tr.setMeta(suggestionPluginKey, { activeId } satisfies SuggestionMeta))
}
export const SuggestionHighlight = Extension.create({
name: 'suggestionHighlight',
@@ -160,17 +181,29 @@ export const SuggestionHighlight = Extension.create({
new Plugin<PluginState>({
key: suggestionPluginKey,
state: {
init: () => ({ suggestions: [], decorations: DecorationSet.empty }),
init: () => ({ suggestions: [], activeId: null, decorations: DecorationSet.empty }),
apply(tr, value, _oldState, newState) {
const meta = tr.getMeta(suggestionPluginKey) as Suggestion[] | undefined
if (meta) {
return { suggestions: meta, decorations: buildDecorations(newState.doc, meta) }
const meta = tr.getMeta(suggestionPluginKey) as SuggestionMeta | undefined
if (meta && 'suggestions' in meta) {
return {
suggestions: meta.suggestions,
activeId: value.activeId,
decorations: buildDecorations(newState.doc, meta.suggestions, value.activeId),
}
}
if (meta && 'activeId' in meta) {
return {
suggestions: value.suggestions,
activeId: meta.activeId,
decorations: buildDecorations(newState.doc, value.suggestions, meta.activeId),
}
}
// On any document change, re-anchor by string against the new doc.
if (tr.docChanged) {
return {
suggestions: value.suggestions,
decorations: buildDecorations(newState.doc, value.suggestions),
activeId: value.activeId,
decorations: buildDecorations(newState.doc, value.suggestions, value.activeId),
}
}
return value

View File

@@ -0,0 +1,211 @@
import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
import type { Suggestion } from '../../api/client'
import { AskPetal } from './AskPetal'
import { TYPE_META } from './suggestionMeta'
// Vertical breathing room kept between stacked cards when their natural anchors
// would otherwise collide.
const CARD_GAP = 12
export interface RailItem {
suggestion: Suggestion
// Vertical offset (px) of the suggestion's highlight, relative to the editor
// wrapper — the position the card wants to sit beside.
anchorTop: number
}
interface Props {
items: RailItem[]
// The suggestion currently emphasized (its highlight hovered, or this card
// hovered) — gets a lifted, ring-accented treatment so the text↔card link reads.
activeId: string | null
// The card expanded to show the full explanation + Ask Petal, or null.
expandedId: string | null
onAccept: (s: Suggestion) => void
onDismiss: (s: Suggestion) => void
// Pointer entering/leaving a card, so the matching highlight can light up.
onHover: (id: string | null) => void
// A card's body was clicked — scroll its highlight into view and toggle expand.
onActivate: (id: string) => void
onToggleExpand: (id: string) => void
}
// SuggestionRail is the right-margin "comment column": every outstanding
// suggestion as a card, vertically aligned to the text it flags, so the writer
// sees the whole queue at once instead of hunting highlight by highlight. Cards
// are anchored to their highlight's vertical position and pushed down only as far
// as needed to avoid overlapping their neighbor above (a la doc-editor comments).
export function SuggestionRail({
items,
activeId,
expandedId,
onAccept,
onDismiss,
onHover,
onActivate,
onToggleExpand,
}: Props) {
// Measured resolved tops keyed by suggestion id (after collision avoidance).
const [tops, setTops] = useState<Record<string, number>>({})
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// Bumped whenever a card's own height changes (Ask Petal streaming in, wrapping
// text) so the stack re-packs and cards below don't get overlapped.
const [measureTick, setMeasureTick] = useState(0)
const observerRef = useRef<ResizeObserver | null>(null)
if (!observerRef.current && typeof ResizeObserver !== 'undefined') {
observerRef.current = new ResizeObserver(() => setMeasureTick((t) => t + 1))
}
useLayoutEffect(() => () => observerRef.current?.disconnect(), [])
// Sort by anchor, then greedily stack: each card sits at its anchor unless that
// would overlap the previous card, in which case it slides down just enough.
// Re-runs whenever the anchors or the expanded card (which changes a height)
// shift. Reads live offsetHeight so variable-length explanations pack tightly.
const ordered = [...items].sort((a, b) => a.anchorTop - b.anchorTop)
const layoutKey = ordered.map((i) => `${i.suggestion.id}:${Math.round(i.anchorTop)}`).join('|')
useLayoutEffect(() => {
let cursor = -Infinity
const next: Record<string, number> = {}
for (const { suggestion, anchorTop } of ordered) {
const h = cardRefs.current.get(suggestion.id)?.offsetHeight ?? 96
const top = Math.max(anchorTop, cursor)
next[suggestion.id] = top
cursor = top + h + CARD_GAP
}
setTops((prev) => {
const ids = Object.keys(next)
if (ids.length === Object.keys(prev).length && ids.every((id) => prev[id] === next[id])) return prev
return next
})
// layoutKey captures anchor positions; expandedId/measureTick capture height
// changes (toggling detail, Ask Petal streaming in).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutKey, expandedId, measureTick])
return (
<div className="petal-rail petal-no-print" aria-label="Suggestions">
{ordered.map(({ suggestion }) => (
<RailCard
key={suggestion.id}
ref={(el) => {
const prev = cardRefs.current.get(suggestion.id)
if (prev && prev !== el) observerRef.current?.unobserve(prev)
if (el) {
cardRefs.current.set(suggestion.id, el)
observerRef.current?.observe(el)
} else {
cardRefs.current.delete(suggestion.id)
}
}}
suggestion={suggestion}
top={tops[suggestion.id] ?? 0}
active={activeId === suggestion.id}
expanded={expandedId === suggestion.id}
onAccept={onAccept}
onDismiss={onDismiss}
onHover={onHover}
onActivate={onActivate}
onToggleExpand={onToggleExpand}
/>
))}
</div>
)
}
interface CardProps {
suggestion: Suggestion
top: number
active: boolean
expanded: boolean
onAccept: (s: Suggestion) => void
onDismiss: (s: Suggestion) => void
onHover: (id: string | null) => void
onActivate: (id: string) => void
onToggleExpand: (id: string) => void
}
const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
{ suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand },
ref,
) {
const meta = TYPE_META[suggestion.type]
const hasReplacement = suggestion.replacement.trim() !== ''
return (
<div
ref={ref}
role="group"
aria-label={`${meta.label} suggestion`}
onMouseEnter={() => onHover(suggestion.id)}
onMouseLeave={() => onHover(null)}
className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`}
style={{ top, borderLeftColor: meta.color }}
>
<div className="flex items-center justify-between">
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[0.68rem] font-bold"
style={{ background: meta.color, color: 'var(--color-plum)' }}
>
{meta.label}
</span>
<button
type="button"
aria-label="Dismiss suggestion"
onClick={() => onDismiss(suggestion)}
className="petal-rail-x"
>
</button>
</div>
{/* The body toggles the expanded explanation + Ask Petal and points the
editor at the flagged text. */}
<button type="button" onClick={() => onActivate(suggestion.id)} className="mt-2 block w-full text-left">
{hasReplacement && (
<span className="flex flex-col gap-0.5" style={{ fontFamily: 'var(--font-body)' }}>
<span className="truncate text-[0.9rem] line-through" style={{ color: 'var(--color-muted)' }}>
{suggestion.original}
</span>
<span className="truncate text-[0.9rem] font-medium" style={{ color: 'var(--color-plum)' }}>
{suggestion.replacement}
</span>
</span>
)}
<span
className={`mt-1.5 block text-[0.82rem] leading-snug${expanded ? '' : ' line-clamp-2'}`}
style={{ color: 'var(--color-plum)' }}
>
{suggestion.explanation}
</span>
</button>
{expanded && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
<div className="mt-2.5 flex items-center gap-2">
{hasReplacement && (
<button
type="button"
onClick={() => onAccept(suggestion)}
className="rounded-full px-3 py-1 text-xs font-bold text-white"
style={{ background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
Accept
</button>
)}
<button
type="button"
onClick={() => onToggleExpand(suggestion.id)}
className="rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
style={{
background: expanded ? 'var(--color-surface-alt)' : 'transparent',
color: 'var(--color-accent-hover)',
}}
>
{expanded ? 'Hide Petal' : 'Ask Petal ✨'}
</button>
</div>
</div>
)
})

View File

@@ -0,0 +1,12 @@
import type { SuggestionType } from '../../api/client'
// Per-type accent color + human label, mirroring the design tokens. Shared by the
// inline hover SuggestionCard and the margin SuggestionRail so a given suggestion
// type reads the same color/name wherever it surfaces.
export const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
grammar: { color: 'var(--color-mint)', label: 'Grammar' },
phrasing: { color: 'var(--color-peach)', label: 'Phrasing' },
idiom: { color: 'var(--color-lavender)', label: 'Idiom' },
clarity: { color: 'var(--color-sky)', label: 'Clarity' },
voice: { color: 'var(--color-honey)', label: 'Voice' },
}

View File

@@ -20,17 +20,29 @@ export function useCheckpoint(docId: string | null) {
const [llmDown, setLlmDown] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
// Pending auto-retry timer for a failed checkpoint (see runCheck).
const retryRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const docIdRef = useRef(docId)
docIdRef.current = docId
// Token to discard responses from a doc we've since navigated away from.
const runRef = useRef(0)
const runCheck = useCallback(async () => {
// Backoff schedule for re-running a checkpoint that failed. A paste fires
// exactly ONE checkpoint, and nothing re-fires until the next keystroke — so a
// single transient failure (a momentary stall on the shared inference box)
// would otherwise strand the user on "resting" indefinitely after a paste.
// These delays clear the server's 30s per-doc floor by the later attempts, and
// a failed pass now releases its slot server-side so a retry can truly re-run.
const RETRY_DELAYS_MS = [3000, 12000, 35000]
const runCheck = useCallback(async (attempt = 0) => {
const id = docIdRef.current
if (!id) return
clearTimeout(retryRef.current)
const run = ++runRef.current
setChecking(true)
let retrying = false
try {
const fresh = await api.checkDoc(id)
if (run === runRef.current && id === docIdRef.current) {
@@ -39,9 +51,18 @@ export function useCheckpoint(docId: string | null) {
}
} catch (err) {
console.error('checkpoint failed', err)
if (run === runRef.current) setLlmDown(true)
if (run !== runRef.current) return
if (attempt < RETRY_DELAYS_MS.length) {
// Keep trying quietly — don't flag "resting" until retries are exhausted.
retrying = true
retryRef.current = setTimeout(() => void runCheck(attempt + 1), RETRY_DELAYS_MS[attempt])
} else {
setLlmDown(true)
}
} finally {
if (run === runRef.current) setChecking(false)
// Stay in the "checking" state while a retry is queued so the breathing dot
// keeps reassuring rather than flickering off between attempts.
if (run === runRef.current && !retrying) setChecking(false)
}
}, [])
@@ -51,6 +72,7 @@ export function useCheckpoint(docId: string | null) {
const runVoice = useCallback(async () => {
const id = docIdRef.current
if (!id) return
clearTimeout(retryRef.current) // a voice pass supersedes a queued grammar retry
const run = ++runRef.current
setVoicing(true)
try {
@@ -70,13 +92,15 @@ export function useCheckpoint(docId: string | null) {
// Call on every edit; schedules a check 4s after typing settles.
const schedule = useCallback(() => {
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(runCheck, DEBOUNCE_MS)
clearTimeout(retryRef.current) // a fresh edit supersedes any queued retry
debounceRef.current = setTimeout(() => void runCheck(), DEBOUNCE_MS)
}, [runCheck])
// Load existing pending suggestions whenever the document changes, and cancel
// any in-flight debounce from the previous doc.
useEffect(() => {
clearTimeout(debounceRef.current)
clearTimeout(retryRef.current)
runRef.current++
setSuggestions([])
setChecking(false)
@@ -97,7 +121,13 @@ export function useCheckpoint(docId: string | null) {
}
}, [docId])
useEffect(() => () => clearTimeout(debounceRef.current), [])
useEffect(
() => () => {
clearTimeout(debounceRef.current)
clearTimeout(retryRef.current)
},
[],
)
// Drop one suggestion locally (after accept/dismiss) without a refetch.
const removeSuggestion = useCallback((id: string) => {

View File

@@ -223,6 +223,67 @@ button, a, input {
animation: petal-suggestion-in 200ms ease both;
}
/* Text emphasized from its margin card (or a hover) — a soft wash so the
card↔text link reads at a glance, both directions. */
.petal-suggestion-active {
background: var(--color-surface-alt);
box-shadow: 0 0 0 3px var(--color-surface-alt);
border-radius: 3px;
}
/* --- Suggestion margin rail -------------------------------------------------
The right-hand "comment column": every outstanding suggestion as a card,
vertically aligned to the text it flags, so the whole queue is visible at a
glance instead of one-highlight-at-a-time on hover. It floats in the whitespace
just right of the editor column and is only mounted when there's room for it
(EditorCore measures the gap). Cards are absolutely positioned by a resolved
top (anchor + collision avoidance), so the container only anchors the column. */
.petal-rail {
position: absolute;
top: 0;
left: 100%;
margin-left: 32px;
width: 300px;
/* Below the companion mascot (z-40): where a stacked card reaches the
bottom-right corner it simply tucks behind the sleeping cat. */
z-index: 10;
}
.petal-rail-card {
position: absolute;
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-border); /* type color set inline */
border-radius: var(--radius-card);
box-shadow: var(--shadow-soft);
padding: 0.7rem 0.85rem;
/* `top` eases so re-stacking (accept/dismiss/edit) glides instead of jumping.
The entrance uses fill `backwards` so its translateY doesn't linger and fight
the active-state transform once it's done. */
transition: top 240ms cubic-bezier(0.2, 0.7, 0.3, 1), box-shadow 200ms ease,
transform 200ms ease, border-color 200ms ease;
animation: petal-suggestion-in 220ms ease backwards;
}
.petal-rail-card-active {
transform: translateX(-5px);
box-shadow: 0 8px 26px rgba(180, 130, 160, 0.24);
border-top-color: var(--color-accent);
border-right-color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
.petal-rail-x {
color: var(--color-muted);
font-size: 0.72rem;
line-height: 1;
padding: 2px 5px;
border-radius: var(--radius-pill);
}
.petal-rail-x:hover {
background: var(--color-surface-alt);
color: var(--color-plum);
}
/* --- Find & Replace ---------------------------------------------------------
In-document search (Ctrl/Cmd+F). Every match gets a soft honey wash; the
current match is brighter with a rose ring so it stands out as you step