Phase 4: Ask Petal SSE chat

Conversational follow-up on a suggestion, streamed token-by-token.

Backend (interface-only; handlers never touch a concrete LLM client):
- internal/llm/chat.go: StreamAskPetal with conversational sampling
  (max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop "\n\n\n"),
  reusing AskPetalSystemPrompt + TrimHistory.
- internal/suggestions/chat.go: POST /api/suggestions/:id/chat. One
  user-scoped join loads the suggestion + parent content_text;
  surroundingParagraph extracts the \n\n-bounded paragraph at from_pos
  (whole-doc fallback when unlocated) and injects it server-side.
  Streams event: token / event: done SSE frames with JSON-encoded data
  so token newlines can't break framing; real http.Flusher per chunk.
  LLM-unreachable -> 502 before SSE headers; unknown suggestion -> 404.

Frontend:
- streamSuggestionChat: fetch + ReadableStream SSE parser (not
  EventSource, needs POST), abortable.
- AskPetal.tsx: whole conversation in component state (no persistence,
  cleared on close), Petal's first bubble pre-seeded with the
  explanation, rose/lavender bubbles, CJK font stack on the bubbles
  only (Note #17), streaming caret.
- SuggestionCard "Ask Petal" pill pins the card open while chatting
  (hover-close suppressed, click-away closes) and widens it to 340px.

Tests: chat_test.go covers streamed-text concat + done event,
server-side context injection on the system message, sampling params,
404, and surroundingParagraph. go build/vet/test clean, tsc clean,
vite build OK. Live SSE smoke-tested against a fake streaming vLLM:
tokens flushed individually through the chi middleware stack, done
terminator, 502 on LLM-down, 404 on unknown suggestion.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 21:05:39 -07:00
parent 3f7e705028
commit 3c5f3ecb96
10 changed files with 599 additions and 10 deletions

View File

@@ -46,10 +46,11 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- [x] `SuggestionHighlight` (ProseMirror **decorations**, re-anchored by `original` string on every doc change — not stored marks) + `SuggestionCard` (accept applies replacement in-editor then PATCHes; dismiss)
- [x] Suggestion colors: grammar=mint, phrasing=peach, idiom=lavender, clarity=sky (honey reserved for voice)
### Phase 4 — Ask Petal (conversational follow-up)
- [ ] `POST /api/suggestions/:id/chat` SSE streaming; server-side context injection
- [ ] AskPetal component, token-by-token render, no persistence
- [ ] CJK font fallbacks on chat bubbles (spec Note #17)
### Phase 4 — Ask Petal (conversational follow-up)
- [x] `POST /api/suggestions/:id/chat` SSE streaming; server-side context injection`internal/suggestions/chat.go` loads the suggestion + parent doc in one user-scoped query, extracts the `\n\n`-bounded paragraph around `from_pos` (falls back to truncated doc when `from_pos == -1`), injects it via `AskPetalSystemPrompt`, streams `event: token`/`event: done` SSE frames (JSON-encoded data so token newlines don't break framing). LLM-unreachable returns a clean 502 before any SSE headers; unknown suggestion 404s.
- [x] AskPetal component, token-by-token render, no persistence`AskPetal.tsx` holds the whole conversation in component state (cleared on close), pre-seeds Petal's first bubble with the suggestion explanation, streams via `streamSuggestionChat` (fetch + ReadableStream, not EventSource). `SuggestionCard` gains an "Ask Petal ✨" pill; the card pins open (hover-close suppressed, click-away to dismiss) while the panel is expanded.
- [x] CJK font fallbacks on chat bubbles (spec Note #17) — bubbles + input use the `'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC'` stack (the user asks in Mandarin); applied to the chat surface only, not the serif editor body.
- `internal/llm/chat.go`: `StreamAskPetal` (max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop `\n\n\n`) reusing the existing `AskPetalSystemPrompt` + `TrimHistory`. Backend stays interface-only; the SSE handler never touches a concrete client.
### Phase 5 — Voice consistency pass (Tier 1)
- [ ] `POST /api/docs/:id/voice`, whole-document, slow cadence / explicit action
@@ -75,3 +76,4 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
- 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone.
- 2026-06-25: **Phase 2 complete.** Backend `internal/docs`: chi sub-router (list/create/get/update/delete) mounted at `/api/docs`, local-user scoped, RETURNING on create, COALESCE partial-update (one PUT serves rename + full save), 404/400 JSON errors; `handlers_test.go` walks the full lifecycle. Frontend: `api/client.ts`, `useAutoSave` (1.5s debounce + `saveNow` flush), `EditorCore` (Tiptap StarterKit/Underline/TextAlign/Placeholder/CharacterCount) + `Toolbar`, `DocList`/`DocListItem`, `StatusBar`, rewritten `App.tsx` orchestrating load/select/create/delete with optimistic sidebar patching. `.petal-prose` styles (Lora body, Nunito headings). tsc clean, vite build OK, go build OK; smoke-tested full CRUD incl. CJK title round-trip + SPA serve. Next: **Phase 3 (LLM grammar checkpoint).**
- 2026-06-25: **Phase 3 complete.** Backend `internal/llm`: `LLMClient` interface + factory (vLLM OpenAI-compat + Ollama native, both Complete/Stream), `prompts.go` (checkpoint + Ask Petal templates), `checkpoint.go` (brace-matched JSON salvage, per-doc 30s `RateLimiter`, doc/history truncation). `internal/suggestions`: `/api/docs/:id/check` + `:id/suggestions` + `/api/suggestions/:id/{accept,dismiss}`; each check replaces the pending set in a tx (accepted/rejected kept as history), throttled checks return the current set, positions located by `strings.Index` (advisory only). Frontend: `useCheckpoint` (4s debounce, loads existing on doc open, run-token guards stale responses), `SuggestionHighlight` Tiptap extension rendering ProseMirror **decorations** re-anchored by `original` string on every doc change (precise textblock offset→PM-pos mapping, handles inline atoms), `SuggestionCard` (type-colored tag, original→replacement diff, accept applies replacement in-editor + PATCHes, hover-bridge with close delay), breathing rose checkpoint dot in StatusBar, suggestion fade-float + breathe CSS. Tests: llm parse/rate-limit/truncate, suggestions full flow + rate-limit over httptest with a stub client. go build/vet/test clean, tsc clean, vite build OK; end-to-end smoke-tested against a fake vLLM endpoint (anchoring verified: `I has`→0:5, `two apple`→6:15) and 502 path when LLM unreachable. Next: **Phase 4 (Ask Petal SSE chat).**
- 2026-06-25: **Phase 4 complete.** Backend: `internal/llm/chat.go` (`StreamAskPetal` — conversational sampling params, reuses `AskPetalSystemPrompt`/`TrimHistory`), `internal/suggestions/chat.go` (`POST /api/suggestions/:id/chat` — one user-scoped join loads the suggestion + parent `content_text`, `surroundingParagraph` extracts the `\n\n`-bounded paragraph at `from_pos` with whole-doc fallback, streams `event: token`/`event: done` SSE frames with JSON-encoded data, `X-Accel-Buffering: no`, real `http.Flusher` per chunk; LLM-down → 502 before SSE headers, unknown id → 404). Handler imports the interface only. Frontend: `streamSuggestionChat` (fetch + ReadableStream SSE parser, abortable), `AskPetal.tsx` (in-component history — no persistence, pre-seeded first bubble, rose/lavender bubbles, CJK font stack per Note #17, streaming caret), `SuggestionCard` "Ask Petal ✨" pill that pins the card open (hover-close suppressed, click-away closes) and widens it to 340px. Tests: `chat_test.go` (streamed-text concat + done event, server-side context injection asserted on the system message, sampling params, 404, `surroundingParagraph` unit). go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke test against a fake streaming vLLM (fresh ports 8077/8088 — a pre-existing dev petal on :8099 left untouched): tokens flushed individually through the chi middleware stack, `done` terminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: **Phase 5 (voice consistency pass, Tier 1).**

29
internal/llm/chat.go Normal file
View File

@@ -0,0 +1,29 @@
package llm
import "context"
// AskPetalMessages assembles the message array for one Ask Petal turn: the
// suggestion-context system prompt followed by the (length-capped) client-side
// conversation history. The backend is stateless, so the full history rides on
// every request (spec: no server-side sessions).
func AskPetalMessages(systemPrompt string, history []Message) []Message {
msgs := make([]Message, 0, len(history)+1)
msgs = append(msgs, Message{Role: "system", Content: systemPrompt})
msgs = append(msgs, TrimHistory(history)...)
return msgs
}
// StreamAskPetal opens an SSE-friendly token stream for an Ask Petal reply using
// the conversational sampling parameters from the spec. The caller forwards the
// returned chunks to the browser; the channel closes when generation ends.
func StreamAskPetal(ctx context.Context, client LLMClient, systemPrompt string, history []Message) (<-chan string, error) {
return client.Stream(ctx, CompletionRequest{
Messages: AskPetalMessages(systemPrompt, history),
MaxTokens: 512,
Temperature: 0.7,
RepetitionPenalty: 1.15,
TopP: 0.92,
Stop: []string{"\n\n\n"},
Stream: true,
})
}

View File

@@ -0,0 +1,126 @@
package suggestions
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// chatRequest is the body the AskPetal panel posts: the full conversation so
// far. The suggestion context is loaded server-side and never trusted from the
// client (spec Note #10).
type chatRequest struct {
Messages []llm.Message `json:"messages"`
}
// chat streams an Ask Petal conversational reply over SSE. It loads the
// suggestion and its parent document's surrounding paragraph, injects them as
// the tutor system prompt, then relays the model's tokens to the browser as
// `data:` events. History persistence lives entirely in the client.
func (h *Handler) chat(w http.ResponseWriter, r *http.Request) {
sugID := chi.URLParam(r, "id")
var body chatRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid request body")
return
}
// One query for the suggestion fields and the parent document's plain text,
// scoped to the local user so a stray id can't read another user's doc.
var (
original, replacement, explanation, typ string
fromPos int
contentText string
)
err := h.DB.QueryRow(
`SELECT s.original, s.replacement, s.explanation, s.type, s.from_pos, d.content_text
FROM suggestions s
JOIN documents d ON d.id = s.doc_id
WHERE s.id = ? AND d.user_id = ?`,
sugID, db.LocalUserID,
).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText)
if errors.Is(err, sql.ErrNoRows) {
errorJSON(w, http.StatusNotFound, "suggestion not found")
return
}
if err != nil {
serverError(w, err)
return
}
paragraph := surroundingParagraph(contentText, fromPos)
systemPrompt := llm.AskPetalSystemPrompt(original, replacement, typ, explanation, paragraph)
// SSE requires an unbuffered, flushable writer. chi's middleware writers pass
// Flush through; bail with a plain error if somehow they don't.
flusher, ok := w.(http.Flusher)
if !ok {
serverError(w, errors.New("streaming unsupported"))
return
}
ch, err := llm.StreamAskPetal(r.Context(), h.Client, systemPrompt, body.Messages)
if err != nil {
// The stream never opened (e.g. LLM unreachable) — a normal JSON error is
// still appropriate since we haven't written SSE headers yet.
errorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error())
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (e.g. nginx)
w.WriteHeader(http.StatusOK)
flusher.Flush()
for chunk := range ch {
writeSSE(w, "token", map[string]string{"text": chunk})
flusher.Flush()
}
// Signal a clean end so the client can stop reading without waiting on EOF.
writeSSE(w, "done", map[string]bool{"done": true})
flusher.Flush()
}
// writeSSE emits one named SSE event with a JSON data payload. JSON-encoding the
// data keeps token text (which may contain newlines) from breaking SSE framing.
func writeSSE(w http.ResponseWriter, event string, data any) {
payload, err := json.Marshal(data)
if err != nil {
return
}
_, _ = w.Write([]byte("event: " + event + "\ndata: "))
_, _ = w.Write(payload)
_, _ = w.Write([]byte("\n\n"))
}
// surroundingParagraph returns the paragraph of contentText containing the
// plain-text offset from. Tiptap flattens blocks with blank-line separators, so
// paragraphs are bounded by "\n\n". When the offset is unknown (original wasn't
// located, from == -1) it falls back to the latency-capped document so the tutor
// still has context to work with.
func surroundingParagraph(contentText string, from int) string {
if from < 0 || from > len(contentText) {
return strings.TrimSpace(llm.TruncateDoc(contentText))
}
start := strings.LastIndex(contentText[:from], "\n\n")
if start < 0 {
start = 0
} else {
start += 2
}
end := len(contentText)
if rel := strings.Index(contentText[from:], "\n\n"); rel >= 0 {
end = from + rel
}
return strings.TrimSpace(contentText[start:end])
}

View File

@@ -0,0 +1,148 @@
package suggestions
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/llm"
)
// streamClient serves a canned checkpoint (so a suggestion can be seeded through
// the public /check route) and a fixed token stream for chat. It records the
// last streamed request so a test can assert on the injected system context.
type streamClient struct {
checkpoint string
tokens []string
lastStream llm.CompletionRequest
}
func (c *streamClient) Complete(_ context.Context, _ llm.CompletionRequest) (string, error) {
return c.checkpoint, nil
}
func (c *streamClient) Stream(_ context.Context, req llm.CompletionRequest) (<-chan string, error) {
c.lastStream = req
ch := make(chan string)
go func() {
defer close(ch)
for _, t := range c.tokens {
ch <- t
}
}()
return ch, nil
}
func TestAskPetalChat(t *testing.T) {
client := &streamClient{
checkpoint: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}]}`,
tokens: []string{"Because ", "\"I\" ", "takes ", "\"have\"."},
}
srv, docID := newTestServer(t, client)
// Seed one suggestion via the checkpoint route, then grab its id.
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
listed := do(t, srv, http.MethodGet, "/docs/"+docID+"/suggestions", "")
var sugs []db.Suggestion
if err := json.Unmarshal(listed.Body.Bytes(), &sugs); err != nil {
t.Fatalf("decode suggestions: %v", err)
}
if len(sugs) != 1 {
t.Fatalf("seed: want 1 suggestion, got %d", len(sugs))
}
sugID := sugs[0].ID
rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/chat",
`{"messages":[{"role":"user","content":"why is this wrong?"}]}`)
if rec.Code != http.StatusOK {
t.Fatalf("chat: code=%d body=%s", rec.Code, rec.Body)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/event-stream" {
t.Fatalf("chat: content-type=%q, want text/event-stream", ct)
}
// The streamed tokens should arrive concatenated across data: events, and the
// stream should terminate with a done event.
body := rec.Body.String()
got := collectSSEText(body)
if want := "Because \"I\" takes \"have\"."; got != want {
t.Fatalf("streamed text = %q, want %q", got, want)
}
if !strings.Contains(body, "event: done") {
t.Fatalf("chat stream missing done event:\n%s", body)
}
// Context injection: the system prompt (first message) must carry the
// suggestion's original text and the surrounding paragraph — loaded
// server-side, never from the client.
msgs := client.lastStream.Messages
if len(msgs) < 2 || msgs[0].Role != "system" {
t.Fatalf("expected a leading system message, got %+v", msgs)
}
sys := msgs[0].Content
if !strings.Contains(sys, "I has") {
t.Fatalf("system prompt missing original text:\n%s", sys)
}
if !strings.Contains(sys, "I has two apple.") {
t.Fatalf("system prompt missing surrounding paragraph:\n%s", sys)
}
// The client's user message rides after the system turn.
if last := msgs[len(msgs)-1]; last.Role != "user" || last.Content != "why is this wrong?" {
t.Fatalf("user message not forwarded: %+v", last)
}
// Chat sampling parameters from the spec.
if client.lastStream.MaxTokens != 512 || client.lastStream.Temperature != 0.7 {
t.Fatalf("unexpected chat params: %+v", client.lastStream)
}
}
func TestAskPetalChatNotFound(t *testing.T) {
client := &streamClient{checkpoint: `{"suggestions":[]}`}
srv, _ := newTestServer(t, client)
rec := do(t, srv, http.MethodPost, "/suggestions/does-not-exist/chat",
`{"messages":[{"role":"user","content":"hi"}]}`)
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 for unknown suggestion, got %d", rec.Code)
}
}
func TestSurroundingParagraph(t *testing.T) {
text := "First paragraph here.\n\nThe target sentence lives here.\n\nThird paragraph."
from := strings.Index(text, "target")
got := surroundingParagraph(text, from)
if got != "The target sentence lives here." {
t.Fatalf("paragraph = %q", got)
}
// Unknown offset falls back to the (trimmed) document.
if got := surroundingParagraph(text, -1); !strings.Contains(got, "First paragraph") {
t.Fatalf("fallback paragraph = %q", got)
}
}
// collectSSEText concatenates the JSON text fields from every `event: token`
// frame in an SSE body.
func collectSSEText(body string) string {
var b strings.Builder
for _, frame := range strings.Split(body, "\n\n") {
if !strings.Contains(frame, "event: token") {
continue
}
for _, line := range strings.Split(frame, "\n") {
data, ok := strings.CutPrefix(line, "data: ")
if !ok {
continue
}
var payload struct {
Text string `json:"text"`
}
_ = json.Unmarshal([]byte(data), &payload)
b.WriteString(payload.Text)
}
}
return b.String()
}

View File

@@ -47,6 +47,7 @@ func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Post("/{id}/accept", h.accept)
r.Post("/{id}/dismiss", h.dismiss)
r.Post("/{id}/chat", h.chat)
return r
}

View File

@@ -78,3 +78,64 @@ export const api = {
dismissSuggestion: (id: string) =>
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
}
// One turn in an Ask Petal conversation. History lives only in the component —
// the server is stateless and re-injects the suggestion context every request.
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
// streamSuggestionChat POSTs the conversation to the SSE chat endpoint and
// invokes onToken for each text chunk as it arrives. It uses fetch + a
// ReadableStream reader (not EventSource, which can't POST) and resolves when
// the stream ends. Abort via the optional signal to cancel mid-response.
export async function streamSuggestionChat(
suggestionId: string,
messages: ChatMessage[],
onToken: (text: string) => void,
signal?: AbortSignal,
): Promise<void> {
const res = await fetch(`/api/suggestions/${suggestionId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
signal,
})
if (!res.ok || !res.body) {
const detail = await res.text().catch(() => '')
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
// SSE events are separated by a blank line. Process every complete one and
// keep the trailing partial in the buffer.
let sep: number
while ((sep = buf.indexOf('\n\n')) >= 0) {
const event = parseSSE(buf.slice(0, sep))
buf = buf.slice(sep + 2)
if (event.name === 'done') return
if (event.name === 'token' && event.data) {
const text = (JSON.parse(event.data) as { text: string }).text
if (text) onToken(text)
}
}
}
}
// parseSSE pulls the event name and data payload out of one raw SSE frame.
function parseSSE(frame: string): { name: string; data: string } {
let name = 'message'
const dataLines: string[] = []
for (const line of frame.split('\n')) {
if (line.startsWith('event:')) name = line.slice(6).trim()
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
}
return { name, data: dataLines.join('\n') }
}

