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
+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() || " ";
}