fix(dashboard): recover mobile chat reconnect

This commit is contained in:
Shannon Sands 2026-07-09 15:50:34 +10:00 committed by kshitij
parent a4ba8c9640
commit 0b2b08d54c
6 changed files with 542 additions and 22 deletions

View file

@ -382,7 +382,7 @@ export function ChatSidebar({
onClick={reconnect}
prefix={<RefreshCw />}
>
reconnect
reconnect tools feed
</Button>
)}
</div>

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
normalizePtyMobileInput,
shouldTreatInputAsMobileReplacement,
updatePtyInputLine,
} from "./pty-mobile-input";
describe("shouldTreatInputAsMobileReplacement", () => {
it("recognizes explicit browser replacement input", () => {
expect(shouldTreatInputAsMobileReplacement("insertReplacementText", "Kain", false)).toBe(true);
expect(shouldTreatInputAsMobileReplacement("insertFromComposition", "Kain", false)).toBe(true);
expect(shouldTreatInputAsMobileReplacement("insertCompositionText", "Kain", false)).toBe(true);
});
it("treats multi-character mobile insertText as replacement-like", () => {
expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", true)).toBe(true);
expect(shouldTreatInputAsMobileReplacement("insertText", "K", true)).toBe(false);
expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", false)).toBe(false);
});
});
describe("normalizePtyMobileInput", () => {
it("turns a Gboard full-line suggestion into a line replacement", () => {
const result = normalizePtyMobileInput(
"hello my name is Kain Kain",
"hello my name is kain",
true,
);
expect(result.normalized).toBe(true);
expect(result.nextLine).toBe("hello my name is Kain");
expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain");
});
it("turns a Gboard last-word suggestion into a last-word replacement", () => {
const result = normalizePtyMobileInput("Kain", "hello my name is kain", true);
expect(result.normalized).toBe(true);
expect(result.nextLine).toBe("hello my name is Kain");
expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain");
});
it("does not normalize ordinary appends when replacement is not active", () => {
const result = normalizePtyMobileInput(
"hello my name is Kain Kain",
"hello my name is kain",
false,
);
expect(result.normalized).toBe(false);
expect(result.nextLine).toBe("hello my name is kainhello my name is Kain Kain");
});
it("does not normalize control input", () => {
const result = normalizePtyMobileInput("\r", "hello", true);
expect(result.normalized).toBe(false);
expect(result.nextLine).toBe("");
expect(result.data).toBe("\r");
});
});
describe("updatePtyInputLine", () => {
it("tracks printable text, delete, and submit", () => {
expect(updatePtyInputLine("", "abc")).toBe("abc");
expect(updatePtyInputLine("abc", "\x7f")).toBe("ab");
expect(updatePtyInputLine("abc", "\r")).toBe("");
});
});

View file

@ -0,0 +1,115 @@
const DELETE = "\x7f";
function chars(text: string): string[] {
return Array.from(text);
}
function removeLastChar(text: string): string {
const c = chars(text);
c.pop();
return c.join("");
}
function isPlainText(data: string): boolean {
return !/[\x00-\x1f\x7f]/.test(data);
}
function lastWordMatch(line: string): RegExpMatchArray | null {
return line.match(/^(.*?)(\S+)(\s*)$/u);
}
function collapseDuplicatedFinalWord(text: string, previousLine: string): string {
const match = text.match(/^(.*?)(\S+)(\s+)(\S+)(\s*)$/u);
if (!match) return text;
const [, prefix, first, , second, trailing] = match;
if (first.toLocaleLowerCase() !== second.toLocaleLowerCase()) return text;
if (!previousLine.trimEnd().toLocaleLowerCase().endsWith(first.toLocaleLowerCase())) {
return text;
}
return `${prefix}${first}${trailing}`;
}
function replacementLineForMobileInput(
currentLine: string,
incoming: string,
): string | null {
if (!currentLine || currentLine.length < 2 || !incoming) return null;
const currentLower = currentLine.toLocaleLowerCase();
const incomingLower = incoming.toLocaleLowerCase();
if (incomingLower.startsWith(currentLower)) {
return collapseDuplicatedFinalWord(incoming, currentLine);
}
const word = lastWordMatch(currentLine);
if (!word) return null;
const [, prefix, last, trailing] = word;
if (trailing) return null;
const incomingFirst = incoming.trimStart().split(/\s+/u)[0] ?? "";
if (
incomingFirst &&
incomingFirst.toLocaleLowerCase() === last.toLocaleLowerCase()
) {
return `${prefix}${collapseDuplicatedFinalWord(incoming, currentLine)}`;
}
return null;
}
export function shouldTreatInputAsMobileReplacement(
inputType: string | undefined,
data: string | null | undefined,
isMobileLike: boolean,
): boolean {
if (
inputType === "insertReplacementText" ||
inputType === "insertFromComposition" ||
inputType === "insertCompositionText"
) {
return true;
}
return isMobileLike && inputType === "insertText" && (data?.length ?? 0) > 1;
}
export function updatePtyInputLine(currentLine: string, data: string): string {
let next = currentLine;
for (const ch of chars(data)) {
if (ch === "\r" || ch === "\n") {
next = "";
} else if (ch === DELETE || ch === "\b") {
next = removeLastChar(next);
} else if (ch === "\x15") {
next = "";
} else if (isPlainText(ch)) {
next += ch;
}
}
return next;
}
export function normalizePtyMobileInput(
data: string,
currentLine: string,
replacementActive: boolean,
): { data: string; nextLine: string; normalized: boolean } {
if (replacementActive && isPlainText(data)) {
const replacementLine = replacementLineForMobileInput(currentLine, data);
if (replacementLine !== null) {
return {
data: DELETE.repeat(chars(currentLine).length) + replacementLine,
nextLine: replacementLine,
normalized: true,
};
}
}
return {
data,
nextLine: updatePtyInputLine(currentLine, data),
normalized: false,
};
}

