diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 354e9c9c..6cff9a7b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -22,7 +22,8 @@ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse", - "timeout": 5 + "timeout": 5, + "async": true } ] } @@ -34,7 +35,8 @@ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse", - "timeout": 5 + "timeout": 5, + "async": true } ] } diff --git a/client/src/components/Sagi/PetSprite.tsx b/client/src/components/Sagi/PetSprite.tsx index 95b1c5f1..38e74ce4 100644 --- a/client/src/components/Sagi/PetSprite.tsx +++ b/client/src/components/Sagi/PetSprite.tsx @@ -58,6 +58,8 @@ const ROW: Record = { /** What the user is doing to the pet, which outranks its mood. */ export type PetAction = "run-left" | "run-right" | "wave"; const ACTION_ROW: Record = { "run-right": 1, "run-left": 2, wave: 3 }; +/** Loops of a row before the sprite rests on its first frame. */ +const SPRITE_LOOPS = 4; export function PetSprite({ id, @@ -95,13 +97,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(); diff --git a/client/src/components/Sagi/sagi.css b/client/src/components/Sagi/sagi.css index a728f7c1..36609679 100644 --- a/client/src/components/Sagi/sagi.css +++ b/client/src/components/Sagi/sagi.css @@ -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ĩ @@ -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; } /* ── reduced motion: kill all continuous animation ── */ @@ -339,7 +343,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; } .sagi-bubble { max-width: 16rem; @@ -375,7 +379,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"] { diff --git a/client/src/components/Sagi/useSagiBrain.ts b/client/src/components/Sagi/useSagiBrain.ts index e34ac9bd..37ff132f 100644 --- a/client/src/components/Sagi/useSagiBrain.ts +++ b/client/src/components/Sagi/useSagiBrain.ts @@ -77,6 +77,17 @@ import { import { pickQuip } from "./quips"; 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; // Minimum gap between non-error bubbles, so a burst of activity doesn't spam. const BUBBLE_THROTTLE_MS = 3000; @@ -153,7 +164,10 @@ export function useSagiBrain(): SagiBrain { setState((prev) => { const { state: next, pulse } = reduceSagi(prev, msg, t); if (pulse) showBubble(pickQuip(pulse), pulse === "error"); - return next; + // 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. + return onlyActivityMoved(prev, next) ? prev : next; }); }); const unsubConn = eventBus.onConnection((connected) => { @@ -169,15 +183,25 @@ 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); }, []); useEffect(() => () => clearTimeout(bubbleTimer.current), []); - 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 stateRef = useRef(state); + stateRef.current = state; + const moodRef = useRef(mood); + moodRef.current = mood; const status = useMemo(() => statusOf(state), [state]); const dismissBubble = useCallback(() => { diff --git a/client/src/components/conversation/ConversationView.tsx b/client/src/components/conversation/ConversationView.tsx index 81c6cdf6..2df66a35 100644 --- a/client/src/components/conversation/ConversationView.tsx +++ b/client/src/components/conversation/ConversationView.tsx @@ -61,7 +61,7 @@ * * ----------------------------------------------------------------------------- */ -import { useEffect, useState, useCallback, useMemo, useRef } from "react"; +import { memo, useEffect, useState, useCallback, useMemo, useRef } from "react"; import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react"; import { api } from "../../lib/api"; import { eventBus } from "../../lib/eventBus"; @@ -112,7 +112,7 @@ interface ConversationViewProps { onNotStarted?: (notStarted: boolean) => void; } -export function ConversationView({ +function ConversationViewPane({ sessionId, initialTranscriptId, fill = false, @@ -646,3 +646,7 @@ export function ConversationView({ ); } + +/** Memoized: a chat's prompt box lives in its parent (PaneChat), and every + * keystroke there re-rendered the whole conversation. */ +export const ConversationView = memo(ConversationViewPane); diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx index 8919ffde..813802e5 100644 --- a/client/src/components/conversation/MessageList.tsx +++ b/client/src/components/conversation/MessageList.tsx @@ -633,7 +633,7 @@ const EditedFiles = memo( sameResults(n.blocks, p.toolResultMap, n.toolResultMap) ); -export function MessageList({ +function MessageListView({ messages, loading, onOpenFile, @@ -1550,3 +1550,6 @@ const MessageRow = memo( MessageRowView, (p, n) => p.msg === n.msg && sameResults(n.msg.content, p.toolResultMap, n.toolResultMap) ); + +/** Memoized: its owner re-renders on scroll state and its parent's typing. */ +export const MessageList = memo(MessageListView); diff --git a/client/src/components/run/PaneChat.tsx b/client/src/components/run/PaneChat.tsx index b10f7062..aa638b67 100644 --- a/client/src/components/run/PaneChat.tsx +++ b/client/src/components/run/PaneChat.tsx @@ -467,6 +467,19 @@ export const PaneChat = memo(function PaneChat({ [] ); + // Stable, so typing in the prompt box does not re-render the conversation + // (ConversationView is memoized). + const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]); + const openFile = useCallback( + (path: string) => { + // The workspace's Changes panel when the file is in its folder; + // else this view's own preview. + if (laneId !== null && openInChanges(laneId, cwd, path)) return; + setPreview(path); + }, + [laneId, cwd] + ); + // Before the session starts the CLI may be asking in the terminal (trust // this folder? with "No, exit" preselected): an Enter from here answers it. // Asked, not refused: whether the session row appears right after the @@ -515,15 +528,10 @@ export const PaneChat = memo(function PaneChat({ follow={follow} waiting={asking} stopped={stops} - onOpenTerminal={() => showTerminal(runId, sessionId)} + onOpenTerminal={openTerminal} onContext={onContext} onNotStarted={setNotStarted} - onOpenFile={(path) => { - // The workspace's Changes panel when the file is in its folder; - // else this view's own preview. - if (laneId !== null && openInChanges(laneId, cwd, path)) return; - setPreview(path); - }} + onOpenFile={openFile} /> {!outlineOpen && !preview && (