tenseleyflow/claudex / dfb80df

Browse files

ui: terminalpane tracing + strictmode-safe remount cleanup

Authored by espadonne
SHA
dfb80df4ac96a89b30aae87df49771662a7786a6
Parents
ad9ef9d
Tree
7a616bb

1 changed file

StatusFile+-
M src/components/TerminalPane.tsx 82 25
src/components/TerminalPane.tsxmodified
@@ -22,6 +22,7 @@ import { useEffect, useRef } from "react";
2222
 
2323
 import {
2424
   getPtyBuffer,
25
+  logFrontend,
2526
   onPtyData,
2627
   resizePty,
2728
   spawnPty,
@@ -36,6 +37,13 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
3637
 import { WebglAddon } from "@xterm/addon-webgl";
3738
 import { Terminal } from "@xterm/xterm";
3839
 
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
+
3947
 /** Theme matching the claudex design tokens in `src/index.css`. */
4048
 const TERMINAL_THEME = {
4149
   foreground: "#d4d4d8",
@@ -81,42 +89,76 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
8189
 
8290
   useEffect(() => {
8391
     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 });
8597
 
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
+    }
97121
 
98122
     const fit = new FitAddon();
99123
     term.loadAddon(fit);
100124
     term.loadAddon(new WebLinksAddon());
101125
     term.loadAddon(new Unicode11Addon());
102
-    term.unicode.activeVersion = "11";
103126
 
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
+    }
105144
 
106145
     // 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
108147
     // canvas rendering automatically.
109148
     try {
110149
       term.loadAddon(new WebglAddon());
150
+      trace("webgl addon loaded");
111151
     } catch (err) {
112
-      console.warn("[pty] webgl addon failed, using canvas fallback", err);
152
+      trace("webgl addon failed, using canvas fallback", { error: String(err) });
113153
     }
114154
 
115155
     try {
116156
       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
+      });
120162
     }
121163
 
122164
     let ptyId: string | null = null;
@@ -136,20 +178,25 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
136178
     const attach = async () => {
137179
       const existing = useSessionStore.getState().ptyIds.get(sessionId);
138180
       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 });
141182
         ptyId = existing;
142183
         try {
143184
           const snapshot = await getPtyBuffer(existing);
144185
           if (cancelled) return;
145186
           if (snapshot.length > 0) writeBase64(snapshot);
187
+          trace("ring buffer replayed", { bytes: snapshot.length });
146188
         } 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
+          });
148192
         }
149193
       } else {
150
-        // Spawn path — mint a new id, ask the backend to fork claude,
151
-        // and record the binding in the store.
152194
         const newId = crypto.randomUUID();
195
+        trace("spawn path", {
196
+          ptyId: newId,
197
+          cols: term.cols,
198
+          rows: term.rows,
199
+        });
153200
         try {
154201
           await spawnPty({
155202
             ptyId: newId,
@@ -159,7 +206,9 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
159206
             cols: term.cols,
160207
             rows: term.rows,
161208
           });
209
+          trace("spawn_pty ok", { ptyId: newId });
162210
         } catch (err) {
211
+          trace("spawn_pty failed", { error: String(err) });
163212
           if (!cancelled) {
164213
             term.write(
165214
               `\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)
228277
     ro.observe(container);
229278
 
230279
     return () => {
280
+      trace("unmount", { sessionId, ptyId });
231281
       cancelled = true;
232282
       ro.disconnect();
233283
       if (unlistenData) unlistenData();
@@ -237,7 +287,14 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
237287
       // survive this unmount so reattaching later still works. The
238288
       // only teardown paths are `store.closeSessionPty` and the
239289
       // 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();
241298
     };
242299
     // Mount-once per sessionId; claudeArgs and cwd are baked in
243300
     // at spawn time and don't change while mounted. Remount via