View file

@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
shouldBlockPtyInput,
shouldReconnectPtyOnPageResume,
} from "./pty-reconnect";
describe("shouldReconnectPtyOnPageResume", () => {
it("reconnects a missing socket when the active page becomes visible", () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
online: true,
socketReadyState: null,
ptyState: "reconnecting",
}),
).toBe(true);
});
it("reconnects closed or closing sockets on visible resume", () => {
for (const socketReadyState of [2, 3]) {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
online: true,
socketReadyState,
ptyState: "reconnecting",
}),
).toBe(true);
}
});
it("does not reconnect an open socket on visible resume", () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
online: true,
socketReadyState: 1,
ptyState: "open",
}),
).toBe(false);
});
it("reconnects a still-connecting socket when the page is already in reconnecting state", () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
online: true,
socketReadyState: 0,
ptyState: "reconnecting",
}),
).toBe(true);
});
it("does not reconnect while the page is hidden", () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "hidden",
online: true,
socketReadyState: 3,
ptyState: "reconnecting",
}),
).toBe(false);
});
it("defers reconnect while offline", () => {
expect(
shouldReconnectPtyOnPageResume({
isActive: true,
visibilityState: "visible",
online: false,
socketReadyState: 3,
ptyState: "reconnecting",
}),
).toBe(false);
});
});
describe("shouldBlockPtyInput", () => {
it("allows input only while the PTY socket is open", () => {
expect(shouldBlockPtyInput("open")).toBe(false);
expect(shouldBlockPtyInput("connecting")).toBe(true);
expect(shouldBlockPtyInput("reconnecting")).toBe(true);
expect(shouldBlockPtyInput("closed")).toBe(true);
expect(shouldBlockPtyInput("ended")).toBe(true);
});
});

View file

@ -0,0 +1,60 @@
export type PtyConnectionState =
| "connecting"
| "open"
| "reconnecting"
| "closed"
| "ended";
export const PTY_RECONNECT_INPUT_MESSAGE =
"Chat is reconnecting. Input will resume when connected.";
export interface PtyResumeReconnectInput {
isActive: boolean;
visibilityState?: DocumentVisibilityState;
online: boolean;
socketReadyState?: number | null;
ptyState: PtyConnectionState;
}
const WS_CONNECTING = 0;
const WS_OPEN = 1;
const WS_CLOSING = 2;
const WS_CLOSED = 3;
export function shouldReconnectPtyOnPageResume({
isActive,
visibilityState,
online,
socketReadyState,
ptyState,
}: PtyResumeReconnectInput): boolean {
if (!isActive || !online || visibilityState === "hidden") {
return false;
}
if (ptyState === "ended") {
return false;
}
if (socketReadyState === WS_OPEN) {
return false;
}
if (
socketReadyState === WS_CONNECTING &&
ptyState !== "reconnecting" &&
ptyState !== "closed"
) {
return false;
}
return (
socketReadyState === null ||
socketReadyState === undefined ||
socketReadyState === WS_CONNECTING ||
socketReadyState === WS_CLOSING ||
socketReadyState === WS_CLOSED ||
ptyState === "reconnecting" ||
ptyState === "closed"
);
}
export function shouldBlockPtyInput(ptyState: PtyConnectionState): boolean {
return ptyState !== "open";
}

