@admin-layout/tailwind-chat-pro 12.2.5-alpha.2 → 12.2.5-alpha.22
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.
- package/CHANGELOG.md +63 -0
- package/lib/components/messages-container-ui/MessagesContainerUI.d.ts +2 -2
- package/lib/components/messages-container-ui/MessagesContainerUI.d.ts.map +1 -1
- package/lib/components/messages-container-ui/ModernMessageGroup.d.ts +3 -0
- package/lib/components/messages-container-ui/ModernMessageGroup.d.ts.map +1 -1
- package/lib/components/messages-container-ui/ModernMessageGroup.js +75 -19
- package/lib/components/messages-container-ui/ModernMessageGroup.js.map +1 -1
- package/lib/components/messages-container-ui/PlanModeView.d.ts +2 -2
- package/lib/components/messages-container-ui/PlanModeView.d.ts.map +1 -1
- package/lib/components/messages-container-ui/PlanModeView.js +13 -4
- package/lib/components/messages-container-ui/PlanModeView.js.map +1 -1
- package/lib/components/messages-container-ui/index.d.ts +1 -1
- package/lib/components/messages-container-ui/index.d.ts.map +1 -1
- package/lib/components/messages-container-ui/markdownTables.d.ts +25 -0
- package/lib/components/messages-container-ui/markdownTables.d.ts.map +1 -0
- package/lib/components/messages-container-ui/markdownTables.js +103 -0
- package/lib/components/messages-container-ui/markdownTables.js.map +1 -0
- package/lib/components/messages-container-ui/messenger-slot-fill-name-enum.d.ts +5 -1
- package/lib/components/messages-container-ui/messenger-slot-fill-name-enum.d.ts.map +1 -1
- package/lib/components/messages-container-ui/messenger-slot-fill-name-enum.js +6 -0
- package/lib/components/messages-container-ui/messenger-slot-fill-name-enum.js.map +1 -1
- package/lib/components/messages-container-ui/modernStreaming.d.ts +2 -1
- package/lib/components/messages-container-ui/modernStreaming.d.ts.map +1 -1
- package/lib/components/messages-container-ui/modernStreaming.js +26 -9
- package/lib/components/messages-container-ui/modernStreaming.js.map +1 -1
- package/lib/components/messages-container-ui/types.d.ts +7 -0
- package/lib/components/messages-container-ui/types.d.ts.map +1 -1
- package/lib/components/messages-container-ui/useBubbleEntrance.d.ts +12 -0
- package/lib/components/messages-container-ui/useBubbleEntrance.d.ts.map +1 -0
- package/lib/components/messages-container-ui/useBubbleEntrance.js +36 -0
- package/lib/components/messages-container-ui/useBubbleEntrance.js.map +1 -0
- package/package.json +9 -10
- package/src/__tests__/stubs/common-graphql.ts +2 -0
- package/src/__tests__/stubs/common.ts +5 -0
- package/src/__tests__/stubs/components-pro.ts +2 -0
- package/src/components/messages-container-ui/MessagesContainerUI.tsx +2 -2
- package/src/components/messages-container-ui/ModernMessageGroup.tsx +88 -23
- package/src/components/messages-container-ui/PlanModeView.tsx +26 -18
- package/src/components/messages-container-ui/__tests__/markdownTables.test.tsx +94 -0
- package/src/components/messages-container-ui/index.ts +7 -1
- package/src/components/messages-container-ui/markdownTables.ts +108 -0
- package/src/components/messages-container-ui/messenger-slot-fill-name-enum.ts +6 -0
- package/src/components/messages-container-ui/modernStreaming.tsx +20 -6
- package/src/components/messages-container-ui/types.ts +8 -0
- package/src/components/messages-container-ui/useBubbleEntrance.ts +31 -0
- 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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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 (
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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}`}
|
|
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
|
|
1428
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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-
|
|
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 {
|
|
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,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<
|
|
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
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -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
|
+
});
|