@admin-layout/tailwind-chat-pro 12.2.5-alpha.2 → 12.2.5-alpha.20

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 (46) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/lib/components/messages-container-ui/MessagesContainerUI.d.ts +2 -2
  3. package/lib/components/messages-container-ui/MessagesContainerUI.d.ts.map +1 -1
  4. package/lib/components/messages-container-ui/ModernMessageGroup.d.ts +3 -0
  5. package/lib/components/messages-container-ui/ModernMessageGroup.d.ts.map +1 -1
  6. package/lib/components/messages-container-ui/ModernMessageGroup.js +75 -19
  7. package/lib/components/messages-container-ui/ModernMessageGroup.js.map +1 -1
  8. package/lib/components/messages-container-ui/PlanModeView.d.ts +2 -2
  9. package/lib/components/messages-container-ui/PlanModeView.d.ts.map +1 -1
  10. package/lib/components/messages-container-ui/PlanModeView.js +13 -4
  11. package/lib/components/messages-container-ui/PlanModeView.js.map +1 -1
  12. package/lib/components/messages-container-ui/index.d.ts +1 -1
  13. package/lib/components/messages-container-ui/index.d.ts.map +1 -1
  14. package/lib/components/messages-container-ui/markdownTables.d.ts +25 -0
  15. package/lib/components/messages-container-ui/markdownTables.d.ts.map +1 -0
  16. package/lib/components/messages-container-ui/markdownTables.js +103 -0
  17. package/lib/components/messages-container-ui/markdownTables.js.map +1 -0
  18. package/lib/components/messages-container-ui/messenger-slot-fill-name-enum.d.ts +5 -1
  19. package/lib/components/messages-container-ui/messenger-slot-fill-name-enum.d.ts.map +1 -1
  20. package/lib/components/messages-container-ui/messenger-slot-fill-name-enum.js +6 -0
  21. package/lib/components/messages-container-ui/messenger-slot-fill-name-enum.js.map +1 -1
  22. package/lib/components/messages-container-ui/modernStreaming.d.ts +2 -1
  23. package/lib/components/messages-container-ui/modernStreaming.d.ts.map +1 -1
  24. package/lib/components/messages-container-ui/modernStreaming.js +26 -9
  25. package/lib/components/messages-container-ui/modernStreaming.js.map +1 -1
  26. package/lib/components/messages-container-ui/types.d.ts +7 -0
  27. package/lib/components/messages-container-ui/types.d.ts.map +1 -1
  28. package/lib/components/messages-container-ui/useBubbleEntrance.d.ts +12 -0
  29. package/lib/components/messages-container-ui/useBubbleEntrance.d.ts.map +1 -0
  30. package/lib/components/messages-container-ui/useBubbleEntrance.js +36 -0
  31. package/lib/components/messages-container-ui/useBubbleEntrance.js.map +1 -0
  32. package/package.json +9 -10
  33. package/src/__tests__/stubs/common-graphql.ts +2 -0
  34. package/src/__tests__/stubs/common.ts +5 -0
  35. package/src/__tests__/stubs/components-pro.ts +2 -0
  36. package/src/components/messages-container-ui/MessagesContainerUI.tsx +2 -2
  37. package/src/components/messages-container-ui/ModernMessageGroup.tsx +88 -23
  38. package/src/components/messages-container-ui/PlanModeView.tsx +26 -18
  39. package/src/components/messages-container-ui/__tests__/markdownTables.test.tsx +94 -0
  40. package/src/components/messages-container-ui/index.ts +7 -1
  41. package/src/components/messages-container-ui/markdownTables.ts +108 -0
  42. package/src/components/messages-container-ui/messenger-slot-fill-name-enum.ts +6 -0
  43. package/src/components/messages-container-ui/modernStreaming.tsx +20 -6
  44. package/src/components/messages-container-ui/types.ts +8 -0
  45. package/src/components/messages-container-ui/useBubbleEntrance.ts +31 -0
  46. package/vitest.config.ts +17 -0
@@ -9,6 +9,8 @@ import remarkGfm from 'remark-gfm';
9
9
  import { MessengerSlotFillNameEnum } from './messenger-slot-fill-name-enum';
10
10
  import { FilesList } from './FilesList';
11
11
  import { ErrorFixCard } from './ErrorFixCard';
12
+ import { useBubbleEntrance } from './useBubbleEntrance';
13
+ import { TABLE_SCROLL_CLASS, TABLE_STYLES, wrapTables } from './markdownTables';
12
14
 
13
15
  // Configure marked for better markdown rendering
