@runtypelabs/persona 4.0.0 → 4.1.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.
@@ -2929,7 +2929,7 @@ type AgentWidgetStatusIndicatorConfig = {
2929
2929
  type AgentWidgetVoiceRecognitionConfig = {
2930
2930
  enabled?: boolean;
2931
2931
  pauseDuration?: number;
2932
- /** Text shown in the user message placeholder while voice is being processed. Default: "🎤 Processing voice..." */
2932
+ /** Text shown in the user message placeholder while voice is being processed. Default: "Processing voice..." */
2933
2933
  processingText?: string;
2934
2934
  /** Text shown in the assistant message if voice processing fails. Default: "Voice processing failed. Please try again." */
2935
2935
  processingErrorText?: string;
@@ -2929,7 +2929,7 @@ type AgentWidgetStatusIndicatorConfig = {
2929
2929
  type AgentWidgetVoiceRecognitionConfig = {
2930
2930
  enabled?: boolean;
2931
2931
  pauseDuration?: number;
2932
- /** Text shown in the user message placeholder while voice is being processed. Default: "🎤 Processing voice..." */
2932
+ /** Text shown in the user message placeholder while voice is being processed. Default: "Processing voice..." */
2933
2933
  processingText?: string;
2934
2934
  /** Text shown in the assistant message if voice processing fails. Default: "Voice processing failed. Please try again." */
2935
2935
  processingErrorText?: string;
package/dist/widget.css CHANGED
@@ -1621,6 +1621,20 @@
1621
1621
  background-color: rgba(0, 0, 0, 0.02);
1622
1622
  }
1623
1623
 
1624
+ /* While a message is still streaming, lock table column widths so incoming rows
1625
+ append without the per-chunk horizontal reflow (Telegram-style space
1626
+ reservation). The class is removed on the final render, relaxing the finished
1627
+ table to natural content-fit widths. overflow-wrap keeps long cell content
1628
+ from spilling past the fixed columns mid-stream. */
1629
+ .persona-content-streaming table {
1630
+ table-layout: fixed;
1631
+ }
1632
+
1633
+ .persona-content-streaming th,
1634
+ .persona-content-streaming td {
1635
+ overflow-wrap: break-word;
1636
+ }
1637
+
1624
1638
  /* ============================================
1625
1639
  Markdown Horizontal Rule Styles
1626
1640
  ============================================ */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -795,6 +795,13 @@ export const createStandardBubble = (
795
795
  const contentDiv = document.createElement("div");
796
796
  contentDiv.classList.add("persona-message-content");
797
797
 
798
+ // While streaming, lock table column widths (see widget.css) so rows append
799
+ // without per-chunk horizontal reflow. The class is dropped on the final,
800
+ // non-streaming render, relaxing tables to natural content-fit widths.
801
+ if (message.streaming) {
802
+ contentDiv.classList.add("persona-content-streaming");
803
+ }
804
+
798
805
  if (streamAnimationActive && streamPlugin) {
799
806
  if (streamPlugin.containerClass) {
800
807
  contentDiv.classList.add(streamPlugin.containerClass);
@@ -1621,6 +1621,20 @@
1621
1621
  background-color: rgba(0, 0, 0, 0.02);
1622
1622
  }
1623
1623
 
1624
+ /* While a message is still streaming, lock table column widths so incoming rows
1625
+ append without the per-chunk horizontal reflow (Telegram-style space
1626
+ reservation). The class is removed on the final render, relaxing the finished
1627
+ table to natural content-fit widths. overflow-wrap keeps long cell content
1628
+ from spilling past the fixed columns mid-stream. */
1629
+ .persona-content-streaming table {
1630
+ table-layout: fixed;
1631
+ }
1632
+
1633
+ .persona-content-streaming th,
1634
+ .persona-content-streaming td {
1635
+ overflow-wrap: break-word;
1636
+ }
1637
+
1624
1638
  /* ============================================
1625
1639
  Markdown Horizontal Rule Styles
1626
1640
  ============================================ */
package/src/types.ts CHANGED
@@ -1791,7 +1791,7 @@ export type AgentWidgetStatusIndicatorConfig = {
1791
1791
  export type AgentWidgetVoiceRecognitionConfig = {
1792
1792
  enabled?: boolean;
1793
1793
  pauseDuration?: number;
1794
- /** Text shown in the user message placeholder while voice is being processed. Default: "🎤 Processing voice..." */
1794
+ /** Text shown in the user message placeholder while voice is being processed. Default: "Processing voice..." */
1795
1795
  processingText?: string;
1796
1796
  /** Text shown in the assistant message if voice processing fails. Default: "Voice processing failed. Please try again." */
1797
1797
  processingErrorText?: string;
package/src/ui.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { escapeHtml, createMarkdownProcessorFromConfig } from "./postprocessors";
2
2
  import { resolveSanitizer } from "./utils/sanitize";
3
+ import { stabilizeStreamingTables } from "./utils/streaming-table";
3
4
  import { loadMarkdownParsers, getMarkdownParsersSync } from "./markdown-parsers-loader";
4
5
  import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
5
6
  import {
@@ -473,8 +474,12 @@ export const buildPostprocessor = (
473
474
  // sanitizer's degraded fallback. Honors `sanitize: false` (pass-through) as before.
474
475
  html = sanitize ? sanitize(out) : out;
475
476
  } else if (markdownProcessor) {
477
+ // While streaming, normalize tables-in-progress so they render as a real
478
+ // <table> from the first row with a stable column count (Telegram-style
479
+ // space reservation). The final, non-streaming render is left untouched.
480
+ const source = context.streaming ? stabilizeStreamingTables(nextText) : nextText;
476
481
  // Already escapeHtml(text) (single, safe) while parsers are not loaded.
477
- const out = markdownProcessor(nextText);
482
+ const out = markdownProcessor(source);
478
483
  html = sanitize && parsersReady ? sanitize(out) : out;
479
484
  } else {
480
485
  // Plain text: escapeHtml output is inert — never re-sanitize (the second escape).
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { stabilizeStreamingTables } from "./streaming-table";
3
+ import { createMarkdownProcessor } from "../postprocessors";
4
+
5
+ describe("stabilizeStreamingTables", () => {
6
+ it("leaves text without pipes untouched", () => {
7
+ const md = "# Heading\n\nA paragraph with no tables.";
8
+ expect(stabilizeStreamingTables(md)).toBe(md);
9
+ });
10
+
11
+ it("does not treat a header-only line as a table (no delimiter yet)", () => {
12
+ // Ambiguous: could just be inline pipe text. Leave it alone until a
13
+ // delimiter row starts streaming in.
14
+ const md = "| Name | Price |";
15
+ expect(stabilizeStreamingTables(md)).toBe(md);
16
+ });
17
+
18
+ it("completes a partial delimiter row so the table renders immediately", () => {
19
+ const md = "| Name | Price |\n| --";
20
+ expect(stabilizeStreamingTables(md)).toBe("| Name | Price |\n| --- | --- |");
21
+ });
22
+
23
+ it("completes a delimiter that is just a leading pipe with one dash", () => {
24
+ const md = "| A | B | C |\n|-";
25
+ expect(stabilizeStreamingTables(md)).toBe("| A | B | C |\n| --- | --- | --- |");
26
+ });
27
+
28
+ it("pads a partial trailing row to the header column count", () => {
29
+ const md = "| Name | Price |\n| --- | --- |\n| Apple";
30
+ expect(stabilizeStreamingTables(md)).toBe(
31
+ "| Name | Price |\n| --- | --- |\n| Apple | |"
32
+ );
33
+ });
34
+
35
+ it("keeps a complete table stable (idempotent)", () => {
36
+ const md = "| Name | Price |\n| --- | --- |\n| Apple | $1 |";
37
+ expect(stabilizeStreamingTables(md)).toBe(md);
38
+ expect(stabilizeStreamingTables(stabilizeStreamingTables(md))).toBe(md);
39
+ });
40
+
41
+ it("counts columns from a header without outer pipes", () => {
42
+ const md = "Name | Price\n---";
43
+ expect(stabilizeStreamingTables(md)).toBe("Name | Price\n| --- | --- |");
44
+ });
45
+
46
+ it("normalizes rows with extra cells down to the header column count", () => {
47
+ const md = "| A | B |\n| --- | --- |\n| 1 | 2 | 3 |";
48
+ expect(stabilizeStreamingTables(md)).toBe("| A | B |\n| --- | --- |\n| 1 | 2 |");
49
+ });
50
+
51
+ it("stops the table region at a blank line and leaves following text alone", () => {
52
+ const md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nSome | trailing | text";
53
+ expect(stabilizeStreamingTables(md)).toBe(md);
54
+ });
55
+
56
+ it("preserves leading prose before a streaming table", () => {
57
+ const md = "Here you go:\n\n| A | B |\n| -";
58
+ expect(stabilizeStreamingTables(md)).toBe("Here you go:\n\n| A | B |\n| --- | --- |");
59
+ });
60
+
61
+ it("handles two separate tables in one stream", () => {
62
+ const md =
63
+ "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nmiddle\n\n| C | D | E |\n| --";
64
+ expect(stabilizeStreamingTables(md)).toBe(
65
+ "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nmiddle\n\n| C | D | E |\n| --- | --- | --- |"
66
+ );
67
+ });
68
+
69
+ it("preserves alignment-colon delimiters as a valid table start", () => {
70
+ const md = "| A | B |\n| :-";
71
+ // Detection accepts colons; the synthesized delimiter normalizes to dashes
72
+ // (alignment reappears in the untouched final render).
73
+ expect(stabilizeStreamingTables(md)).toBe("| A | B |\n| --- | --- |");
74
+ });
75
+ });
76
+
77
+ describe("stabilizeStreamingTables → marked integration", () => {
78
+ const md = createMarkdownProcessor();
79
+
80
+ it("renders a <table> from a header + partial delimiter that marked alone would not", () => {
81
+ const partial = "| Name | Price |\n| --";
82
+
83
+ // Without stabilization marked sees no complete delimiter → no table.
84
+ expect(md(partial)).not.toContain("<table");
85
+
86
+ const html = md(stabilizeStreamingTables(partial));
87
+ expect(html).toContain("<table");
88
+ expect(html).toContain("<th>Name</th>");
89
+ expect(html).toContain("<th>Price</th>");
90
+ });
91
+
92
+ it("renders a body row for a partial trailing row", () => {
93
+ const partial = "| Name | Price |\n| --- | --- |\n| Apple";
94
+ const html = md(stabilizeStreamingTables(partial));
95
+ expect(html).toContain("<table");
96
+ expect(html).toContain("<td>Apple</td>");
97
+ // The padded empty cell keeps the column count stable.
98
+ expect(html.match(/<td/g)?.length).toBe(2);
99
+ });
100
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Streaming markdown table stabilizer (Telegram-style space reservation).
3
+ *
4
+ * During SSE streaming the full accumulated markdown is re-parsed by `marked`
5
+ * on every chunk. For GFM tables that produces two jarring jolts:
6
+ *
7
+ * 1. The paragraph→table flip. GFM only recognizes a table once the *delimiter*
8
+ * row (`| --- | --- |`) has arrived, so a freshly-streamed header line first
9
+ * renders as a plain paragraph and then snaps into a `<table>`.
10
+ * 2. Partial-row flicker. The in-flight last row grows cell-by-cell.
11
+ *
12
+ * This module rewrites table-in-progress regions so a real `<table>` renders
13
+ * from the first row onward with a stable column count: it completes the
14
+ * delimiter row as soon as it starts streaming and pads the trailing partial
15
+ * row to the header's column count. Combined with `table-layout: fixed` while
16
+ * streaming (see `.persona-content-streaming table` in widget.css), columns lock
17
+ * to even widths so rows append vertically without horizontal reflow.
18
+ *
19
+ * It runs ONLY while a message is streaming; the final render uses the real,
20
+ * untouched `marked` output, so correctness is never affected.
21
+ */
22
+
23
+ /**
24
+ * A GFM delimiter row, full or still streaming in: only delimiter characters
25
+ * (`-`, `:`, `|`, whitespace) and at least one dash. Matches `|`-led partials
26
+ * like `| -`, `|--`, `| :--`, and complete rows like `| --- | :--: |`.
27
+ */
28
+ const DELIMITER_RE = /^\s*\|?[\s:|-]*-[\s:|-]*$/;
29
+
30
+ /** A candidate table row contains at least one pipe. */
31
+ const hasPipe = (line: string): boolean => line.includes("|");
32
+
33
+ /** Split a markdown table row into trimmed cell strings, ignoring outer pipes. */
34
+ const splitCells = (line: string): string[] => {
35
+ let s = line.trim();
36
+ if (s.startsWith("|")) s = s.slice(1);
37
+ if (s.endsWith("|")) s = s.slice(0, -1);
38
+ return s.split("|").map((cell) => cell.trim());
39
+ };
40
+
41
+ /** Render cells back into a normalized, pipe-delimited row. */
42
+ const buildRow = (cells: string[]): string => `| ${cells.join(" | ")} |`;
43
+
44
+ /** Build a complete delimiter row with the given column count. */
45
+ const buildDelimiter = (cols: number): string =>
46
+ `| ${Array.from({ length: cols }, () => "---").join(" | ")} |`;
47
+
48
+ /** Pad (or trim) a row's cells to exactly `cols` columns. */
49
+ const fitCells = (cells: string[], cols: number): string[] => {
50
+ if (cells.length >= cols) return cells.slice(0, cols);
51
+ return cells.concat(Array.from({ length: cols - cells.length }, () => ""));
52
+ };
53
+
54
+ /**
55
+ * Normalize any streaming-in-progress GFM tables in `markdown` so they render as
56
+ * complete tables with a stable column count. Returns the input unchanged when
57
+ * there is nothing to stabilize (cheap fast-path for the common no-table case).
58
+ */
59
+ export const stabilizeStreamingTables = (markdown: string): string => {
60
+ if (!markdown || !markdown.includes("|")) return markdown;
61
+
62
+ const lines = markdown.split("\n");
63
+ let changed = false;
64
+
65
+ for (let i = 0; i < lines.length - 1; i++) {
66
+ const header = lines[i];
67
+ const delimiter = lines[i + 1];
68
+
69
+ // A table starts at a header line (has a pipe, is not itself a delimiter)
70
+ // immediately followed by a delimiter row that is full or still streaming.
71
+ if (!hasPipe(header) || DELIMITER_RE.test(header)) continue;
72
+ if (!DELIMITER_RE.test(delimiter)) continue;
73
+
74
+ const cols = splitCells(header).length;
75
+ if (cols < 1) continue;
76
+
77
+ // Complete the delimiter to match the header's column count so `marked`
78
+ // recognizes the table immediately instead of waiting for it to finish.
79
+ const fullDelimiter = buildDelimiter(cols);
80
+ if (lines[i + 1] !== fullDelimiter) {
81
+ lines[i + 1] = fullDelimiter;
82
+ changed = true;
83
+ }
84
+
85
+ // Normalize body rows (including a partial trailing one) to `cols` columns
86
+ // so each row occupies its slot instead of growing cell-by-cell. The region
87
+ // ends at the first blank or pipe-less line.
88
+ let j = i + 2;
89
+ for (; j < lines.length; j++) {
90
+ const row = lines[j];
91
+ if (row.trim() === "" || !hasPipe(row)) break;
92
+ const normalized = buildRow(fitCells(splitCells(row), cols));
93
+ if (lines[j] !== normalized) {
94
+ lines[j] = normalized;
95
+ changed = true;
96
+ }
97
+ }
98
+
99
+ i = j - 1; // resume scanning after this table region
100
+ }
101
+
102
+ return changed ? lines.join("\n") : markdown;
103
+ };