perf(chat): hidden panes stop polling, rows stop re-rendering, a covered terminal stops drawing
The Workspace keeps panes of other workspaces and tabs mounted (display:none), and every one of them kept following the live stream as if shown. - useOnScreen (IntersectionObserver): ConversationView, the outline and the 700 ms menu scan skip while their pane is hidden and catch up once on show. - A new_event refetches the tail at most every 1.5 s (leading + trailing). - MessageList: WorkGroup/MessageRow/EditedFiles compare only their own blocks and results, so a tail append no longer re-renders every row. - A terminal under the chat is moved off the viewport with a transform, so xterm pauses drawing without a resize. - PaneChat and TerminalView are memoized; LaneConsolePane passes a stable onExit. - RunningStep's clock and the outline poll skip unchanged or hidden updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,13 +61,15 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { 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;
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,6 +625,13 @@ 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({
|
export function MessageList({
|
||||||
messages,
|
messages,
|
||||||
@@ -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,8 @@ 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)
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 @@ 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,7 +460,10 @@ 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 }
|
||||||
|
),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -678,4 +690,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,
|
||||||
@@ -153,9 +154,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
|
||||||
@@ -709,7 +723,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>
|
||||||
@@ -810,4 +829,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;
|
||||||
Reference in New Issue
Block a user