14
16
  marked.setOptions({
@@ -50,8 +52,8 @@ export function AssistantResponseWrapper({
50
52
  );
51
53
  }
52
54
 
53
- // Enhanced CSS styles for HTML content rendering with prettification
54
- const htmlContentStyles = `
55
+ // Enhanced CSS styles for HTML content rendering with prettification (exported for its tests)
56
+ export const htmlContentStyles = `
55
57
  .html-content {
56
58
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
57
59
  line-height: 1.6;
@@ -89,6 +91,7 @@ const htmlContentStyles = `
89
91
  --llm-color-code-text: #d96b00;
90
92
  --llm-color-code-block-border: #ebeef3;
91
93
  --llm-shadow-soft: 0 1px 2px rgba(18, 25, 38, 0.04), 0 10px 32px rgba(18, 25, 38, 0.05);
94
+ --llm-table-scroll-shadow: rgba(18, 25, 38, 0.14);
92
95
  --llm-radius-bubble: 1.5rem;
93
96
  --llm-radius-code: 0.85rem;
94
97
  --llm-line-height-body: 1.62;
@@ -102,8 +105,11 @@ const htmlContentStyles = `
102
105
  background: transparent;
103
106
  }
104
107
 
108
+ /* The app's dark mode is a \`dark\` class on <html> (design-pro toggles it); nothing sets
109
+ data-color-mode, so without \`.dark\` a reply's table stayed a white card on a dark page. */
105
110
  .${ASSISTANT_RESPONSE_THEME_CLASS}[data-color-mode='dark'],
106
- [data-color-mode='dark'] .${ASSISTANT_RESPONSE_THEME_CLASS} {
111
+ [data-color-mode='dark'] .${ASSISTANT_RESPONSE_THEME_CLASS},
112
+ .dark .${ASSISTANT_RESPONSE_THEME_CLASS} {
107
113
  --llm-color-bg: #0f1115;
108
114
  --llm-color-surface: #171a20;
109
115
  --llm-color-surface-2: #1f2530;
@@ -118,6 +124,7 @@ const htmlContentStyles = `
118
124
  --llm-color-code-text: #ffb36b;
119
125
  --llm-color-code-block-border: #262d39;
120
126
  --llm-shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.28), 0 14px 36px rgba(0, 0, 0, 0.3);
127
+ --llm-table-scroll-shadow: rgba(0, 0, 0, 0.55);
121
128
  }
122
129
 
123
130
  .${ASSISTANT_RESPONSE_CLASS} {
@@ -138,7 +145,9 @@ const htmlContentStyles = `
138
145
  min-height: 1.75rem;
139
146
  border-radius: var(--llm-radius-bubble);
140
147
  color: inherit;
141
- word-break: break-word;
148
+ /* Long tokens (paths, URLs) wrap rather than widen the chat. Same behaviour as the
149
+ legacy \`word-break: break-word\` this replaces; tables opt out (see TABLE_STYLES). */
150
+ overflow-wrap: anywhere;
142
151
  }
143
152
 
144
153
  .${ASSISTANT_RESPONSE_MARKDOWN_CLASS} {
@@ -303,15 +312,21 @@ const htmlContentStyles = `
303
312
  -webkit-box-decoration-break: clone;
304
313
  }
305
314
 
306
- .${ASSISTANT_RESPONSE_MARKDOWN_CLASS} table {
307
- width: 100%;
315
+ /* The card is the scroll box around the table, so a table wider than the reply scrolls
316
+ inside a card that keeps its rounded edges on screen. It is also the size container the
317
+ narrow-table rule below measures, and so never widens the reply. */
318
+ .${ASSISTANT_RESPONSE_MARKDOWN_CLASS} .${TABLE_SCROLL_CLASS} {
308
319
  margin: 1rem 0 1.1rem;
309
- border-collapse: separate;
310
- border-spacing: 0;
311
320
  border: 1px solid var(--llm-color-border);
312
321
  border-radius: 1rem;
313
- overflow: hidden;
314
322
  box-shadow: var(--llm-shadow-soft);
323
+ container-type: inline-size;
324
+ }
325
+
326
+ .${ASSISTANT_RESPONSE_MARKDOWN_CLASS} table {
327
+ width: 100%;
328
+ border-collapse: separate;
329
+ border-spacing: 0;
315
330
  }
316
331
 
317
332
  .${ASSISTANT_RESPONSE_MARKDOWN_CLASS} thead {
@@ -326,6 +341,17 @@ const htmlContentStyles = `
326
341
  vertical-align: top;
327
342
  }
328
343
 
344
+ /* A column the markdown aligns (\`|---:|\`, \`|:---:|\`) keeps that alignment. */
345
+ .${ASSISTANT_RESPONSE_MARKDOWN_CLASS} th[align='right'],
346
+ .${ASSISTANT_RESPONSE_MARKDOWN_CLASS} td[align='right'] {
347
+ text-align: right;
348
+ }
349
+
350
+ .${ASSISTANT_RESPONSE_MARKDOWN_CLASS} th[align='center'],
351
+ .${ASSISTANT_RESPONSE_MARKDOWN_CLASS} td[align='center'] {
352
+ text-align: center;
353
+ }
354
+
329
355
  .${ASSISTANT_RESPONSE_MARKDOWN_CLASS} th {
330
356
  font-size: 0.8rem;
331
357
  font-weight: 600;
@@ -354,7 +380,20 @@ const htmlContentStyles = `
354
380
  padding: 0.75rem 0.8rem;
355
381
  }
356
382
  }
357
- * we must neutralize it so it doesn't override the pill background/padding.
383
+
384
+ /* The same tighter cells in a narrow chat pane on a wide screen, which the viewport query
385
+ above never sees: the table measures its own card. */
386
+ @container (max-width: 30rem) {
387
+ .${ASSISTANT_RESPONSE_MARKDOWN_CLASS} th,
388
+ .${ASSISTANT_RESPONSE_MARKDOWN_CLASS} td {
389
+ padding: 0.75rem 0.8rem;
390
+ }
391
+ }
392
+
393
+ /* The user pill is drawn by .user-message. A shared global class (.message-container, from
394
+ * @messenger-box/tailwind-ui-inbox) ships its own background and padding, so neutralize it
395
+ * inside the pill. (This comment had lost its opening, which turned the rule below into an
396
+ * invalid selector that never applied.)
358
397
  */
359
398
  .user-message .message-container {
360
399
  background: transparent !important;
@@ -373,7 +412,7 @@ const htmlContentStyles = `
373
412
  max-width: 100%;
374
413
  overflow-wrap: anywhere;
375
414
  }
376
-
415
+ ${TABLE_STYLES}
377
416
  /* Assistant replies must read as plain text on the page — no card. A shared
378
417
  global class (.message-container, from @messenger-box/tailwind-ui-inbox)
379
418
  ships its own pill background/padding; neutralize it here the same way the
@@ -584,9 +623,16 @@ const getMessageSlotName = (type: string | undefined): string | null => {
584
623
  if (t === 'MEDIA') return MessengerSlotFillNameEnum.MEDIA_NAME;
585
624
  if (t === 'WORKFLOW') return MessengerSlotFillNameEnum.WORKFLOW_NAME;
586
625
  if (t === 'CONNECTOR') return MessengerSlotFillNameEnum.CONNECTOR_NAME;
587
- // Shared catch-all card (shopping, design, page/resume, …). Routes type:'generic' to the
626
+ // Shared catch-all card (design, local services, …). Routes type:'generic' to the
588
627
  // registered `generic` Fill (MessageGeneric); without this the generic Fill never mounts.
589
628
  if (t === 'GENERIC') return MessengerSlotFillNameEnum.GENERIC_NAME;
629
+ // Result cards (ai-chat-ext MessageResults). The host routes a parsed `jobs` / `resume` /
630
+ // `shopping` / `movies` widget to a message of that type; without these the bubble had no
631
+ // slot for it and the agent's card never appeared - only the prose around it.
632
+ if (t === 'SHOPPING') return MessengerSlotFillNameEnum.SHOPPING_NAME;
633
+ if (t === 'MOVIES') return MessengerSlotFillNameEnum.MOVIES_NAME;
634
+ if (t === 'JOBS') return MessengerSlotFillNameEnum.JOBS_NAME;
635
+ if (t === 'RESUME') return MessengerSlotFillNameEnum.RESUME_NAME;
590
636
  return null;
591
637
  };
592
638
 
@@ -665,6 +711,7 @@ const prettifyHtmlContent = (htmlContent: string): string => {
665
711
  prettified = prettified.replace(/<table>/g, '<table class="html-table">');
666
712
  prettified = prettified.replace(/<th>/g, '<th class="html-table-header">');
667
713
  prettified = prettified.replace(/<td>/g, '<td class="html-table-cell">');
714
+ prettified = wrapTables(prettified);
668
715
 
669
716
  // Add info boxes for certain content patterns
670
717
  prettified = prettified.replace(
@@ -1033,7 +1080,7 @@ export const FormattedMessageContent: React.FC<{
1033
1080
  const parsed = marked.parse(trimmed, { async: false }) as string;
1034
1081
  // Only return if parsing actually produced HTML (not just the same text)
1035
1082
  if (parsed && parsed.trim() !== trimmed && parsed.includes('<')) {
1036
- return { htmlContent: parsed, markdownSource: trimmed as string };
1083
+ return { htmlContent: wrapTables(parsed), markdownSource: trimmed as string };
1037
1084
  }
1038
1085
  }
1039
1086
 
@@ -1181,26 +1228,35 @@ export const FormattedMessageContent: React.FC<{
1181
1228
  ...props
1182
1229
  }: React.TableHTMLAttributes<HTMLTableElement> & { children?: React.ReactNode }) =>
1183
1230
  isUserVariant ? (
1184
- <div className="overflow-x-auto my-4 w-full rounded-md border border-border">
1231
+ <div className={`${TABLE_SCROLL_CLASS} my-4 w-full rounded-md border border-border`}>
1185
1232
  <table className="w-full text-sm" {...props}>
1186
1233
  {children}
1187
1234
  </table>
1188
1235
  </div>
1189
1236
  ) : (
1190
- <div className="overflow-x-auto my-4 w-full">
1237
+ <div className={TABLE_SCROLL_CLASS}>
1191
1238
  <table {...props}>{children}</table>
1192
1239
  </div>
1193
1240
  ),
1194
1241
  thead: ({ children }: { children?: React.ReactNode }) =>
1195
1242
  isUserVariant ? <thead className="bg-muted text-left">{children}</thead> : <thead>{children}</thead>,
1196
- th: ({ children }: { children?: React.ReactNode }) =>
1243
+ // `style` carries the column's markdown alignment (`|---:|` is text-align: right).
1244
+ th: ({ children, style }: { children?: React.ReactNode; style?: React.CSSProperties }) =>
1197
1245
  isUserVariant ? (
1198
- <th className={`px-3 py-2 text-left font-semibold ${userTextClass}`}>{children}</th>
1246
+ <th className={`px-3 py-2 text-left font-semibold ${userTextClass}`} style={style}>
1247
+ {children}
1248
+ </th>
1199
1249
  ) : (
1200
- <th>{children}</th>
1250
+ <th style={style}>{children}</th>
1251
+ ),
1252
+ td: ({ children, style }: { children?: React.ReactNode; style?: React.CSSProperties }) =>
1253
+ isUserVariant ? (
1254
+ <td className={`px-3 py-2 ${userMutedTextClass}`} style={style}>
1255
+ {children}
1256
+ </td>
1257
+ ) : (
1258
+ <td style={style}>{children}</td>
1201
1259
  ),
1202
- td: ({ children }: { children?: React.ReactNode }) =>
1203
- isUserVariant ? <td className={`px-3 py-2 ${userMutedTextClass}`}>{children}</td> : <td>{children}</td>,
1204
1260
  pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
1205
1261
  code: ({ className, children, ...rest }: { className?: string; children?: React.ReactNode }) => {
1206
1262
  const match = /language-(\w+)/.exec(className || '');
@@ -1343,6 +1399,10 @@ const tryExtractBuilderBlocks = (content?: string): any[] | null => {
1343
1399
  }
1344
1400
  };
1345
1401
 
1402
+ /** Shared with the streaming frame so a landing stream doesn't shift. */
1403
+ export const assistantFrameClass = (isDesktopView: boolean, isSmallScreen: boolean) =>
1404
+ isDesktopView ? 'mb-4 px-4 py-3' : isSmallScreen ? 'mb-2 px-2 py-1' : 'mb-3 px-3 py-1';
1405
+
1346
1406
  const ModernMessageGroup: React.FC<MessageGroupProps> = ({
1347
1407
  author,
1348
1408
  messages,
@@ -1423,9 +1483,10 @@ const ModernMessageGroup: React.FC<MessageGroupProps> = ({
1423
1483
  // For other messages (non-user), show the traditional group layout
1424
1484
  return (
1425
1485
  <div
1426
- className={`group transition-all duration-300 ease-in-out ${
1427
- isDesktopView ? 'mb-4 px-4 py-3' : isSmallScreen ? 'mb-2 px-2 py-1' : 'mb-3 px-3 py-1'
1428
- } rounded-lg`}
1486
+ className={`group transition-all duration-300 ease-in-out ${assistantFrameClass(
1487
+ isDesktopView,
1488
+ isSmallScreen,
1489
+ )} rounded-lg`}
1429
1490
  >
1430
1491
  <div
1431
1492
  className={`flex items-start ${
@@ -1523,6 +1584,9 @@ const ModernMessageBubble: React.FC<ModernMessageBubbleProps> = ({
1523
1584
  const isLongUserMessage =
1524
1585
  !isAssistantRole && ((message as any)?.message?.length ?? 0) > USER_MESSAGE_COLLAPSE_CHAR_THRESHOLD;
1525
1586
  const isCollapsed = isLongUserMessage && !expanded;
1587
+ const entranceRef = useBubbleEntrance<HTMLDivElement>(
1588
+ isOwnMessage && !isAssistantRole && (message as any)?.props?.animateEntry === true,
1589
+ );
1526
1590
  // For user messages, create a right-aligned layout with avatar and name
1527
1591
  if (isOwnMessage) {
1528
1592
  const authorName =
@@ -1542,6 +1606,7 @@ const ModernMessageBubble: React.FC<ModernMessageBubbleProps> = ({
1542
1606
 
1543
1607
  {/* User message bubble (Image 2) */}
1544
1608
  <div
1609
+ ref={entranceRef}
1545
1610
  className={`${isAssistantRole ? 'text-sm' : USER_MESSAGE_TEXT_SIZE_CLASS} ${
1546
1611
  isAssistantRole
1547
1612
  ? 'cursor-pointer rounded-2xl border border-border bg-muted text-foreground px-4 py-2.5'
@@ -1,7 +1,7 @@
1
1
  import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { AiAgentMessageRole, PostTypeEnum } from 'common';
4
- import type { MessageGroupComponentType, PlanMessage } from './types';
4
+ import type { MessageGroupComponentType, PlanMessage, StreamingRenderOptions } from './types';
5
5
  import { DefaultMessageGroup } from './DefaultMessageGroup';
6
6
  import { DefaultInputToolBar } from './DefaultInputToolBar';
7
7
 
@@ -124,7 +124,7 @@ export interface PlanModeViewProps {
124
124
  * Optional: how to render the in-flight streaming assistant content. Defaults to a
125
125
  * plain-text bubble. Inject a richer renderer (markdown / code) if desired.
126
126
  */
127
- renderStreamingContent?: (content: string) => React.ReactNode;
127
+ renderStreamingContent?: (content: string, opts?: StreamingRenderOptions) => React.ReactNode;
128
128
  /**
129
129
  * Optional: override how a PlanMessage is mapped to the post shape handed to
130
130
  * `MessageGroupComponent`. Defaults to {@link defaultPlanMessageToPost}.
@@ -421,23 +421,31 @@ export function PlanModeView({
421
421
  </div>
422
422
  ))}
423
423
 
424
- {streamingContent?.trim() && (
425
- <div className="flex justify-start">
426
- <div
427
- className={clsx(
428
- 'max-w-[85%] w-full rounded-2xl px-4 py-3 text-gray-900',
429
- ASSISTANT_BUBBLE_BG,
430
- showMessageBubbleBorder && `border ${BORDER_COLOR}`,
431
- )}
432
- >
433
- <div className="min-w-0 text-[15px] leading-relaxed break-words whitespace-pre-wrap">
434
- {renderStreamingContent
435
- ? renderStreamingContent(streamingContent)
436
- : streamingContent}
424
+ {streamingContent?.trim() &&
425
+ (renderStreamingContent ? (
426
+ // A custom renderer draws the post's own frame; this bubble belongs to the plain-text default.
427
+ <div className={clsx('mb-3', postContainerClassName)}>
428
+ {renderStreamingContent(streamingContent, {
429
+ isDesktopView,
430
+ isSmallScreen,
431
+ showCopyDownloadOnCodeBlock,
432
+ })}
433
+ </div>
434
+ ) : (
435
+ <div className="flex justify-start">
436
+ <div
437
+ className={clsx(
438
+ 'max-w-[85%] w-full rounded-2xl px-4 py-3 text-gray-900',
439
+ ASSISTANT_BUBBLE_BG,
440
+ showMessageBubbleBorder && `border ${BORDER_COLOR}`,
441
+ )}
442
+ >
443
+ <div className="min-w-0 text-[15px] leading-relaxed break-words whitespace-pre-wrap">
444
+ {streamingContent}
445
+ </div>
437
446
  </div>
438
447
  </div>
439
- </div>
440
- )}
448
+ ))}
441
449
 
442
450
  {isLoading && (
443
451
  <div className="flex justify-start">
@@ -450,7 +458,7 @@ export function PlanModeView({
450
458
  </div>
451
459
  </div>
452
460
  {shouldRenderBottomToolbar && (
453
- <div className="border-t border-gray-200 bg-white p-3">{renderToolbar(inputToolbarProps)}</div>
461
+ <div className="border-t border-border bg-background p-3">{renderToolbar(inputToolbarProps)}</div>
454
462
  )}
455
463
  </div>
456
464
  );
@@ -0,0 +1,94 @@
1
+ import React from 'react';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { marked } from 'marked';
5
+ import { TABLE_SCROLL_CLASS, TABLE_STYLES, wrapTables } from '../markdownTables';
6
+ import { FormattedMessageContent, htmlContentStyles } from '../ModernMessageGroup';
7
+
8
+ // The table from the report: in a 520px pane its numbers wrapped mid-digit ("2,00" / "0").
9
+ const BUDGET = [
10
+ "Here's the budget:",
11
+ '',
12
+ '| Category | Mar | Apr |',
13
+ '|---|---:|---:|',
14
+ '| Events & Promotions | 2,000 | 3,500 |',
15
+ '| Email Marketing | 600 | 750 |',
16
+ ].join('\n');
17
+
18
+ /** The declarations of every rule whose selector list includes `selector`. */
19
+ function declarationsFor(css: string, selector: string): string {
20
+ const rules = css.matchAll(/([^{}]+)\{([^{}]*)\}/g);
21
+ return [...rules]
22
+ .filter(([, selectors]) => selectors.split(',').some((s) => s.trim() === selector))
23
+ .map(([, , body]) => body)
24
+ .join('\n');
25
+ }
26
+
27
+ describe('wrapTables', () => {
28
+ it('puts a markdown table in its own scroll box', () => {
29
+ const html = wrapTables(marked.parse(BUDGET, { async: false }) as string);
30
+ expect(html).toMatch(new RegExp(`<div class="${TABLE_SCROLL_CLASS}"><table>[\\s\\S]*</table>\\s*</div>`));
31
+ });
32
+
33
+ it('keeps a table that has attributes, and nested tables, balanced', () => {
34
+ const html = wrapTables('<table class="x"><tr><td><table><tr><td>1</td></tr></table></td></tr></TABLE>');
35
+ expect(html.match(/<div class=/g)).toHaveLength(2);
36
+ expect(html.match(/<\/div>/g)).toHaveLength(2);
37
+ expect(html.startsWith(`<div class="${TABLE_SCROLL_CLASS}"><table class="x">`)).toBe(true);
38
+ });
39
+
40
+ it('leaves a table written inside a code block alone', () => {
41
+ const html = marked.parse('```html\n<table><tr><td>1</td></tr></table>\n```', { async: false }) as string;
42
+ expect(wrapTables(html)).toBe(html);
43
+ });
44
+ });
45
+
46
+ describe('FormattedMessageContent tables', () => {
47
+ for (const showCopyDownloadOnCodeBlock of [false, true]) {
48
+ const lane = showCopyDownloadOnCodeBlock ? 'react-markdown' : 'marked';
49
+ for (const variant of ['default', 'user'] as const) {
50
+ it(`a ${variant} message's table scrolls in its own box (${lane})`, () => {
51
+ const html = renderToStaticMarkup(
52
+ <FormattedMessageContent
53
+ content={BUDGET}
54
+ variant={variant}
55
+ showCopyDownloadOnCodeBlock={showCopyDownloadOnCodeBlock}
56
+ />,
57
+ );
58
+ expect(html).toMatch(new RegExp(`<div class="${TABLE_SCROLL_CLASS}[^"]*"><table`));
59
+ });
60
+ }
61
+
62
+ it(`keeps the markdown's column alignment (${lane})`, () => {
63
+ const html = renderToStaticMarkup(
64
+ <FormattedMessageContent content={BUDGET} showCopyDownloadOnCodeBlock={showCopyDownloadOnCodeBlock} />,
65
+ );
66
+ // marked writes align="right"; react-markdown writes the same as a style.
67
+ expect(html).toMatch(/<td (align="right"|style="text-align:right")>2,000<\/td>/);
68
+ });
69
+ }
70
+ });
71
+
72
+ describe('table styles', () => {
73
+ // Declarations only: the comments name the rules they replace.
74
+ const css = `${htmlContentStyles}\n${TABLE_STYLES}`.replace(/\/\*[\s\S]*?\*\//g, '');
75
+
76
+ it('lets no word inside a table break to fit, so a number never splits', () => {
77
+ const rule = declarationsFor(css, `.${TABLE_SCROLL_CLASS} table *`);
78
+ expect(rule).toMatch(/word-break:\s*normal/);
79
+ expect(rule).toMatch(/overflow-wrap:\s*normal/);
80
+ });
81
+
82
+ it('wraps prose with overflow-wrap, not the legacy word-break keyword a table cannot opt out of', () => {
83
+ // `word-break: break-word` overrides every overflow-wrap below it, tables included.
84
+ expect(css).not.toMatch(/word-break:\s*break-word/);
85
+ });
86
+
87
+ it('scrolls a table that does not fit', () => {
88
+ expect(declarationsFor(css, `.${TABLE_SCROLL_CLASS}`)).toMatch(/overflow-x:\s*auto/);
89
+ });
90
+
91
+ it("follows the app's dark mode (a dark class on <html>)", () => {
92
+ expect(declarationsFor(css, '.dark .llm-response-theme')).toMatch(/--llm-color-bg:\s*#0f1115/);
93
+ });
94
+ });
@@ -15,4 +15,10 @@ export {
15
15
  } from './ModernMessageGroup';
16
16
  export { renderModernStreamingContent } from './modernStreaming';
17
17
  export { MessengerSlotFillNameEnum } from './messenger-slot-fill-name-enum';
18
- export type { PlanMessage, CurrentUser, MessageGroupRenderProps, MessageGroupComponentType } from './types';
18
+ export type {
19
+ PlanMessage,
20
+ CurrentUser,
21
+ MessageGroupRenderProps,
22
+ MessageGroupComponentType,
23
+ StreamingRenderOptions,
24
+ } from './types';
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Markdown tables in a chat message: every table sits in its own box that scrolls sideways, and
3
+ * nothing inside a table may break a word to make it fit.
4
+ *
5
+ * A message's prose wraps anywhere (`overflow-wrap: anywhere`), so a long path or URL never
6
+ * pushes the chat wider. That rule also counts every character as a break when a table sizes its
7
+ * columns, so a table in a narrow pane squeezed its number columns to a few characters and split
8
+ * "2,000" into "2,00" / "0". Inside a table a line now breaks only where the text has a space,
9
+ * and a table still too wide for the message scrolls in its box instead.
10
+ */
11
+
12
+ /** The box every rendered markdown table sits in. */
13
+ export const TABLE_SCROLL_CLASS = 'llm-table-scroll';
14
+
15
+ /**
16
+ * Put each `<table>` of rendered HTML in a {@link TABLE_SCROLL_CLASS} box. Every opening tag gets
17
+ * a box and every closing tag closes it, so well-formed HTML stays balanced (nested tables too).
18
+ * Escaped markup, such as a table inside a code block, is text and is left alone.
19
+ */
20
+ export function wrapTables(html: string): string {
21
+ return html
22
+ .replace(/<table\b[^>]*>/gi, (open) => `<div class="${TABLE_SCROLL_CLASS}">${open}`)
23
+ .replace(/<\/table\s*>/gi, (close) => `${close}</div>`);
24
+ }
25
+
26
+ /**
27
+ * The box, the no-split rule and the scroll cue, for both the assistant's reply and the person's
28
+ * own bubble. `.llm-table-scroll table *` outranks the bubbles' wrap-anywhere rules
29
+ * (`.user-message *`, the reply's bubble), which is what keeps a number whole.
30
+ */
31
+ export const TABLE_STYLES = `
32
+ .${TABLE_SCROLL_CLASS} {
33
+ max-width: 100%;
34
+ overflow-x: auto;
35
+ }
36
+
37
+ /* The box carries the spacing: a scroll box never collapses its child's margins, so a
38
+ table's own (prose gives it 1em) would show as an empty band inside the box. */
39
+ .${TABLE_SCROLL_CLASS} > table {
40
+ margin-top: 0;
41
+ margin-bottom: 0;
42
+ }
43
+
44
+ .${TABLE_SCROLL_CLASS} table,
45
+ .${TABLE_SCROLL_CLASS} table * {
46
+ word-break: normal;
47
+ overflow-wrap: normal;
48
+ }
49
+
50
+ .${TABLE_SCROLL_CLASS} th,
51
+ .${TABLE_SCROLL_CLASS} td {
52
+ font-variant-numeric: tabular-nums;
53
+ }
54
+
55
+ /* A cut-off column alone reads as a finished table (or as a number split in two), so a side
56
+ that has more columns to scroll to carries a soft shadow. Both shadows are pinned to the
57
+ box's edges (sticky, in the table's grid cell) and fade with the scroll position; a table
58
+ that fits has no scroll timeline, so neither shows. Where the browser cannot tie an
59
+ animation to scrolling, the box scrolls without the cue. */
60
+ @supports (animation-timeline: scroll()) {
61
+ .${TABLE_SCROLL_CLASS} {
62
+ display: grid;
63
+ scroll-timeline: --llm-table-scroll inline;
64
+ }
65
+
66
+ .${TABLE_SCROLL_CLASS} > table,
67
+ .${TABLE_SCROLL_CLASS}::before,
68
+ .${TABLE_SCROLL_CLASS}::after {
69
+ grid-area: 1 / 1;
70
+ }
71
+
72
+ .${TABLE_SCROLL_CLASS}::before,
73
+ .${TABLE_SCROLL_CLASS}::after {
74
+ content: '';
75
+ position: sticky;
76
+ z-index: 1;
77
+ width: 0.875rem;
78
+ pointer-events: none;
79
+ opacity: 0;
80
+ }
81
+
82
+ .${TABLE_SCROLL_CLASS}::before {
83
+ left: 0;
84
+ justify-self: start;
85
+ background: linear-gradient(to right, var(--llm-table-scroll-shadow, rgba(18, 25, 38, 0.14)), transparent);
86
+ animation: llm-table-scroll-start linear both;
87
+ animation-timeline: --llm-table-scroll;
88
+ }
89
+
90
+ .${TABLE_SCROLL_CLASS}::after {
91
+ right: 0;
92
+ justify-self: end;
93
+ background: linear-gradient(to left, var(--llm-table-scroll-shadow, rgba(18, 25, 38, 0.14)), transparent);
94
+ animation: llm-table-scroll-end linear both;
95
+ animation-timeline: --llm-table-scroll;
96
+ }
97
+ }
98
+
99
+ @keyframes llm-table-scroll-start {
100
+ 0% { opacity: 0; }
101
+ 6%, 100% { opacity: 1; }
102
+ }
103
+
104
+ @keyframes llm-table-scroll-end {
105
+ 0%, 94% { opacity: 1; }
106
+ 100% { opacity: 0; }
107
+ }
108
+ `;
@@ -12,4 +12,10 @@ export enum MessengerSlotFillNameEnum {
12
12
  WORKFLOW_NAME = 'workflow',
13
13
  CONNECTOR_NAME = 'connector',
14
14
  GENERIC_NAME = 'generic',
15
+ // Result cards registered by ai-chat-ext (MessageResults): a row of product, showtime or
16
+ // role cards, and the single resume document card.
17
+ SHOPPING_NAME = 'shopping',
18
+ MOVIES_NAME = 'movies',
19
+ JOBS_NAME = 'jobs',
20
+ RESUME_NAME = 'resume',
15
21
  }
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
- import { CodeFormatter, detectContentType, FormattedMessageContent } from './ModernMessageGroup';
2
+ import { assistantFrameClass, CodeFormatter, detectContentType, FormattedMessageContent } from './ModernMessageGroup';
3
+ import type { StreamingRenderOptions } from './types';
3
4
 
4
5
  /**
5
6
  * Renders in-flight streaming assistant content with the rich Modern renderer:
@@ -9,10 +10,23 @@ import { CodeFormatter, detectContentType, FormattedMessageContent } from './Mod
9
10
  * Pass this as `MessagesContainerUI` / `PlanModeView`'s `renderStreamingContent` to keep
10
11
  * markdown / code rendering while streaming (the base default is plain text).
11
12
  */
12
- export function renderModernStreamingContent(content: string, showCopyDownloadOnCodeBlock = true): React.ReactNode {
13
- return detectContentType(content) === 'code' ? (
14
- <CodeFormatter content={content} showCopyDownload={showCopyDownloadOnCodeBlock} />
15
- ) : (
16
- <FormattedMessageContent content={content} showCopyDownloadOnCodeBlock={showCopyDownloadOnCodeBlock} />
13
+ export function renderModernStreamingContent(content: string, opts: StreamingRenderOptions = {}): React.ReactNode {
14
+ const { isDesktopView = false, isSmallScreen = false, showCopyDownloadOnCodeBlock = true } = opts;
15
+ return (
16
+ // Same frame as ModernMessageGroup's assistant post, so nothing restyles when the stream lands.
17
+ <div className={`group rounded-lg ${assistantFrameClass(isDesktopView, isSmallScreen)}`}>
18
+ <div className="min-w-0 overflow-hidden py-2">
19
+ <div className="min-w-0 relative max-w-full w-full text-foreground">
20
+ {detectContentType(content) === 'code' ? (
21
+ <CodeFormatter content={content} showCopyDownload={showCopyDownloadOnCodeBlock} />
22
+ ) : (
23
+ <FormattedMessageContent
24
+ content={content}
25
+ showCopyDownloadOnCodeBlock={showCopyDownloadOnCodeBlock}
26
+ />
27
+ )}
28
+ </div>
29
+ </div>
30
+ </div>
17
31
  );
18
32
  }
@@ -13,6 +13,7 @@ export interface PlanMessage {
13
13
  createdAt?: Date | string;
14
14
  /** Optional: pass through to the injected message-group renderer (propsConfiguration, props, files, etc.) */
15
15
  propsConfiguration?: any;
16
+ /** `props.animateEntry: true` plays the just-sent entrance on a user bubble (see useBubbleEntrance). */
16
17
  props?: any;
17
18
  files?: any;
18
19
  author?: any;
@@ -66,3 +67,10 @@ export interface MessageGroupRenderProps {
66
67
 
67
68
  /** The injectable message-group renderer. Defaults to a plain-text bubble renderer. */
68
69
  export type MessageGroupComponentType = React.ComponentType<MessageGroupRenderProps>;
70
+
71
+ /** What `renderStreamingContent` gets to frame the stream like the post it becomes. */
72
+ export interface StreamingRenderOptions {
73
+ isDesktopView?: boolean;
74
+ isSmallScreen?: boolean;
75
+ showCopyDownloadOnCodeBlock?: boolean;
76
+ }
@@ -0,0 +1,31 @@
1
+ import { useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ /** Rises out of the composer toward its tail corner; ease-out so it lands softly. */
4
+ const KEYFRAMES: Keyframe[] = [
5
+ { opacity: 0, transform: 'translateY(12px) scale(0.96)', transformOrigin: 'right bottom' },
6
+ { opacity: 1, transform: 'translateY(0) scale(1)', transformOrigin: 'right bottom' },
7
+ ];
8
+ const TIMING: KeyframeAnimationOptions = { duration: 320, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' };
9
+
10
+ /**
11
+ * The entrance a message plays when the user has just sent it (a post with
12
+ * `props.animateEntry`). History, chat switches and re-renders never animate:
13
+ * the host flags only the bubble it just appended, and the decision is taken
14
+ * once at mount so the flag going away later cannot cut or replay it.
15
+ *
16
+ * Transform and opacity only, so it runs on the compositor and never moves the
17
+ * layout around it. Runs before paint, so the bubble never flashes in place
18
+ * first. Reduced-motion users get the bubble with no motion.
19
+ */
20
+ export function useBubbleEntrance<T extends HTMLElement>(requested: boolean) {
21
+ const ref = useRef<T>(null);
22
+ const [play] = useState(requested);
23
+ useLayoutEffect(() => {
24
+ const el = ref.current;
25
+ if (!play || !el || typeof el.animate !== 'function') return undefined;
26
+ if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return undefined;
27
+ const animation = el.animate(KEYFRAMES, TIMING);
28
+ return () => animation.cancel();
29
+ }, [play]);
30
+ return ref;
31
+ }
@@ -0,0 +1,17 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ // eslint-disable-next-line import/no-extraneous-dependencies -- the test runner's own config; vitest is a devDependency
3
+ import { defineConfig } from 'vitest/config';
4
+
5
+ const stub = (file: string) => fileURLToPath(new URL(`./src/__tests__/stubs/${file}`, import.meta.url));
6
+
7
+ // The renderer imports app packages: `common` (generated models and hooks, built separately) and
8
+ // components-pro. The tests stand in for them, so they run without building any.
9
+ export default defineConfig({
10
+ test: {
11
+ alias: [
12
+ { find: /^common$/, replacement: stub('common.ts') },
13
+ { find: /^common\/graphql$/, replacement: stub('common-graphql.ts') },
14
+ { find: /^@common-stack\/components-pro$/, replacement: stub('components-pro.ts') },
15
+ ],
16
+ },
17
+ });