perf: rest animations, cheaper chat typing and server polls, async per-tool hooks

UI
- No animation runs forever: Sagi moods, the custom pet sprite, status dots
  (pulse-dot, pulse-slow, ping) and the chat shimmer play a few cycles and
  rest. An infinite animation kept the browser drawing 60 frames a second.
- useSagiBrain: frames that only move lastActivityAt by <1 s keep the old
  state; the 1 s tick re-renders only when the mood changes.
- ConversationView and MessageList are memoized and PaneChat passes stable
  callbacks: typing in the chat box no longer re-renders the conversation.
- The chat's 700 ms menu poll reads plain text first and scans the terminal
  cell by cell (reusing one cell) only when a row could be a menu cursor row.

Server
- GET /sessions/:id/transcript?after=N skips lines <= N before JSON.parse.
- GET /sessions/:id/outline is cached until the session's events change.
- broadcastLane uses the cached tmux.liveSessions(); tmux.hasSession answers
  "yes" from the fresh shared list instead of a synchronous spawn (50-150 ms
  per psmux call on Windows, blocking terminal echo).
- pane-mode polls every 150 ms; writeHourly writes only changed hour buckets.
- /api/metrics: agenthub_event_loop_delay_seconds{quantile}.

Hooks
- PreToolUse/PostToolUse run with "async": true (settings installer,
  plugin.json, S3 managed settings): Claude Code no longer waits a Node start
  before and after every tool call.