View File

@@ -0,0 +1,159 @@
import { useEffect, useRef, useState } from 'react'
import { streamSuggestionChat, type ChatMessage } from '../../api/client'
interface Props {
suggestionId: string
// Pre-populates Petal's first bubble so the conversation opens with context.
explanation: string
}
// CJK fallback stack — Nunito has no Chinese glyphs, and the user asks questions
// in Mandarin (spec Note #17). Applied to the bubbles specifically, not the
// serif editor body.
const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif"
// AskPetal is the mini chat panel inside an expanded SuggestionCard. The whole
// conversation lives in this component's state — nothing is persisted; closing
// 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 },
])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Keep the latest bubble in view as tokens arrive.
useEffect(() => {
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
}, [messages])
// Focus the input when the panel opens.
useEffect(() => {
inputRef.current?.focus()
}, [])
async function send() {
const text = input.trim()
if (!text || streaming) return
setInput('')
// Append the user turn plus an empty assistant bubble to stream into.
const history: ChatMessage[] = [...messages, { role: 'user', content: text }]
setMessages([...history, { role: 'assistant', content: '' }])
setStreaming(true)
try {
await streamSuggestionChat(suggestionId, history, (token) => {
setMessages((prev) => {
const next = prev.slice()
const last = next[next.length - 1]
next[next.length - 1] = { ...last, content: last.content + token }
return next
})
})
} catch (err) {
setMessages((prev) => {
const next = prev.slice()
next[next.length - 1] = {
role: 'assistant',
content: 'Sorry, I had trouble responding just now. Please try again. 🌸',
}
return next
})
console.error('Ask Petal chat failed:', err)
} finally {
setStreaming(false)
inputRef.current?.focus()
}
}
return (
<div
className="mt-3 flex flex-col"
style={{
borderTop: '1px solid var(--color-border)',
paddingTop: '0.625rem',
fontFamily: CHAT_FONT,
}}
>
<div
ref={scrollRef}
className="flex flex-col gap-2 overflow-y-auto pr-1"
style={{ maxHeight: 220 }}
>
{messages.map((m, i) => (
<Bubble key={i} role={m.role} content={m.content} streaming={streaming && i === messages.length - 1} />
))}
</div>
<form
className="mt-2 flex items-center gap-1.5"
onSubmit={(e) => {
e.preventDefault()
void send()
}}
>
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask why… / 问为什么…"
className="min-w-0 flex-1 rounded-full px-3 py-1.5 text-xs focus:outline-none"
style={{
background: 'var(--color-surface-alt)',
border: '1px solid var(--color-border)',
color: 'var(--color-plum)',
fontFamily: CHAT_FONT,
}}
/>
<button
type="submit"
disabled={streaming || input.trim() === ''}
className="rounded-full px-3 py-1.5 text-xs font-bold text-white disabled:opacity-50"
style={{ background: 'var(--color-accent)' }}
>
Send
</button>
</form>
</div>
)
}
// Bubble renders one chat turn: Petal rose-tinted and left-aligned, the user
// lavender and right-aligned. A trailing caret marks the actively streaming
// reply until its first token lands.
function Bubble({
role,
content,
streaming,
}: {
role: ChatMessage['role']
content: string
streaming: boolean
}) {
const isPetal = role === 'assistant'
return (
<div className={`flex ${isPetal ? 'justify-start' : 'justify-end'}`}>
<div
className="max-w-[85%] rounded-2xl px-3 py-1.5 text-xs leading-snug"
style={{
background: isPetal ? 'var(--color-surface-alt)' : 'var(--color-lavender)',
color: 'var(--color-plum)',
fontFamily: CHAT_FONT,
whiteSpace: 'pre-wrap',
}}
>
{content}
{streaming && content === '' && (
<span className="petal-chat-caret" aria-hidden>
</span>
)}
</div>
</div>
)
}

