@runtypelabs/persona 3.9.1 → 3.10.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.
Files changed (38) hide show
  1. package/dist/index.cjs +46 -44
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +119 -0
  4. package/dist/index.d.ts +119 -0
  5. package/dist/index.global.js +67 -65
  6. package/dist/index.global.js.map +1 -1
  7. package/dist/index.js +46 -44
  8. package/dist/index.js.map +1 -1
  9. package/dist/theme-editor.cjs +828 -212
  10. package/dist/theme-editor.d.cts +128 -3
  11. package/dist/theme-editor.d.ts +128 -3
  12. package/dist/theme-editor.js +824 -212
  13. package/dist/theme-reference.cjs +1 -1
  14. package/dist/theme-reference.d.cts +8 -0
  15. package/dist/theme-reference.d.ts +8 -0
  16. package/dist/theme-reference.js +1 -1
  17. package/dist/widget.css +124 -0
  18. package/package.json +1 -1
  19. package/src/client.test.ts +312 -1
  20. package/src/client.ts +247 -24
  21. package/src/components/messages.ts +1 -1
  22. package/src/components/reasoning-bubble.ts +117 -28
  23. package/src/components/tool-bubble.ts +162 -28
  24. package/src/defaults.ts +13 -1
  25. package/src/styles/widget.css +124 -0
  26. package/src/theme-editor/index.ts +5 -0
  27. package/src/theme-editor/preview-utils.test.ts +58 -0
  28. package/src/theme-editor/preview-utils.ts +220 -4
  29. package/src/theme-editor/sections.test.ts +20 -0
  30. package/src/theme-editor/sections.ts +10 -0
  31. package/src/theme-reference.ts +8 -3
  32. package/src/tool-call-display-defaults.test.ts +23 -0
  33. package/src/types.ts +126 -0
  34. package/src/ui.scroll.test.ts +104 -0
  35. package/src/ui.tool-display.test.ts +204 -0
  36. package/src/ui.ts +103 -3
  37. package/src/utils/message-fingerprint.test.ts +17 -0
  38. package/src/utils/message-fingerprint.ts +13 -1
package/src/client.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  AgentWidgetMessage,
4
4
  AgentWidgetEvent,
5
5
  AgentWidgetStreamParser,
6
+ AgentWidgetStreamParserResult,
6
7
  AgentWidgetContextProvider,
7
8
  AgentWidgetRequestMiddleware,
8
9
  AgentWidgetRequestPayload,
@@ -85,6 +86,40 @@ function getParserFromType(parserType?: "plain" | "json" | "regex-json" | "xml")
85
86
 
86
87
  export type SSEEventCallback = (eventType: string, payload: unknown) => void;
87
88
 
