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
This commit is contained in:
prosolis
2026-06-26 12:23:02 -07:00
parent 035dcf5d25
commit 82e2bcc777
12 changed files with 629 additions and 41 deletions

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>