Merge feat/suggestion-rail: right-margin comment rail + Mandarin explanations

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 12:23:22 -07:00
12 changed files with 629 additions and 41 deletions

View File

@@ -165,3 +165,24 @@ func RewriteMessages(text, style string) []Message {
{Role: "user", Content: text}, {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}/accept", h.accept)
r.Post("/{id}/dismiss", h.dismiss) r.Post("/{id}/dismiss", h.dismiss)
r.Post("/{id}/chat", h.chat) r.Post("/{id}/chat", h.chat)
r.Post("/{id}/translate", h.translate)
return r return r
} }

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' }), req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
dismissSuggestion: (id: string) => dismissSuggestion: (id: string) =>
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }), 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 // Version history. listVersions returns metadata only (no bodies); getVersion
// loads one full snapshot for preview; snapshotDoc takes an explicit restore // loads one full snapshot for preview; snapshotDoc takes an explicit restore

View File

@@ -1,9 +1,11 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { streamSuggestionChat, type ChatMessage } from '../../api/client' import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
interface Props { interface Props {
suggestionId: string 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 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- // 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 [messages, setMessages] = useState<ChatMessage[]>([ // Opening bubble starts empty (caret-only) and fills with the Mandarin
{ role: 'assistant', content: explanation }, // 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 [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false) const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
@@ -36,6 +39,31 @@ export function AskPetal({ suggestionId, explanation }: Props) {
inputRef.current?.focus() 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() { async function send() {
const text = input.trim() const text = input.trim()
if (!text || streaming) return if (!text || streaming) return
@@ -86,7 +114,12 @@ export function AskPetal({ suggestionId, explanation }: Props) {
style={{ maxHeight: 220 }} style={{ maxHeight: 220 }}
> >
{messages.map((m, i) => ( {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> </div>

View File

@@ -18,7 +18,8 @@ import type { EditorView } from '@tiptap/pm/view'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Toolbar } from '../Toolbar/Toolbar' import { Toolbar } from '../Toolbar/Toolbar'
import { SuggestionCard } from './SuggestionCard' 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 { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
import { MisspellCard } from './MisspellCard' import { MisspellCard } from './MisspellCard'
import { WordCard } from './WordCard' import { WordCard } from './WordCard'
@@ -219,6 +220,18 @@ export function EditorCore({
const rewriteReqRef = useRef(0) const rewriteReqRef = useRef(0)
// The in-document Find & Replace bar (Ctrl/Cmd+F). // The in-document Find & Replace bar (Ctrl/Cmd+F).
const [findOpen, setFindOpen] = useState(false) 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({ const editor = useEditor({
extensions: [ extensions: [
@@ -282,6 +295,8 @@ export function EditorCore({
content_text: editor.getText(), content_text: editor.getText(),
word_count: editor.storage.characterCount.words(), word_count: editor.storage.characterCount.words(),
}) })
// Edits reflow the text, so the rail anchors need re-measuring.
recomputeRailRef.current()
}, },
onSelectionUpdate: ({ editor }) => { onSelectionUpdate: ({ editor }) => {
// A selection supersedes the hover gloss; an empty one clears the bubble. // 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) setSuggestions(editor.state, editor.view.dispatch, suggestions)
// Close the card if its suggestion is gone. // Close the card if its suggestion is gone.
setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null)) 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]) }, [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( const openCardFor = useCallback(
(id: string, el: HTMLElement) => { (id: string, el: HTMLElement) => {
const wrapper = wrapperRef.current const wrapper = wrapperRef.current
@@ -366,10 +460,17 @@ export function EditorCore({
if (!target) return if (!target) return
const id = target.getAttribute('data-suggestion-id') const id = target.getAttribute('data-suggestion-id')
if (!id) return 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) clearTimeout(closeTimer.current)
openCardFor(id, target) openCardFor(id, target)
}, },
[openCardFor], [openCardFor, railEnabled],
) )
const scheduleClose = useCallback(() => { const scheduleClose = useCallback(() => {
@@ -389,35 +490,53 @@ export function EditorCore({
// pointer can bridge the small gap from text to card. // pointer can bridge the small gap from text to card.
const handleMouseOut = useCallback( const handleMouseOut = useCallback(
(e: React.MouseEvent) => { (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), []) const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
// Accept applies the replacement to the document, plays a little confetti // 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( const handleAccept = useCallback(
(s: Suggestion) => { (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() !== '') { if (editor && s.replacement.trim() !== '') {
const range = findRange(editor.state.doc, s.original) const range = findRange(editor.state.doc, s.original)
if (range) { if (range) {
editor.chain().focus().insertContentAt(range, s.replacement).run() editor.chain().focus().insertContentAt(range, s.replacement).run()
} }
} }
setHover((h) => { // Fall back to the hover card's position if the highlight wasn't found.
if (h) { if (!burst && hover) burst = { top: hover.top, left: hover.left + 16 }
setConfetti({ top: h.top, left: h.left + 16 }) if (burst) {
setConfetti(burst)
clearTimeout(confettiTimer.current) clearTimeout(confettiTimer.current)
confettiTimer.current = setTimeout(() => setConfetti(null), 720) confettiTimer.current = setTimeout(() => setConfetti(null), 720)
} }
return h
})
closeCard() closeCard()
setRailExpandedId(null)
onAccept(s) onAccept(s)
}, },
[editor, onAccept, closeCard], [editor, onAccept, closeCard, hover],
) )
const handleDismiss = useCallback( const handleDismiss = useCallback(
@@ -439,7 +558,16 @@ export function EditorCore({
const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
if (suggestionEl) { if (suggestionEl) {
const id = suggestionEl.getAttribute('data-suggestion-id') 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 return
} }
if (!editor || !spellChecker) return if (!editor || !spellChecker) return
@@ -461,7 +589,7 @@ export function EditorCore({
setWordInfo(null) setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left }) setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
}, },
[editor, spellChecker, closeCard, openCardFor], [editor, spellChecker, closeCard, openCardFor, railEnabled],
) )
const replaceMisspelling = useCallback( const replaceMisspelling = useCallback(
@@ -854,7 +982,7 @@ export function EditorCore({
onAdd={addMisspellingToDict} onAdd={addMisspellingToDict}
/> />
)} )}
{hover && ( {hover && !railEnabled && (
<SuggestionCard <SuggestionCard
suggestion={hover.suggestion} suggestion={hover.suggestion}
style={{ top: hover.top, left: hover.left }} style={{ top: hover.top, left: hover.left }}
@@ -865,6 +993,18 @@ export function EditorCore({
onExpandChange={setPinned} 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>
</div> </div>
) )

View File

@@ -1,15 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import type { Suggestion, SuggestionType } from '../../api/client' import type { Suggestion } from '../../api/client'
import { AskPetal } from './AskPetal' import { AskPetal } from './AskPetal'
import { TYPE_META } from './suggestionMeta'
// 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' },
}
interface Props { interface Props {
suggestion: Suggestion suggestion: Suggestion

View File

@@ -16,9 +16,17 @@ export const suggestionPluginKey = new PluginKey<PluginState>('petalSuggestions'
interface PluginState { interface PluginState {
suggestions: Suggestion[] 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 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 // mapOffset converts a character offset within a textblock's flattened text into
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard // an absolute ProseMirror position, accounting for inline atoms (e.g. hard
// breaks) that occupy a position but contribute no text. // 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 return result
} }
function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet { function buildDecorations(doc: PMNode, suggestions: Suggestion[], activeId: string | null): DecorationSet {
const decos: Decoration[] = [] const decos: Decoration[] = []
for (const s of suggestions) { for (const s of suggestions) {
const range = findRange(doc, s.original) const range = findRange(doc, s.original)
if (!range) continue if (!range) continue
const active = s.id === activeId ? ' petal-suggestion-active' : ''
decos.push( decos.push(
Decoration.inline(range.from, range.to, { 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, '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 // setSuggestions pushes a new suggestion list into the plugin. Decorations are
// rebuilt against the current document immediately. // rebuilt against the current document immediately.
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) { 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) 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({ export const SuggestionHighlight = Extension.create({
name: 'suggestionHighlight', name: 'suggestionHighlight',
@@ -160,17 +181,29 @@ export const SuggestionHighlight = Extension.create({
new Plugin<PluginState>({ new Plugin<PluginState>({
key: suggestionPluginKey, key: suggestionPluginKey,
state: { state: {
init: () => ({ suggestions: [], decorations: DecorationSet.empty }), init: () => ({ suggestions: [], activeId: null, decorations: DecorationSet.empty }),
apply(tr, value, _oldState, newState) { apply(tr, value, _oldState, newState) {
const meta = tr.getMeta(suggestionPluginKey) as Suggestion[] | undefined const meta = tr.getMeta(suggestionPluginKey) as SuggestionMeta | undefined
if (meta) { if (meta && 'suggestions' in meta) {
return { suggestions: meta, decorations: buildDecorations(newState.doc, 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. // On any document change, re-anchor by string against the new doc.
if (tr.docChanged) { if (tr.docChanged) {
return { return {
suggestions: value.suggestions, suggestions: value.suggestions,
decorations: buildDecorations(newState.doc, value.suggestions), activeId: value.activeId,
decorations: buildDecorations(newState.doc, value.suggestions, value.activeId),
} }
} }
return value 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

@@ -223,6 +223,67 @@ button, a, input {
animation: petal-suggestion-in 200ms ease both; 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 --------------------------------------------------------- /* --- Find & Replace ---------------------------------------------------------
In-document search (Ctrl/Cmd+F). Every match gets a soft honey wash; the 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 current match is brighter with a rose ring so it stands out as you step