View File

@@ -55,6 +55,9 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
const [hover, setHover] = useState<HoverState | null>(null)
// Delays card close so the pointer can travel from highlight to card.
const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
// While the Ask Petal panel is expanded the card is pinned: the hover-close
// timer is suppressed so chatting doesn't dismiss it. A click outside closes.
const [pinned, setPinned] = useState(false)
const editor = useEditor({
extensions: [
@@ -109,7 +112,11 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth),
)
const top = elRect.bottom - wrapRect.top + 6
setHover({ suggestion, top, left })
setHover((prev) => {
// Moving to a different highlight resets any Ask Petal pin.
if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
return { suggestion, top, left }
})
},
[suggestions],
)
@@ -127,8 +134,16 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
)
const scheduleClose = useCallback(() => {
if (pinned) return // Ask Petal open — keep the card until an explicit close.
clearTimeout(closeTimer.current)
closeTimer.current = setTimeout(() => setHover(null), 160)
}, [pinned])
// Fully close the card and drop any pin (used on accept/dismiss/click-away).
const closeCard = useCallback(() => {
clearTimeout(closeTimer.current)
setPinned(false)
setHover(null)
}, [])
// Leaving a highlight schedules a close; entering the card cancels it, so the
@@ -151,22 +166,33 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
editor.chain().focus().insertContentAt(range, s.replacement).run()
}
}
setHover(null)
closeCard()
onAccept(s)
},
[editor, onAccept],
[editor, onAccept, closeCard],
)
const handleDismiss = useCallback(
(s: Suggestion) => {
setHover(null)
closeCard()
onDismiss(s)
},
[onDismiss],
[onDismiss, closeCard],
)
useEffect(() => () => clearTimeout(closeTimer.current), [])
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
// the only way to dismiss a pinned card without accept/dismiss.
useEffect(() => {
if (!pinned) return
const onDown = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest('.petal-suggestion-card')) closeCard()
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [pinned, closeCard])
return (
<div className="flex flex-1 flex-col">
<Toolbar editor={editor} />
@@ -185,6 +211,7 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
onDismiss={handleDismiss}
onPointerEnter={keepOpen}
onPointerLeave={scheduleClose}
onExpandChange={setPinned}
/>
)}
</div>

