@tangle-network/sandbox-ui 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,38 @@ import { useState, useEffect, useRef, useCallback } from "react";
3
3
  function createEmptyBatch() {
4
4
  return { data: "", waiters: [] };
5
5
  }
6
+ var WS_OPEN_TIMEOUT_MS = 1500;
7
+ function toWsUrl(apiUrl, sessionId) {
8
+ try {
9
+ const url = new URL(`${apiUrl}/terminals/${sessionId}/ws`);
10
+ if (url.protocol === "https:") url.protocol = "wss:";
11
+ else if (url.protocol === "http:") url.protocol = "ws:";
12
+ else return null;
13
+ return url.toString();
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+ var BEARER_SUBPROTOCOL_PREFIX = "bearer.";
19
+ function toBearerSubprotocol(token) {
20
+ if (typeof btoa === "undefined") return null;
21
+ let encoded;
22
+ try {
23
+ encoded = btoa(token);
24
+ } catch {
25
+ return null;
26
+ }
27
+ return `${BEARER_SUBPROTOCOL_PREFIX}${encoded.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
28
+ }
29
+ var stdinEncoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
30
+ function encodeStdin(text) {
31
+ if (!stdinEncoder) {
32
+ throw new Error(
33
+ "TextEncoder is unavailable; WebSocket transport cannot encode stdin"
34
+ );
35
+ }
36
+ return stdinEncoder.encode(text);
37
+ }
6
38
  function usePtySession({ apiUrl, token, onData }) {
7
39
  const [isConnected, setIsConnected] = useState(false);
8
40
  const [error, setError] = useState(null);
@@ -13,6 +45,8 @@ function usePtySession({ apiUrl, token, onData }) {
13
45
  const mountedRef = useRef(true);
14
46
  const onDataRef = useRef(onData);
15
47
  const connectStreamRef = useRef(null);
48
+ const wsRef = useRef(null);
49
+ const pendingWsRef = useRef(null);
16
50
  const pendingBatchRef = useRef(createEmptyBatch());
17
51
  const drainPromiseRef = useRef(null);
18
52
  const inputAbortRef = useRef(null);
@@ -37,8 +71,27 @@ function usePtySession({ apiUrl, token, onData }) {
37
71
  abortRef.current = null;
38
72
  }
39
73
  }, []);
74
+ const closeWs = useCallback(() => {
75
+ const active = wsRef.current;
76
+ if (active) {
77
+ wsRef.current = null;
78
+ try {
79
+ active.close();
80
+ } catch {
81
+ }
82
+ }
83
+ const pending = pendingWsRef.current;
84
+ if (pending) {
85
+ pendingWsRef.current = null;
86
+ try {
87
+ pending.close();
88
+ } catch {
89
+ }
90
+ }
91
+ }, []);
40
92
  const cleanup = useCallback(() => {
41
93
  abortStream();
94
+ closeWs();
42
95
  if (inputAbortRef.current) {
43
96
  inputAbortRef.current.abort();
44
97
  inputAbortRef.current = null;
@@ -55,7 +108,110 @@ function usePtySession({ apiUrl, token, onData }) {
55
108
  }
56
109
  rejectPendingInput("Terminal session is not connected");
57
110
  setIsConnected(false);
58
- }, [apiUrl, token, abortStream, rejectPendingInput]);
111
+ }, [apiUrl, token, abortStream, closeWs, rejectPendingInput]);
112
+ const connectWs = useCallback((sessionId) => {
113
+ return new Promise((resolve) => {
114
+ if (typeof WebSocket === "undefined" || stdinEncoder === null) {
115
+ resolve(false);
116
+ return;
117
+ }
118
+ const wsUrl = toWsUrl(apiUrl, sessionId);
119
+ if (!wsUrl) {
120
+ resolve(false);
121
+ return;
122
+ }
123
+ const subprotocol = toBearerSubprotocol(token);
124
+ if (!subprotocol) {
125
+ resolve(false);
126
+ return;
127
+ }
128
+ let ws;
129
+ try {
130
+ ws = new WebSocket(wsUrl, [subprotocol]);
131
+ } catch {
132
+ resolve(false);
133
+ return;
134
+ }
135
+ ws.binaryType = "arraybuffer";
136
+ pendingWsRef.current = ws;
137
+ let opened = false;
138
+ let settled = false;
139
+ const settle = (ok) => {
140
+ if (settled) return;
141
+ settled = true;
142
+ clearTimeout(handshakeTimer);
143
+ resolve(ok);
144
+ };
145
+ const handshakeTimer = setTimeout(() => {
146
+ if (opened) return;
147
+ try {
148
+ ws.close();
149
+ } catch {
150
+ }
151
+ settle(false);
152
+ }, WS_OPEN_TIMEOUT_MS);
153
+ ws.onopen = () => {
154
+ opened = true;
155
+ if (pendingWsRef.current !== ws || !mountedRef.current) {
156
+ try {
157
+ ws.close();
158
+ } catch {
159
+ }
160
+ settle(false);
161
+ return;
162
+ }
163
+ pendingWsRef.current = null;
164
+ wsRef.current = ws;
165
+ setIsConnected(true);
166
+ setError(null);
167
+ retryCountRef.current = 0;
168
+ settle(true);
169
+ };
170
+ ws.onmessage = (ev) => {
171
+ if (!mountedRef.current) return;
172
+ const data = ev.data;
173
+ let text;
174
+ if (typeof data === "string") {
175
+ text = data;
176
+ } else if (data instanceof ArrayBuffer) {
177
+ text = new TextDecoder().decode(data);
178
+ } else if (ArrayBuffer.isView(data)) {
179
+ text = new TextDecoder().decode(data);
180
+ } else {
181
+ data.text().then((t) => {
182
+ if (mountedRef.current) onDataRef.current(t);
183
+ }).catch(() => {
184
+ });
185
+ return;
186
+ }
187
+ if (text) onDataRef.current(text);
188
+ };
189
+ ws.onerror = () => {
190
+ };
191
+ ws.onclose = () => {
192
+ if (pendingWsRef.current === ws) {
193
+ pendingWsRef.current = null;
194
+ }
195
+ const wasActive = wsRef.current === ws;
196
+ if (wasActive) {
197
+ wsRef.current = null;
198
+ }
199
+ if (!opened) {
200
+ settle(false);
201
+ return;
202
+ }
203
+ if (!wasActive || !mountedRef.current) return;
204
+ setIsConnected(false);
205
+ if (sessionIdRef.current) {
206
+ retryTimerRef.current = setTimeout(() => {
207
+ if (mountedRef.current && sessionIdRef.current) {
208
+ connectStreamRef.current?.(sessionIdRef.current);
209
+ }
210
+ }, 1e3);
211
+ }
212
+ };
213
+ });
214
+ }, [apiUrl, token]);
59
215
  const connectStream = useCallback(async (sessionId) => {
60
216
  abortStream();
61
217
  setError(null);
@@ -162,7 +318,12 @@ function usePtySession({ apiUrl, token, onData }) {
162
318
  if (!mountedRef.current) return;
163
319
  sessionIdRef.current = sessionId;
164
320
  inputAbortRef.current = new AbortController();
321
+ const wsOk = await connectWs(sessionId);
322
+ if (!mountedRef.current || sessionIdRef.current !== sessionId) return;
165
323
  ensureDrainRunningRef.current?.();
324
+ if (wsOk) {
325
+ return;
326
+ }
166
327
  await connectStream(sessionId);
167
328
  } catch (err) {
168
329
  if (err.name === "AbortError") return;
@@ -172,10 +333,19 @@ function usePtySession({ apiUrl, token, onData }) {
172
333
  setIsConnected(false);
173
334
  }
174
335
  }
175
- }, [apiUrl, token, cleanup, connectStream]);
336
+ }, [apiUrl, token, cleanup, connectWs, connectStream]);
176
337
  const resizeTerminal = useCallback(async (cols, rows) => {
177
338
  const sid = sessionIdRef.current;
178
339
  if (!sid || cols <= 0 || rows <= 0) return;
340
+ const ws = wsRef.current;
341
+ if (ws && ws.readyState === WebSocket.OPEN) {
342
+ try {
343
+ ws.send(JSON.stringify({ type: "resize", cols, rows }));
344
+ return;
345
+ } catch (err) {
346
+ console.warn("Terminal resize over WS failed; falling back to HTTP", err);
347
+ }
348
+ }
179
349
  try {
180
350
  const res = await fetch(`${apiUrl}/terminals/${sid}`, {
181
351
  method: "PATCH",
@@ -201,6 +371,16 @@ function usePtySession({ apiUrl, token, onData }) {
201
371
  }
202
372
  const batch = pendingBatchRef.current;
203
373
  pendingBatchRef.current = createEmptyBatch();
374
+ const ws = wsRef.current;
375
+ if (ws && ws.readyState === WebSocket.OPEN) {
376
+ try {
377
+ ws.send(encodeStdin(batch.data));
378
+ for (const w of batch.waiters) w.resolve();
379
+ } catch (err) {
380
+ for (const w of batch.waiters) w.reject(err);
381
+ }
382
+ continue;
383
+ }
204
384
  try {
205
385
  const res = await fetch(`${apiUrl}/terminals/${sid}/input`, {
206
386
  method: "POST",
package/dist/globals.css CHANGED
@@ -283,6 +283,9 @@
283
283
  .relative {
284
284
  position: relative;
285
285
  }
286
+ .static {
287
+ position: static;
288
+ }
286
289
  .sticky {
287
290
  position: sticky;
288
291
  }
package/dist/hooks.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { A as AuthUser, S as SandboxMetrics, a as SidecarMetricsPayload, U as UseAuthOptions, b as UseAuthResult, c as UsePtySessionOptions, d as UsePtySessionReturn, e as UseSandboxMetricsOptions, f as UseSandboxMetricsResult, g as createAuthFetcher, u as useApiKey, h as useAuth, i as useLiveTime, j as usePtySession, k as useSandboxMetrics } from './use-sandbox-metrics-B64diPqN.js';
1
+ export { A as AuthUser, S as SandboxMetrics, a as SidecarMetricsPayload, U as UseAuthOptions, b as UseAuthResult, c as UsePtySessionOptions, d as UsePtySessionReturn, e as UseSandboxMetricsOptions, f as UseSandboxMetricsResult, g as createAuthFetcher, u as useApiKey, h as useAuth, i as useLiveTime, j as usePtySession, k as useSandboxMetrics } from './use-sandbox-metrics-DWc0k9Xm.js';
2
2
  export { AgentStreamEvent, AppendUserMessageOptions, ApplySdkEventOptions, AutomationStreamEvent, BeginAssistantMessageOptions, BotStreamEvent, CompleteAssistantMessageOptions, ConnectionState, RealtimeSessionOptions, RealtimeSessionRegistry, RealtimeSessionRegistryProps, RealtimeSessionState, RealtimeSessionTarget, SSEEvent, SdkSessionAttachment, SdkSessionEvent, SdkSessionSeed, SessionInfo, SidecarAuth, TaskStreamEvent, TerminalStreamEvent, UseRunGroupsOptions, UseSSEStreamOptions, UseSSEStreamResult, UseSdkSessionOptions, UseSdkSessionReturn, UseSessionStreamOptions, UseSessionStreamResult, UseSidecarAuthOptions, UseToolCallStreamReturn, useAutoScroll, useDropdownMenu, useRealtimeSession, useRunCollapseState, useRunGroups, useSSEStream, useSdkSession, useSessionStream, useSidecarAuth, useToolCallStream } from './sdk-hooks.js';
3
3
  import * as _tanstack_react_query from '@tanstack/react-query';
4
4
  import { S as Session } from './sidecar-CFU2W9j1.js';
package/dist/hooks.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  } from "./chunk-DPGIXDAI.js";
8
8
  import {
9
9
  usePtySession
10
- } from "./chunk-ESVYBDGA.js";
10
+ } from "./chunk-AG7QDC2Q.js";
11
11
  import {
12
12
  RealtimeSessionRegistry,
13
13
  useDropdownMenu,
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ export { c as BillingDashboard, d as BillingDashboardProps, e as PricingCards, f
20
20
  export { AuthHeader, GitHubLoginButton, LoginLayout, LoginLayoutProps, UserMenu } from './auth.js';
21
21
  export { CodeBlock, CodeBlock as CodeBlockDisplay, CopyButton, Markdown, MarkdownProps } from './markdown.js';
22
22
  export { AppendUserMessageOptions, ApplySdkEventOptions, BeginAssistantMessageOptions, CompleteAssistantMessageOptions, RealtimeSessionOptions, RealtimeSessionRegistry, RealtimeSessionRegistryProps, RealtimeSessionState, RealtimeSessionTarget, SdkSessionAttachment, SdkSessionEvent, SdkSessionSeed, UseSdkSessionOptions, UseSdkSessionReturn, UseToolCallStreamReturn, useAutoScroll, useDropdownMenu, useRealtimeSession, useRunCollapseState, useRunGroups, useSSEStream, useSdkSession, useSessionStream, useSidecarAuth, useToolCallStream } from './sdk-hooks.js';
23
- export { S as SandboxMetrics, a as SidecarMetricsPayload, e as UseSandboxMetricsOptions, f as UseSandboxMetricsResult, g as createAuthFetcher, u as useApiKey, h as useAuth, i as useLiveTime, j as usePtySession, k as useSandboxMetrics } from './use-sandbox-metrics-B64diPqN.js';
23
+ export { S as SandboxMetrics, a as SidecarMetricsPayload, e as UseSandboxMetricsOptions, f as UseSandboxMetricsResult, g as createAuthFetcher, u as useApiKey, h as useAuth, i as useLiveTime, j as usePtySession, k as useSandboxMetrics } from './use-sandbox-metrics-DWc0k9Xm.js';
24
24
  export { G as GroupedMessage, R as Run, b as RunStats, T as ToolCategory } from './run-CtFZ6s-D.js';
25
25
  export { S as Session } from './sidecar-CFU2W9j1.js';
26
26
  export { C as CustomToolRenderer, D as DisplayVariant, T as ToolDisplayMetadata } from './tool-display-Ct9nFAzJ.js';
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  } from "./chunk-DPGIXDAI.js";
32
32
  import {
33
33
  usePtySession
34
- } from "./chunk-ESVYBDGA.js";
34
+ } from "./chunk-AG7QDC2Q.js";
35
35
  import {
36
36
  RealtimeSessionRegistry,
37
37
  useDropdownMenu,
package/dist/styles.css CHANGED
@@ -283,6 +283,9 @@
283
283
  .relative {
284
284
  position: relative;
285
285
  }
286
+ .static {
287
+ position: static;
288
+ }
286
289
  .sticky {
287
290
  position: sticky;
288
291
  }
package/dist/terminal.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  usePtySession
3
- } from "./chunk-ESVYBDGA.js";
3
+ } from "./chunk-AG7QDC2Q.js";
4
4
 
5
5
  // src/terminal/terminal-view.tsx
6
6
  import "@xterm/xterm/css/xterm.css";
@@ -48,8 +48,19 @@ function TerminalView({
48
48
  const containerRef = useRef(null);
49
49
  const termRef = useRef(null);
50
50
  const fitAddonRef = useRef(null);
51
+ const pendingWritesRef = useRef([]);
52
+ const writeRafRef = useRef(null);
51
53
  const onData = useCallback((data) => {
52
- termRef.current?.write(data);
54
+ if (!data) return;
55
+ pendingWritesRef.current.push(data);
56
+ if (writeRafRef.current !== null) return;
57
+ writeRafRef.current = requestAnimationFrame(() => {
58
+ writeRafRef.current = null;
59
+ const chunks = pendingWritesRef.current;
60
+ if (chunks.length === 0) return;
61
+ pendingWritesRef.current = [];
62
+ termRef.current?.write(chunks.length === 1 ? chunks[0] : chunks.join(""));
63
+ });
53
64
  }, []);
54
65
  const { isConnected, error, sendCommand, resizeTerminal, reconnect } = usePtySession({
55
66
  apiUrl,
@@ -74,6 +85,25 @@ function TerminalView({
74
85
  term.loadAddon(fitAddon);
75
86
  term.loadAddon(webLinksAddon);
76
87
  term.open(containerRef.current);
88
+ let webglAddon = null;
89
+ let webglCancelled = false;
90
+ void (async () => {
91
+ try {
92
+ const mod = await import("@xterm/addon-webgl");
93
+ if (webglCancelled) return;
94
+ try {
95
+ const addon = new mod.WebglAddon();
96
+ addon.onContextLoss(() => {
97
+ webglAddon?.dispose();
98
+ webglAddon = null;
99
+ });
100
+ webglAddon = addon;
101
+ term.loadAddon(addon);
102
+ } catch {
103
+ }
104
+ } catch {
105
+ }
106
+ })();
77
107
  requestAnimationFrame(() => {
78
108
  fitAddon.fit();
79
109
  });
@@ -102,7 +132,14 @@ function TerminalView({
102
132
  });
103
133
  ro.observe(containerRef.current);
104
134
  return () => {
135
+ webglCancelled = true;
105
136
  ro.disconnect();
137
+ if (writeRafRef.current !== null) {
138
+ cancelAnimationFrame(writeRafRef.current);
139
+ writeRafRef.current = null;
140
+ }
141
+ pendingWritesRef.current = [];
142
+ webglAddon?.dispose();
106
143
  term.dispose();
107
144
  termRef.current = null;
108
145
  fitAddonRef.current = null;
@@ -46,7 +46,7 @@ interface UsePtySessionOptions {
46
46
  onData: (data: string) => void;
47
47
  }
48
48
  interface UsePtySessionReturn {
49
- /** Whether the SSE stream is connected and receiving data. */
49
+ /** Whether the underlying transport is connected and receiving data. */
50
50
  isConnected: boolean;
51
51
  /** Connection or API error, if any. */
52
52
  error: string | null;
@@ -60,11 +60,23 @@ interface UsePtySessionReturn {
60
60
  /**
61
61
  * Manages a PTY session against the sidecar terminal API.
62
62
  *
63
- * Protocol:
64
- * - POST /terminals create session → { data: { sessionId } }
65
- * - GET /terminals/{id}/stream → SSE output (raw PTY with ANSI codes)
66
- * - POST /terminals/{id}/input send input { data: "..." }
67
- * - DELETE /terminals/{id} close session
63
+ * Transport:
64
+ * 1. POST /terminals creates the session.
65
+ * 2. The hook tries WebSocket: GET /terminals/:id/ws (Upgrade).
66
+ * - Serverclient: TEXT frames carrying raw PTY output.
67
+ * - Clientserver: BINARY frames carrying stdin (UTF-8); TEXT
68
+ * frames carrying JSON control messages (`{"type":"resize",...}`).
69
+ * 3. If the WS does not reach OPEN within WS_OPEN_TIMEOUT_MS, or it
70
+ * errors before opening, the hook falls back to SSE+POST:
71
+ * - GET /terminals/:id/stream (SSE for output)
72
+ * - POST /terminals/:id/input (one batched POST at a time)
73
+ * - PATCH /terminals/:id (resize)
74
+ * 4. DELETE /terminals/:id closes the session (both transports).
75
+ *
76
+ * The WS path eliminates the per-keystroke HTTP round-trip that
77
+ * dominates typing latency through edge proxies; the HTTP+SSE path is
78
+ * preserved as a fallback so the hook keeps working against
79
+ * deployments that have not yet shipped the WS endpoint.
68
80
  */
69
81
  declare function usePtySession({ apiUrl, token, onData }: UsePtySessionOptions): UsePtySessionReturn;
70
82
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/sandbox-ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Unified UI component library for Tangle Sandbox — primitives, chat, dashboard, terminal, editor, and workspace components",
5
5
  "repository": {
6
6
  "type": "git",
@@ -119,6 +119,9 @@
119
119
  "@xterm/addon-web-links": {
120
120
  "optional": true
121
121
  },
122
+ "@xterm/addon-webgl": {
123
+ "optional": true
124
+ },
122
125
  "@tanstack/react-query": {
123
126
  "optional": true
124
127
  },
@@ -206,6 +209,7 @@
206
209
  "@types/turndown": "^5.0.6",
207
210
  "@xterm/addon-fit": "^0.11.0",
208
211
  "@xterm/addon-web-links": "^0.12.0",
212
+ "@xterm/addon-webgl": "^0.19.0",
209
213
  "@xterm/xterm": "^6.0.0",
210
214
  "jsdom": "^29.0.2",
211
215
  "postcss": "^8.5.8",