89
+ const looksStructured = (value: string) =>
90
+ value.startsWith("{") || value.startsWith("[") || value.startsWith("<");
91
+
92
+ /**
93
+ * Choose the best content source for sealed-segment reconciliation.
94
+ * Prefers the final structured payload from step_complete when the raw
95
+ * buffer is only a partial/unparseable prefix of the same structured format.
96
+ */
97
+ export function preferFinalStructuredContent(
98
+ rawBuffer: string | undefined,
99
+ finalString: string
100
+ ): string {
101
+ if (!rawBuffer) return finalString;
102
+
103
+ const rawTrimmed = rawBuffer.trim();
104
+ const finalTrimmed = finalString.trim();
105
+ if (rawTrimmed.length === 0) return finalString;
106
+ if (finalTrimmed.length === 0) return rawBuffer;
107
+
108
+ const rawLooksStructured = looksStructured(rawTrimmed);
109
+ const finalLooksStructured = looksStructured(finalTrimmed);
110
+
111
+ if (!finalLooksStructured) return rawBuffer;
112
+ if (!rawLooksStructured) return finalString;
113
+ if (finalTrimmed === rawTrimmed) return finalString;
114
+ if (finalTrimmed.startsWith(rawTrimmed)) return finalString;
115
+
116
+ const rawJsonText = extractTextFromJson(rawBuffer);
117
+ const finalJsonText = extractTextFromJson(finalString);
118
+ if (finalJsonText !== null && rawJsonText === null) return finalString;
119
+
120
+ return rawBuffer;
121
+ }
122
+
88
123
  export class AgentWidgetClient {
89
124
  private readonly apiUrl: string;
90
125
  private readonly headers: Record<string, string>;
@@ -1260,6 +1295,136 @@ export class AgentWidgetClient {
1260
1295
  const streamParsers = new Map<string, AgentWidgetStreamParser>();
1261
1296
  // Track accumulated raw content for structured formats (JSON, XML, etc.)
1262
1297
  const rawContentBuffers = new Map<string, string>();
1298
+ // Reorder buffer for out-of-order step_delta chunks (keyed by assistant message id)
1299
+ const seqChunkBuffers = new Map<string, Array<{ seq: number; text: string }>>();
1300
+ // Reorder buffer for out-of-order reason_delta chunks (keyed by reasoning id)
1301
+ const reasonSeqBuffers = new Map<string, Array<{ seq: number; text: string }>>();
1302
+
1303
+ /**
1304
+ * Insert a chunk into a sequence-ordered buffer and return
1305
+ * the full accumulated text in correct sequence order.
1306
+ */
1307
+ function insertSeqChunk(
1308
+ bufferMap: Map<string, Array<{ seq: number; text: string }>>,
1309
+ key: string,
1310
+ seq: number,
1311
+ text: string
1312
+ ): string {
1313
+ let buf = bufferMap.get(key);
1314
+ if (!buf) {
1315
+ buf = [];
1316
+ bufferMap.set(key, buf);
1317
+ }
1318
+ // Binary-search insert to keep sorted by seq
1319
+ let lo = 0, hi = buf.length;
1320
+ while (lo < hi) {
1321
+ const mid = (lo + hi) >>> 1;
1322
+ if (buf[mid].seq < seq) lo = mid + 1;
1323
+ else hi = mid;
1324
+ }
1325
+ buf.splice(lo, 0, { seq, text });
1326
+ // Join all chunks in seq order
1327
+ let result = "";
1328
+ for (let i = 0; i < buf.length; i++) result += buf[i].text;
1329
+ return result;
1330
+ }
1331
+
1332
+ // Only the most-recently sealed segment is reconciled with step_complete's
1333
+ // final response. Earlier segments rely on their own async parser microtasks
1334
+ // resolving via the closure-captured `assistant` variable.
1335
+ let lastSealedTextSegment: AgentWidgetMessage | null = null;
1336
+
1337
+ /**
1338
+ * After text_end + didSplitByPartId, merge the authoritative final response into the
1339
+ * sealed message when streaming left content short (e.g. async parser lag).
1340
+ */
1341
+ const reconcileSealedAssistantWithFinalResponse = (
1342
+ msg: AgentWidgetMessage,
1343
+ finalContent: unknown
1344
+ ) => {
1345
+ const finalString = ensureStringContent(finalContent);
1346
+ const rawBuffer = rawContentBuffers.get(msg.id);
1347
+ const contentToProcess = preferFinalStructuredContent(rawBuffer, finalString);
1348
+ msg.rawContent = contentToProcess;
1349
+ const parser = streamParsers.get(msg.id);
1350
+
1351
+ const mergeIfBetter = (mergedDisplay: string) => {
1352
+ const cur = msg.content ?? "";
1353
+ if (mergedDisplay.trim() === "") return;
1354
+ // Only replace when empty, or when the stream left a strict prefix of the
1355
+ // authoritative final (truncation). Do not use length alone — multi-segment
1356
+ // flows can have a short last bubble whose content is not a prefix of the
1357
+ // full step response.
1358
+ if (
1359
+ cur.trim().length === 0 ||
1360
+ mergedDisplay.startsWith(cur) ||
1361
+ mergedDisplay.trimStart().startsWith(cur.trim())
1362
+ ) {
1363
+ msg.content = mergedDisplay;
1364
+ }
1365
+ };
1366
+
1367
+ const finalizeCleanup = () => {
1368
+ if (parser) {
1369
+ const closeResult = parser.close?.();
1370
+ if (closeResult instanceof Promise) closeResult.catch(() => {});
1371
+ }
1372
+ streamParsers.delete(msg.id);
1373
+ rawContentBuffers.delete(msg.id);
1374
+ msg.streaming = false;
1375
+ emitMessage(msg);
1376
+ };
1377
+
1378
+ if (!parser) {
1379
+ mergeIfBetter(finalString);
1380
+ finalizeCleanup();
1381
+ return;
1382
+ }
1383
+
1384
+ // Prefer JSON fast path when the final payload is JSON-shaped
1385
+ const extractedFromJson = extractTextFromJson(contentToProcess);
1386
+ if (extractedFromJson !== null && extractedFromJson.trim() !== "") {
1387
+ mergeIfBetter(extractedFromJson);
1388
+ finalizeCleanup();
1389
+ return;
1390
+ }
1391
+
1392
+ const bestDisplayText = (
1393
+ result: AgentWidgetStreamParserResult | string | null
1394
+ ): string => {
1395
+ const text =
1396
+ typeof result === "string" ? result : result?.text ?? null;
1397
+ if (text !== null && text.trim() !== "") return text;
1398
+ const extracted = parser.getExtractedText();
1399
+ if (extracted !== null && extracted.trim() !== "") return extracted;
1400
+ return finalString;
1401
+ };
1402
+
1403
+ let parsedResult: ReturnType<typeof parser.processChunk>;
1404
+ try {
1405
+ parsedResult = parser.processChunk(contentToProcess);
1406
+ } catch {
1407
+ mergeIfBetter(finalString);
1408
+ finalizeCleanup();
1409
+ return;
1410
+ }
1411
+
1412
+ if (parsedResult instanceof Promise) {
1413
+ parsedResult
1414
+ .then((result) => {
1415
+ mergeIfBetter(bestDisplayText(result));
1416
+ finalizeCleanup();
1417
+ })
1418
+ .catch(() => {
1419
+ mergeIfBetter(finalString);
1420
+ finalizeCleanup();
1421
+ });
1422
+ return;
1423
+ }
1424
+
1425
+ mergeIfBetter(bestDisplayText(parsedResult));
1426
+ finalizeCleanup();
1427
+ };
1263
1428
 
1264
1429
  // Agent execution state tracking
1265
1430
  let agentExecution: AgentExecutionState | null = null;
@@ -1366,7 +1531,16 @@ export class AgentWidgetClient {
1366
1531
  payload.delta ??
1367
1532
  "";
1368
1533
  if (chunk && payload.hidden !== true) {
1369
- reasoningMessage.reasoning.chunks.push(String(chunk));
1534
+ const reasonSeq = typeof payload.sequenceIndex === 'number' ? payload.sequenceIndex : undefined;
1535
+ if (reasonSeq !== undefined) {
1536
+ // Use reorder buffer so out-of-order chunks are assembled correctly
1537
+ const ordered = insertSeqChunk(reasonSeqBuffers, reasoningId, reasonSeq, String(chunk));
1538
+ // Rebuild the chunks array from the ordered concatenation.
1539
+ // We store a single joined string to avoid duplicated / mis-ordered entries.
1540
+ reasoningMessage.reasoning.chunks = [ordered];
1541
+ } else {
1542
+ reasoningMessage.reasoning.chunks.push(String(chunk));
1543
+ }
1370
1544
  }
1371
1545
  reasoningMessage.reasoning.status = payload.done ? "complete" : "streaming";
1372
1546
  if (payload.done) {
@@ -1378,6 +1552,7 @@ export class AgentWidgetClient {
1378
1552
  0,
1379
1553
  (reasoningMessage.reasoning.completedAt ?? Date.now()) - start
1380
1554
  );
1555
+ reasonSeqBuffers.delete(reasoningId);
1381
1556
  }
1382
1557
  reasoningMessage.streaming = reasoningMessage.reasoning.status !== "complete";
1383
1558
  emitMessage(reasoningMessage);
@@ -1398,6 +1573,7 @@ export class AgentWidgetClient {
1398
1573
  (reasoningMessage.reasoning.completedAt ?? Date.now()) - start
1399
1574
  );
1400
1575
  reasoningMessage.streaming = false;
1576
+ reasonSeqBuffers.delete(reasoningId);
1401
1577
  emitMessage(reasoningMessage);
1402
1578
  }
1403
1579
  const stepKey = getStepKey(payload);
@@ -1527,6 +1703,7 @@ export class AgentWidgetClient {
1527
1703
  if (prev) {
1528
1704
  prev.streaming = false;
1529
1705
  emitMessage(prev);
1706
+ lastSealedTextSegment = prev;
1530
1707
  assistantMessage = null;
1531
1708
  didSplitByPartId = true;
1532
1709
  }
@@ -1541,6 +1718,7 @@ export class AgentWidgetClient {
1541
1718
  if (prev) {
1542
1719
  prev.streaming = false;
1543
1720
  emitMessage(prev);
1721
+ lastSealedTextSegment = prev;
1544
1722
  assistantMessage = null;
1545
1723
  didSplitByPartId = true;
1546
1724
  }
@@ -1561,6 +1739,7 @@ export class AgentWidgetClient {
1561
1739
  if (prev) {
1562
1740
  prev.streaming = false;
1563
1741
  emitMessage(prev);
1742
+ lastSealedTextSegment = prev;
1564
1743
  assistantMessage = null;
1565
1744
  didSplitByPartId = true;
1566
1745
  }
@@ -1576,9 +1755,19 @@ export class AgentWidgetClient {
1576
1755
  // Support various field names: text, delta, content, chunk (Runtype uses 'chunk')
1577
1756
  const chunk = payload.text ?? payload.delta ?? payload.content ?? payload.chunk ?? "";
1578
1757
  if (chunk) {
1579
- // Accumulate raw content for structured format parsing
1580
- const rawBuffer = rawContentBuffers.get(assistant.id) ?? "";
1581
- const accumulatedRaw = rawBuffer + chunk;
1758
+ // Check if the event carries a sequence number for reordering
1759
+ const chunkSeq = typeof payload.seq === 'number' ? payload.seq : undefined;
1760
+
1761
+ // Accumulate raw content for structured format parsing.
1762
+ // When seq is present, use the reorder buffer so out-of-order
1763
+ // chunks are assembled in the correct server-intended order.
1764
+ let accumulatedRaw: string;
1765
+ if (chunkSeq !== undefined) {
1766
+ accumulatedRaw = insertSeqChunk(seqChunkBuffers, assistant.id, chunkSeq, chunk);
1767
+ } else {
1768
+ const rawBuffer = rawContentBuffers.get(assistant.id) ?? "";
1769
+ accumulatedRaw = rawBuffer + chunk;
1770
+ }
1582
1771
  // Store raw content for action parsing, but NEVER set assistant.content to raw JSON
1583
1772
  assistant.rawContent = accumulatedRaw;
1584
1773
 
@@ -1601,7 +1790,13 @@ export class AgentWidgetClient {
1601
1790
 
1602
1791
  // If plain text parser, just append the chunk directly
1603
1792
  if (isPlainTextParser) {
1604
- assistant.content += chunk;
1793
+ // When seq-ordered, rebuild from the reorder buffer;
1794
+ // otherwise fall back to simple append.
1795
+ if (chunkSeq !== undefined) {
1796
+ assistant.content = accumulatedRaw;
1797
+ } else {
1798
+ assistant.content += chunk;
1799
+ }
1605
1800
  // Clear any raw buffer/parser since we're in plain text mode
1606
1801
  rawContentBuffers.delete(assistant.id);
1607
1802
  streamParsers.delete(assistant.id);
@@ -1620,18 +1815,19 @@ export class AgentWidgetClient {
1620
1815
  const text = typeof result === 'string' ? result : result?.text ?? null;
1621
1816
 
1622
1817
  if (text !== null && text.trim() !== "") {
1623
- // Parser successfully extracted text
1624
- // Update the message content with extracted text
1625
- const currentAssistant = assistantMessage;
1626
- if (currentAssistant && currentAssistant.id === assistant.id) {
1627
- currentAssistant.content = text;
1628
- emitMessage(currentAssistant);
1629
- }
1818
+ // Parser successfully extracted text — update the chunk's assistant
1819
+ // (not assistantMessage; text_end may have cleared that ref before microtasks run)
1820
+ assistant.content = text;
1821
+ emitMessage(assistant);
1630
1822
  } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
1631
1823
  // Not a structured format - show as plain text
1632
1824
  const currentAssistant = assistantMessage;
1633
1825
  if (currentAssistant && currentAssistant.id === assistant.id) {
1634
- currentAssistant.content += chunk;
1826
+ if (chunkSeq !== undefined) {
1827
+ currentAssistant.content = accumulatedRaw;
1828
+ } else {
1829
+ currentAssistant.content += chunk;
1830
+ }
1635
1831
  rawContentBuffers.delete(currentAssistant.id);
1636
1832
  streamParsers.delete(currentAssistant.id);
1637
1833
  currentAssistant.rawContent = undefined;
@@ -1642,7 +1838,11 @@ export class AgentWidgetClient {
1642
1838
  // Don't emit message if parser hasn't extracted text yet
1643
1839
  }).catch(() => {
1644
1840
  // On error, treat as plain text
1645
- assistant.content += chunk;
1841
+ if (chunkSeq !== undefined) {
1842
+ assistant.content = accumulatedRaw;
1843
+ } else {
1844
+ assistant.content += chunk;
1845
+ }
1646
1846
  rawContentBuffers.delete(assistant.id);
1647
1847
  streamParsers.delete(assistant.id);
1648
1848
  assistant.rawContent = undefined;
@@ -1660,7 +1860,11 @@ export class AgentWidgetClient {
1660
1860
  emitMessage(assistant);
1661
1861
  } else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
1662
1862
  // Not a structured format - show as plain text
1663
- assistant.content += chunk;
1863
+ if (chunkSeq !== undefined) {
1864
+ assistant.content = accumulatedRaw;
1865
+ } else {
1866
+ assistant.content += chunk;
1867
+ }
1664
1868
  // Clear any raw buffer/parser if we were in structured format mode
1665
1869
  rawContentBuffers.delete(assistant.id);
1666
1870
  streamParsers.delete(assistant.id);
@@ -1713,6 +1917,7 @@ export class AgentWidgetClient {
1713
1917
  // Clean up
1714
1918
  streamParsers.delete(currentAssistant.id);
1715
1919
  rawContentBuffers.delete(currentAssistant.id);
1920
+ seqChunkBuffers.delete(currentAssistant.id);
1716
1921
  emitMessage(currentAssistant);
1717
1922
  }
1718
1923
  }
@@ -1744,6 +1949,7 @@ export class AgentWidgetClient {
1744
1949
  streamParsers.delete(assistant.id);
1745
1950
  }
1746
1951
  rawContentBuffers.delete(assistant.id);
1952
+ seqChunkBuffers.delete(assistant.id);
1747
1953
  assistant.streaming = false;
1748
1954
  emitMessage(assistant);
1749
1955
  }
@@ -1758,17 +1964,29 @@ export class AgentWidgetClient {
1758
1964
  continue;
1759
1965
  }
1760
1966
  if (didSplitByPartId) {
1761
- // text_end already sealed the assistant message(s) — don't recreate
1762
- // one from step_complete's full response (would cause duplication)
1967
+ // Sealed segment(s) — do not create a second bubble from step_complete.
1968
+ // Merge authoritative final response into the last sealed segment (fixes async lag).
1763
1969
  if (assistantMessage !== null) {
1764
1970
  const msg: AgentWidgetMessage = assistantMessage;
1765
1971
  streamParsers.delete(msg.id);
1766
1972
  rawContentBuffers.delete(msg.id);
1973
+ seqChunkBuffers.delete(msg.id);
1767
1974
  if (msg.streaming !== false) {
1768
1975
  msg.streaming = false;
1769
1976
  emitMessage(msg);
1770
1977
  }
1771
1978
  }
1979
+ const splitFinalContent = payload.result?.response;
1980
+ const sealedForReconcile = lastSealedTextSegment;
1981
+ if (sealedForReconcile) {
1982
+ if (splitFinalContent !== undefined && splitFinalContent !== null) {
1983
+ reconcileSealedAssistantWithFinalResponse(sealedForReconcile, splitFinalContent);
1984
+ } else {
1985
+ streamParsers.delete(sealedForReconcile.id);
1986
+ rawContentBuffers.delete(sealedForReconcile.id);
1987
+ }
1988
+ }
1989
+ lastSealedTextSegment = null;
1772
1990
  continue;
1773
1991
  }
1774
1992
  const finalContent = payload.result?.response;
@@ -1817,6 +2035,7 @@ export class AgentWidgetClient {
1817
2035
  // Clean up
1818
2036
  streamParsers.delete(currentAssistant.id);
1819
2037
  rawContentBuffers.delete(currentAssistant.id);
2038
+ seqChunkBuffers.delete(currentAssistant.id);
1820
2039
  emitMessage(currentAssistant);
1821
2040
  }
1822
2041
  } else {
@@ -1834,6 +2053,7 @@ export class AgentWidgetClient {
1834
2053
  // Clean up
1835
2054
  streamParsers.delete(currentAssistant.id);
1836
2055
  rawContentBuffers.delete(currentAssistant.id);
2056
+ seqChunkBuffers.delete(currentAssistant.id);
1837
2057
  emitMessage(currentAssistant);
1838
2058
  }
1839
2059
  }
@@ -1881,6 +2101,7 @@ export class AgentWidgetClient {
1881
2101
  }
1882
2102
  streamParsers.delete(assistant.id);
1883
2103
  rawContentBuffers.delete(assistant.id);
2104
+ seqChunkBuffers.delete(assistant.id);
1884
2105
  assistant.streaming = false;
1885
2106
  emitMessage(assistant);
1886
2107
  }
@@ -1888,6 +2109,7 @@ export class AgentWidgetClient {
1888
2109
  // No final content, just mark as complete and clean up
1889
2110
  streamParsers.delete(assistant.id);
1890
2111
  rawContentBuffers.delete(assistant.id);
2112
+ seqChunkBuffers.delete(assistant.id);
1891
2113
  assistant.streaming = false;
1892
2114
  emitMessage(assistant);
1893
2115
  }
@@ -1900,6 +2122,7 @@ export class AgentWidgetClient {
1900
2122
  const msg: AgentWidgetMessage = assistantMessage;
1901
2123
  streamParsers.delete(msg.id);
1902
2124
  rawContentBuffers.delete(msg.id);
2125
+ seqChunkBuffers.delete(msg.id);
1903
2126
  if (msg.streaming !== false) {
1904
2127
  msg.streaming = false;
1905
2128
  emitMessage(msg);
@@ -1926,12 +2149,9 @@ export class AgentWidgetClient {
1926
2149
  // Extract text from result (could be string or object)
1927
2150
  const text = typeof result === 'string' ? result : result?.text ?? null;
1928
2151
  if (text !== null) {
1929
- const currentAssistant = assistantMessage;
1930
- if (currentAssistant && currentAssistant.id === assistant.id) {
1931
- currentAssistant.content = text;
1932
- currentAssistant.streaming = false;
1933
- emitMessage(currentAssistant);
1934
- }
2152
+ assistant.content = text;
2153
+ assistant.streaming = false;
2154
+ emitMessage(assistant);
1935
2155
  }
1936
2156
  });
1937
2157
  }
@@ -1944,7 +2164,8 @@ export class AgentWidgetClient {
1944
2164
  // Clean up parser and buffer
1945
2165
  streamParsers.delete(assistant.id);
1946
2166
  rawContentBuffers.delete(assistant.id);
1947
-
2167
+ seqChunkBuffers.delete(assistant.id);
2168
+
1948
2169
  // Only emit if something actually changed to avoid flicker
1949
2170
  const contentChanged = displayContent !== assistant.content;
1950
2171
  const streamingChanged = assistant.streaming !== false;
@@ -1966,6 +2187,7 @@ export class AgentWidgetClient {
1966
2187
  const msg: AgentWidgetMessage = assistantMessage;
1967
2188
  streamParsers.delete(msg.id);
1968
2189
  rawContentBuffers.delete(msg.id);
2190
+ seqChunkBuffers.delete(msg.id);
1969
2191
 
1970
2192
  // Only emit if streaming state changed
1971
2193
  if (msg.streaming !== false) {
@@ -2369,6 +2591,7 @@ export class AgentWidgetClient {
2369
2591
  assistantMessageRef.current = null;
2370
2592
  streamParsers.delete(id);
2371
2593
  rawContentBuffers.delete(id);
2594
+ seqChunkBuffers.delete(id);
2372
2595
  } else if (payloadType === "error" && payload.error) {
2373
2596
  onEvent({
2374
2597
  type: "error",
@@ -21,7 +21,7 @@ export const renderMessages = (
21
21
  let bubble: HTMLElement;
22
22
  if (message.variant === "reasoning" && message.reasoning) {
23
23
  if (!showReasoning) return;
24
- bubble = createReasoningBubble(message);
24
+ bubble = createReasoningBubble(message, config);
25
25
  } else if (message.variant === "tool" && message.toolCall) {
26
26
  if (!showToolCalls) return;
27
27
  bubble = createToolBubble(message, config);
@@ -1,16 +1,41 @@
1
1
  import { createElement } from "../utils/dom";
2
- import { AgentWidgetMessage } from "../types";
2
+ import { AgentWidgetConfig, AgentWidgetMessage } from "../types";
3
3
  import { describeReasonStatus } from "../utils/formatting";
4
4
  import { renderLucideIcon } from "../utils/icons";
5
5
 
6
6
  // Expansion state per widget instance
7
7
  export const reasoningExpansionState = new Set<string>();
8
8
 
9
+ const appendRenderedValue = (
10
+ container: HTMLElement,
11
+ value: HTMLElement | string | null | undefined
12
+ ): boolean => {
13
+ if (value == null) return false;
14
+ if (typeof value === "string") {
15
+ container.textContent = value;
16
+ return true;
17
+ }
18
+ container.appendChild(value);
19
+ return true;
20
+ };
21
+
22
+ const getReasoningPreviewText = (message: AgentWidgetMessage, maxLines: number): string => {
23
+ const text = message.reasoning?.chunks.join("").trim() ?? "";
24
+ if (!text) return "";
25
+ return text
26
+ .split(/\r?\n/)
27
+ .map((line) => line.trim())
28
+ .filter(Boolean)
29
+ .slice(0, maxLines)
30
+ .join("\n");
31
+ };
32
+
9
33
  // Helper function to update reasoning bubble UI after expansion state changes
10
34
  export const updateReasoningBubbleUI = (messageId: string, bubble: HTMLElement): void => {
11
35
  const expanded = reasoningExpansionState.has(messageId);
12
36
  const header = bubble.querySelector('button[data-expand-header="true"]') as HTMLElement;
13
37
  const content = bubble.querySelector('.persona-border-t') as HTMLElement;
38
+ const preview = bubble.querySelector('[data-persona-collapsed-preview="reasoning"]') as HTMLElement | null;
14
39
 
15
40
  if (!header || !content) return;
16
41
 
@@ -31,9 +56,14 @@ export const updateReasoningBubbleUI = (messageId: string, bubble: HTMLElement):
31
56
  }
32
57
 
33
58
  content.style.display = expanded ? "" : "none";
59
+ if (preview) {
60
+ preview.style.display = expanded
61
+ ? "none"
62
+ : ((preview.textContent || preview.childNodes.length) ? "" : "none");
63
+ }
34
64
  };
35
65
 
36
- export const createReasoningBubble = (message: AgentWidgetMessage): HTMLElement => {
66
+ export const createReasoningBubble = (message: AgentWidgetMessage, config?: AgentWidgetConfig): HTMLElement => {
37
67
  const reasoning = message.reasoning;
38
68
  const bubble = createElement(
39
69
  "div",
@@ -61,20 +91,44 @@ export const createReasoningBubble = (message: AgentWidgetMessage): HTMLElement
61
91
  return bubble;
62
92
  }
63
93
 
64
- let expanded = reasoningExpansionState.has(message.id);
94
+ const reasoningDisplayConfig = config?.features?.reasoningDisplay ?? {};
95
+ const expandable = reasoningDisplayConfig.expandable !== false;
96
+ let expanded = expandable && reasoningExpansionState.has(message.id);
97
+ const isActive = reasoning.status !== "complete";
98
+ const previewText = getReasoningPreviewText(message, reasoningDisplayConfig.previewMaxLines ?? 3);
65
99
  const header = createElement(
66
100
  "button",
67
- "persona-flex persona-w-full persona-items-center persona-justify-between persona-gap-3 persona-bg-transparent persona-px-4 persona-py-3 persona-text-left persona-cursor-pointer persona-border-none"
101
+ expandable
102
+ ? "persona-flex persona-w-full persona-items-center persona-justify-between persona-gap-3 persona-bg-transparent persona-px-4 persona-py-3 persona-text-left persona-cursor-pointer persona-border-none"
103
+ : "persona-flex persona-w-full persona-items-center persona-justify-between persona-gap-3 persona-bg-transparent persona-px-4 persona-py-3 persona-text-left persona-cursor-default persona-border-none"
68
104
  ) as HTMLButtonElement;
69
105
  header.type = "button";
70
- header.setAttribute("aria-expanded", expanded ? "true" : "false");
71
- header.setAttribute("data-expand-header", "true");
106
+ if (expandable) {
107
+ header.setAttribute("aria-expanded", expanded ? "true" : "false");
108
+ header.setAttribute("data-expand-header", "true");
109
+ }
72
110
  header.setAttribute("data-bubble-type", "reasoning");
73
111
 
74
112
  const headerContent = createElement("div", "persona-flex persona-flex-col persona-text-left");
75
113
  const title = createElement("span", "persona-text-xs persona-text-persona-primary");
76
- title.textContent = "Thinking...";
77
- headerContent.appendChild(title);
114
+ const defaultSummary = "Thinking...";
115
+ const customSummary = config?.reasoning?.renderCollapsedSummary?.({
116
+ message,
117
+ reasoning,
118
+ defaultSummary,
119
+ previewText,
120
+ isActive,
121
+ config: config ?? {},
122
+ });
123
+ if (typeof customSummary === "string" && customSummary.trim()) {
124
+ title.textContent = customSummary;
125
+ headerContent.appendChild(title);
126
+ } else if (customSummary instanceof HTMLElement) {
127
+ headerContent.appendChild(customSummary);
128
+ } else {
129
+ title.textContent = defaultSummary;
130
+ headerContent.appendChild(title);
131
+ }
78
132
 
79
133
  const status = createElement("span", "persona-text-xs persona-text-persona-primary");
80
134
  status.textContent = describeReasonStatus(reasoning);
@@ -86,20 +140,54 @@ export const createReasoningBubble = (message: AgentWidgetMessage): HTMLElement
86
140
  title.style.display = "";
87
141
  }
88
142
 
89
- const toggleIcon = createElement("div", "persona-flex persona-items-center");
90
- const iconColor = "currentColor";
91
- const chevronIcon = renderLucideIcon(expanded ? "chevron-up" : "chevron-down", 16, iconColor, 2);
92
- if (chevronIcon) {
93
- toggleIcon.appendChild(chevronIcon);
143
+ let toggleIcon: HTMLElement | null = null;
144
+ if (expandable) {
145
+ toggleIcon = createElement("div", "persona-flex persona-items-center");
146
+ const iconColor = "currentColor";
147
+ const chevronIcon = renderLucideIcon(expanded ? "chevron-up" : "chevron-down", 16, iconColor, 2);
148
+ if (chevronIcon) {
149
+ toggleIcon.appendChild(chevronIcon);
150
+ } else {
151
+ toggleIcon.textContent = expanded ? "Hide" : "Show";
152
+ }
153
+
154
+ const headerMeta = createElement("div", "persona-flex persona-items-center persona-ml-auto");
155
+ headerMeta.append(toggleIcon);
156
+ header.append(headerContent, headerMeta);
94
157
  } else {
95
- // Fallback to text if icon fails
96
- toggleIcon.textContent = expanded ? "Hide" : "Show";
158
+ header.append(headerContent);
97
159
  }
98
160
 
99
- const headerMeta = createElement("div", "persona-flex persona-items-center persona-ml-auto");
100
- headerMeta.append(toggleIcon);
161
+ const collapsedPreview = createElement(
162
+ "div",
163
+ "persona-px-4 persona-py-3 persona-text-xs persona-leading-snug persona-text-persona-muted"
164
+ );
165
+ collapsedPreview.setAttribute("data-persona-collapsed-preview", "reasoning");
166
+ collapsedPreview.style.display = "none";
167
+ collapsedPreview.style.whiteSpace = "pre-wrap";
168
+
169
+ if (!expanded && isActive && reasoningDisplayConfig.activePreview && previewText) {
170
+ const renderedPreview = config?.reasoning?.renderCollapsedPreview?.({
171
+ message,
172
+ reasoning,
173
+ defaultPreview: previewText,
174
+ isActive,
175
+ config: config ?? {},
176
+ });
177
+ if (!appendRenderedValue(collapsedPreview, renderedPreview)) {
178
+ collapsedPreview.textContent = previewText;
179
+ }
180
+ collapsedPreview.style.display = "";
181
+ }
101
182
 
102
- header.append(headerContent, headerMeta);
183
+ if (!expanded && isActive && reasoningDisplayConfig.activeMinHeight) {
184
+ bubble.style.minHeight = reasoningDisplayConfig.activeMinHeight;
185
+ }
186
+
187
+ if (!expandable) {
188
+ bubble.append(header, collapsedPreview);
189
+ return bubble;
190
+ }
103
191
 
104
192
  const content = createElement(
105
193
  "div",
@@ -121,22 +209,23 @@ export const createReasoningBubble = (message: AgentWidgetMessage): HTMLElement
121
209
 
122
210
  const applyExpansionState = () => {
123
211
  header.setAttribute("aria-expanded", expanded ? "true" : "false");
124
- // Update chevron icon
125
- toggleIcon.innerHTML = "";
126
- const iconColor = "currentColor";
127
- const chevronIcon = renderLucideIcon(expanded ? "chevron-up" : "chevron-down", 16, iconColor, 2);
128
- if (chevronIcon) {
129
- toggleIcon.appendChild(chevronIcon);
130
- } else {
131
- // Fallback to text if icon fails
132
- toggleIcon.textContent = expanded ? "Hide" : "Show";
212
+ if (toggleIcon) {
213
+ toggleIcon.innerHTML = "";
214
+ const iconColor = "currentColor";
215
+ const chevronIcon = renderLucideIcon(expanded ? "chevron-up" : "chevron-down", 16, iconColor, 2);
216
+ if (chevronIcon) {
217
+ toggleIcon.appendChild(chevronIcon);
218
+ } else {
219
+ toggleIcon.textContent = expanded ? "Hide" : "Show";
220
+ }
133
221
  }
134
222
  content.style.display = expanded ? "" : "none";
223
+ collapsedPreview.style.display = expanded ? "none" : ((collapsedPreview.textContent || collapsedPreview.childNodes.length) ? "" : "none");
135
224
  };
136
225
 
137
226
  applyExpansionState();
138
227
 
139
- bubble.append(header, content);
228
+ bubble.append(header, collapsedPreview, content);
140
229
  return bubble;
141
230
  };
142
231