@tangle-network/sandbox-ui 0.94.2 → 0.95.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.
@@ -1,4 +1,5 @@
1
1
  // src/hooks/use-session-stream.ts
2
+ import { createParser } from "eventsource-parser";
2
3
  import { useCallback, useEffect, useRef, useState } from "react";
3
4
 
4
5
  // src/hooks/wire-encoding.ts
@@ -22,6 +23,17 @@ function encodeTextForWire(text) {
22
23
  }
23
24
 
24
25
  // src/hooks/use-session-stream.ts
26
+ var SessionStreamError = class extends Error {
27
+ code;
28
+ status;
29
+ constructor(code, message, status) {
30
+ super(message);
31
+ this.name = "SessionStreamError";
32
+ this.code = code;
33
+ this.status = status;
34
+ }
35
+ };
36
+ var SESSION_STREAM_RECONNECT_DELAY_MS = 1e3;
25
37
  function mapApiMessage(msg, counterRef) {
26
38
  const created = msg.info.timestamp ? new Date(msg.info.timestamp).getTime() : Date.now();
27
39
  const message = {
@@ -57,11 +69,39 @@ function mapApiMessage(msg, counterRef) {
57
69
  });
58
70
  return { message, parts };
59
71
  }
72
+ function readErrorDetails(value) {
73
+ if (typeof value === "string") return { message: value };
74
+ if (!value || typeof value !== "object") return {};
75
+ const record = value;
76
+ const nested = readErrorDetails(record.error);
77
+ return {
78
+ code: (typeof record.code === "string" ? record.code : void 0) ?? nested.code,
79
+ message: (typeof record.message === "string" ? record.message : void 0) ?? nested.message
80
+ };
81
+ }
82
+ function formatError(details, fallback) {
83
+ const message = details.message ?? fallback;
84
+ return details.code && !message.includes(details.code) ? `${message} (${details.code})` : message;
85
+ }
86
+ async function responseError(res, prefix) {
87
+ let details = {};
88
+ try {
89
+ details = readErrorDetails(await res.json());
90
+ } catch {
91
+ }
92
+ const code = details.code ?? `HTTP_${res.status}`;
93
+ const fallback = `${prefix}: HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
94
+ return new SessionStreamError(
95
+ code,
96
+ formatError(details, fallback),
97
+ res.status
98
+ );
99
+ }
60
100
  async function fetchJson(url, token, init) {
61
101
  const headers = { Authorization: `Bearer ${token}` };
62
102
  if (init?.body) headers["Content-Type"] = "application/json";
63
103
  const res = await fetch(url, { ...init, headers: { ...headers, ...init?.headers }, credentials: "include" });
64
- if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
104
+ if (!res.ok) throw await responseError(res, "Request failed");
65
105
  return res.json();
66
106
  }
67
107
  function useSessionStream({
@@ -71,19 +111,28 @@ function useSessionStream({
71
111
  enabled = true
72
112
  }) {
73
113
  const [messages, setMessages] = useState([]);
74
- const [partMap, setPartMap] = useState({});
114
+ const [partsState, setPartsState] = useState({
115
+ values: {},
116
+ sourceIds: {}
117
+ });
118
+ const partMap = partsState.values;
75
119
  const [isStreaming, setIsStreaming] = useState(false);
76
120
  const [error, setError] = useState(null);
77
121
  const [connected, setConnected] = useState(false);
78
122
  const abortRef = useRef(null);
79
123
  const reconnectTimerRef = useRef(null);
80
- const streamingMsgIdRef = useRef(null);
124
+ const activeAssistantMessageIdsRef = useRef(/* @__PURE__ */ new Set());
125
+ const completedExecutionIdsRef = useRef(/* @__PURE__ */ new Set());
126
+ const lastEventIdRef = useRef("");
81
127
  const insertionCounterRef = useRef(0);
82
128
  const echoCounterRef = useRef(0);
83
129
  const pendingEchoesRef = useRef(/* @__PURE__ */ new Map());
84
130
  const handleSSEEventRef = useRef(null);
85
131
  useEffect(() => {
86
132
  pendingEchoesRef.current.clear();
133
+ activeAssistantMessageIdsRef.current.clear();
134
+ completedExecutionIdsRef.current.clear();
135
+ lastEventIdRef.current = "";
87
136
  }, [sessionId]);
88
137
  const refetch = useCallback(async () => {
89
138
  if (!token || !sessionId || !apiUrl) return;
@@ -98,13 +147,23 @@ function useSessionStream({
98
147
  newMessages.push(message);
99
148
  newPartMap[message.id] = parts;
100
149
  }
101
- const echoes = [...pendingEchoesRef.current.values()];
150
+ const canonicalIds = new Set(newMessages.map((message) => message.id));
151
+ const echoes = [...pendingEchoesRef.current.entries()].filter(
152
+ ([, echo]) => !echo.canonicalMessageId || !canonicalIds.has(echo.canonicalMessageId)
153
+ ).map(([, echo]) => echo);
154
+ for (const [echoId, echo] of pendingEchoesRef.current) {
155
+ if (echo.canonicalMessageId && canonicalIds.has(echo.canonicalMessageId)) {
156
+ pendingEchoesRef.current.delete(echoId);
157
+ }
158
+ }
102
159
  setMessages([...newMessages, ...echoes.map((echo) => echo.message)]);
103
- setPartMap({
104
- ...newPartMap,
105
- ...Object.fromEntries(echoes.map((echo) => [echo.message.id, echo.parts]))
160
+ setPartsState({
161
+ values: {
162
+ ...newPartMap,
163
+ ...Object.fromEntries(echoes.map((echo) => [echo.message.id, echo.parts]))
164
+ },
165
+ sourceIds: {}
106
166
  });
107
- streamingMsgIdRef.current = null;
108
167
  } catch (err) {
109
168
  const msg = err instanceof Error ? err.message : "Failed to fetch messages";
110
169
  setError(msg);
@@ -120,59 +179,109 @@ function useSessionStream({
120
179
  const controller = new AbortController();
121
180
  abortRef.current = controller;
122
181
  try {
123
- const url = `${apiUrl}/session/events?sessionId=${encodeURIComponent(sessionId)}`;
182
+ const eventQuery = new URLSearchParams({ sessionId });
183
+ const activeExecutionIds = [...activeAssistantMessageIdsRef.current];
184
+ if (activeExecutionIds.length === 1) {
185
+ eventQuery.set("executionId", activeExecutionIds[0]);
186
+ } else if (activeExecutionIds.length > 1) {
187
+ eventQuery.set("replayExecutionIds", activeExecutionIds.join(","));
188
+ }
189
+ if (lastEventIdRef.current) {
190
+ eventQuery.set("since", lastEventIdRef.current);
191
+ }
192
+ const url = `${apiUrl}/session/events?${eventQuery.toString()}`;
124
193
  const res = await fetch(url, {
125
194
  headers: { Authorization: `Bearer ${token}` },
126
195
  signal: controller.signal,
127
196
  credentials: "include"
128
197
  });
129
- if (!res.ok) throw new Error(`SSE connection failed: ${res.status}`);
198
+ if (!res.ok) throw await responseError(res, "Live response connection failed");
130
199
  setConnected(true);
131
200
  setError(null);
132
201
  const reader = res.body?.getReader();
133
202
  if (!reader) throw new Error("No response body");
134
203
  const decoder = new TextDecoder();
135
- let buffer = "";
136
- while (true) {
137
- const { done, value } = await reader.read();
138
- if (done) break;
139
- buffer += decoder.decode(value, { stream: true });
140
- const frames = buffer.split("\n\n");
141
- buffer = frames.pop() ?? "";
142
- for (const frame of frames) {
143
- if (!frame.trim()) continue;
144
- let eventType = "message";
145
- const dataLines = [];
146
- for (const line of frame.split("\n")) {
147
- if (line.startsWith("event:")) {
148
- eventType = line.slice(6).trim();
149
- } else if (line.startsWith("data:")) {
150
- dataLines.push(line.slice(5).trim());
151
- }
152
- }
153
- if (dataLines.length === 0) continue;
154
- let parsed;
204
+ let parserFailure = null;
205
+ const parser = createParser({
206
+ onEvent(event) {
207
+ if (event.id) lastEventIdRef.current = event.id;
208
+ if (!event.data) return;
155
209
  try {
156
- parsed = JSON.parse(dataLines.join("\n"));
210
+ const parsed = JSON.parse(event.data);
211
+ handleSSEEventRef.current?.(event.event ?? "message", parsed);
157
212
  } catch {
158
- continue;
213
+ parserFailure = new SessionStreamError(
214
+ "INVALID_SESSION_EVENT",
215
+ "The live response stream sent malformed data."
216
+ );
159
217
  }
160
- handleSSEEventRef.current?.(eventType, parsed);
218
+ },
219
+ onError(parseError) {
220
+ parserFailure = new SessionStreamError(
221
+ "INVALID_EVENT_STREAM",
222
+ `The live response stream could not be parsed: ${parseError.message}`
223
+ );
161
224
  }
225
+ });
226
+ while (true) {
227
+ const { done, value } = await reader.read();
228
+ if (done) break;
229
+ parser.feed(decoder.decode(value, { stream: true }));
230
+ if (parserFailure) throw parserFailure;
162
231
  }
232
+ const tail = decoder.decode();
233
+ if (tail) parser.feed(tail);
234
+ parser.reset({ consume: true });
235
+ if (parserFailure) throw parserFailure;
236
+ throw new SessionStreamError(
237
+ "SESSION_STREAM_CLOSED",
238
+ "The live response stream closed before the session ended. Reconnecting\u2026"
239
+ );
163
240
  } catch (err) {
164
241
  if (err.name === "AbortError") return;
165
242
  const msg = err instanceof Error ? err.message : "SSE connection error";
166
243
  setError(msg);
167
244
  setConnected(false);
168
245
  if (!controller.signal.aborted) {
169
- reconnectTimerRef.current = setTimeout(() => connectSSE(), 3e3);
246
+ reconnectTimerRef.current = setTimeout(
247
+ () => connectSSE(),
248
+ SESSION_STREAM_RECONNECT_DELAY_MS
249
+ );
170
250
  }
171
251
  }
172
252
  }, [apiUrl, token, sessionId, enabled]);
173
253
  const handleSSEEvent = useCallback((type, raw) => {
174
254
  const envelope = raw?.properties;
175
255
  const props = envelope?.info ?? envelope?.part ?? envelope ?? raw;
256
+ const associatePendingEcho = (executionId) => {
257
+ for (const echo of pendingEchoesRef.current.values()) {
258
+ if (!echo.executionId) {
259
+ echo.executionId = executionId;
260
+ return;
261
+ }
262
+ }
263
+ };
264
+ const retireExecution = (executionId) => {
265
+ if (!executionId) {
266
+ const activeExecutionIds = [...activeAssistantMessageIdsRef.current];
267
+ if (activeExecutionIds.length === 1) {
268
+ return retireExecution(activeExecutionIds[0]);
269
+ }
270
+ if (activeExecutionIds.length > 1) {
271
+ return true;
272
+ }
273
+ pendingEchoesRef.current.clear();
274
+ return false;
275
+ }
276
+ completedExecutionIdsRef.current.add(executionId);
277
+ activeAssistantMessageIdsRef.current.delete(executionId);
278
+ for (const [echoId, echo] of pendingEchoesRef.current) {
279
+ if (echo.executionId === executionId || !echo.executionId && pendingEchoesRef.current.size === 1) {
280
+ pendingEchoesRef.current.delete(echoId);
281
+ }
282
+ }
283
+ return activeAssistantMessageIdsRef.current.size > 0;
284
+ };
176
285
  if (type === "message.updated") {
177
286
  const id = props.id ?? props.messageId ?? "";
178
287
  const role = props.role ?? "assistant";
@@ -191,28 +300,51 @@ function useSessionStream({
191
300
  ];
192
301
  });
193
302
  if (role === "assistant") {
194
- streamingMsgIdRef.current = id;
195
- setIsStreaming(true);
303
+ associatePendingEcho(id);
304
+ if (!completedExecutionIdsRef.current.has(id)) {
305
+ activeAssistantMessageIdsRef.current.add(id);
306
+ setIsStreaming(true);
307
+ }
196
308
  }
197
309
  } else if (type === "message.part.updated") {
198
- const msgId = streamingMsgIdRef.current;
310
+ const msgId = props.messageID ?? props.messageId ?? "";
199
311
  if (!msgId) return;
200
312
  const partType = props.type ?? "text";
201
- setIsStreaming(true);
202
- setPartMap((prev) => {
203
- const existing = prev[msgId] ?? [];
313
+ const sourcePartId = props.id ?? `${partType}-${msgId}`;
314
+ associatePendingEcho(msgId);
315
+ if (!completedExecutionIdsRef.current.has(msgId)) {
316
+ activeAssistantMessageIdsRef.current.add(msgId);
317
+ setIsStreaming(true);
318
+ }
319
+ setMessages((prev) => {
320
+ if (prev.some((message) => message.id === msgId)) return prev;
321
+ return [
322
+ ...prev,
323
+ {
324
+ id: msgId,
325
+ role: "assistant",
326
+ time: { created: Date.now() },
327
+ _insertionIndex: insertionCounterRef.current++
328
+ }
329
+ ];
330
+ });
331
+ setPartsState((prev) => {
332
+ const existing = prev.values[msgId] ?? [];
333
+ const sourceIds = prev.sourceIds[msgId] ?? [];
204
334
  const updated = [...existing];
335
+ const updatedSourceIds = [...sourceIds];
336
+ const knownIndex = sourceIds.indexOf(sourcePartId);
205
337
  if (partType === "text") {
206
338
  const text = props.text ?? props.content ?? "";
207
- const idx = updated.findIndex((p) => p.type === "text");
208
339
  const textPart = { type: "text", text };
209
- if (idx >= 0) {
210
- updated[idx] = textPart;
340
+ if (knownIndex >= 0) {
341
+ updated[knownIndex] = textPart;
211
342
  } else {
343
+ updatedSourceIds.push(sourcePartId);
212
344
  updated.push(textPart);
213
345
  }
214
346
  } else if (partType === "tool") {
215
- const toolId = props.id ?? props.toolId ?? `tool-${Date.now()}`;
347
+ const toolId = props.id ?? props.toolId ?? sourcePartId;
216
348
  const toolName = props.tool ?? props.name ?? "unknown";
217
349
  const state = props.state ?? { status: "running" };
218
350
  const toolPart = {
@@ -228,41 +360,69 @@ function useSessionStream({
228
360
  time: state.time
229
361
  }
230
362
  };
231
- const idx = updated.findIndex((p) => p.type === "tool" && p.id === toolId);
232
- if (idx >= 0) {
233
- updated[idx] = toolPart;
363
+ if (knownIndex >= 0) {
364
+ updated[knownIndex] = toolPart;
234
365
  } else {
366
+ updatedSourceIds.push(sourcePartId);
235
367
  updated.push(toolPart);
236
368
  }
237
369
  } else if (partType === "reasoning") {
238
370
  const text = props.text ?? "";
239
- const idx = updated.findIndex((p) => p.type === "reasoning");
240
371
  const reasoningPart = { type: "reasoning", text };
241
- if (idx >= 0) {
242
- updated[idx] = reasoningPart;
372
+ if (knownIndex >= 0) {
373
+ updated[knownIndex] = reasoningPart;
243
374
  } else {
375
+ updatedSourceIds.push(sourcePartId);
244
376
  updated.push(reasoningPart);
245
377
  }
378
+ } else {
379
+ return prev;
246
380
  }
247
- return { ...prev, [msgId]: updated };
381
+ return {
382
+ values: { ...prev.values, [msgId]: updated },
383
+ sourceIds: { ...prev.sourceIds, [msgId]: updatedSourceIds }
384
+ };
248
385
  });
249
386
  } else if (type === "session.idle") {
250
- setIsStreaming(false);
251
- streamingMsgIdRef.current = null;
252
- pendingEchoesRef.current.clear();
253
- refetch();
387
+ const executionId = props.executionId ?? props.executionID ?? "";
388
+ const stillStreaming = retireExecution(executionId);
389
+ setIsStreaming(stillStreaming);
390
+ if (!stillStreaming) refetch();
254
391
  } else if (type === "session.error") {
255
- setIsStreaming(false);
256
- streamingMsgIdRef.current = null;
257
- const errorMsg = props.error ?? props.message ?? "Agent error";
258
- setError(errorMsg);
259
- pendingEchoesRef.current.clear();
260
- refetch();
392
+ const executionId = props.executionId ?? props.executionID ?? "";
393
+ const stillStreaming = retireExecution(executionId);
394
+ setIsStreaming(stillStreaming);
395
+ setError(formatError(readErrorDetails(props), "Agent response failed"));
396
+ if (!stillStreaming) refetch();
261
397
  }
262
398
  }, [refetch]);
263
399
  handleSSEEventRef.current = handleSSEEvent;
264
400
  const send = useCallback(async (text, options) => {
265
- if (!token || !sessionId || !apiUrl) return;
401
+ if (!token || !sessionId || !apiUrl || !enabled) {
402
+ const admissionError = new SessionStreamError(
403
+ "CHAT_NOT_READY",
404
+ "Chat is not ready yet. Wait for the session connection and try again."
405
+ );
406
+ setError(admissionError.message);
407
+ throw admissionError;
408
+ }
409
+ if (!connected) {
410
+ const admissionError = new SessionStreamError(
411
+ "CHAT_DISCONNECTED",
412
+ "Chat is reconnecting. Your message was not sent."
413
+ );
414
+ setError(admissionError.message);
415
+ throw admissionError;
416
+ }
417
+ if (pendingEchoesRef.current.size > 0 || activeAssistantMessageIdsRef.current.size > 0) {
418
+ const admissionError = new SessionStreamError(
419
+ "CHAT_BUSY",
420
+ "The agent is still responding. Wait for this turn to finish before sending another message."
421
+ );
422
+ setError(admissionError.message);
423
+ throw admissionError;
424
+ }
425
+ setError(null);
266
426
  const echoId = `local-echo-${echoCounterRef.current++}`;
267
427
  const echo = {
268
428
  message: {
@@ -275,11 +435,14 @@ function useSessionStream({
275
435
  };
276
436
  pendingEchoesRef.current.set(echoId, echo);
277
437
  setMessages((prev) => [...prev, echo.message]);
278
- setPartMap((prev) => ({ ...prev, [echoId]: echo.parts }));
438
+ setPartsState((prev) => ({
439
+ values: { ...prev.values, [echoId]: echo.parts },
440
+ sourceIds: prev.sourceIds
441
+ }));
279
442
  setIsStreaming(true);
280
443
  try {
281
444
  const url = `${apiUrl}/session/sessions/${encodeURIComponent(sessionId)}/messages`;
282
- await fetchJson(url, token, {
445
+ const response = await fetchJson(url, token, {
283
446
  method: "POST",
284
447
  body: JSON.stringify({
285
448
  // The sidecar base64-decodes every text part at the route boundary;
@@ -291,22 +454,37 @@ function useSessionStream({
291
454
  ...options?.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}
292
455
  })
293
456
  });
457
+ if (response.userMessageId) {
458
+ const pending = pendingEchoesRef.current.get(echoId);
459
+ if (pending) pending.canonicalMessageId = response.userMessageId;
460
+ }
461
+ const executionId = response.info?.id;
462
+ if (executionId) {
463
+ const pending = pendingEchoesRef.current.get(echoId);
464
+ if (pending) pending.executionId = executionId;
465
+ if (!completedExecutionIdsRef.current.has(executionId)) {
466
+ activeAssistantMessageIdsRef.current.add(executionId);
467
+ }
468
+ }
294
469
  } catch (err) {
295
470
  pendingEchoesRef.current.delete(echoId);
296
471
  setMessages((prev) => prev.filter((message) => message.id !== echoId));
297
- setPartMap((prev) => {
298
- if (!(echoId in prev)) return prev;
299
- const next = { ...prev };
300
- delete next[echoId];
301
- return next;
472
+ setPartsState((prev) => {
473
+ if (!(echoId in prev.values)) return prev;
474
+ const values = { ...prev.values };
475
+ const sourceIds = { ...prev.sourceIds };
476
+ delete values[echoId];
477
+ delete sourceIds[echoId];
478
+ return { values, sourceIds };
302
479
  });
303
- if (pendingEchoesRef.current.size === 0 && !streamingMsgIdRef.current) {
480
+ if (pendingEchoesRef.current.size === 0 && activeAssistantMessageIdsRef.current.size === 0) {
304
481
  setIsStreaming(false);
305
482
  }
306
483
  const msg = err instanceof Error ? err.message : "Failed to send message";
307
484
  setError(msg);
485
+ throw err;
308
486
  }
309
- }, [apiUrl, token, sessionId]);
487
+ }, [apiUrl, connected, enabled, token, sessionId]);
310
488
  const abort = useCallback(async () => {
311
489
  if (!token || !sessionId || !apiUrl) return;
312
490
  try {
@@ -440,6 +618,8 @@ function useSidecarAuth({ resourceId, apiUrl, signMessage }) {
440
618
  }
441
619
 
442
620
  export {
621
+ SessionStreamError,
622
+ SESSION_STREAM_RECONNECT_DELAY_MS,
443
623
  useSessionStream,
444
624
  useSidecarAuth
445
625
  };
@@ -237,9 +237,29 @@ var EDGE_LABEL_LANE = 180;
237
237
  var CARD_CHROME = 20;
238
238
  var HEADER_ROW = 34;
239
239
  var DESCRIPTION_ROW = 40;
240
- var META_ROW = 27;
241
- var OUTPUT_ROWS = 3;
242
- var OUTPUT_ROW = 89;
240
+ var AGENT_BODY_ROWS = 5;
241
+ var ACTION_OUTPUT_ROWS = 3;
242
+ var STRUCTURAL_OUTPUT_ROWS = 1;
243
+ var WELL_PROSE_LINE = 15.125;
244
+ var WELL_JSON_LINE = 14.4375;
245
+ var WELL_JSON_GAP = 2;
246
+ var WELL_CHROME = 39.5;
247
+ function wellRow(rows) {
248
+ const body = Math.max(
249
+ rows * WELL_PROSE_LINE,
250
+ rows * WELL_JSON_LINE + (rows - 1) * WELL_JSON_GAP
251
+ );
252
+ return Math.ceil(WELL_CHROME + body) + 3;
253
+ }
254
+ var SLIM_HEADER_ROW = 22;
255
+ var BODY_LINE = 15.125;
256
+ var AGENT_BODY_ROW = Math.ceil(8 + AGENT_BODY_ROWS * BODY_LINE);
257
+ var CHIP_SLOT_ROW = 22;
258
+ var DECISION_HEADER_ROW = 45;
259
+ var STRUCTURAL_KINDS = /* @__PURE__ */ new Set(["parallel", "foreach"]);
260
+ function isStructuralKind(kind) {
261
+ return kind !== void 0 && STRUCTURAL_KINDS.has(kind);
262
+ }
243
263
  var OUTPUT_PREVIEW_CHARS = 240;
244
264
  var FOOTER_ROW = 28;
245
265
  var COMPACT_TILE = 76;
@@ -258,10 +278,19 @@ var COMPACT_NODE_SIZE_TB = {
258
278
  height: COMPACT_TILE
259
279
  };
260
280
  function nodeHeight(data, withRunState) {
261
- let h = CARD_CHROME + HEADER_ROW;
262
- if (data.description) h += DESCRIPTION_ROW;
263
- if (withRunState) h += META_ROW + OUTPUT_ROW + FOOTER_ROW;
264
- return h;
281
+ if (!withRunState || data.tone === "trigger") {
282
+ return CARD_CHROME + HEADER_ROW + (data.description ? DESCRIPTION_ROW : 0);
283
+ }
284
+ if (data.kind === "decision") {
285
+ return CARD_CHROME + DECISION_HEADER_ROW + DESCRIPTION_ROW + FOOTER_ROW;
286
+ }
287
+ if (isStructuralKind(data.kind)) {
288
+ return CARD_CHROME + HEADER_ROW + CHIP_SLOT_ROW + FOOTER_ROW;
289
+ }
290
+ if (data.kind === "agent.run") {
291
+ return CARD_CHROME + SLIM_HEADER_ROW + AGENT_BODY_ROW + FOOTER_ROW;
292
+ }
293
+ return CARD_CHROME + HEADER_ROW + wellRow(ACTION_OUTPUT_ROWS) + FOOTER_ROW;
265
294
  }
266
295
  function geometry(compact, runMode, direction) {
267
296
  if (compact) {
@@ -552,6 +581,75 @@ function describeTrigger(on) {
552
581
  asRecord(kind ? rec[kind] : void 0)
553
582
  );
554
583
  }
584
+ var WRAP_TARGET_ASPECT = 742 / 480;
585
+ var WRAP_ASPECT_TRIGGER = 4.2;
586
+ var WRAP_ROW_GAP = 28;
587
+ function foldColumns(count, cellW, cellH, colSep, rowSep) {
588
+ let best = count;
589
+ let bestError = Number.POSITIVE_INFINITY;
590
+ for (let cols = 1; cols <= count; cols++) {
591
+ const rows = Math.ceil(count / cols);
592
+ const width = cols * cellW + (cols - 1) * colSep;
593
+ const height = rows * cellH + (rows - 1) * rowSep;
594
+ const error = Math.abs(Math.log(width / height / WRAP_TARGET_ASPECT));
595
+ if (error < bestError) {
596
+ bestError = error;
597
+ best = cols;
598
+ }
599
+ }
600
+ return best;
601
+ }
602
+ function layoutSerpentine(nodes, edges, colSep, laneSep) {
603
+ const rowSep = Math.max(laneSep, WRAP_ROW_GAP);
604
+ const byRank = /* @__PURE__ */ new Map();
605
+ for (const n of nodes) {
606
+ const arr = byRank.get(n.rank);
607
+ if (arr) arr.push(n);
608
+ else byRank.set(n.rank, [n]);
609
+ }
610
+ const ranks = [...byRank.keys()].sort((a, b) => a - b);
611
+ if (ranks.some((r) => byRank.get(r).length !== 1)) return null;
612
+ const rankOf = new Map(nodes.map((n) => [n.id, n.rank]));
613
+ const joinsNextLayer = (e) => {
614
+ const from = rankOf.get(e.source);
615
+ const to = rankOf.get(e.target);
616
+ return from !== void 0 && to !== void 0 && to === from + 1;
617
+ };
618
+ if (!edges.every(joinsNextLayer)) return null;
619
+ const ordered = ranks.map((r) => byRank.get(r)[0]);
620
+ const count = ordered.length;
621
+ const cellW = Math.max(...ordered.map((n) => n.width));
622
+ const cellH = Math.max(...ordered.map((n) => n.height));
623
+ const lineAspect = (count * cellW + (count - 1) * colSep) / cellH;
624
+ if (lineAspect <= WRAP_TARGET_ASPECT * WRAP_ASPECT_TRIGGER) return null;
625
+ const cols = foldColumns(count, cellW, cellH, colSep, rowSep);
626
+ if (cols >= count) return null;
627
+ return ordered.map((node, i) => {
628
+ const row = Math.floor(i / cols);
629
+ const indexInRow = i % cols;
630
+ const leftToRight = row % 2 === 0;
631
+ const col = leftToRight ? indexInRow : cols - 1 - indexInRow;
632
+ return {
633
+ id: node.id,
634
+ position: {
635
+ x: col * (cellW + colSep) + (cellW - node.width) / 2,
636
+ y: row * (cellH + rowSep) + (cellH - node.height) / 2
637
+ },
638
+ width: node.width,
639
+ height: node.height,
640
+ data: node.data,
641
+ // Every node leaves the way its row travels and is entered from behind —
642
+ // including the one that turns the corner. Mirroring puts the last cell
643
+ // of a row directly above the first cell of the next, so a turn is
644
+ // source-right into target-right (or left into left): the renderer draws
645
+ // it as a bracket in the margin beside the column. Routing it through the
646
+ // BOTTOM instead would have to start below the name that sits under the
647
+ // tile, leaving the arrow floating clear of the node it comes from.
648
+ targetSide: leftToRight ? "left" : "right",
649
+ sourceSide: leftToRight ? "right" : "left"
650
+ };
651
+ });
652
+ }
555
653
  function layoutLayers(nodes, direction, rankSep, crossSep) {
556
654
  const isLR = direction === "LR";
557
655
  const mainSize = (n) => isLR ? n.width : n.height;
@@ -778,8 +876,9 @@ function buildWorkflowGraph(yaml, options) {
778
876
  (e) => e.whenLabel !== void 0 || e.backEdge === true
779
877
  );
780
878
  const rankSep = labelled ? Math.max(geo.rankSep, EDGE_LABEL_LANE) : geo.rankSep;
879
+ const folded = options?.wrap && direction === "LR" && !labelled ? layoutSerpentine(logical, edges, rankSep, geo.crossSep) : null;
781
880
  return {
782
- nodes: layoutLayers(logical, direction, rankSep, geo.crossSep),
881
+ nodes: folded ?? layoutLayers(logical, direction, rankSep, geo.crossSep),
783
882
  edges,
784
883
  error: null
785
884
  };
@@ -797,7 +896,10 @@ export {
797
896
  triggerNodeIndex,
798
897
  actionNodeId,
799
898
  branchNodeId,
800
- OUTPUT_ROWS,
899
+ AGENT_BODY_ROWS,
900
+ ACTION_OUTPUT_ROWS,
901
+ STRUCTURAL_OUTPUT_ROWS,
902
+ isStructuralKind,
801
903
  OUTPUT_PREVIEW_CHARS,
802
904
  COMPACT_TILE,
803
905
  COMPACT_NODE_SIZE,
package/dist/globals.css CHANGED
@@ -643,6 +643,24 @@
643
643
  -webkit-box-orient: vertical;
644
644
  -webkit-line-clamp: 3;
645
645
  }
646
+ .line-clamp-4 {
647
+ overflow: hidden;
648
+ display: -webkit-box;
649
+ -webkit-box-orient: vertical;
650
+ -webkit-line-clamp: 4;
651
+ }
652
+ .line-clamp-5 {
653
+ overflow: hidden;
654
+ display: -webkit-box;
655
+ -webkit-box-orient: vertical;
656
+ -webkit-line-clamp: 5;
657
+ }
658
+ .line-clamp-6 {
659
+ overflow: hidden;
660
+ display: -webkit-box;
661
+ -webkit-box-orient: vertical;
662
+ -webkit-line-clamp: 6;
663
+ }
646
664
  .block {
647
665
  display: block;
648
666
  }
@@ -805,6 +823,9 @@
805
823
  .h-\[28rem\] {
806
824
  height: 28rem;
807
825
  }
826
+ .h-\[30rem\] {
827
+ height: 30rem;
828
+ }
808
829
  .h-\[32rem\] {
809
830
  height: 32rem;
810
831
  }
package/dist/hooks.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { AgentStreamEvent, AppendUserMessageOptions, ApplySdkEventOptions, AuthUser, AutomationStreamEvent, BeginAssistantMessageOptions, BotStreamEvent, CompleteAssistantMessageOptions, ConnectionState, RealtimeSessionOptions, RealtimeSessionRegistry, RealtimeSessionRegistryProps, RealtimeSessionState, RealtimeSessionTarget, SSEEvent, SdkSessionAttachment, SdkSessionEvent, SdkSessionSeed, TaskStreamEvent, TerminalStreamEvent, UseAuthOptions, UseAuthResult, UseRunGroupsOptions, UseSSEStreamOptions, UseSSEStreamResult, UseSdkSessionOptions, UseSdkSessionReturn, UseToolCallStreamReturn, createAuthFetcher, useApiKey, useAuth, useAutoScroll, useDropdownMenu, useLiveTime, useRealtimeSession, useRunCollapseState, useRunGroups, useSSEStream, useSdkSession, useToolCallStream } from '@tangle-network/ui/hooks';
2
2
  import * as _tanstack_react_query from '@tanstack/react-query';
3
3
  import { Session } from './types.js';
4
- export { e as SendMessageOptions, S as SessionInfo, a as SidecarAuth, U as UseSessionStreamOptions, b as UseSessionStreamResult, c as UseSidecarAuthOptions, u as useSessionStream, d as useSidecarAuth } from './sdk-hooks-VtiMwKa6.js';
4
+ export { e as SESSION_STREAM_RECONNECT_DELAY_MS, f as SendMessageOptions, S as SessionInfo, g as SessionStreamError, a as SidecarAuth, U as UseSessionStreamOptions, b as UseSessionStreamResult, c as UseSidecarAuthOptions, u as useSessionStream, d as useSidecarAuth } from './sdk-hooks-Cjt_Wq7g.js';
5
5
  import '@tangle-network/ui/types';
6
6
  import '@tangle-network/ui/stores';
7
7
  import '@tangle-network/ui/sdk-hooks';