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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user