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:
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+2
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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++;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user