View File

@@ -1,4 +1,6 @@
import { useState } from 'react'
import type { Suggestion, SuggestionType } 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 }> = {
@@ -16,6 +18,9 @@ interface Props {
onDismiss: (s: Suggestion) => void
onPointerEnter: () => void
onPointerLeave: () => void
// Pins the card open while the Ask Petal panel is expanded, so the chat isn't
// dismissed by the hover-close timer when the pointer drifts away.
onExpandChange: (expanded: boolean) => void
}
// SuggestionCard is the hover panel for a single suggestion: a colored type tag,
@@ -29,9 +34,19 @@ export function SuggestionCard({
onDismiss,
onPointerEnter,
onPointerLeave,
onExpandChange,
}: Props) {
const meta = TYPE_META[suggestion.type]
const hasReplacement = suggestion.replacement.trim() !== ''
const [asking, setAsking] = useState(false)
function toggleAsking() {
setAsking((prev) => {
const next = !prev
onExpandChange(next)
return next
})
}
return (
<div
@@ -39,8 +54,9 @@ export function SuggestionCard({
aria-label={`${meta.label} suggestion`}
onMouseEnter={onPointerEnter}
onMouseLeave={onPointerLeave}
className="petal-suggestion-card absolute z-20 w-[300px] p-3.5 text-sm"
className="petal-suggestion-card absolute z-20 p-3.5 text-sm"
style={{
width: asking ? 340 : 300,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
@@ -70,6 +86,20 @@ export function SuggestionCard({
{suggestion.explanation}
</p>
<button
type="button"
onClick={toggleAsking}
className="mt-2 rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
style={{
background: asking ? 'var(--color-surface-alt)' : 'transparent',
color: 'var(--color-accent-hover)',
}}
>
{asking ? 'Hide Petal' : 'Ask Petal ✨'}
</button>
{asking && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
<div className="mt-3 flex items-center gap-2">
{hasReplacement && (
<button

View File

@@ -136,6 +136,12 @@ button, a, input {
animation: petal-suggestion-in 200ms ease both;
}
/* Blinking caret in the Ask Petal bubble while awaiting the first token. */
.petal-chat-caret {
animation: petal-breathe 1s ease-in-out infinite;
color: var(--color-accent);
}
/* Breathing rose dot shown in the StatusBar while a checkpoint runs. */
.petal-checkpoint-dot {
animation: petal-breathe 2s ease-in-out infinite;