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
+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(() => {