View file

@ -36,6 +36,16 @@ import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { api } from "@/lib/api";
import { normalizeSessionTitle } from "@/lib/chat-title";
import {
PTY_RECONNECT_INPUT_MESSAGE,
type PtyConnectionState,
shouldBlockPtyInput,
shouldReconnectPtyOnPageResume,
} from "@/lib/pty-reconnect";
import {
normalizePtyMobileInput,
shouldTreatInputAsMobileReplacement,
} from "@/lib/pty-mobile-input";
import { PluginSlot } from "@/plugins";
import { useTheme } from "@/themes";
import { useProfileScope } from "@/contexts/useProfileScope";
@ -160,27 +170,53 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectAttemptRef = useRef(0);
const forceFreshPtyRef = useRef(false);
const blockedInputNoticeRef = useRef(false);
const lastResumeReconnectAtRef = useRef(0);
const ptyInputLineRef = useRef("");
const mobileReplacementInputUntilRef = useRef(0);
const [ptyState, setPtyState] =
useState<PtyConnectionState>("connecting");
const ptyStateRef = useRef<PtyConnectionState>("connecting");
const [lastCloseCode, setLastCloseCode] = useState<number | null>(null);
// NS-504: when the agent process exits cleanly (the user typed `/exit`, or
// started a new session that ended the current PTY child), the PTY socket
// closes with a normal code. Before this fix the terminal just printed
// "[session ended]" and went dead — the only recovery was a full page
// refresh. `sessionEnded` flips on that clean close and renders an explicit
// "Start new session" affordance; clicking it bumps `reconnectNonce`, which
// is a dependency of the connect effect, so a fresh PTY spawns in place.
const [sessionEnded, setSessionEnded] = useState(false);
// refresh. `ptyState === "ended"` renders an explicit "Start new session"
// affordance; clicking it bumps `reconnectNonce`, which is a dependency of
// the connect effect, so a fresh PTY spawns in place.
const [reconnectNonce, setReconnectNonce] = useState(0);
useEffect(() => {
ptyStateRef.current = ptyState;
}, [ptyState]);
const clearReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}, []);
const reconnect = useCallback(() => {
const reconnectPty = useCallback(() => {
forceFreshPtyRef.current = false;
reconnectAttemptRef.current = 0;
clearReconnectTimer();
blockedInputNoticeRef.current = false;
ptyInputLineRef.current = "";
mobileReplacementInputUntilRef.current = 0;
setBanner(null);
setLastCloseCode(null);
setPtyState("connecting");
setReconnectNonce((n) => n + 1);
}, [clearReconnectTimer]);
const startFreshPty = useCallback(() => {
forceFreshPtyRef.current = true;
reconnectAttemptRef.current = 0;
clearReconnectTimer();
setSessionEnded(false);
blockedInputNoticeRef.current = false;
ptyInputLineRef.current = "";
mobileReplacementInputUntilRef.current = 0;
setBanner(null);
setLastCloseCode(null);
setPtyState("connecting");
setReconnectNonce((n) => n + 1);
}, [clearReconnectTimer]);
const startFreshDashboardChat = useCallback(() => {
@ -190,9 +226,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
forceFreshPtyRef.current = true;
reconnectAttemptRef.current = 0;
clearReconnectTimer();
blockedInputNoticeRef.current = false;
ptyInputLineRef.current = "";
mobileReplacementInputUntilRef.current = 0;
setSearchParams(next, { replace: true });
setSessionEnded(false);
setBanner(null);
setLastCloseCode(null);
setPtyState("connecting");
setReconnectNonce((n) => n + 1);
}, [clearReconnectTimer, searchParams, setSearchParams]);
// Raw state for the mobile side-sheet + a derived value that force-
@ -556,8 +596,43 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
term.loadAddon(new WebLinksAddon());
let mobileInputCleanup: (() => void) | null = null;
term.open(host);
const textarea = term.textarea;
if (textarea) {
textarea.setAttribute("autocomplete", "off");
textarea.setAttribute("autocorrect", "off");
textarea.setAttribute("autocapitalize", "off");
textarea.setAttribute("spellcheck", "false");
const isMobileLike =
typeof navigator !== "undefined" &&
/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent);
const markReplacementInput = (ev: Event) => {
const input = ev as InputEvent;
if (
shouldTreatInputAsMobileReplacement(
input.inputType,
input.data,
isMobileLike,
)
) {
mobileReplacementInputUntilRef.current = Date.now() + 350;
}
};
const markCompositionEnd = () => {
mobileReplacementInputUntilRef.current = Date.now() + 350;
};
textarea.addEventListener("beforeinput", markReplacementInput, true);
textarea.addEventListener("compositionend", markCompositionEnd, true);
mobileInputCleanup = () => {
textarea.removeEventListener("beforeinput", markReplacementInput, true);
textarea.removeEventListener("compositionend", markCompositionEnd, true);
};
}
// WebGL draws from a texture atlas sized with device pixels. On phones and
// in DevTools device mode that often produces *visually* much larger cells
// than `fontSize` suggests — users see "huge" text even at 79px settings.
@ -693,10 +768,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const attempt = Math.min(reconnectAttemptRef.current + 1, 5);
reconnectAttemptRef.current = attempt;
const delayMs = Math.min(250 * 2 ** (attempt - 1), 3000);
setSessionEnded(false);
setBanner(
`Chat connection interrupted (code ${code}). Reconnecting…`,
);
setBanner(null);
setLastCloseCode(code);
setPtyState("reconnecting");
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null;
setReconnectNonce((n) => n + 1);
@ -724,7 +798,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
clearReconnectTimer();
reconnectAttemptRef.current = 0;
setBanner(null);
setSessionEnded(false);
setLastCloseCode(null);
setPtyState("open");
blockedInputNoticeRef.current = false;
// Connected — cancel any pending reconnect from a prior transient drop.
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
@ -775,7 +851,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// pty_ws in web_server.py); echo it verbatim alongside the close code.
const why = ev.reason ? ` reason=${ev.reason}` : "";
console.warn(`[chat] PTY WebSocket closed code=${ev.code}${why}`);
setLastCloseCode(ev.code);
if (ev.code === 4401) {
setPtyState("closed");
setBanner(
ev.reason
? `Auth failed (${ev.reason}). Reload to refresh the session.`
@ -785,6 +863,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
}
if (ev.code === 4403) {
// Host/Origin mismatch (DNS-rebinding guard).
setPtyState("closed");
setBanner(
ev.reason
? `Refused: ${ev.reason}.`
@ -793,6 +872,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
return;
}
if (ev.code === 4404) {
setPtyState("closed");
setBanner(
ev.reason
? `Chat websocket unavailable: ${ev.reason}.`
@ -801,6 +881,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
return;
}
if (ev.code === 4408) {
setPtyState("closed");
setBanner(
ev.reason
? `Refused: ${ev.reason}.`
@ -810,6 +891,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
}
if (ev.code === 1011) {
// Server already wrote an ANSI error frame.
setPtyState("closed");
return;
}
// Keep-alive close-code contract (web_server.pty_ws + pty_session):
@ -817,10 +899,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// 4409 = superseded by a newer tab attaching the same token → stay quiet.
if (ev.code === 4410) {
term.write(`\r\n\x1b[90m[session ended]\x1b[0m\r\n`);
setSessionEnded(true);
setPtyState("ended");
return;
}
if (ev.code === 4409) {
setPtyState("closed");
return;
}
if (!ev.wasClean || ev.code === 1001 || ev.code === 1006) {
@ -837,7 +920,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
term.write(
`\r\n\x1b[90m[session ended (code ${ev.code})]\x1b[0m\r\n`,
);
setSessionEnded(true);
setPtyState("ended");
};
// Keystrokes → PTY.
@ -857,13 +940,33 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
onDataDisposable = term.onData((data) => {
if (ws.readyState !== WebSocket.OPEN) return;
if (
ws.readyState !== WebSocket.OPEN ||
shouldBlockPtyInput(ptyStateRef.current)
) {
if (!blockedInputNoticeRef.current) {
blockedInputNoticeRef.current = true;
term.write(
`\r\n\x1b[33m[${PTY_RECONNECT_INPUT_MESSAGE}]\x1b[0m\r\n`,
);
}
return;
}
if (SGR_MOUSE_RE.test(data)) {
return;
}
ws.send(data);
const normalized = normalizePtyMobileInput(
data,
ptyInputLineRef.current,
Date.now() <= mobileReplacementInputUntilRef.current,
);
ptyInputLineRef.current = normalized.nextLine;
if (normalized.normalized) {
mobileReplacementInputUntilRef.current = 0;
}
ws.send(normalized.data);
});
onResizeDisposable = term.onResize(({ cols, rows }) => {
@ -880,6 +983,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
syncMetricsRef.current = null;
onDataDisposable?.dispose();
onResizeDisposable?.dispose();
mobileInputCleanup?.();
if (metricsDebounce) clearTimeout(metricsDebounce);
window.removeEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.removeEventListener(
@ -957,6 +1061,55 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
};
}, [isActive]);
const maybeReconnectOnPageResume = useCallback(() => {
const visibilityState =
typeof document !== "undefined" ? document.visibilityState : "visible";
const online =
typeof navigator === "undefined" ? true : navigator.onLine !== false;
const socketReadyState = wsRef.current?.readyState ?? null;
if (banner && ptyStateRef.current === "closed") {
return;
}
if (
shouldReconnectPtyOnPageResume({
isActive,
visibilityState,
online,
socketReadyState,
ptyState: ptyStateRef.current,
})
) {
const now = Date.now();
if (now - lastResumeReconnectAtRef.current < 1000) {
return;
}
lastResumeReconnectAtRef.current = now;
reconnectPty();
}
}, [banner, isActive, reconnectPty]);
useEffect(() => {
if (!isActive || typeof window === "undefined") {
return;
}
const onResume = () => maybeReconnectOnPageResume();
document.addEventListener("visibilitychange", onResume);
window.addEventListener("pageshow", onResume);
window.addEventListener("focus", onResume);
window.addEventListener("online", onResume);
return () => {
document.removeEventListener("visibilitychange", onResume);
window.removeEventListener("pageshow", onResume);
window.removeEventListener("focus", onResume);
window.removeEventListener("online", onResume);
};
}, [isActive, maybeReconnectOnPageResume]);
// Keep the live xterm theme in sync when the active theme's terminal
// colors change (e.g. user switches to a custom YAML theme mid-session).
useEffect(() => {
@ -980,6 +1133,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main
// dashboard column uses `relative z-2`, which traps `position:fixed`
// descendants below those layers (see Toast.tsx).
const reconnectBanner =
ptyState === "reconnecting"
? `Chat connection interrupted${
lastCloseCode ? ` (code ${lastCloseCode})` : ""
}. Reconnecting...`
: null;
const visibleBanner = banner ?? reconnectBanner;
const showReconnectOverlay =
ptyState === "reconnecting" || (ptyState === "closed" && !banner);
const mobileModelToolsPortal =
isActive &&
narrow &&
@ -1071,9 +1233,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
<PluginSlot name="chat:top" />
{mobileModelToolsPortal}
{banner && (
{visibleBanner && (
<div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide">
{banner}
{visibleBanner}
</div>
)}
@ -1093,16 +1255,37 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1"
/>
{showReconnectOverlay && (
<div className="absolute inset-x-3 top-3 z-20 flex justify-center sm:inset-x-auto sm:right-3 sm:justify-end">
<div className="flex max-w-[min(28rem,calc(100vw-3rem))] flex-col items-start gap-2 border border-warning/60 bg-black/80 px-3 py-2 text-xs text-warning shadow-lg">
<div className="tracking-wide">
{ptyState === "reconnecting"
? "Chat is reconnecting."
: "Chat disconnected."}
</div>
<Button
size="sm"
outlined
onClick={reconnectPty}
prefix={<RotateCcw className="h-4 w-4" />}
aria-label="Reconnect chat"
>
Reconnect now
</Button>
</div>
</div>
)}
{/* NS-504: the agent process exited (e.g. `/exit` or a new session).
Offer an in-place restart so the user never has to refresh the
whole page to get a working chat back. */}
{sessionEnded && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/60">
{ptyState === "ended" && (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-black/60">
<div className="text-sm tracking-wide text-white/80">
Session ended.
</div>
<Button
onClick={reconnect}
onClick={startFreshPty}
prefix={<RotateCcw className="h-4 w-4" />}
aria-label="Start a new chat session"
>