Merge branch 'perf/lag': dashboard lag fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # client/src/components/Sagi/PetSprite.tsx # client/src/components/Sagi/useSagiBrain.ts
This commit is contained in:
@@ -63,6 +63,8 @@ const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, w
|
||||
/** A moment's row: wave, jump, failed, waiting, review. It outranks the mood,
|
||||
* and the user's own action outranks it. */
|
||||
const MOMENT_ROW: Record<PetMoment, number> = { wave: 3, jump: 4, fail: 5, wait: 6, review: 8 };
|
||||
/** Loops of a row before the sprite rests on its first frame. */
|
||||
const SPRITE_LOOPS = 4;
|
||||
|
||||
export function PetSprite({
|
||||
id,
|
||||
@@ -102,13 +104,17 @@ export function PetSprite({
|
||||
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
|
||||
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
|
||||
let i = 0;
|
||||
let loops = 0;
|
||||
let timer = 0;
|
||||
// `i` always indexes `list`: it wraps at its end.
|
||||
// `i` always indexes `list`: it wraps at its end. A few loops, then the
|
||||
// first frame rests — a sprite that never stops re-paints the page several
|
||||
// times a second for as long as it is open; a new mood or action plays again.
|
||||
const tick = () => {
|
||||
timer = window.setTimeout(() => {
|
||||
if (++i === list.length) {
|
||||
i = 0;
|
||||
if (MOMENTS.has(row)) list = play(0);
|
||||
if (++loops >= SPRITE_LOOPS) return setCell(list[0]!);
|
||||
}
|
||||
setCell(list[i]!);
|
||||
tick();
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* sagi.css - animations + mood expressions for the Sagi companion.
|
||||
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
|
||||
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous
|
||||
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion.
|
||||
* with warm pink accents (ears, cheeks, nose) for cuteness. No mood animates
|
||||
* forever: each plays a few cycles when the mood begins and then rests — an
|
||||
* infinite animation makes the browser draw a new frame 60 times a second for
|
||||
* as long as the page is open (the drop-shadow filter re-rendered each time),
|
||||
* which made the whole desktop stutter. All motion is disabled under
|
||||
* [data-reduced="1"] and prefers-reduced-motion.
|
||||
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
||||
* and .sagi-eye-blink for which reference box each one resolves against.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -206,20 +210,20 @@
|
||||
|
||||
/* idle: gentle breathing + occasional blink */
|
||||
.sagi-avatar[data-mood="idle"] {
|
||||
animation: sagi-breathe 4s ease-in-out infinite;
|
||||
animation: sagi-breathe 4s ease-in-out 0s 3;
|
||||
}
|
||||
/* Blink lives on the inner group so it never overrides the outer group's
|
||||
eye-tracking translate (which would freeze the eyes - see the CSS-blink
|
||||
note in SagiAvatar.tsx). */
|
||||
.sagi-avatar[data-mood="idle"] .sagi-eye-blink {
|
||||
animation: sagi-blink 5s ease-in-out infinite;
|
||||
animation: sagi-blink 5s ease-in-out 0s 3;
|
||||
}
|
||||
/* happy: whole-mascot bob + twinkling sparkle */
|
||||
.sagi-avatar[data-mood="happy"] {
|
||||
animation: sagi-bob 0.5s ease-in-out 0s 4;
|
||||
}
|
||||
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
|
||||
transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */
|
||||
}
|
||||
/* worried: alarmed shake */
|
||||
@@ -228,19 +232,19 @@
|
||||
}
|
||||
/* stuck: slow, drooping breathe (fatigued) */
|
||||
.sagi-avatar[data-mood="stuck"] {
|
||||
animation: sagi-droop 2.4s ease-in-out infinite;
|
||||
animation: sagi-droop 2.4s ease-in-out 0s 3;
|
||||
}
|
||||
/* thinking: rocking head tilt */
|
||||
.sagi-avatar[data-mood="thinking"] {
|
||||
animation: sagi-rock 1.8s ease-in-out infinite;
|
||||
animation: sagi-rock 1.8s ease-in-out 0s 4;
|
||||
}
|
||||
/* watching: attentive sway while a session is live */
|
||||
.sagi-avatar[data-mood="watching"] {
|
||||
animation: sagi-sway 3.2s ease-in-out infinite;
|
||||
animation: sagi-sway 3.2s ease-in-out 0s 3;
|
||||
}
|
||||
/* sleeping: slow breathe, droop */
|
||||
.sagi-avatar[data-mood="sleeping"] {
|
||||
animation: sagi-breathe 5s ease-in-out infinite;
|
||||
animation: sagi-breathe 5s ease-in-out 0s 3;
|
||||
}
|
||||
|
||||
/* ── moments: one-shot reactions to an event or a pat (useSagiBrain), played
|
||||
@@ -267,7 +271,7 @@
|
||||
animation: sagi-peek 2.2s ease-in-out 0s 1;
|
||||
}
|
||||
.sagi-avatar[data-moment="jump"] .sagi-sparkle {
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
|
||||
transform-origin: 797px 274px;
|
||||
}
|
||||
@keyframes sagi-hop {
|
||||
@@ -417,7 +421,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #0c0c14;
|
||||
animation: sagi-error-pulse 1.2s ease-in-out infinite;
|
||||
animation: sagi-error-pulse 1.2s ease-in-out 0s 5;
|
||||
}
|
||||
/* Sessions waiting on the user, when none errored: the same dot, amber. */
|
||||
.sagi-error-dot[data-kind="waiting"] {
|
||||
@@ -496,7 +500,7 @@
|
||||
/* An uploaded still image has no frames of its own: it bobs, faster while an
|
||||
agent works and slower asleep. A Codex spritesheet animates by its frames. */
|
||||
.sagi-pet-image {
|
||||
animation: sagi-pet-bob 2.6s ease-in-out infinite;
|
||||
animation: sagi-pet-bob 2.6s ease-in-out 0s 3;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.sagi-pet-image[data-mood="thinking"] {
|
||||
|
||||
@@ -90,6 +90,17 @@ import { greetingKey, pickLine, pickQuip } from "./quips";
|
||||
import { statusSummary } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
|
||||
/** `next` differs from `prev` only by a lastActivityAt less than a second
|
||||
* newer — nothing the mascot shows, at the moods' 1 s resolution. */
|
||||
function onlyActivityMoved(prev: SagiState, next: SagiState): boolean {
|
||||
if (next === prev) return true;
|
||||
if (next.lastActivityAt - prev.lastActivityAt >= 1000) return false;
|
||||
for (const k of Object.keys(next) as (keyof SagiState)[]) {
|
||||
if (k !== "lastActivityAt" && next[k] !== prev[k]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const BUBBLE_MS = 4500;
|
||||
// A bubble that names a session stays long enough to click it.
|
||||
const SUBJECT_BUBBLE_MS = 7000;
|
||||
@@ -283,8 +294,12 @@ export function useSagiBrain(): SagiBrain {
|
||||
// Subscribe to the live stream and connection status.
|
||||
useEffect(() => {
|
||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||
const { state: next, pulse, subject } = reduceSagi(stateRef.current, msg, Date.now());
|
||||
update(() => next);
|
||||
const prev = stateRef.current;
|
||||
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
||||
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
||||
// resolution: keep the old state then, so a hook burst (several frames a
|
||||
// second) does not re-render the mascot on every frame.
|
||||
if (!onlyActivityMoved(prev, next)) update(() => next);
|
||||
if (pulse) notify(pulse, subject);
|
||||
});
|
||||
const unsubConn = eventBus.onConnection((connected) => {
|
||||
@@ -300,8 +315,12 @@ export function useSagiBrain(): SagiBrain {
|
||||
// happy/worried) re-evaluate without needing a new event.
|
||||
useEffect(() => {
|
||||
// Hidden tab: no re-render; the first visible tick catches the mood up.
|
||||
// Only a tick that changes the mood re-renders: an idle mascot is not
|
||||
// drawn again every second for nothing.
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") setTick(Date.now());
|
||||
if (document.visibilityState !== "visible") return;
|
||||
const now = Date.now();
|
||||
if (deriveMood(stateRef.current, now) !== moodRef.current) setTick(now);
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
@@ -315,7 +334,9 @@ export function useSagiBrain(): SagiBrain {
|
||||
[]
|
||||
);
|
||||
|
||||
const mood = useMemo(() => deriveMood(state, tick), [state, tick]);
|
||||
// Judged at render time: `tick` only moves when the mood would change, so
|
||||
// it may be old when a frame re-renders.
|
||||
const mood = useMemo(() => deriveMood(state, Math.max(tick, Date.now())), [state, tick]);
|
||||
const status = useMemo(() => statusOf(state), [state]);
|
||||
const attention = useMemo(() => attentionOf(state), [state]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user