diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 47cb5e72b05..e6c6d09e0a1 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -382,7 +382,7 @@ export function ChatSidebar({ onClick={reconnect} prefix={} > - reconnect + reconnect tools feed )} diff --git a/web/src/lib/pty-mobile-input.test.ts b/web/src/lib/pty-mobile-input.test.ts new file mode 100644 index 00000000000..1b436943902 --- /dev/null +++ b/web/src/lib/pty-mobile-input.test.ts @@ -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(""); + }); +}); diff --git a/web/src/lib/pty-mobile-input.ts b/web/src/lib/pty-mobile-input.ts new file mode 100644 index 00000000000..a4991ad6ec2 --- /dev/null +++ b/web/src/lib/pty-mobile-input.ts @@ -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, + }; +} diff --git a/web/src/lib/pty-reconnect.test.ts b/web/src/lib/pty-reconnect.test.ts new file mode 100644 index 00000000000..3e8fc40648f --- /dev/null +++ b/web/src/lib/pty-reconnect.test.ts @@ -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); + }); +}); diff --git a/web/src/lib/pty-reconnect.ts b/web/src/lib/pty-reconnect.ts new file mode 100644 index 00000000000..51bd3bdf38b --- /dev/null +++ b/web/src/lib/pty-reconnect.ts @@ -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"; +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 708b9df88c2..7a133227c39 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -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 | 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("connecting"); + const ptyStateRef = useRef("connecting"); + const [lastCloseCode, setLastCloseCode] = useState(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 7–9px 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 }) { {mobileModelToolsPortal} - {banner && ( + {visibleBanner && (
- {banner} + {visibleBanner}
)} @@ -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 && ( +
+
+
+ {ptyState === "reconnecting" + ? "Chat is reconnecting." + : "Chat disconnected."} +
+ +
+
+ )} + {/* 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 && ( -
+ {ptyState === "ended" && ( +
Session ended.