diff --git a/cmd/server/main.go b/cmd/server/main.go index 9d8cc11..e130e23 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,8 @@ package main import ( + "crypto/sha256" + "encoding/hex" "errors" "io/fs" "log" @@ -34,12 +36,26 @@ func main() { r.Use(middleware.Logger) r.Use(middleware.Recoverer) + // Build version: a hash of the embedded SPA shell. Vite rewrites index.html + // with content-hashed asset names on every build, so this string changes + // exactly when a new frontend is deployed โ€” the client polls it to know when + // to offer a refresh. + version := buildVersion() + log.Printf("frontend build version %s", version) + r.Route("/api", func(api chi.Router) { api.Get("/health", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"ok"}`)) }) + api.Get("/version", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Never cache: a stale cached version would defeat the whole check. + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte(`{"version":"` + version + `"}`)) + }) + llmClient := llm.NewLLMClient(cfg) sug := suggestions.New(database, llmClient) @@ -63,6 +79,19 @@ func main() { } } +// buildVersion derives a short, stable identifier for the currently embedded +// frontend by hashing dist/index.html. Vite stamps content-hashed asset names +// into that file each build, so the digest is a reliable "did the deploy +// change?" signal. Falls back to "dev" when the frontend hasn't been built. +func buildVersion() string { + data, err := fs.ReadFile(web.DistFS, "dist/index.html") + if err != nil { + return "dev" + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:])[:12] +} + // spaHandler serves the embedded web/dist as a single-page app: static files // when they exist, falling back to index.html for unknown paths. If the // frontend hasn't been built yet, it returns a friendly dev hint instead. diff --git a/web/src/App.tsx b/web/src/App.tsx index cd915e5..08e95e7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,8 +7,11 @@ import { DocList } from './components/DocList/DocList' import { EditorCore, type EditorChange } from './components/Editor/EditorCore' import { StatusBar } from './components/StatusBar/StatusBar' import { PetalCompanion } from './components/Companion/PetalCompanion' +import { UpdateBanner } from './components/UpdateBanner/UpdateBanner' +import { useVersionWatch } from './hooks/useVersionWatch' export default function App() { + const updateAvailable = useVersionWatch() const [docs, setDocs] = useState([]) const [currentDoc, setCurrentDoc] = useState(null) const [title, setTitle] = useState('') @@ -250,6 +253,8 @@ export default function App() { + {updateAvailable && } + (`/suggestions/${id}/accept`, { method: 'POST' }), dismissSuggestion: (id: string) => req(`/suggestions/${id}/dismiss`, { method: 'POST' }), + + // Current deployed build id โ€” changes whenever a new frontend ships. The + // app polls this to offer a refresh. Bypasses any cache so the answer is live. + version: () => req<{ version: string }>('/version', { cache: 'no-store' }), } // One turn in an Ask Petal conversation. History lives only in the component โ€” diff --git a/web/src/components/Companion/PetalCompanion.tsx b/web/src/components/Companion/PetalCompanion.tsx index ac233f3..01389d6 100644 --- a/web/src/components/Companion/PetalCompanion.tsx +++ b/web/src/components/Companion/PetalCompanion.tsx @@ -44,10 +44,20 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: // dozes them, keep their normal idle pose instead of a sleepy face. Only a // mascot with a real sleep animation (the always-asleep cat) shows the zzz. const hasSleepClip = Boolean(companion.animations.sleeping) - const renderMood: Mood = - mood === 'sleeping' && !companion.alwaysAsleep && !hasSleepClip ? 'idle' : mood + // An always-asleep mascot (the cat) stays pinned to one pose so its Lottie + // never reloads as the engine flips moods underneath it. + const renderMood: Mood = companion.alwaysAsleep + ? 'idle' + : mood === 'sleeping' && !hasSleepClip + ? 'idle' + : mood const napping = companion.alwaysAsleep || (mood === 'sleeping' && hasSleepClip) + // Never swap the cute companion for a bare emoji on an idle nap: fall back to + // the idle animation when a mood has no clip of its own, and only reach for + // the emoji if the companion ships no animations at all. + const animationData = companion.animations[renderMood] ?? companion.animations.idle + function choose(id: string) { setCompanionId(id) try { @@ -153,8 +163,8 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: aria-label="Choose a companion" className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`} style={{ - width: 128, - height: 128, + width: 144, + height: 144, padding: 0, borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)', @@ -167,9 +177,9 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: > {MOOD_EMOJI[renderMood]}} + animationData={animationData} + className="h-32 w-32" + fallback={{MOOD_EMOJI[renderMood]}} /> {napping && ( diff --git a/web/src/components/UpdateBanner/UpdateBanner.tsx b/web/src/components/UpdateBanner/UpdateBanner.tsx new file mode 100644 index 0000000..7c8d010 --- /dev/null +++ b/web/src/components/UpdateBanner/UpdateBanner.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react' + +// UpdateBanner gently floats down from the top when a newer build has been +// deployed, inviting a refresh. Mandarin-first copy (north star Note #17), soft +// rose styling, and a clear primary action. Dismissable โ€” if she dismisses it, +// the next deploy (or reload) will surface a fresh one. +export function UpdateBanner() { + const [dismissed, setDismissed] = useState(false) + if (dismissed) return null + + return ( +
+
+ + ๐ŸŒธ + +
+

+ ๆœ‰ๆ–ฐ็‰ˆๆœฌๅ•ฆ +

+

+ A new version is ready โ€” refresh to update. +

+
+ + +
+
+ ) +} diff --git a/web/src/hooks/useVersionWatch.ts b/web/src/hooks/useVersionWatch.ts new file mode 100644 index 0000000..f8b051b --- /dev/null +++ b/web/src/hooks/useVersionWatch.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '../api/client' + +// How often to ask the server whether a newer frontend has shipped. Gentle โ€” +// a deploy is rare, and the check is a tiny no-store GET. +const POLL_MS = 90_000 + +// useVersionWatch records the build id the app loaded with, then quietly polls +// /api/version. When the server reports a different id, a new version has been +// deployed and `updateAvailable` flips true (and stays true) so the UI can +// offer a refresh. Network blips are ignored โ€” it only ever reacts to a real, +// confirmed change. Returns false until the baseline is established. +export function useVersionWatch(): boolean { + const [updateAvailable, setUpdateAvailable] = useState(false) + // The version we're currently running. Null until the first successful fetch. + const baseline = useRef(null) + + useEffect(() => { + let active = true + + const check = async () => { + try { + const { version } = await api.version() + if (!active || !version) return + if (baseline.current === null) { + baseline.current = version // first read: this is "us" + } else if (version !== baseline.current) { + setUpdateAvailable(true) + } + } catch { + /* offline / server bounce โ€” try again next tick, never alarm the user */ + } + } + + check() + const id = setInterval(check, POLL_MS) + // Re-check the moment she returns to the tab, so a deploy that happened while + // she was away surfaces right away instead of up to POLL_MS later. + const onVisible = () => { + if (document.visibilityState === 'visible') check() + } + document.addEventListener('visibilitychange', onVisible) + + return () => { + active = false + clearInterval(id) + document.removeEventListener('visibilitychange', onVisible) + } + }, []) + + return updateAvailable +} diff --git a/web/src/index.css b/web/src/index.css index 73d6660..04222af 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -239,6 +239,15 @@ button, a, input { 100% { opacity: 0; transform: translateY(-14px) scale(1.1); } } +/* "New version available" banner โ€” drifts down from the top like a petal. */ +.petal-update { + animation: petal-drop 360ms cubic-bezier(0.2, 0.8, 0.3, 1.2) both; +} +@keyframes petal-drop { + from { opacity: 0; transform: translateY(-14px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + /* Blinking caret in the Ask Petal bubble while awaiting the first token. */ .petal-chat-caret { animation: petal-breathe 1s ease-in-out infinite;