fix(dashboard): only open the chat PTY once the chat tab is active (#59551)

* fix(dashboard): only open the chat PTY once the chat tab is active

The dashboard mounts ChatPage persistently (hidden with CSS) on every route
so the embedded chat PTY survives tab switches. But the PTY-connect effect
never checked whether the chat tab was active, so it opened `/api/pty` on
mount for ANY dashboard page. On a source/RPi install that spawns the whole
TUI + agent bootstrap (`Installing TUI dependencies…` → `npm install`) merely
by loading /sessions, /system, etc. — work the user never asked for, and the
trigger behind "dashboard loses custom themes on /chat load".

Gate the connect effect on a sticky activation latch: the PTY is not spawned
until the chat tab has been active at least once, and stays connected across
later tab switches so the persistence UX is preserved.

* test(dashboard): cover chat PTY activation latch

Asserts the invariant behind the fix: activation is sticky. It stays false
while the chat tab has never been active (so the persistently-mounted,
hidden ChatPage never opens /api/pty), flips true when the tab activates,
and stays true after the user navigates away (PTY persistence).
This commit is contained in:
xxxigm 2026-07-18 11:50:13 +07:00 committed by GitHub
parent bf517f9301
commit 3bcd0c1b00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 69 additions and 1 deletions

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { latchChatActivation } from "./chat-activation";
describe("latchChatActivation", () => {
it("stays inactive while the chat tab has never been active", () => {
// A dashboard sitting on /sessions, /system, … must not flip the latch,
// so the persistently-mounted ChatPage never opens /api/pty (which would
// trigger the TUI/agent bootstrap on every page).
expect(latchChatActivation(false, false)).toBe(false);
});
it("activates when the chat tab becomes active", () => {
expect(latchChatActivation(false, true)).toBe(true);
});
it("stays activated after the chat tab is left (sticky / persistence)", () => {
// Once the user has opened /chat, the PTY must survive navigating away so
// a running agent turn is not torn down on every tab switch.
expect(latchChatActivation(true, false)).toBe(true);
});
it("stays activated while the chat tab remains active", () => {
expect(latchChatActivation(true, true)).toBe(true);
});
});

View file

@ -0,0 +1,17 @@
/**
* Chat PTY activation latch.
*
* The dashboard keeps `ChatPage` mounted persistently (just hidden with CSS)
* on every route so the embedded chat PTY survives tab switches. The downside
* is that the PTY-connect effect would otherwise open `/api/pty` which spawns
* the whole TUI + agent bootstrap (on a fresh checkout this prints
* `Installing TUI dependencies…` and runs `npm install`) the moment the
* dashboard loads *any* page, even one the user never navigates the chat into.
*
* The fix is to only open the PTY once the chat tab has actually been active,
* while keeping activation **sticky** so the PTY still persists across later
* tab switches. This helper computes that latch: once `true`, it stays `true`.
*/
export function latchChatActivation(previous: boolean, isActive: boolean): boolean {
return previous || isActive;
}

View file

@ -35,6 +35,7 @@ import { ChatSessionList } from "@/components/ChatSessionList";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { api } from "@/lib/api";
import { latchChatActivation } from "@/lib/chat-activation";
import { normalizeSessionTitle } from "@/lib/chat-title";
import {
PTY_CONNECTING_TIMEOUT_MS,
@ -160,6 +161,17 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// the moment `isActive` flips back to true (display:none → display:flex
// collapses the host's box, so ResizeObserver never fires on return).
const syncMetricsRef = useRef<(() => void) | null>(null);
// Sticky activation latch: the PTY-connect effect below must not open
// `/api/pty` until the chat tab has actually been active at least once.
// The dashboard mounts ChatPage persistently (hidden) on every route, so
// without this gate merely loading /sessions, /system, etc. would spawn the
// TUI/agent bootstrap (`Installing TUI dependencies…`). Latching keeps the
// PTY alive across later tab switches (the persistence UX) — once true it
// stays true.
const [hasActivated, setHasActivated] = useState(isActive);
useEffect(() => {
setHasActivated((prev) => latchChatActivation(prev, isActive));
}, [isActive]);
const [searchParams, setSearchParams] = useSearchParams();
// Lazy-init: the missing-token check happens at construction so the effect
// body doesn't have to setState (React 19's set-state-in-effect rule).
@ -447,6 +459,12 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
};
useEffect(() => {
// Don't spawn the chat PTY (and the TUI/agent bootstrap it triggers)
// until the chat tab has been activated. Prevents the persistently
// mounted, hidden ChatPage from opening `/api/pty` on every dashboard
// page. Sticky, so switching away from /chat keeps the PTY alive.
if (!hasActivated) return;
const host = hostRef.current;
if (!host) return;
@ -1165,7 +1183,14 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
reconnectTimerRef.current = null;
}
};
}, [channel, clearReconnectTimer, resumeParam, scopedProfile, reconnectNonce]);
}, [
hasActivated,
channel,
clearReconnectTimer,
resumeParam,
scopedProfile,
reconnectNonce,
]);
// When the user returns to the chat tab (isActive: false → true), the
// terminal host just transitioned from display:none to display:flex.