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:
2026-10-14 12:26:41 +07:00
36 changed files with 676 additions and 101 deletions
+4 -2
View File
@@ -22,7 +22,8 @@
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
"timeout": 5 "timeout": 5,
"async": true
} }
] ]
} }
@@ -34,7 +35,8 @@
{ {
"type": "command", "type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse", "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
"timeout": 5 "timeout": 5,
"async": true
} }
] ]
} }
+7 -1
View File
@@ -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, /** A moment's row: wave, jump, failed, waiting, review. It outranks the mood,
* and the user's own action outranks it. */ * and the user's own action outranks it. */
const MOMENT_ROW: Record<PetMoment, number> = { wave: 3, jump: 4, fail: 5, wait: 6, review: 8 }; 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({ export function PetSprite({
id, id,
@@ -102,13 +104,17 @@ export function PetSprite({
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1); const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own; let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
let i = 0; let i = 0;
let loops = 0;
let timer = 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 = () => { const tick = () => {
timer = window.setTimeout(() => { timer = window.setTimeout(() => {
if (++i === list.length) { if (++i === list.length) {
i = 0; i = 0;
if (MOMENTS.has(row)) list = play(0); if (MOMENTS.has(row)) list = play(0);
if (++loops >= SPRITE_LOOPS) return setCell(list[0]!);
} }
setCell(list[i]!); setCell(list[i]!);
tick(); tick();
+16 -12
View File
@@ -1,8 +1,12 @@
/** /**
* sagi.css - animations + mood expressions for the Sagi companion. * sagi.css - animations + mood expressions for the Sagi companion.
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale) * Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous * with warm pink accents (ears, cheeks, nose) for cuteness. No mood animates
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion. * 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 * Transform origins are the delicate part - see the comments on .sagi-avatar
* and .sagi-eye-blink for which reference box each one resolves against. * and .sagi-eye-blink for which reference box each one resolves against.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -206,20 +210,20 @@
/* idle: gentle breathing + occasional blink */ /* idle: gentle breathing + occasional blink */
.sagi-avatar[data-mood="idle"] { .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 /* 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 eye-tracking translate (which would freeze the eyes - see the CSS-blink
note in SagiAvatar.tsx). */ note in SagiAvatar.tsx). */
.sagi-avatar[data-mood="idle"] .sagi-eye-blink { .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 */ /* happy: whole-mascot bob + twinkling sparkle */
.sagi-avatar[data-mood="happy"] { .sagi-avatar[data-mood="happy"] {
animation: sagi-bob 0.5s ease-in-out 0s 4; animation: sagi-bob 0.5s ease-in-out 0s 4;
} }
.sagi-avatar[data-mood="happy"] .sagi-sparkle { .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 */ transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */
} }
/* worried: alarmed shake */ /* worried: alarmed shake */
@@ -228,19 +232,19 @@
} }
/* stuck: slow, drooping breathe (fatigued) */ /* stuck: slow, drooping breathe (fatigued) */
.sagi-avatar[data-mood="stuck"] { .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 */ /* thinking: rocking head tilt */
.sagi-avatar[data-mood="thinking"] { .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 */ /* watching: attentive sway while a session is live */
.sagi-avatar[data-mood="watching"] { .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 */ /* sleeping: slow breathe, droop */
.sagi-avatar[data-mood="sleeping"] { .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 /* ── 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; animation: sagi-peek 2.2s ease-in-out 0s 1;
} }
.sagi-avatar[data-moment="jump"] .sagi-sparkle { .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; transform-origin: 797px 274px;
} }
@keyframes sagi-hop { @keyframes sagi-hop {
@@ -417,7 +421,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 2px solid #0c0c14; 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. */ /* Sessions waiting on the user, when none errored: the same dot, amber. */
.sagi-error-dot[data-kind="waiting"] { .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 /* 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. */ agent works and slower asleep. A Codex spritesheet animates by its frames. */
.sagi-pet-image { .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; -webkit-user-drag: none;
} }
.sagi-pet-image[data-mood="thinking"] { .sagi-pet-image[data-mood="thinking"] {
+25 -4
View File
@@ -90,6 +90,17 @@ import { greetingKey, pickLine, pickQuip } from "./quips";
import { statusSummary } from "./intents"; import { statusSummary } from "./intents";
import { sagiPrefs } from "./prefs"; 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; const BUBBLE_MS = 4500;
// A bubble that names a session stays long enough to click it. // A bubble that names a session stays long enough to click it.
const SUBJECT_BUBBLE_MS = 7000; const SUBJECT_BUBBLE_MS = 7000;
@@ -283,8 +294,12 @@ export function useSagiBrain(): SagiBrain {
// Subscribe to the live stream and connection status. // Subscribe to the live stream and connection status.
useEffect(() => { useEffect(() => {
const unsubMsg = eventBus.subscribe((msg: WSMessage) => { const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
const { state: next, pulse, subject } = reduceSagi(stateRef.current, msg, Date.now()); const prev = stateRef.current;
update(() => next); 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); if (pulse) notify(pulse, subject);
}); });
const unsubConn = eventBus.onConnection((connected) => { const unsubConn = eventBus.onConnection((connected) => {
@@ -300,8 +315,12 @@ export function useSagiBrain(): SagiBrain {
// happy/worried) re-evaluate without needing a new event. // happy/worried) re-evaluate without needing a new event.
useEffect(() => { useEffect(() => {
// Hidden tab: no re-render; the first visible tick catches the mood up. // 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(() => { 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); }, 1000);
return () => clearInterval(id); 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 status = useMemo(() => statusOf(state), [state]);
const attention = useMemo(() => attentionOf(state), [state]); const attention = useMemo(() => attentionOf(state), [state]);
@@ -61,13 +61,15 @@
* *
* ----------------------------------------------------------------------------- */ * ----------------------------------------------------------------------------- */
import { useEffect, useState, useCallback, useRef } from "react"; import { memo, useEffect, useState, useCallback, useMemo, useRef } from "react";
import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react"; import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus"; import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents"; import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList, promptTokens } from "./MessageList"; import { MessageList, promptTokens } from "./MessageList";
import { useSpin } from "../../hooks/useSpin"; import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types"; import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
@@ -80,6 +82,10 @@ const POLL_INTERVAL_MS = 3000;
// Rescan the transcripts list periodically so new subagents that spawn // Rescan the transcripts list periodically so new subagents that spawn
// mid-session appear in the dropdown without a page reload. // mid-session appear in the dropdown without a page reload.
const TRANSCRIPTS_REFRESH_MS = 15000; const TRANSCRIPTS_REFRESH_MS = 15000;
// A hook burst (Pre + Post per tool call, several sessions) refetches the tail
// at most this often: the first event still fetches at once, the last is never
// lost, and a long transcript is not re-read for every single frame.
const EVENT_FETCH_MS = 1500;
interface ConversationViewProps { interface ConversationViewProps {
sessionId: string; sessionId: string;
@@ -106,7 +112,7 @@ interface ConversationViewProps {
onNotStarted?: (notStarted: boolean) => void; onNotStarted?: (notStarted: boolean) => void;
} }
export function ConversationView({ function ConversationViewPane({
sessionId, sessionId,
initialTranscriptId, initialTranscriptId,
fill = false, fill = false,
@@ -161,6 +167,10 @@ export function ConversationView({
// Bumped whenever the session or the selected transcript changes: a tail // Bumped whenever the session or the selected transcript changes: a tail
// fetch started for the previous transcript must not append to the new one. // fetch started for the previous transcript must not append to the new one.
const genRef = useRef(0); const genRef = useRef(0);
// A pane of another workspace or tab stays mounted but hidden: it neither
// polls nor follows events, and catches up once when it is shown again.
const onShowRef = useRef<() => void>(() => {});
const onScreen = useOnScreen(scrollContainerRef, () => onShowRef.current());
// Refresh-button spinner state - separate from initial `loading` so the // Refresh-button spinner state - separate from initial `loading` so the
// existing skeleton doesn't blink during a manual refresh. // existing skeleton doesn't blink during a manual refresh.
@@ -179,7 +189,7 @@ export function ConversationView({
} }
loadTranscripts(); loadTranscripts();
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
if (document.visibilityState === "visible") loadTranscripts(); if (isShown(onScreen)) loadTranscripts();
}, TRANSCRIPTS_REFRESH_MS); }, TRANSCRIPTS_REFRESH_MS);
return () => { return () => {
cancelled = true; cancelled = true;
@@ -299,24 +309,33 @@ export function ConversationView({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, selectedTranscript]); }, [sessionId, selectedTranscript]);
// WebSocket subscription: refetch on every new_event for this session. // WebSocket subscription: refetch on new_event for this session, throttled.
// Hook coverage isn't complete (a user-typed message fires no hook), so we // Hook coverage isn't complete (a user-typed message fires no hook), so we
// also poll below to catch what WS misses. // also poll below to catch what WS misses.
const fetchSoon = useMemo(
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
[fetchNewMessages]
);
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
useEffect(() => { useEffect(() => {
const unsubscribe = eventBus.subscribe((msg: WSMessage) => { const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
// Hidden tab: the visibility handler below catches up once on show. // Hidden tab or hidden pane: catching up once on show is enough.
if (document.visibilityState !== "visible") return; if (!isShown(onScreen)) return;
if (isRemoteDataRefreshMessage(msg)) { if (isRemoteDataRefreshMessage(msg)) {
fetchNewMessages(); fetchSoon();
return; return;
} }
if (msg.type !== "new_event") return; if (msg.type !== "new_event") return;
const data = msg.data as { session_id?: string }; const data = msg.data as { session_id?: string };
if (data.session_id !== sessionId) return; if (data.session_id !== sessionId) return;
fetchNewMessages(); fetchSoon();
}); });
return unsubscribe; return unsubscribe;
}, [sessionId, fetchNewMessages]); }, [sessionId, fetchSoon, onScreen]);
onShowRef.current = () => {
if (document.visibilityState === "visible") void fetchNewMessages();
};
// Resync on WebSocket reconnect: events that landed during a transient // Resync on WebSocket reconnect: events that landed during a transient
// disconnect are gone from the bus, but the JSONL still has them, so a // disconnect are gone from the bus, but the JSONL still has them, so a
@@ -337,7 +356,7 @@ export function ConversationView({
function start() { function start() {
if (interval !== null) return; if (interval !== null) return;
interval = window.setInterval(() => { interval = window.setInterval(() => {
if (document.visibilityState === "visible") fetchNewMessages(); if (isShown(onScreen)) fetchNewMessages();
}, POLL_INTERVAL_MS); }, POLL_INTERVAL_MS);
} }
function stop() { function stop() {
@@ -363,7 +382,7 @@ export function ConversationView({
stop(); stop();
document.removeEventListener("visibilitychange", onVisibility); document.removeEventListener("visibilitychange", onVisibility);
}; };
}, [fetchNewMessages]); }, [fetchNewMessages, onScreen]);
// Manual refresh - surfaces a control in the toolbar so users can force // Manual refresh - surfaces a control in the toolbar so users can force
// a sync without reloading the page. // a sync without reloading the page.
@@ -627,3 +646,7 @@ export function ConversationView({
</div> </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);
@@ -179,6 +179,17 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
return map; return map;
} }
type ResultMap = ReturnType<typeof buildToolResultMap>;
/** The map is rebuilt on every tail append, but a block's own result keeps its
* identity (old messages are kept, not copied): comparing just those lets a
* memoized row skip the re-render when nothing it shows changed. */
const sameResults = (blocks: TranscriptContent[], a: ResultMap, b: ResultMap) =>
a === b || blocks.every((bl) => !bl.id || a.get(bl.id) === b.get(bl.id));
const sameBlocks = (a: TranscriptContent[], b: TranscriptContent[]) =>
a === b || (a.length === b.length && a.every((bl, i) => bl === b[i]));
/** The whole prompt a message was answered from: `input_tokens` is only its /** The whole prompt a message was answered from: `input_tokens` is only its
* uncached tail (a few tokens once the cache is warm), the rest is cache. */ * uncached tail (a few tokens once the cache is warm), the rest is cache. */
export function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number { export function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
@@ -544,7 +555,7 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
/** Claude Desktop's end-of-turn card: the files the turn changed, each with /** Claude Desktop's end-of-turn card: the files the turn changed, each with
* the lines added and removed. A call still running or failed is left out. */ * the lines added and removed. A call still running or failed is left out. */
function EditedFiles({ function EditedFilesCard({
blocks, blocks,
toolResultMap, toolResultMap,
onOpenFile, onOpenFile,
@@ -614,8 +625,15 @@ function EditedFiles({
</div> </div>
); );
} }
const EditedFiles = memo(
EditedFilesCard,
(p, n) =>
p.onOpenFile === n.onOpenFile &&
sameBlocks(p.blocks, n.blocks) &&
sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
);
export function MessageList({ function MessageListView({
messages, messages,
loading, loading,
onOpenFile, onOpenFile,
@@ -870,8 +888,16 @@ function RunningStep({
const { t } = useTranslation("sessions"); const { t } = useTranslation("sessions");
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000); // A hidden tab or a hidden pane (another workspace, kept mounted) shows
// no clock: skip the re-render until it is on screen again.
const timer = window.setInterval(() => {
const el = boxRef.current;
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
return;
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, []); }, []);
const counted = useCountUp(tokens); const counted = useCountUp(tokens);
@@ -893,7 +919,7 @@ function RunningStep({
].filter(Boolean); ].filter(Boolean);
const amber = waiting || quiet; const amber = waiting || quiet;
return ( return (
<div data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted"> <div ref={boxRef} data-testid="chat-running-step" className="space-y-1 text-sm text-fg-muted">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{waiting ? ( {waiting ? (
<Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" /> <Hand className="h-3.5 w-3.5 flex-shrink-0 text-status-warning" />
@@ -1369,7 +1395,7 @@ function WorkStep({
/** What the assistant did between two replies as one line, opening onto /** What the assistant did between two replies as one line, opening onto
* one framed list of its steps. */ * one framed list of its steps. */
const WorkGroup = memo(function WorkGroup({ function WorkGroupLine({
blocks, blocks,
toolResultMap, toolResultMap,
}: { }: {
@@ -1391,11 +1417,16 @@ const WorkGroup = memo(function WorkGroup({
</Disclosure> </Disclosure>
</div> </div>
); );
}); }
const WorkGroup = memo(
WorkGroupLine,
(p, n) =>
sameBlocks(p.blocks, n.blocks) && sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
);
/** One user/assistant row. Memoized: a live tail append re-renders only the /** One user/assistant row. Memoized: a live tail append re-renders only the
* new rows; its thinking-block expansion is its own state, kept by key. */ * new rows; its thinking-block expansion is its own state, kept by key. */
const MessageRow = memo(function MessageRow({ function MessageRowView({
msg, msg,
toolResultMap, toolResultMap,
}: { }: {
@@ -1514,4 +1545,11 @@ const MessageRow = memo(function MessageRow({
</div> </div>
</div> </div>
); );
}); }
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);
@@ -0,0 +1,96 @@
/**
* @file ConversationView.hidden.test.tsx
* @description A chat kept mounted in a hidden pane (another workspace or
* tab, `display:none`) does not follow the live stream: a new_event for its
* session fetches nothing while its box is off screen, and showing it again
* catches the tail up once. On screen, a burst of events is throttled to one
* fetch at once plus one after the burst.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
import { api } from "../../../lib/api";
import { eventBus } from "../../../lib/eventBus";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
sessions: {
transcripts: vi.fn(async () => ({ transcripts: [] })),
transcript: vi.fn(async () => ({
messages: [
{
type: "user",
content: [{ type: "text", text: "hello" }],
timestamp: "2026-10-09T00:00:00Z",
},
],
total: 1,
has_more: false,
first_line: 1,
last_line: 1,
})),
},
},
};
});
let intersect: ((visible: boolean) => void) | null = null;
const original = globalThis.IntersectionObserver;
beforeEach(() => {
intersect = null;
// @ts-expect-error test stub
globalThis.IntersectionObserver = class {
constructor(cb: (e: { isIntersecting: boolean }[]) => void) {
intersect = (visible) => cb([{ isIntersecting: visible }]);
}
observe() {}
disconnect() {}
};
});
afterEach(() => {
globalThis.IntersectionObserver = original;
vi.useRealTimers();
vi.clearAllMocks();
});
const event = () =>
act(() => eventBus.publish({ type: "new_event", data: { session_id: "s1" } } as never));
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
describe("ConversationView in a hidden pane", () => {
it("fetches nothing while hidden and catches up once when shown", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const before = calls();
act(() => intersect?.(false));
event();
event();
expect(calls()).toBe(before);
act(() => intersect?.(true));
await act(async () => {});
expect(calls()).toBe(before + 1);
});
it("throttles a burst of events on screen", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
vi.useFakeTimers();
const before = calls();
event();
event();
event();
// The first fetches at once; the rest wait for the throttle window.
expect(calls()).toBe(before + 1);
await act(async () => {
await vi.advanceTimersByTimeAsync(1600);
});
expect(calls()).toBe(before + 2);
});
});
+21 -7
View File
@@ -16,7 +16,14 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useState, type ComponentType, type DragEvent, type ReactNode } from "react"; import {
useEffect,
useState,
type ComponentType,
type DragEvent,
type ReactNode,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink"; import { useSettingsHref } from "../../lib/settingsLink";
@@ -35,6 +42,8 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, type SessionOutline } from "../../lib/api"; import { api, type SessionOutline } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import { isShown } from "../../hooks/useOnScreen";
import { displayName, extOf } from "./ChatFilePreview"; import { displayName, extOf } from "./ChatFilePreview";
import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach"; import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach";
@@ -49,25 +58,30 @@ const EMPTY: SessionOutline = {
mcp: [], mcp: [],
}; };
/** The session's outline, re-read on a visibility-gated poll. */ /** The session's outline, re-read on a visibility-gated poll — skipped too
export function useSessionOutline(sessionId: string): SessionOutline { * while `onScreen` says the pane is hidden (another workspace or tab, kept
* mounted). An unchanged answer keeps the old object: no re-render. */
export function useSessionOutline(
sessionId: string,
onScreen?: RefObject<boolean>
): SessionOutline {
const [outline, setOutline] = useState<SessionOutline>(EMPTY); const [outline, setOutline] = useState<SessionOutline>(EMPTY);
useEffect(() => { useEffect(() => {
let live = true; let live = true;
const load = () => const load = () =>
api.sessions api.sessions
.outline(sessionId) .outline(sessionId)
.then((o) => live && setOutline({ ...EMPTY, ...o })) .then((o) => live && setOutline(keepIfSame<SessionOutline>({ ...EMPTY, ...o })))
.catch(() => {}); .catch(() => {});
void load(); void load();
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
if (document.visibilityState === "visible") void load(); if (onScreen ? isShown(onScreen) : document.visibilityState === "visible") void load();
}, POLL_MS); }, POLL_MS);
return () => { return () => {
live = false; live = false;
window.clearInterval(timer); window.clearInterval(timer);
}; };
}, [sessionId]); }, [sessionId, onScreen]);
return outline; return outline;
} }
@@ -271,7 +285,7 @@ export function ChatOutline({
</span> </span>
) : ( ) : (
<span <span
className={`h-4 w-4 flex-shrink-0 rounded-full border-2 ${task.status === "in_progress" ? "animate-pulse border-accent" : "border-border"}`} className={`h-4 w-4 flex-shrink-0 rounded-full border-2 ${task.status === "in_progress" ? "animate-pulse-dot border-accent" : "border-border"}`}
/> />
)} )}
<span <span
+10 -5
View File
@@ -20,7 +20,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertCircle, Plus } from "lucide-react"; import { AlertCircle, Plus } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
@@ -89,6 +89,13 @@ export function LaneConsolePane({
: null; : null;
const chat = useSessionChat(chatSession); const chat = useSessionChat(chatSession);
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool); const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
// Stable, so the memoized terminal is not re-rendered for a new closure.
const onRunExitRef = useRef(onRunExit);
onRunExitRef.current = onRunExit;
const onTerminalExit = useCallback(() => {
setHandle(null);
onRunExitRef.current?.();
}, []);
useEffect(() => { useEffect(() => {
onHasActiveRunChange?.(handle !== null); onHasActiveRunChange?.(handle !== null);
@@ -210,13 +217,11 @@ export function LaneConsolePane({
runId={handle.id} runId={handle.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")} wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={autoFocus && !(chatable && chat)} autoFocus={autoFocus && !(chatable && chat)}
covered={!!(chatable && chat)}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) — // The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// the tab bar moves on (another tab, or the home's prompt box) // the tab bar moves on (another tab, or the home's prompt box)
// rather than leaving a dead terminal the user has to click out of. // rather than leaving a dead terminal the user has to click out of.
onExit={() => { onExit={onTerminalExit}
setHandle(null);
onRunExit?.();
}}
/> />
</div> </div>
)} )}
+34 -14
View File
@@ -54,7 +54,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react"; import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
@@ -76,6 +76,7 @@ import {
type PaneMenu, type PaneMenu,
} from "../../lib/panePaste"; } from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh"; import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { openInChanges } from "../../lib/openInChanges"; import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
@@ -306,7 +307,10 @@ export function PaneMenuCard({
); );
} }
export function PaneChat({ /** Memoized: a lane_update re-renders the Workspace's pane tree, and every
* prop here is a primitive or the pane's own run handle — nothing in a
* frame about another lane reaches the chat. */
export const PaneChat = memo(function PaneChat({
run, run,
sessionId, sessionId,
cwd, cwd,
@@ -370,7 +374,9 @@ export function PaneChat({
return () => ro.disconnect(); return () => ro.disconnect();
}, []); }, []);
const [list, setList] = useState<OutlineList | null>(null); const [list, setList] = useState<OutlineList | null>(null);
const activity = useSessionOutline(sessionId); // Kept mounted in a hidden workspace or tab: its pollers skip until shown.
const onScreen = useOnScreen(rootRef);
const activity = useSessionOutline(sessionId, onScreen);
// Open by default only in a spanning pane, and only once there is an output. // Open by default only in a spanning pane, and only once there is an output.
const outlineOpen = outlineChoice ?? (panelOpen && activity.outputs.length > 0); const outlineOpen = outlineChoice ?? (panelOpen && activity.outputs.length > 0);
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId }; const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
@@ -421,9 +427,12 @@ export function PaneChat({
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next; return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
}); });
read(); read();
const timer = window.setInterval(read, 700); // Nobody can answer a menu in a hidden tab or pane: no screen scan then.
const timer = window.setInterval(() => {
if (isShown(onScreen)) read();
}, 700);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [runId]); }, [runId, onScreen]);
const asking = waiting || menu !== null; const asking = waiting || menu !== null;
// Gone until the pane redraws: no second press on a stale menu. // Gone until the pane redraws: no second press on a stale menu.
const choose = (index: number) => { const choose = (index: number) => {
@@ -451,10 +460,26 @@ export function PaneChat({
model: null, model: null,
}); });
const onContext = useCallback( const onContext = useCallback(
(tokens: number, model: string | null) => setContext({ tokens, model }), (tokens: number, model: string | null) =>
setContext((prev) =>
prev.tokens === tokens && prev.model === model ? prev : { tokens, model }
),
[] []
); );
// 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 // 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. // this folder? with "No, exit" preselected): an Enter from here answers it.
// Asked, not refused: whether the session row appears right after the // Asked, not refused: whether the session row appears right after the
@@ -503,15 +528,10 @@ export function PaneChat({
follow={follow} follow={follow}
waiting={asking} waiting={asking}
stopped={stops} stopped={stops}
onOpenTerminal={() => showTerminal(runId, sessionId)} onOpenTerminal={openTerminal}
onContext={onContext} onContext={onContext}
onNotStarted={setNotStarted} onNotStarted={setNotStarted}
onOpenFile={(path) => { onOpenFile={openFile}
// 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);
}}
/> />
{!outlineOpen && !preview && ( {!outlineOpen && !preview && (
<button <button
@@ -678,4 +698,4 @@ export function PaneChat({
)} )}
</div> </div>
); );
} });
+36 -6
View File
@@ -42,6 +42,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { import {
memo,
useEffect, useEffect,
useId, useId,
useMemo, useMemo,
@@ -61,7 +62,7 @@ import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme"; import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard"; 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 { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize"; import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
@@ -154,9 +155,22 @@ interface TerminalViewProps {
/** Take the keyboard when it appears (default). Off for a split pane that /** Take the keyboard when it appears (default). Off for a split pane that
* is not the focused one, or keys would land in whichever mounted last. */ * is not the focused one, or keys would land in whichever mounted last. */
autoFocus?: boolean; autoFocus?: boolean;
/** Another layer (the pane's chat) covers it. xterm stops drawing only when
* its IntersectionObserver says off screen, and a covered terminal is not —
* so it is moved out of the viewport with a transform, which keeps its
* layout size (no PTY resize; the chat still reads and answers its menus). */
covered?: boolean;
} }
export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: TerminalViewProps) { /** Memoized: the Workspace re-renders its pane tree on every lane_update, and
* a terminal only cares about its own props (its owner keeps `onExit` stable). */
export const TerminalView = memo(function TerminalView({
runId,
wsBaseUrl,
onExit,
autoFocus = true,
covered = false,
}: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// Read through a ref: an inline `onExit` from the parent changes identity on // Read through a ref: an inline `onExit` from the parent changes identity on
// every render, and as an effect dep it would tear the terminal down and // every render, and as an effect dep it would tear the terminal down and
@@ -648,16 +662,27 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data); if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
}, },
// The rows on screen, dim cells (a TUI's placeholder) left out. // 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; const term = termRef.current;
if (!term) return null; if (!term) return null;
const buf = term.buffer.active; 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 rows: string[] = [];
const reuse = buf.getNullCell();
for (let y = 0; y < term.rows; y++) { for (let y = 0; y < term.rows; y++) {
const line = buf.getLine(buf.baseY + y); const line = buf.getLine(buf.baseY + y);
let row = ""; let row = "";
for (let x = 0; line && x < line.length; x++) { 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. // Width 0: the right half of a wide character.
if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " "; if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " ";
} }
@@ -722,7 +747,12 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
onPasteCapture={onPaste} onPasteCapture={onPaste}
onFocus={() => notePaneFocus(runId)} onFocus={() => notePaneFocus(runId)}
> >
<div ref={containerRef} className="h-full w-full" data-testid="terminal-view" /> <div
ref={containerRef}
className="h-full w-full"
style={covered ? { transform: "translateX(-300vw)" } : undefined}
data-testid="terminal-view"
/>
<p id={hintId} className="sr-only"> <p id={hintId} className="sr-only">
{t("terminal.keyboardHint")} {t("terminal.keyboardHint")}
</p> </p>
@@ -823,4 +853,4 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
)} )}
</div> </div>
); );
} });
@@ -0,0 +1,56 @@
/**
* @file useOnScreen.test.tsx
* @description useOnScreen follows the IntersectionObserver: on screen by
* default (and always without an observer, as in jsdom), off once the element
* stops intersecting, and `onShow` runs only on the way back on screen.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render } from "@testing-library/react";
import { useRef } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useOnScreen, isShown } from "../useOnScreen";
const original = globalThis.IntersectionObserver;
afterEach(() => {
globalThis.IntersectionObserver = original;
});
let seen: { current: boolean | null } = { current: null };
function Probe({ onShow }: { onShow?: () => void }) {
const ref = useRef<HTMLDivElement>(null);
seen = useOnScreen(ref, onShow);
return <div ref={ref} />;
}
describe("useOnScreen", () => {
it("counts as on screen without an IntersectionObserver", () => {
// @ts-expect-error removed for the test
delete globalThis.IntersectionObserver;
render(<Probe />);
expect(seen.current).toBe(true);
expect(isShown(seen as never)).toBe(true);
});
it("follows the observer and calls onShow only when it comes back", () => {
let fire: (v: boolean) => void = () => {};
// @ts-expect-error test stub
globalThis.IntersectionObserver = class {
constructor(cb: (e: { isIntersecting: boolean }[]) => void) {
fire = (v) => cb([{ isIntersecting: v }]);
}
observe() {}
disconnect() {}
};
const onShow = vi.fn();
render(<Probe onShow={onShow} />);
act(() => fire(true));
expect(onShow).not.toHaveBeenCalled();
act(() => fire(false));
expect(seen.current).toBe(false);
expect(isShown(seen as never)).toBe(false);
act(() => fire(true));
expect(seen.current).toBe(true);
expect(onShow).toHaveBeenCalledTimes(1);
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* @file useOnScreen — whether an element is laid out on screen right now, for
* work that only matters while it can be seen. The Workspace keeps panes of
* other workspaces and other tabs MOUNTED but `display:none` (unmounting would
* drop a live terminal), so `document.visibilityState` alone keeps every hidden
* pane polling and re-rendering as if it were shown. An IntersectionObserver
* reports a `display:none` subtree as not intersecting; `onShow` runs when the
* element comes back, so a caller can catch up once instead of all the time.
* Without IntersectionObserver (jsdom, very old browsers) the element counts
* as always on screen — the behaviour before this hook existed.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, type RefObject } from "react";
export function useOnScreen(
ref: RefObject<Element | null>,
onShow?: () => void
): RefObject<boolean> {
const visible = useRef(true);
const showRef = useRef(onShow);
showRef.current = onShow;
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver((entries) => {
const now = entries[entries.length - 1]!.isIntersecting;
const was = visible.current;
visible.current = now;
if (now && !was) showRef.current?.();
});
io.observe(el);
return () => io.disconnect();
}, [ref]);
return visible;
}
/** True while the page is shown AND the element is on screen. */
export const isShown = (onScreen: RefObject<boolean>) =>
document.visibilityState === "visible" && onScreen.current !== false;
+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 { .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. /* The chat's live step, like Claude Code's: a light sweeping over the words —
Reduced motion stops it on the muted colour. */ 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 { .text-shimmer {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
@@ -547,7 +553,7 @@
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
animation: text-shimmer 2.5s linear infinite; animation: text-shimmer 2.5s linear 3;
} }
@keyframes text-shimmer { @keyframes text-shimmer {
@@ -24,6 +24,7 @@ import {
readRunMenu, readRunMenu,
registerPane, registerPane,
takeRunInput, takeRunInput,
MENU_CURSOR_ROW,
} from "../panePaste"; } from "../panePaste";
const RULE = "─".repeat(60); const RULE = "─".repeat(60);
@@ -311,3 +312,17 @@ const CODEX_APPROVAL = [
// "Press enter to confirm or esc to cancel", its dim cells left out. // "Press enter to confirm or esc to cancel", its dim cells left out.
" enteresc", " 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; paste: (text: string, submit?: boolean) => void;
visible: () => boolean; visible: () => boolean;
key?: (data: string) => void; 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>(); const panes = new Map<string, Pane>();
let lastFocused: string | null = null; let lastFocused: string | null = null;
@@ -39,7 +40,7 @@ export function registerPane(
paste: (text: string, submit?: boolean) => void, paste: (text: string, submit?: boolean) => void,
visible: () => boolean = () => true, visible: () => boolean = () => true,
key?: (data: string) => void, key?: (data: string) => void,
screen?: () => PaneScreen | null screen?: (menuOnly?: boolean) => PaneScreen | null
): () => void { ): () => void {
const entry = { paste, visible, key, screen }; const entry = { paste, visible, key, screen };
panes.set(runId, entry); panes.set(runId, entry);
@@ -175,6 +176,8 @@ export type PaneMenu = {
}; };
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/; 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 SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
const RULE_ROW = /^ *[─╌]{3,} *$/; const RULE_ROW = /^ *[─╌]{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/; const CHECK = /^\[([ ✔])\] *(.*)$/;
@@ -251,7 +254,9 @@ function typeRow(options: PaneMenuOption[]): number {
/** The menu `runId`'s visible pane shows now, or null. */ /** The menu `runId`'s visible pane shows now, or null. */
export function readRunMenu(runId: string): PaneMenu | null { export function readRunMenu(runId: string): PaneMenu | null {
const p = panes.get(runId); 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; 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 })}> <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"> <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> <div>
<p className="text-[10px] text-status-success font-medium"> <p className="text-[10px] text-status-success font-medium">
{t("health.wsActive")} {t("health.wsActive")}
+4 -1
View File
@@ -122,7 +122,10 @@ export default {
serif: ["ui-serif", "Cambria", "Times New Roman", "Times", "serif"], serif: ["ui-serif", "Cambria", "Times New Roman", "Times", "serif"],
}, },
animation: { animation: {
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite", // Status indicators pulse a few times and rest (never `infinite`: an
// always-on animation keeps the browser drawing 60 frames a second).
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) 3",
ping: "ping 1s cubic-bezier(0, 0, 0.2, 1) 4",
"fade-in": "fadeIn 0.3s ease-out", "fade-in": "fadeIn 0.3s ease-out",
"slide-up": "slideUp 0.3s ease-out", "slide-up": "slideUp 0.3s ease-out",
}, },
+1
View File
@@ -2118,6 +2118,7 @@ Response `Content-Type: text/plain; version=0.0.4; charset=utf-8`.
| `agenthub_build_info` | gauge | `version` | Always `1`; dashboard version rides on the label | | `agenthub_build_info` | gauge | `version` | Always `1`; dashboard version rides on the label |
| `agenthub_process_uptime_seconds` | gauge | — | Server process uptime | | `agenthub_process_uptime_seconds` | gauge | — | Server process uptime |
| `agenthub_process_resident_memory_bytes` | gauge | — | Server process RSS | | `agenthub_process_resident_memory_bytes` | gauge | — | Server process RSS |
| `agenthub_event_loop_delay_seconds` | gauge | `quantile` (`0.5`, `0.99`, `1` = max) | Event-loop delay over the last minute — how long requests, hooks and terminal echo waited (a laggy terminal shows here) |
| `agenthub_sessions` | gauge | `status` (`active`/`completed`/`error`/`abandoned`) | Sessions by status | | `agenthub_sessions` | gauge | `status` (`active`/`completed`/`error`/`abandoned`) | Sessions by status |
| `agenthub_agents` | gauge | `status` (`working`/`waiting`/`completed`/`error`) | Agents by status | | `agenthub_agents` | gauge | `status` (`working`/`waiting`/`completed`/`error`) | Agents by status |
| `agenthub_events_total` | counter | — | Total events recorded | | `agenthub_events_total` | counter | — | Total events recorded |
+2 -2
View File
@@ -182,7 +182,7 @@ npm run install-hooks
> (Escape hatch for running Claude Code *inside* the same container: > (Escape hatch for running Claude Code *inside* the same container:
> `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.) > `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.)
For each of the eight hook types it adds one entry to `~/.claude/settings.json` that runs the handler with the type as its argument; the five tool/turn hooks (`PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `Notification`) carry `"matcher": "*"`. Entries that are not AgentHub's are left alone. There is nothing else to install — Claude Code runs `scripts/hook-handler.js` directly. For each of the eight hook types it adds one entry to `~/.claude/settings.json` that runs the handler with the type as its argument; the five tool/turn hooks (`PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `Notification`) carry `"matcher": "*"`. `PreToolUse` and `PostToolUse` also carry `"async": true` (`ASYNC_HOOKS` in `scripts/install-hooks.js`, and the same in `.claude-plugin/plugin.json`): they fire around every tool call, and Claude Code waits for a synchronous hook before going on — each a Node start of 0.1–0.2 s, so ~0.4 s added to every tool call. The handler only reports (it prints nothing and decides nothing), so nothing is lost by not waiting; the rarer hooks stay synchronous so a turn's start and end reach the dashboard in order. Entries that are not AgentHub's are left alone. There is nothing else to install — Claude Code runs `scripts/hook-handler.js` directly.
```json ```json
{ {
@@ -191,7 +191,7 @@ For each of the eight hook types it adds one entry to `~/.claude/settings.json`
{ {
"matcher": "*", "matcher": "*",
"hooks": [ "hooks": [
{ "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" PreToolUse" } { "type": "command", "command": "node \"/path/to/agenthub/scripts/hook-handler.js\" PreToolUse", "async": true }
] ]
} }
], ],
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -24,7 +24,8 @@ as they are into an existing Grafana (Dashboards → New → Import).
## Metrics used ## Metrics used
`agenthub_up`, `agenthub_build_info{version}`, `agenthub_process_uptime_seconds`, `agenthub_up`, `agenthub_build_info{version}`, `agenthub_process_uptime_seconds`,
`agenthub_process_resident_memory_bytes`, `agenthub_sessions{status}`, `agenthub_process_resident_memory_bytes`, `agenthub_event_loop_delay_seconds{quantile}`,
`agenthub_sessions{status}`,
`agenthub_agents{status}`, `agenthub_events_total`, `agenthub_websocket_clients`, `agenthub_agents{status}`, `agenthub_events_total`, `agenthub_websocket_clients`,
`agenthub_remote_sources{enabled}`, `agenthub_tokens_total{kind}`. `agenthub_remote_sources{enabled}`, `agenthub_tokens_total{kind}`.
+3 -1
View File
@@ -797,7 +797,9 @@ What a new machine still needs by hand after it:
`PostToolUse` (both with `"matcher": "*"`), `Stop`, `SubagentStop`, `PostToolUse` (both with `"matcher": "*"`), `Stop`, `SubagentStop`,
`Notification`, `SessionStart`, `SessionEnd` and `UserPromptSubmit`, a `Notification`, `SessionStart`, `SessionEnd` and `UserPromptSubmit`, a
`command` hook `/opt/agenthub/libexec/member-hook <Event>` with `command` hook `/opt/agenthub/libexec/member-hook <Event>` with
`"timeout": 5`. `"timeout": 5` — and `"async": true` on `PreToolUse` and `PostToolUse`, so
Claude Code does not wait a Node start before and after every tool call.
Re-running `install-system` rewrites a managed file it wrote itself.
5. Any other daemon that must see every process (monitoring agents, for 5. Any other daemon that must see every process (monitoring agents, for
example) needs group `agenthub-proc`: `sudo usermod -aG agenthub-proc <it>`. example) needs group `agenthub-proc`: `sudo usermod -aG agenthub-proc <it>`.
6. A tool `agenthub server tools` reports as `ESHAREDLAYOUT` (a script in a 6. A tool `agenthub server tools` reports as `ESHAREDLAYOUT` (a script in a
+16 -1
View File
@@ -127,6 +127,12 @@ const HOOKS_WITH_MATCHER = ["PreToolUse", "PostToolUse", "Stop", "SubagentStop",
// tool-name matchers, hence the separate list. // tool-name matchers, hence the separate list.
const HOOKS_WITHOUT_MATCHER = ["SessionStart", "SessionEnd", "UserPromptSubmit"]; const HOOKS_WITHOUT_MATCHER = ["SessionStart", "SessionEnd", "UserPromptSubmit"];
const HOOK_TYPES = [...HOOKS_WITH_MATCHER, ...HOOKS_WITHOUT_MATCHER]; const HOOK_TYPES = [...HOOKS_WITH_MATCHER, ...HOOKS_WITHOUT_MATCHER];
// Fired around EVERY tool call, and Claude Code waits for a synchronous hook
// before it goes on — each one a Node start (0.1–0.2 s), so ~0.4 s added to
// every tool call. The handler only reports (it prints nothing, decides
// nothing), so these run in the background (`async`). The rarer events stay
// synchronous so a turn's start and end still reach the dashboard in order.
const ASYNC_HOOKS = new Set(["PreToolUse", "PostToolUse"]);
function makeHookEntry(hookType) { function makeHookEntry(hookType) {
const entry = { const entry = {
@@ -134,6 +140,7 @@ function makeHookEntry(hookType) {
{ {
type: "command", type: "command",
command: `node "${HOOK_HANDLER}" ${hookType}`, command: `node "${HOOK_HANDLER}" ${hookType}`,
...(ASYNC_HOOKS.has(hookType) && { async: true }),
}, },
], ],
}; };
@@ -247,4 +254,12 @@ if (require.main === module) {
// isOurEntry is also used by scripts/plugin-bootstrap.js to strip hook entries // isOurEntry is also used by scripts/plugin-bootstrap.js to strip hook entries
// a previous checkout install left behind (they would double-count events). // a previous checkout install left behind (they would double-count events).
module.exports = { HOOK_TYPES, installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks }; module.exports = {
ASYNC_HOOKS,
HOOK_TYPES,
installHooks,
isInsideContainer,
isOurEntry,
makeHookEntry,
pluginProvidesHooks,
};
+4 -1
View File
@@ -156,8 +156,11 @@ if [ -f "$MANAGED" ] && ! { [ -f "$MANAGED_MARK" ] && sha256sum -c --status "$MA
else else
"$NODE" -e ' "$NODE" -e '
const ev = ["PreToolUse","PostToolUse","Stop","SubagentStop","Notification","SessionStart","SessionEnd","UserPromptSubmit"]; const ev = ["PreToolUse","PostToolUse","Stop","SubagentStop","Notification","SessionStart","SessionEnd","UserPromptSubmit"];
// Per-tool-call hooks run in the background (async): a synchronous one
// makes Claude Code wait a Node start (0.1-0.2 s) before and after every call.
const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}), const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}),
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5 }] }]; hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5,
...(["PreToolUse","PostToolUse"].includes(e) ? { async: true } : {}) }] }];
process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n"); process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n");
' >"$MANAGED" ' >"$MANAGED"
chmod 0644 "$MANAGED" chmod 0644 "$MANAGED"
+40
View File
@@ -234,6 +234,46 @@ describe("TranscriptCache tokensByHour", () => {
}); });
}); });
describe("writeHourly skips unchanged hours", () => {
const { writeHourly, forgetHourly } = require("../lib/hourly-usage");
const bucket = (input) => ({
hour: "2026-10-05T08",
model: "claude-opus-4-1",
speed: "standard",
geo: "",
tier: "standard",
input,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cacheWrite1h: 0,
webSearch: 0,
webFetch: 0,
codeExec: 0,
});
const rows = () =>
db
.prepare("SELECT input_tokens FROM token_usage_hourly WHERE session_id = 'skip-hourly'")
.all()
.map((r) => r.input_tokens);
it("writes a changed hour, skips an unchanged one, and writes again once forgotten", () => {
session("skip-hourly", "2026-10-05T08:00:00.000Z");
writeHourly("skip-hourly", { a: bucket(5) });
assert.deepEqual(rows(), [5]);
writeHourly("skip-hourly", { a: bucket(9) });
assert.deepEqual(rows(), [9]);
// Deleted behind the writer's back: an unchanged bucket is not re-sent…
db.prepare("DELETE FROM token_usage_hourly WHERE session_id = 'skip-hourly'").run();
writeHourly("skip-hourly", { a: bucket(9) });
assert.deepEqual(rows(), []);
// …which is why every path that deletes hourly rows forgets them.
forgetHourly(["skip-hourly"]);
writeHourly("skip-hourly", { a: bucket(9) });
assert.deepEqual(rows(), [9]);
});
});
describe("session list cost", () => { describe("session list cost", () => {
it("equals the session's own cost, speed and server-tool fees included", async () => { it("equals the session's own cost, speed and server-tool fees included", async () => {
session("list-fast", "2026-10-01T00:00:00.000Z"); session("list-fast", "2026-10-01T00:00:00.000Z");
+13
View File
@@ -142,6 +142,19 @@ describe("install-hooks host-only guard (#193)", () => {
}); });
}); });
describe("async hooks", () => {
it("runs only the per-tool-call hooks in the background, in settings and the plugin alike", () => {
const { makeHookEntry, ASYNC_HOOKS } = require("../../scripts/install-hooks");
const plugin = require("../../.claude-plugin/plugin.json");
assert.deepEqual([...ASYNC_HOOKS].sort(), ["PostToolUse", "PreToolUse"]);
for (const type of HOOK_TYPES) {
const want = ASYNC_HOOKS.has(type) ? true : undefined;
assert.equal(makeHookEntry(type).hooks[0].async, want, `settings ${type}`);
assert.equal(plugin.hooks[type][0].hooks[0].async, want, `plugin ${type}`);
}
});
});
describe("HOOK_TYPES", () => { describe("HOOK_TYPES", () => {
it("is the event list the plugin ships, so the hook status checks every one", () => { it("is the event list the plugin ships, so the hook status checks every one", () => {
const exported = require("../../scripts/install-hooks").HOOK_TYPES; const exported = require("../../scripts/install-hooks").HOOK_TYPES;
+1
View File
@@ -93,6 +93,7 @@ describe("GET /api/metrics", () => {
"agenthub_build_info", "agenthub_build_info",
"agenthub_process_uptime_seconds", "agenthub_process_uptime_seconds",
"agenthub_process_resident_memory_bytes", "agenthub_process_resident_memory_bytes",
"agenthub_event_loop_delay_seconds",
"agenthub_sessions", "agenthub_sessions",
"agenthub_agents", "agenthub_agents",
"agenthub_events_total", "agenthub_events_total",
+15
View File
@@ -19,6 +19,21 @@ describe("tmux wrapper", () => {
assert.equal(tmux.hasSession("agenthub-lane-1"), true); assert.equal(tmux.hasSession("agenthub-lane-1"), true);
}); });
it("hasSession answers yes from a fresh list, and asks tmux for anything else", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args[0]);
if (args[0] === "list-sessions") return "agenthub-lane-1\nagenthub-lane-1-2\n";
throw Object.assign(new Error("no session"), { status: 1 });
});
tmux.listSessions("agenthub-lane-");
assert.equal(tmux.hasSession("agenthub-lane-1-2"), true);
assert.deepEqual(calls, ["list-sessions"]); // no has-session spawn
// Not listed: asked for real (a pane that just died is never trusted).
assert.equal(tmux.hasSession("agenthub-lane-1-3"), false);
assert.deepEqual(calls, ["list-sessions", "has-session"]);
});
it("hasSession returns false when execFileSync throws", () => { it("hasSession returns false when execFileSync throws", () => {
tmux.__setExecImpl(() => { tmux.__setExecImpl(() => {
const e = new Error("no such session"); const e = new Error("no such session");
+26 -5
View File
@@ -11,11 +11,25 @@ const { db, stmts } = require("../db");
const TranscriptCache = require("./transcript-cache"); const TranscriptCache = require("./transcript-cache");
const { longArgs } = require("./token-usage"); const { longArgs } = require("./token-usage");
/** Write every hour of one transcript parse (MAX per hour, see the statement). */ // What this process last wrote per session and hour bucket: a long session has
// one bucket per active hour per model, and every hook re-sent all of them.
// The upsert keeps the MAX, so skipping a bucket whose numbers did not move
// writes exactly the same rows. Bounded (oldest session dropped first); a miss
// only means one full write, never a lost one.
const WRITTEN_MAX = 500;
const written = new Map();
/** Write the hours of one transcript parse that changed (MAX per hour, see the statement). */
function writeHourly(sessionId, tokensByHour) { function writeHourly(sessionId, tokensByHour) {
// Runs every hour of the session on each hook — ~1 row per active hour per let seen = written.get(sessionId);
// model; the statement leaves a row that did not grow untouched (no write). if (seen) written.delete(sessionId);
for (const t of Object.values(tokensByHour || {})) { else seen = new Map();
written.set(sessionId, seen);
if (written.size > WRITTEN_MAX) written.delete(written.keys().next().value);
for (const [key, t] of Object.entries(tokensByHour || {})) {
const sig = JSON.stringify(t);
if (seen.get(key) === sig) continue;
seen.set(key, sig);
stmts.upsertHourlyUsage.run( stmts.upsertHourlyUsage.run(
sessionId, sessionId,
t.hour, t.hour,
@@ -73,4 +87,11 @@ function backfillHourly() {
}); });
} }
module.exports = { writeHourly, backfillHourly }; /** Forget what was written — for `ids`, or every session. Whatever deletes
* hourly rows calls it, or the next hooks would skip re-writing them. */
function forgetHourly(ids = null) {
if (!ids) return written.clear();
for (const id of ids) written.delete(id);
}
module.exports = { writeHourly, backfillHourly, forgetHourly };
+6 -1
View File
@@ -57,13 +57,18 @@ function readMode(runId) {
const fail = (code, message) => Object.assign(new Error(message), { code }); const fail = (code, message) => Object.assign(new Error(message), { code });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Between two reads of the pane. Each read is a SYNCHRONOUS capture-pane
// (through sudo for a member's pane) that holds up every other request and
// socket while it runs, so a mode change polls gently rather than 20×/s.
const POLL_MS = 150;
// The mode once it differs from `from` — or what the pane shows at the deadline. // The mode once it differs from `from` — or what the pane shows at the deadline.
async function nextMode(runId, from, waitMs) { async function nextMode(runId, from, waitMs) {
const until = Date.now() + waitMs; const until = Date.now() + waitMs;
for (;;) { for (;;) {
const now = readMode(runId); const now = readMode(runId);
if ((now && now !== from) || Date.now() >= until) return now; if ((now && now !== from) || Date.now() >= until) return now;
await sleep(50); await sleep(POLL_MS);
} }
} }
+14
View File
@@ -352,7 +352,21 @@ const session = (name) => (exact() ? `=${name}` : name);
const pane = (name) => (exact() ? `=${name}:` : name); const pane = (name) => (exact() ? `=${name}:` : name);
function hasSession(name) { function hasSession(name) {
// A fresh shared list that names it answers "yes" without a spawn: each
// synchronous one blocks every socket (terminal echo included) — 50-150 ms
// per psmux call on Windows — and a pane-token request, an AI-proxy call or
// a lane broadcast asks this every time. "No" is always asked for real, so a
// pane that just died is never trusted past the list's 1 s TTL.
const hit = listed.get("");
try { try {
if (
hit &&
!hit.err &&
Date.now() - hit.at < LIST_TTL_MS &&
hit.out.split("\n").includes(name) &&
!accountOf(name)
)
return true;
onServerOf(name, ["has-session", "-t", session(name)]); onServerOf(name, ["has-session", "-t", session(name)]);
return true; return true;
} catch { } catch {
+5 -1
View File
@@ -211,7 +211,11 @@ function featurePayload(feature) {
/** Push the current state of one lane to every connected client. */ /** Push the current state of one lane to every connected client. */
function broadcastLane(id) { function broadcastLane(id) {
const lane = lanesLib.getLane(id); const lane = lanesLib.getLane(id);
if (lane) broadcast("lane_update", { lane: payload(lane) }); if (!lane) return;
// The cached list-sessions (as GET / uses), not a synchronous has-session —
// through sudo for a member's pane — on every hook that moves a lane.
const live = lane.run_id ? tmux.liveSessions() : null;
broadcast("lane_update", { lane: payload(lane, live) });
} }
router.get("/", (req, res) => { router.get("/", (req, res) => {
+33
View File
@@ -29,6 +29,22 @@ const { calculateCost, dailyTokenRows } = require("./pricing");
const router = Router(); const router = Router();
// How long the event loop was held up — what an operator sees as a laggy
// terminal: every keystroke's echo waits for it. Sampled every 20 ms and reset
// each minute, so the gauges describe the last minute or so.
const LOOP_WINDOW_MS = 60_000;
const loopDelay = require("perf_hooks").monitorEventLoopDelay({ resolution: 20 });
loopDelay.enable();
let loopWindow = null; // the last full minute, reported until the next one ends
setInterval(() => {
loopWindow = {
p50: loopDelay.percentile(50),
p99: loopDelay.percentile(99),
max: loopDelay.max,
};
loopDelay.reset();
}, LOOP_WINDOW_MS).unref();
// Resolved once at load — the dashboard version, surfaced as a build-info label // Resolved once at load — the dashboard version, surfaced as a build-info label
// (the canonical Prometheus idiom for exposing a version string as a metric). // (the canonical Prometheus idiom for exposing a version string as a metric).
const APP_VERSION = (() => { const APP_VERSION = (() => {
@@ -100,6 +116,23 @@ router.get("/", (_req, res) => {
[{ value: process.memoryUsage().rss }] [{ value: process.memoryUsage().rss }]
); );
const loop = loopWindow || {
p50: loopDelay.percentile(50),
p99: loopDelay.percentile(99),
max: loopDelay.max,
};
appendMetric(
out,
"agenthub_event_loop_delay_seconds",
"Event-loop delay over the last minute (p50, p99, max): how long requests, hooks and terminal echo waited.",
"gauge",
[
{ labels: { quantile: "0.5" }, value: loop.p50 / 1e9 },
{ labels: { quantile: "0.99" }, value: loop.p99 / 1e9 },
{ labels: { quantile: "1" }, value: loop.max / 1e9 },
]
);
// Sessions by status. // Sessions by status.
const sessionCounts = new Map(stmts.sessionStatusCounts.all().map((r) => [r.status, r.count])); const sessionCounts = new Map(stmts.sessionStatusCounts.all().map((r) => [r.status, r.count]));
appendMetric( appendMetric(
+26 -8
View File
@@ -475,11 +475,31 @@ function sessionOutline(sessionId) {
}; };
} }
// Each open chat polls its outline every few seconds, and building it parses
// every tool event of the session: keep the last answer per session until its
// events change (a new id, or fewer rows after a cleanup).
const outlineStamp = db.prepare(
"SELECT MAX(id) AS max_id, COUNT(*) AS n FROM events WHERE session_id = ?"
);
const OUTLINE_CACHE_MAX = 100;
const outlineCache = new Map();
function cachedOutline(sessionId) {
const st = outlineStamp.get(sessionId);
const stamp = `${st.max_id}:${st.n}`;
const hit = outlineCache.get(sessionId);
if (hit && hit.stamp === stamp) return hit.body;
const body = sessionOutline(sessionId);
outlineCache.delete(sessionId);
outlineCache.set(sessionId, { stamp, body });
if (outlineCache.size > OUTLINE_CACHE_MAX) outlineCache.delete(outlineCache.keys().next().value);
return body;
}
router.get("/:id/outline", (req, res) => { router.get("/:id/outline", (req, res) => {
if (!stmts.getSession.get(req.params.id)) { if (!stmts.getSession.get(req.params.id)) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } }); return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
} }
res.json(sessionOutline(req.params.id)); res.json(cachedOutline(req.params.id));
}); });
// An image the session's own Read tool returned (`?path=`, exact), for the Chat // An image the session's own Read tool returned (`?path=`, exact), for the Chat
@@ -1187,10 +1207,13 @@ router.get("/:id/transcript", async (req, res) => {
} }
if (afterLine !== null) { if (afterLine !== null) {
// Incremental mode: skip lines until after afterLine, collect up to limit, then stop // Incremental mode: skip lines until after afterLine, collect up to limit, then stop.
let foundStart = false; // A line the client already has is skipped BEFORE it is parsed: every open
// chat tails its transcript every few seconds, and parsing the whole JSONL
// (MBs for a long session) each time was most of the server's steady CPU.
for await (const line of rl) { for await (const line of rl) {
lineNum++; lineNum++;
if (lineNum <= afterLine) continue;
if (!line.trim()) continue; if (!line.trim()) continue;
let entry; let entry;
try { try {
@@ -1200,11 +1223,6 @@ router.get("/:id/transcript", async (req, res) => {
} }
if (!renders(entry)) continue; if (!renders(entry)) continue;
if (!foundStart) {
if (lineNum <= afterLine) continue;
foundStart = true;
}
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum); const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
if (!message) continue; if (!message) continue;
total++; total++;
+3
View File
@@ -11,6 +11,7 @@ const v8 = require("v8");
const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../db"); const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../db");
const { getConnectionCount } = require("../websocket"); const { getConnectionCount } = require("../websocket");
const { transcriptCache } = require("./hooks"); const { transcriptCache } = require("./hooks");
const { forgetHourly } = require("../lib/hourly-usage");
const router = Router(); const router = Router();
@@ -179,6 +180,7 @@ router.post("/clear-data", (_req, res) => {
function clearAll() { function clearAll() {
db.prepare("DELETE FROM token_usage").run(); db.prepare("DELETE FROM token_usage").run();
db.prepare("DELETE FROM token_usage_hourly").run(); db.prepare("DELETE FROM token_usage_hourly").run();
forgetHourly();
db.prepare("DELETE FROM events").run(); db.prepare("DELETE FROM events").run();
db.prepare("DELETE FROM agents").run(); db.prepare("DELETE FROM agents").run();
db.prepare("DELETE FROM sessions").run(); db.prepare("DELETE FROM sessions").run();
@@ -782,6 +784,7 @@ router.post("/cleanup", (req, res) => {
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run( db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
...chunk ...chunk
); );
forgetHourly(chunk);
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...chunk); db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...chunk);
} }
result.purged_sessions = ids.length; result.purged_sessions = ids.length;