@runtypelabs/persona 4.0.0 → 4.2.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.
@@ -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
  ============================================ */
@@ -2391,6 +2405,230 @@
2391
2405
  transform: scale(0.97);
2392
2406
  }
2393
2407
 
2408
+ /* ============================================
2409
+ Approval card (default renderer, components/approval-actions.ts)
2410
+ Neutral surface card. Every color is a --persona-* token; the legacy
2411
+ --persona-approval-* shorthands are honored fallback-first so a consumer who
2412
+ themed them still wins, then the new semantic default applies.
2413
+ ============================================ */
2414
+ .persona-approval-card {
2415
+ width: 100%;
2416
+ max-width: 100%;
2417
+ box-sizing: border-box;
2418
+ background: var(--persona-approval-bg, var(--persona-surface, #ffffff));
2419
+ border: 0.5px solid var(--persona-approval-border, var(--persona-border, rgba(11, 11, 11, 0.1)));
2420
+ border-radius: 0.75rem;
2421
+ padding: 0.5rem;
2422
+ }
2423
+ .persona-approval-card.persona-shadow-sm {
2424
+ box-shadow: var(--persona-approval-shadow, 0 1px 2px 0 rgba(11, 11, 11, 0.06), 0 2px 8px 0 rgba(11, 11, 11, 0.04));
2425
+ }
2426
+
2427
+ .persona-approval-head {
2428
+ display: flex;
2429
+ align-items: center;
2430
+ gap: 0.75rem;
2431
+ width: 100%;
2432
+ border: none;
2433
+ background: transparent;
2434
+ padding: 0.4rem 0.6rem;
2435
+ cursor: pointer;
2436
+ text-align: left;
2437
+ font: inherit;
2438
+ color: var(--persona-text, #0b0b0b);
2439
+ border-radius: 0.5rem;
2440
+ transition: background 0.12s ease;
2441
+ }
2442
+ .persona-approval-head:hover { background: var(--persona-container, rgba(11, 11, 11, 0.04)); }
2443
+ .persona-approval-head[data-static="true"] { cursor: default; }
2444
+ .persona-approval-head[data-static="true"]:hover { background: transparent; }
2445
+
2446
+ .persona-approval-logo {
2447
+ flex-shrink: 0;
2448
+ width: 24px;
2449
+ height: 24px;
2450
+ display: grid;
2451
+ place-items: center;
2452
+ box-sizing: border-box;
2453
+ border: 0.5px solid var(--persona-border, rgba(11, 11, 11, 0.12));
2454
+ border-radius: 6.5px;
2455
+ background: var(--persona-surface, #ffffff);
2456
+ box-shadow: 0 1px 2px 0 rgba(11, 11, 11, 0.08);
2457
+ color: var(--persona-muted, #6b7280);
2458
+ overflow: hidden;
2459
+ }
2460
+ .persona-approval-logo svg { width: 15px; height: 15px; display: block; }
2461
+
2462
+ .persona-approval-title {
2463
+ flex: 1;
2464
+ min-width: 0;
2465
+ font-size: 0.875rem;
2466
+ line-height: 1.4;
2467
+ color: var(--persona-text, #0b0b0b);
2468
+ }
2469
+ .persona-approval-title strong { font-weight: 600; }
2470
+
2471
+ .persona-approval-toggle {
2472
+ display: inline-flex;
2473
+ align-items: center;
2474
+ margin-left: 0.25rem;
2475
+ color: var(--persona-muted, #6b7280);
2476
+ vertical-align: middle;
2477
+ transition: transform 0.15s ease;
2478
+ }
2479
+ .persona-approval-toggle svg { width: 0.85rem; height: 0.85rem; display: block; }
2480
+ .persona-approval-head[aria-expanded="false"] .persona-approval-toggle { transform: rotate(-90deg); }
2481
+
2482
+ .persona-approval-body {
2483
+ padding: 0.5rem 0.6rem 0.35rem calc(24px + 0.75rem + 0.6rem);
2484
+ }
2485
+
2486
+ .persona-approval-details {
2487
+ margin: 0 0 0.75rem;
2488
+ }
2489
+ .persona-approval-details[hidden] { display: none; }
2490
+
2491
+ .persona-approval-desc {
2492
+ margin: 0 0 0.5rem;
2493
+ font-size: 0.8125rem;
2494
+ line-height: 1.4;
2495
+ color: var(--persona-muted, #6b7280);
2496
+ }
2497
+
2498
+ .persona-approval-params {
2499
+ margin: 0;
2500
+ padding: 0.7rem 0.85rem;
2501
+ border-radius: 0.625rem;
2502
+ background: var(--persona-container, rgba(11, 11, 11, 0.04));
2503
+ color: var(--persona-text, #1f2937);
2504
+ font-size: 0.75rem;
2505
+ line-height: 1rem;
2506
+ overflow-x: auto;
2507
+ max-height: 9rem;
2508
+ }
2509
+
2510
+ .persona-approval-reason {
2511
+ margin: 0 0 0.6rem;
2512
+ font-size: 0.8125rem;
2513
+ line-height: 1.4;
2514
+ color: var(--persona-muted, #6b7280);
2515
+ }
2516
+ .persona-approval-reason-label { font-weight: 500; }
2517
+
2518
+ .persona-approval-resolved {
2519
+ display: flex;
2520
+ align-items: center;
2521
+ gap: 0.45rem;
2522
+ margin: 0.5rem 0;
2523
+ padding: 0.3rem 0.15rem;
2524
+ font-size: 0.8rem;
2525
+ color: var(--persona-muted, #6b7280);
2526
+ }
2527
+ .persona-approval-resolved svg { width: 0.95rem; height: 0.95rem; display: block; flex-shrink: 0; }
2528
+ .persona-approval-resolved-name { font-weight: 600; }
2529
+
2530
+ .persona-approval-actions {
2531
+ display: flex;
2532
+ align-items: center;
2533
+ gap: 0.5rem;
2534
+ flex-wrap: wrap;
2535
+ }
2536
+ .persona-approval-split { position: relative; display: inline-flex; }
2537
+
2538
+ .persona-approval-primary,
2539
+ .persona-approval-caret,
2540
+ .persona-approval-deny {
2541
+ border: none;
2542
+ cursor: pointer;
2543
+ font: inherit;
2544
+ font-size: 0.875rem;
2545
+ font-weight: 500;
2546
+ height: 2rem;
2547
+ display: inline-flex;
2548
+ align-items: center;
2549
+ gap: 0.375rem;
2550
+ transition: background 0.12s ease, box-shadow 0.12s ease;
2551
+ }
2552
+ /* Primary anchors to the brand token via the established button-primary pair. */
2553
+ .persona-approval-primary {
2554
+ background: var(--persona-approval-approve-bg, var(--persona-button-primary-bg, var(--persona-primary, #111827)));
2555
+ color: var(--persona-button-primary-fg, var(--persona-text-inverse, #ffffff));
2556
+ padding: 0 0.75rem;
2557
+ border-radius: 0.5rem 0 0 0.5rem;
2558
+ }
2559
+ .persona-approval-primary--solo { border-radius: 0.5rem; }
2560
+ .persona-approval-caret {
2561
+ position: relative;
2562
+ background: var(--persona-approval-approve-bg, var(--persona-button-primary-bg, var(--persona-primary, #111827)));
2563
+ color: var(--persona-button-primary-fg, var(--persona-text-inverse, #ffffff));
2564
+ width: 2rem;
2565
+ justify-content: center;
2566
+ padding: 0;
2567
+ border-radius: 0 0.5rem 0.5rem 0;
2568
+ }
2569
+ .persona-approval-caret svg { width: 0.95rem; height: 0.95rem; display: block; }
2570
+ .persona-approval-caret::before {
2571
+ content: "";
2572
+ position: absolute;
2573
+ left: 0;
2574
+ top: 4px;
2575
+ bottom: 4px;
2576
+ width: 1px;
2577
+ background: var(--persona-button-primary-fg, #ffffff);
2578
+ opacity: 0.25;
2579
+ transition: opacity 0.12s ease;
2580
+ }
2581
+ .persona-approval-caret:hover::before,
2582
+ .persona-approval-primary:hover + .persona-approval-caret::before { opacity: 0; }
2583
+ .persona-approval-primary:hover,
2584
+ .persona-approval-caret:hover { box-shadow: inset 0 0 0 999px rgba(255, 255, 255, 0.16); }
2585
+
2586
+ .persona-approval-deny {
2587
+ background: var(--persona-approval-deny-bg, var(--persona-container, rgba(11, 11, 11, 0.04)));
2588
+ color: var(--persona-text, #1f2937);
2589
+ padding: 0 0.75rem;
2590
+ border-radius: 0.5rem;
2591
+ box-shadow: inset 0 0 0 0.5px var(--persona-border, rgba(0, 0, 0, 0.1));
2592
+ }
2593
+ .persona-approval-deny:hover { box-shadow: inset 0 0 0 0.5px var(--persona-border, rgba(0, 0, 0, 0.1)), inset 0 0 0 999px rgba(11, 11, 11, 0.04); }
2594
+
2595
+ .persona-approval-menu {
2596
+ width: max-content;
2597
+ max-width: 18rem;
2598
+ box-sizing: border-box;
2599
+ background: var(--persona-surface, #ffffff);
2600
+ border: 0.5px solid var(--persona-border, rgba(11, 11, 11, 0.1));
2601
+ border-radius: 0.625rem;
2602
+ box-shadow: 0 8px 24px rgba(11, 11, 11, 0.12), 0 2px 6px rgba(11, 11, 11, 0.08);
2603
+ padding: 0.25rem;
2604
+ white-space: nowrap;
2605
+ }
2606
+ .persona-approval-menu-item {
2607
+ display: flex;
2608
+ align-items: center;
2609
+ gap: 0.6rem;
2610
+ width: 100%;
2611
+ border: none;
2612
+ background: transparent;
2613
+ cursor: pointer;
2614
+ font: inherit;
2615
+ font-size: 0.875rem;
2616
+ color: var(--persona-text, #1f2937);
2617
+ padding: 0.45rem 0.6rem;
2618
+ border-radius: 0.4rem;
2619
+ text-align: left;
2620
+ transition: background 0.12s ease;
2621
+ }
2622
+ .persona-approval-menu-item:hover { background: var(--persona-container, rgba(11, 11, 11, 0.06)); }
2623
+ .persona-approval-kbd {
2624
+ font-family: inherit;
2625
+ font-size: 0.75rem;
2626
+ line-height: 1;
2627
+ opacity: 0.7;
2628
+ margin-left: 0.25rem;
2629
+ }
2630
+ .persona-approval-menu-item .persona-approval-kbd { margin-left: auto; }
2631
+
2394
2632
  /* ============================================
2395
2633
  Visibility (Tailwind parity: prefix persona-)
2396
2634
  Shipped widget.css is hand-maintained; these utilities are used in TS but not generated by Tailwind at build time.
@@ -227,9 +227,9 @@ export const THEME_TOKEN_DOCS = {
227
227
  },
228
228
  approval: {
229
229
  description:
230
- 'Tool approval bubble styling and behavior. Set to false to disable.',
230
+ 'Tool approval card styling and behavior. Neutral surface card; the primary action anchors to the brand --persona-primary token. Set to false to disable. Set enableAlwaysAllow: true to add the split "Always allow / Allow once" control (needs a backend to persist the policy via onDecision\'s remember).',
231
231
  properties:
232
- 'backgroundColor, borderColor, titleColor, descriptionColor, reasonColor, reasonLabel, approveButtonColor, approveButtonTextColor, denyButtonColor, denyButtonTextColor, parameterBackgroundColor, parameterTextColor, title, approveLabel, denyLabel.',
232
+ 'enableAlwaysAllow, detailsDisplay ("collapsed" | "expanded" | "hidden"), showDetailsLabel, hideDetailsLabel, backgroundColor, borderColor, titleColor, descriptionColor, reasonColor, reasonLabel, approveButtonColor, approveButtonTextColor, denyButtonColor, denyButtonTextColor, parameterBackgroundColor, parameterTextColor, approveLabel, denyLabel, formatDescription. (title no longer renders in the default card; use formatDescription to customize the summary.)',
233
233
  },
234
234
  copy: {
235
235
  description: 'Widget text content.',
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;
@@ -2296,6 +2296,21 @@ export type AgentWidgetApprovalConfig = {
2296
2296
  showDetailsLabel?: string;
2297
2297
  /** Label for the toggle that hides the technical details */
2298
2298
  hideDetailsLabel?: string;
2299
+ /**
2300
+ * Offer an "Always allow" affordance in the default approval bubble.
2301
+ *
2302
+ * Default `false`. When `false`, the bubble shows a single "Allow" (allow
2303
+ * once) plus "Deny". When `true`, the primary action becomes a split control:
2304
+ * "Always allow" (resolves with `{ remember: true }`) with a dropdown for
2305
+ * "Allow once", plus "Deny", and keyboard shortcuts (Enter = Always allow,
2306
+ * Cmd/Ctrl+Enter = Allow once, Esc = Deny).
2307
+ *
2308
+ * Keep this off unless your backend actually persists the don't-ask-again
2309
+ * policy: "Always allow" only resolves the *current* approval — honoring it
2310
+ * for *future* calls is up to your `onDecision` handler (which receives
2311
+ * `options.remember`). Showing it without that wiring would mislead users.
2312
+ */
2313
+ enableAlwaysAllow?: boolean;
2299
2314
  /**
2300
2315
  * Build the user-facing summary line for an approval request. Overrides the
2301
2316
  * default "The assistant wants to use “Tool name”." copy. Return a falsy
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 {
@@ -98,6 +99,7 @@ import {
98
99
  } from "./suggest-replies-tool";
99
100
  import { formatElapsedMs } from "./utils/formatting";
100
101
  import { approvalDetailsExpansionState, createApprovalBubble, updateApprovalDetailsUI } from "./components/approval-bubble";
102
+ import { createBuiltInApprovalPlugin } from "./components/approval-actions";
101
103
  import { createSuggestions } from "./components/suggestions";
102
104
  import { EventStreamBuffer } from "./utils/event-stream-buffer";
103
105
  import { EventStreamStore } from "./utils/event-stream-store";
@@ -473,8 +475,12 @@ export const buildPostprocessor = (
473
475
  // sanitizer's degraded fallback. Honors `sanitize: false` (pass-through) as before.
474
476
  html = sanitize ? sanitize(out) : out;
475
477
  } else if (markdownProcessor) {
478
+ // While streaming, normalize tables-in-progress so they render as a real
479
+ // <table> from the first row with a stable column count (Telegram-style
480
+ // space reservation). The final, non-streaming render is left untouched.
481
+ const source = context.streaming ? stabilizeStreamingTables(nextText) : nextText;
476
482
  // Already escapeHtml(text) (single, safe) while parsers are not loaded.
477
- const out = markdownProcessor(nextText);
483
+ const out = markdownProcessor(source);
478
484
  html = sanitize && parsersReady ? sanitize(out) : out;
479
485
  } else {
480
486
  // Plain text: escapeHtml output is inert — never re-sanitize (the second escape).
@@ -537,7 +543,13 @@ export const createAgentExperience = (
537
543
 
538
544
  // Get plugins for this instance
539
545
  const plugins = pluginRegistry.getForInstance(config.plugins);
540
-
546
+
547
+ // The built-in approval renderer, shaped as a plugin. Resolved as a FALLBACK
548
+ // (not pushed into `plugins`) so a user `renderApproval` plugin always wins
549
+ // and a later config-update plugin push can't reorder ahead of it.
550
+ const { plugin: builtInApprovalPlugin, teardown: teardownBuiltInApprovals } =
551
+ createBuiltInApprovalPlugin();
552
+
541
553
  // Register components from config
542
554
  if (config.components) {
543
555
  componentRegistry.registerAll(config.components);
@@ -2738,6 +2750,11 @@ export const createAgentExperience = (
2738
2750
  }
2739
2751
  });
2740
2752
 
2753
+ // Release this widget's pending built-in approval listeners + "Allow once"
2754
+ // popovers if it's destroyed while an approval is still open. Scoped to this
2755
+ // instance's state, so other widgets on the page are unaffected.
2756
+ destroyCallbacks.push(teardownBuiltInApprovals);
2757
+
2741
2758
  // Activate the stream-animation plugin for this widget instance. Plugins
2742
2759
  // with `styles` inject their CSS into the widget root once; plugins with
2743
2760
  // `onAttach` (e.g., glyph-cycle's MutationObserver for real glyph tick
@@ -3413,7 +3430,10 @@ export const createAgentExperience = (
3413
3430
  // interactivity), and idiomorph imports nodes via `document.importNode`,
3414
3431
  // which strips them. So we build the live element, append a stub during
3415
3432
  // morph, and inject the live element afterward.
3416
- const hasApprovalPlugin = plugins.some((p) => p.renderApproval) && config.approval !== false;
3433
+ // The built-in approval renderer is always available (as a fallback plugin),
3434
+ // so every approval flows through the stub-and-hydrate path whenever
3435
+ // approvals are enabled — a user `renderApproval` plugin just overrides it.
3436
+ const hasApprovalPlugin = config.approval !== false;
3417
3437
  type ApprovalPluginHydrate = {
3418
3438
  messageId: string;
3419
3439
  fingerprint: string;
@@ -3633,7 +3653,8 @@ export const createAgentExperience = (
3633
3653
  // any accordion listeners survive idiomorph's `importNode`. Gate the
3634
3654
  // rebuild on fingerprint so interactive state (e.g. a collapsed
3635
3655
  // accordion) is preserved while the approval stays pending.
3636
- const approvalPlugin = plugins.find((p) => typeof p.renderApproval === "function");
3656
+ const approvalPlugin =
3657
+ plugins.find((p) => typeof p.renderApproval === "function") ?? builtInApprovalPlugin;
3637
3658
  const lastFp = lastApprovalBubbleFingerprint.get(message.id);
3638
3659
  const needsRebuild = lastFp !== fingerprint;
3639
3660
  let liveBubble: HTMLElement | null = null;
@@ -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
+ };
@@ -384,22 +384,25 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
384
384
  icon: 'palette.colors.success.500',
385
385
  },
386
386
  },
387
+ // Neutral surface card (components/approval-actions.ts). The primary action
388
+ // anchors to the brand primary; deny is a neutral tinted button. Consumers who
389
+ // themed these (or set config.approval.* color overrides) still win.
387
390
  approval: {
388
391
  requested: {
389
- background: 'palette.colors.warning.50',
390
- border: 'palette.colors.warning.200',
392
+ background: 'semantic.colors.surface',
393
+ border: 'semantic.colors.border',
391
394
  text: 'palette.colors.gray.900',
392
- shadow: '0 5px 15px rgba(15, 23, 42, 0.08)',
395
+ shadow: '0 1px 2px 0 rgba(11, 11, 11, 0.06), 0 2px 8px 0 rgba(11, 11, 11, 0.04)',
393
396
  },
394
397
  approve: {
395
- background: 'palette.colors.success.500',
396
- foreground: 'palette.colors.gray.50',
398
+ background: 'semantic.colors.primary',
399
+ foreground: 'semantic.colors.textInverse',
397
400
  borderRadius: 'palette.radius.md',
398
401
  padding: 'semantic.spacing.sm',
399
402
  },
400
403
  deny: {
401
- background: 'palette.colors.error.500',
402
- foreground: 'palette.colors.gray.50',
404
+ background: 'semantic.colors.container',
405
+ foreground: 'semantic.colors.text',
403
406
  borderRadius: 'palette.radius.md',
404
407
  padding: 'semantic.spacing.sm',
405
408
  },
@@ -681,12 +684,12 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
681
684
  cssVars['--persona-voice-processing-icon'] = cssVars['--persona-components-voice-processing-icon'] ?? cssVars['--persona-palette-colors-primary-500'];
682
685
  cssVars['--persona-voice-speaking-icon'] = cssVars['--persona-components-voice-speaking-icon'] ?? cssVars['--persona-palette-colors-success-500'];
683
686
 
684
- cssVars['--persona-approval-bg'] = cssVars['--persona-components-approval-requested-background'] ?? cssVars['--persona-palette-colors-warning-50'];
685
- cssVars['--persona-approval-border'] = cssVars['--persona-components-approval-requested-border'] ?? cssVars['--persona-palette-colors-warning-200'];
687
+ cssVars['--persona-approval-bg'] = cssVars['--persona-components-approval-requested-background'] ?? cssVars['--persona-surface'];
688
+ cssVars['--persona-approval-border'] = cssVars['--persona-components-approval-requested-border'] ?? cssVars['--persona-border'];
686
689
  cssVars['--persona-approval-text'] = cssVars['--persona-components-approval-requested-text'] ?? cssVars['--persona-palette-colors-gray-900'];
687
- cssVars['--persona-approval-shadow'] = cssVars['--persona-components-approval-requested-shadow'] ?? '0 5px 15px rgba(15, 23, 42, 0.08)';
688
- cssVars['--persona-approval-approve-bg'] = cssVars['--persona-components-approval-approve-background'] ?? cssVars['--persona-palette-colors-success-500'];
689
- cssVars['--persona-approval-deny-bg'] = cssVars['--persona-components-approval-deny-background'] ?? cssVars['--persona-palette-colors-error-500'];
690
+ cssVars['--persona-approval-shadow'] = cssVars['--persona-components-approval-requested-shadow'] ?? '0 1px 2px 0 rgba(11, 11, 11, 0.06), 0 2px 8px 0 rgba(11, 11, 11, 0.04)';
691
+ cssVars['--persona-approval-approve-bg'] = cssVars['--persona-components-approval-approve-background'] ?? cssVars['--persona-button-primary-bg'];
692
+ cssVars['--persona-approval-deny-bg'] = cssVars['--persona-components-approval-deny-background'] ?? cssVars['--persona-container'];
690
693
 
691
694
  cssVars['--persona-attachment-image-bg'] = cssVars['--persona-components-attachment-image-background'] ?? cssVars['--persona-palette-colors-gray-100'];
692
695
  cssVars['--persona-attachment-image-border'] = cssVars['--persona-components-attachment-image-border'] ?? cssVars['--persona-palette-colors-gray-200'];