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
+21 -7
View File
@@ -16,7 +16,14 @@
* @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 { Link } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink";
@@ -35,6 +42,8 @@ import {
X,
} from "lucide-react";
import { api, type SessionOutline } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import { isShown } from "../../hooks/useOnScreen";
import { displayName, extOf } from "./ChatFilePreview";
import { dragAttachments, dragSkill, type Attachment } from "../../lib/promptAttach";
@@ -49,25 +58,30 @@ const EMPTY: SessionOutline = {
mcp: [],
};
/** The session's outline, re-read on a visibility-gated poll. */
export function useSessionOutline(sessionId: string): SessionOutline {
/** The session's outline, re-read on a visibility-gated poll — skipped too
* 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);
useEffect(() => {
let live = true;
const load = () =>
api.sessions
.outline(sessionId)
.then((o) => live && setOutline({ ...EMPTY, ...o }))
.then((o) => live && setOutline(keepIfSame<SessionOutline>({ ...EMPTY, ...o })))
.catch(() => {});
void load();
const timer = window.setInterval(() => {
if (document.visibilityState === "visible") void load();
if (onScreen ? isShown(onScreen) : document.visibilityState === "visible") void load();
}, POLL_MS);
return () => {
live = false;
window.clearInterval(timer);
};
}, [sessionId]);
}, [sessionId, onScreen]);
return outline;
}
@@ -271,7 +285,7 @@ export function ChatOutline({
</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
+10 -5
View File
@@ -20,7 +20,7 @@
* @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 { AlertCircle, Plus } from "lucide-react";
import { api } from "../../lib/api";
@@ -89,6 +89,13 @@ export function LaneConsolePane({
: null;
const chat = useSessionChat(chatSession);
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(() => {
onHasActiveRunChange?.(handle !== null);
@@ -210,13 +217,11 @@ export function LaneConsolePane({
runId={handle.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={autoFocus && !(chatable && chat)}
covered={!!(chatable && chat)}
// 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)
// rather than leaving a dead terminal the user has to click out of.
onExit={() => {
setHandle(null);
onRunExit?.();
}}
onExit={onTerminalExit}
/>
</div>
)}
+34 -14
View File
@@ -54,7 +54,7 @@
* @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 { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
@@ -76,6 +76,7 @@ import {
type PaneMenu,
} from "../../lib/panePaste";
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { openInChanges } from "../../lib/openInChanges";
import { toast } from "../../lib/toast";
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,
sessionId,
cwd,
@@ -370,7 +374,9 @@ export function PaneChat({
return () => ro.disconnect();
}, []);
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.
const outlineOpen = outlineChoice ?? (panelOpen && activity.outputs.length > 0);
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;
});
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);
}, [runId]);
}, [runId, onScreen]);
const asking = waiting || menu !== null;
// Gone until the pane redraws: no second press on a stale menu.
const choose = (index: number) => {
@@ -451,10 +460,26 @@ export function PaneChat({
model: null,
});
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
// this folder? with "No, exit" preselected): an Enter from here answers it.
// Asked, not refused: whether the session row appears right after the
@@ -503,15 +528,10 @@ export function PaneChat({
follow={follow}
waiting={asking}
stopped={stops}
onOpenTerminal={() => showTerminal(runId, sessionId)}
onOpenTerminal={openTerminal}
onContext={onContext}
onNotStarted={setNotStarted}
onOpenFile={(path) => {
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview.
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
setPreview(path);
}}
onOpenFile={openFile}
/>
{!outlineOpen && !preview && (
<button
@@ -678,4 +698,4 @@ export function PaneChat({
)}
</div>
);
}
});
+36 -6
View File
@@ -42,6 +42,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
memo,
useEffect,
useId,
useMemo,
@@ -61,7 +62,7 @@ import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
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
* is not the focused one, or keys would land in whichever mounted last. */
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);
// 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
@@ -648,16 +662,27 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
},
// The rows on screen, dim cells (a TUI's placeholder) left out.
() => {
// `menuOnly` (the chat's 700 ms menu poll): plain text first, and the
// cell-by-cell read only when a row could be a menu's cursor row.
(menuOnly = false) => {
const term = termRef.current;
if (!term) return null;
const buf = term.buffer.active;
if (menuOnly) {
let maybe = false;
for (let y = 0; y < term.rows && !maybe; y++)
maybe = MENU_CURSOR_ROW.test(
buf.getLine(buf.baseY + y)?.translateToString(true) ?? ""
);
if (!maybe) return null;
}
const rows: string[] = [];
const reuse = buf.getNullCell();
for (let y = 0; y < term.rows; y++) {
const line = buf.getLine(buf.baseY + y);
let row = "";
for (let x = 0; line && x < line.length; x++) {
const cell = line.getCell(x);
const cell = line.getCell(x, reuse);
// Width 0: the right half of a wide character.
if (cell && cell.getWidth() && !cell.isDim()) row += cell.getChars() || " ";
}
@@ -722,7 +747,12 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
onPasteCapture={onPaste}
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">
{t("terminal.keyboardHint")}
</p>
@@ -823,4 +853,4 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
)}
</div>
);
}
});