Committed with --no-verify at the owner's request: the pre-commit full server
suite fails on 9 tests that fail identically on 0c2f498 (lane-sync merge: no
git identity on this host; read-pool; resolveHost; codex-usage 1 ms flake).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 11:55:53 +07:00
parent c3f83d3922
commit 1c5521cf2e
31 changed files with 341 additions and 61 deletions
+7 -1
View File
@@ -58,6 +58,8 @@ const ROW: Record<Mood, number> = {
/** What the user is doing to the pet, which outranks its mood. */
export type PetAction = "run-left" | "run-right" | "wave";
const ACTION_ROW: Record<PetAction, number> = { "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();
+15 -11
View File
@@ -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;
}
/* ── 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"] {
+27 -3
View File
@@ -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(() => {
@@ -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({
</div>
);
}
/** 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);
@@ -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);
+15 -7
View File
@@ -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 && (
<button
+14 -3
View File
@@ -62,7 +62,7 @@ import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
@@ -649,16 +649,27 @@ export const TerminalView = memo(function TerminalView({
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
},
// The rows on screen, dim cells (a TUI's placeholder) left out.
() => {
// `menuOnly` (the chat's 700 ms menu poll): plain text first, and the
// cell-by-cell read only when a row could be a menu's cursor row.
(menuOnly = false) => {
const term = termRef.current;
if (!term) return null;
const buf = term.buffer.active;
if (menuOnly) {
let maybe = false;
for (let y = 0; y < term.rows && !maybe; y++)
maybe = MENU_CURSOR_ROW.test(
buf.getLine(buf.baseY + y)?.translateToString(true) ?? ""
);
if (!maybe) return null;
}
const rows: string[] = [];
const reuse = buf.getNullCell();
for (let y = 0; y < term.rows; y++) {
const line = buf.getLine(buf.baseY + y);
let row = "";
for (let x = 0; line && x < line.length; x++) {
const cell = line.getCell(x);
const cell = line.getCell(x, reuse);
// Width 0: the right half of a wide character.
if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " ";
}
+10 -4
View File
@@ -530,12 +530,18 @@
}
}
/* A few pulses when the dot appears, then it rests lit. Status dots sit in
always-visible chrome; an infinite animation there makes the browser draw a
frame 60 times a second for as long as the page is open, which made the
whole desktop stutter. A state change re-adds the class and pulses again. */
.animate-pulse-dot {
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation: pulse-dot 2s cubic-bezier(0.4, 0, 0.6, 1) 3;
}
/* The chat's live step, like Claude Code's: a light sweeping over the words.
Reduced motion stops it on the muted colour. */
/* The chat's live step, like Claude Code's: a light sweeping over the words —
a few sweeps, then it rests on the muted colour (a background-position
animation repaints the text every frame; the spinner beside it keeps saying
the step runs). Reduced motion stops it at once. */
.text-shimmer {
background: linear-gradient(
90deg,
@@ -547,7 +553,7 @@
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: text-shimmer 2.5s linear infinite;
animation: text-shimmer 2.5s linear 3;
}
@keyframes text-shimmer {
@@ -24,6 +24,7 @@ import {
readRunMenu,
registerPane,
takeRunInput,
MENU_CURSOR_ROW,
} from "../panePaste";
const RULE = "─".repeat(60);
@@ -311,3 +312,17 @@ const CODEX_APPROVAL = [
// "Press enter to confirm or esc to cancel", its dim cells left out.
" enteresc",
];
describe("MENU_CURSOR_ROW", () => {
// The chat's 700 ms poll reads the screen cell by cell only when a row
// matches it, so every menu parsePaneMenu finds must have such a row.
it("matches a row of every menu, and no row of an idle prompt", () => {
const has = (rows: string[]) => rows.some((r) => MENU_CURSOR_ROW.test(r));
for (const menu of [ASK, MULTI, PERMISSION, CODEX_APPROVAL]) {
expect(parsePaneMenu(menu)).not.toBeNull();
expect(has(menu)).toBe(true);
}
expect(has(CLAUDE)).toBe(false);
expect(has(CODEX)).toBe(false);
});
});
+8 -3
View File
@@ -27,7 +27,8 @@ interface Pane {
paste: (text: string, submit?: boolean) => void;
visible: () => boolean;
key?: (data: string) => void;
screen?: () => PaneScreen | null;
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
screen?: (menuOnly?: boolean) => PaneScreen | null;
}
const panes = new Map<string, Pane>();
let lastFocused: string | null = null;
@@ -39,7 +40,7 @@ export function registerPane(
paste: (text: string, submit?: boolean) => void,
visible: () => boolean = () => true,
key?: (data: string) => void,
screen?: () => PaneScreen | null
screen?: (menuOnly?: boolean) => PaneScreen | null
): () => void {
const entry = { paste, visible, key, screen };
panes.set(runId, entry);
@@ -175,6 +176,8 @@ export type PaneMenu = {
};
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
/** A row a menu's cursor sits on (a numbered option or Submit): a menu has one. */
export const MENU_CURSOR_ROW = /^ *[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)/;
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
const RULE_ROW = /^ *[─╌]{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/;
@@ -251,7 +254,9 @@ function typeRow(options: PaneMenuOption[]): number {
/** The menu `runId`'s visible pane shows now, or null. */
export function readRunMenu(runId: string): PaneMenu | null {
const p = panes.get(runId);
const screen = p?.visible() && p.key ? p.screen?.() : null;
// Polled every 700 ms while a chat shows: a screen with no cursor row is
// answered from plain text, without reading it cell by cell.
const screen = p?.visible() && p.key ? p.screen?.(true) : null;
return screen ? parsePaneMenu(screen.rows) : null;
}
+1 -1
View File
@@ -2664,7 +2664,7 @@ function SystemHealthTab() {
<Tip block raw={t("health.wsTip", { count: info.server.ws_connections })}>
<div className="flex items-center gap-3 bg-status-success/5 px-3 py-2.5 rounded-lg border border-status-success/10 cursor-default">
<Activity className="w-3.5 h-3.5 text-status-success animate-pulse" />
<Activity className="w-3.5 h-3.5 text-status-success animate-pulse-dot" />
<div>
<p className="text-[10px] text-status-success font-medium">
{t("health.wsActive")}