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:
Austin Pickett
2026-09-04 20:24:02 -04:00
committed by GitHub
parent f159e581c7
commit 04fd0172cd
8 changed files with 130 additions and 8 deletions
+31
View File
@@ -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);
});
});
+17
View File
@@ -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);
}
+18 -7
View File
@@ -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";