@@ -22,6 +22,7 @@ import { useEffect, useRef } from "react"; |
| 22 | 22 | |
| 23 | 23 | import { |
| 24 | 24 | getPtyBuffer, |
| 25 | + logFrontend, |
| 25 | 26 | onPtyData, |
| 26 | 27 | resizePty, |
| 27 | 28 | spawnPty, |
@@ -36,6 +37,13 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; |
| 36 | 37 | import { WebglAddon } from "@xterm/addon-webgl"; |
| 37 | 38 | import { Terminal } from "@xterm/xterm"; |
| 38 | 39 | |
| 40 | +const trace = (msg: string, extra?: Record<string, unknown>) => { |
| 41 | + const payload = extra ? `${msg} ${JSON.stringify(extra)}` : msg; |
| 42 | + // eslint-disable-next-line no-console |
| 43 | + console.debug("[TerminalPane]", payload); |
| 44 | + void logFrontend("debug", "TerminalPane", payload); |
| 45 | +}; |
| 46 | + |
| 39 | 47 | /** Theme matching the claudex design tokens in `src/index.css`. */ |
| 40 | 48 | const TERMINAL_THEME = { |
| 41 | 49 | foreground: "#d4d4d8", |
@@ -81,42 +89,76 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 81 | 89 | |
| 82 | 90 | useEffect(() => { |
| 83 | 91 | const container = containerRef.current; |
| 84 | | - if (!container) return; |
| 92 | + if (!container) { |
| 93 | + trace("mount aborted: no container ref"); |
| 94 | + return; |
| 95 | + } |
| 96 | + trace("mount begin", { sessionId, cwd, claudeArgs }); |
| 85 | 97 | |
| 86 | | - const term = new Terminal({ |
| 87 | | - theme: TERMINAL_THEME, |
| 88 | | - fontFamily: |
| 89 | | - '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', |
| 90 | | - fontSize: 13, |
| 91 | | - lineHeight: 1.2, |
| 92 | | - cursorBlink: true, |
| 93 | | - scrollback: 10000, |
| 94 | | - // allowProposedApi is required to use term.unicode.activeVersion. |
| 95 | | - allowProposedApi: true, |
| 96 | | - }); |
| 98 | + // React 19 StrictMode double-invokes effects (mount → cleanup → |
| 99 | + // mount). If a previous effect already appended xterm DOM to |
| 100 | + // this container, wipe it before opening a fresh Terminal so |
| 101 | + // we don't stack renderer layers and confuse layout math. |
| 102 | + container.replaceChildren(); |
| 103 | + |
| 104 | + let term: Terminal; |
| 105 | + try { |
| 106 | + term = new Terminal({ |
| 107 | + theme: TERMINAL_THEME, |
| 108 | + fontFamily: |
| 109 | + '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', |
| 110 | + fontSize: 13, |
| 111 | + lineHeight: 1.2, |
| 112 | + cursorBlink: true, |
| 113 | + scrollback: 10000, |
| 114 | + // Required before we can set `term.unicode.activeVersion`. |
| 115 | + allowProposedApi: true, |
| 116 | + }); |
| 117 | + } catch (err) { |
| 118 | + trace("new Terminal threw", { error: String(err) }); |
| 119 | + throw err; |
| 120 | + } |
| 97 | 121 | |
| 98 | 122 | const fit = new FitAddon(); |
| 99 | 123 | term.loadAddon(fit); |
| 100 | 124 | term.loadAddon(new WebLinksAddon()); |
| 101 | 125 | term.loadAddon(new Unicode11Addon()); |
| 102 | | - term.unicode.activeVersion = "11"; |
| 103 | 126 | |
| 104 | | - term.open(container); |
| 127 | + try { |
| 128 | + term.open(container); |
| 129 | + trace("term.open ok", { cols: term.cols, rows: term.rows }); |
| 130 | + } catch (err) { |
| 131 | + trace("term.open threw", { error: String(err) }); |
| 132 | + throw err; |
| 133 | + } |
| 134 | + |
| 135 | + // Set activeVersion AFTER open() — the unicode subsystem walks |
| 136 | + // through `term.unicode` which requires the core to be mounted. |
| 137 | + // Swallow errors so a bad unicode activation never crashes the |
| 138 | + // whole pane. |
| 139 | + try { |
| 140 | + term.unicode.activeVersion = "11"; |
| 141 | + } catch (err) { |
| 142 | + trace("unicode activeVersion failed", { error: String(err) }); |
| 143 | + } |
| 105 | 144 | |
| 106 | 145 | // WebGL must be loaded AFTER open(). If WebGL init throws (e.g. |
| 107 | | - // headless / contextless env), swallow it — xterm falls back to |
| 146 | + // headless / no GPU context), swallow it — xterm falls back to |
| 108 | 147 | // canvas rendering automatically. |
| 109 | 148 | try { |
| 110 | 149 | term.loadAddon(new WebglAddon()); |
| 150 | + trace("webgl addon loaded"); |
| 111 | 151 | } catch (err) { |
| 112 | | - console.warn("[pty] webgl addon failed, using canvas fallback", err); |
| 152 | + trace("webgl addon failed, using canvas fallback", { error: String(err) }); |
| 113 | 153 | } |
| 114 | 154 | |
| 115 | 155 | try { |
| 116 | 156 | fit.fit(); |
| 117 | | - } catch { |
| 118 | | - // No layout yet — the ResizeObserver will retry on the first |
| 119 | | - // container resize. |
| 157 | + trace("fit.fit ok", { cols: term.cols, rows: term.rows }); |
| 158 | + } catch (err) { |
| 159 | + trace("fit.fit failed (will retry via ResizeObserver)", { |
| 160 | + error: String(err), |
| 161 | + }); |
| 120 | 162 | } |
| 121 | 163 | |
| 122 | 164 | let ptyId: string | null = null; |
@@ -136,20 +178,25 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 136 | 178 | const attach = async () => { |
| 137 | 179 | const existing = useSessionStore.getState().ptyIds.get(sessionId); |
| 138 | 180 | if (existing) { |
| 139 | | - // Reattach path — pull the ring buffer snapshot, replay it, |
| 140 | | - // then start listening for new data. |
| 181 | + trace("reattach path", { ptyId: existing }); |
| 141 | 182 | ptyId = existing; |
| 142 | 183 | try { |
| 143 | 184 | const snapshot = await getPtyBuffer(existing); |
| 144 | 185 | if (cancelled) return; |
| 145 | 186 | if (snapshot.length > 0) writeBase64(snapshot); |
| 187 | + trace("ring buffer replayed", { bytes: snapshot.length }); |
| 146 | 188 | } catch (err) { |
| 147 | | - console.warn("[pty] get_pty_buffer failed during reattach", err); |
| 189 | + trace("get_pty_buffer failed during reattach", { |
| 190 | + error: String(err), |
| 191 | + }); |
| 148 | 192 | } |
| 149 | 193 | } else { |
| 150 | | - // Spawn path — mint a new id, ask the backend to fork claude, |
| 151 | | - // and record the binding in the store. |
| 152 | 194 | const newId = crypto.randomUUID(); |
| 195 | + trace("spawn path", { |
| 196 | + ptyId: newId, |
| 197 | + cols: term.cols, |
| 198 | + rows: term.rows, |
| 199 | + }); |
| 153 | 200 | try { |
| 154 | 201 | await spawnPty({ |
| 155 | 202 | ptyId: newId, |
@@ -159,7 +206,9 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 159 | 206 | cols: term.cols, |
| 160 | 207 | rows: term.rows, |
| 161 | 208 | }); |
| 209 | + trace("spawn_pty ok", { ptyId: newId }); |
| 162 | 210 | } catch (err) { |
| 211 | + trace("spawn_pty failed", { error: String(err) }); |
| 163 | 212 | if (!cancelled) { |
| 164 | 213 | term.write( |
| 165 | 214 | `\r\n\x1b[31m[claudex] spawn_pty failed: ${formatErr(err)}\x1b[0m\r\n`, |
@@ -228,6 +277,7 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 228 | 277 | ro.observe(container); |
| 229 | 278 | |
| 230 | 279 | return () => { |
| 280 | + trace("unmount", { sessionId, ptyId }); |
| 231 | 281 | cancelled = true; |
| 232 | 282 | ro.disconnect(); |
| 233 | 283 | if (unlistenData) unlistenData(); |
@@ -237,7 +287,14 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 237 | 287 | // survive this unmount so reattaching later still works. The |
| 238 | 288 | // only teardown paths are `store.closeSessionPty` and the |
| 239 | 289 | // window-destroy reaper in Rust. |
| 240 | | - term.dispose(); |
| 290 | + try { |
| 291 | + term.dispose(); |
| 292 | + } catch (err) { |
| 293 | + trace("term.dispose threw", { error: String(err) }); |
| 294 | + } |
| 295 | + // Clear container so a re-mount on the same ref (React 19 |
| 296 | + // StrictMode) starts from a clean slate. |
| 297 | + container.replaceChildren(); |
| 241 | 298 | }; |
| 242 | 299 | // Mount-once per sessionId; claudeArgs and cwd are baked in |
| 243 | 300 | // at spawn time and don't change while mounted. Remount via |