fix(dashboard): stop the embedded TUI repainting on every OS app-switch (#103165)
* feat(pty_bridge): mark the dashboard-spawned TUI with HERMES_PTY_HOST Ink needs to know when its emulator is the dashboard's xterm.js rather than a native terminal, so it can drop hidden-tab recovery work that only makes sense for emulators that coalesce output. Co-authored-by: Raymond <supere989@users.noreply.github.com> * fix(tui): skip the focus-in erase+repaint under the dashboard PTY Ink answers a DECSET 1004 focus-in with a full clear+repaint to heal rows a native emulator may have dropped while the tab was hidden. xterm.js fed by the dashboard WebSocket never drops frames, and it reports focus on every OS window blur/focus, so under the dashboard that repaint was a visible "session reloaded" flash on every alt-tab. Keep the mode re-assert and keep delivering the focus report to TerminalFocusProvider (the composer hides its cursor on blur); only the repaint is skipped. Co-authored-by: Raymond <supere989@users.noreply.github.com> * fix(web): restore terminal focus after an OS app-switch Alt-tabbing away and back lands browser focus on <body>, so Ctrl+V never reached the composer. Pull focus back into xterm on window focus under the same ownership rule tab activation already uses, extracted into shouldRestoreTerminalFocus so both paths share it. Co-authored-by: Raymond <supere989@users.noreply.github.com> --------- Co-authored-by: Raymond <supere989@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldRestoreTerminalFocus } from "./pty-focus";
|
||||
|
||||
describe("shouldRestoreTerminalFocus", () => {
|
||||
const body = document.body;
|
||||
const host = document.createElement("div");
|
||||
const textarea = document.createElement("textarea");
|
||||
const sidebarInput = document.createElement("input");
|
||||
host.appendChild(textarea);
|
||||
body.appendChild(host);
|
||||
body.appendChild(sidebarInput);
|
||||
|
||||
it("restores when nothing holds focus (OS app-switch drops focus onto <body>)", () => {
|
||||
expect(shouldRestoreTerminalFocus(null, host)).toBe(true);
|
||||
expect(shouldRestoreTerminalFocus(body, host)).toBe(true);
|
||||
});
|
||||
|
||||
it("restores when the terminal itself already had focus", () => {
|
||||
expect(shouldRestoreTerminalFocus(textarea, host)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not steal focus from another control on the page", () => {
|
||||
expect(shouldRestoreTerminalFocus(sidebarInput, host)).toBe(false);
|
||||
});
|
||||
|
||||
it("restores when the terminal host is not mounted yet", () => {
|
||||
expect(shouldRestoreTerminalFocus(sidebarInput, null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// Whether ChatPage may pull keyboard focus back into the xterm textarea.
|
||||
//
|
||||
// Only when nothing else on the page is holding focus: `document.activeElement`
|
||||
// is null / <body> (first activation after mount, or a return from another OS
|
||||
// app or browser tab where focus fell back to <body>), or already inside the
|
||||
// terminal host. If the user had clicked into the sidebar (model picker,
|
||||
// tool-call entry) we must not yank focus away from wherever they left it —
|
||||
// that's a surprise and an a11y foot-gun.
|
||||
export function shouldRestoreTerminalFocus(
|
||||
active: Element | null,
|
||||
host: Element | null,
|
||||
): boolean {
|
||||
if (active === null || active === document.body || host === null) {
|
||||
return true;
|
||||
}
|
||||
return host.contains(active);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { latchChatActivation } from "@/lib/chat-activation";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { normalizeSessionTitle } from "@/lib/chat-title";
|
||||
import { createPtyCompositionForwarder } from "@/lib/pty-composition";
|
||||
import { shouldRestoreTerminalFocus } from "@/lib/pty-focus";
|
||||
import { PtyResumeSanitizer } from "@/lib/pty-resume-sanitizer";
|
||||
import {
|
||||
PTY_CONNECTING_TIMEOUT_MS,
|
||||
@@ -1610,16 +1611,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
raf2 = requestAnimationFrame(() => {
|
||||
raf2 = 0;
|
||||
syncMetricsRef.current?.();
|
||||
const host = hostRef.current;
|
||||
const active = typeof document !== "undefined"
|
||||
? document.activeElement
|
||||
: null;
|
||||
const focusIsElsewhereInChatPage =
|
||||
active !== null &&
|
||||
active !== document.body &&
|
||||
host !== null &&
|
||||
!host.contains(active);
|
||||
if (!focusIsElsewhereInChatPage) {
|
||||
if (shouldRestoreTerminalFocus(active, hostRef.current)) {
|
||||
termRef.current?.focus();
|
||||
}
|
||||
});
|
||||
@@ -1630,6 +1625,22 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
};
|
||||
}, [isActive]);
|
||||
|
||||
// Returning from another OS app (alt-tab to copy text, then back) lands
|
||||
// browser focus on <body>, not on the xterm textarea, so the next Ctrl+V
|
||||
// goes nowhere. Pull focus back into the terminal under the same
|
||||
// ownership rule as tab activation above. This listener must not touch
|
||||
// the PTY connection — the resume/reconnect path is separate.
|
||||
useEffect(() => {
|
||||
if (!isActive || typeof window === "undefined") return;
|
||||
const onWindowFocus = () => {
|
||||
if (shouldRestoreTerminalFocus(document.activeElement, hostRef.current)) {
|
||||
termRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("focus", onWindowFocus);
|
||||
return () => window.removeEventListener("focus", onWindowFocus);
|
||||
}, [isActive]);
|
||||
|
||||
const maybeReconnectOnPageResume = useCallback(() => {
|
||||
const visibilityState =
|
||||
typeof document !== "undefined" ? document.visibilityState : "visible";
|
||||
|
||||
Reference in New Issue
Block a user