@djangocfg/ui-tools 2.1.466 → 2.1.469
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/package.json +6 -6
- package/src/tools/chat/messages/MessageBubble.tsx +5 -4
- package/src/tools/chat/messages/MessageDateSeparator.tsx +36 -0
- package/src/tools/chat/messages/MessageList.tsx +81 -5
- package/src/tools/chat/messages/__tests__/messageDate.test.ts +50 -0
- package/src/tools/chat/messages/index.ts +7 -0
- package/src/tools/chat/messages/messageDate.ts +79 -0
- package/src/tools/chat/styles/bubbleTokens.ts +9 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.469",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -334,8 +334,8 @@
|
|
|
334
334
|
"test:watch": "vitest"
|
|
335
335
|
},
|
|
336
336
|
"peerDependencies": {
|
|
337
|
-
"@djangocfg/i18n": "^2.1.
|
|
338
|
-
"@djangocfg/ui-core": "^2.1.
|
|
337
|
+
"@djangocfg/i18n": "^2.1.469",
|
|
338
|
+
"@djangocfg/ui-core": "^2.1.469",
|
|
339
339
|
"consola": "^3.4.2",
|
|
340
340
|
"lodash-es": "^4.18.1",
|
|
341
341
|
"lucide-react": "^0.545.0",
|
|
@@ -418,9 +418,9 @@
|
|
|
418
418
|
"@maplibre/maplibre-gl-geocoder": "^1.9.4"
|
|
419
419
|
},
|
|
420
420
|
"devDependencies": {
|
|
421
|
-
"@djangocfg/i18n": "^2.1.
|
|
422
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
423
|
-
"@djangocfg/ui-core": "^2.1.
|
|
421
|
+
"@djangocfg/i18n": "^2.1.469",
|
|
422
|
+
"@djangocfg/typescript-config": "^2.1.469",
|
|
423
|
+
"@djangocfg/ui-core": "^2.1.469",
|
|
424
424
|
"@types/lodash-es": "^4.17.12",
|
|
425
425
|
"@types/mapbox__mapbox-gl-draw": "^1.4.9",
|
|
426
426
|
"@types/node": "^25.9.5",
|
|
@@ -41,6 +41,7 @@ import type { ComposerAppearance } from '../composer/types';
|
|
|
41
41
|
export interface MessageBubbleProps {
|
|
42
42
|
message: ChatMessage;
|
|
43
43
|
isUser?: boolean;
|
|
44
|
+
/** Show the sender avatar. Defaults to `false` for the flat transcript layout. */
|
|
44
45
|
showAvatar?: boolean;
|
|
45
46
|
/** Override avatar URL (skips persona resolution). */
|
|
46
47
|
avatarSrc?: string;
|
|
@@ -158,9 +159,8 @@ const APPEARANCE_CLASSES: Record<ComposerAppearance, {
|
|
|
158
159
|
avatar: 'size-10',
|
|
159
160
|
avatarFallback: 'text-sm',
|
|
160
161
|
content: 'max-w-[44rem]',
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
bubble: 'rounded-3xl px-5 py-3.5 text-base leading-relaxed',
|
|
162
|
+
// Both roles share roomy geometry; their surfaces differ by intensity.
|
|
163
|
+
bubble: 'text-base leading-relaxed',
|
|
164
164
|
meta: 'text-xs',
|
|
165
165
|
},
|
|
166
166
|
};
|
|
@@ -168,7 +168,7 @@ const APPEARANCE_CLASSES: Record<ComposerAppearance, {
|
|
|
168
168
|
const MessageBubbleInner = ({
|
|
169
169
|
message,
|
|
170
170
|
isUser: isUserProp,
|
|
171
|
-
showAvatar =
|
|
171
|
+
showAvatar = false,
|
|
172
172
|
avatarSrc,
|
|
173
173
|
avatarFallback,
|
|
174
174
|
user,
|
|
@@ -346,6 +346,7 @@ const MessageBubbleInner = ({
|
|
|
346
346
|
// `select-text` on the prose itself: the text is the only
|
|
347
347
|
// selection island, bounded to this one message.
|
|
348
348
|
'inline-block max-w-full rounded-2xl px-3.5 py-2 text-sm',
|
|
349
|
+
appearance === 'full' && 'rounded-3xl px-5 py-3.5',
|
|
349
350
|
ap.bubble,
|
|
350
351
|
bubbleSurface,
|
|
351
352
|
)}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { memo } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
4
|
+
|
|
5
|
+
import { formatMessageDateLabel } from './messageDate';
|
|
6
|
+
|
|
7
|
+
export interface MessageDateSeparatorProps {
|
|
8
|
+
timestamp: number;
|
|
9
|
+
timeZone: string;
|
|
10
|
+
locale?: string;
|
|
11
|
+
now?: number;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const MessageDateSeparator = memo(function MessageDateSeparator({
|
|
16
|
+
timestamp,
|
|
17
|
+
timeZone,
|
|
18
|
+
locale,
|
|
19
|
+
now,
|
|
20
|
+
className,
|
|
21
|
+
}: MessageDateSeparatorProps) {
|
|
22
|
+
const label = formatMessageDateLabel(timestamp, { locale, timeZone, now });
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
role="separator"
|
|
27
|
+
aria-label={label}
|
|
28
|
+
className={cn(
|
|
29
|
+
'flex items-center justify-center px-3 pb-2 pt-5 text-xs font-medium text-muted-foreground',
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
<time dateTime={new Date(timestamp).toISOString()}>{label}</time>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
});
|
|
@@ -19,6 +19,8 @@ import { useCopy } from '@djangocfg/ui-core/hooks';
|
|
|
19
19
|
import type { ChatMessage } from '../types';
|
|
20
20
|
import { useChatContextOptional } from '../context';
|
|
21
21
|
import { MessageBubble } from './MessageBubble';
|
|
22
|
+
import { MessageDateSeparator } from './MessageDateSeparator';
|
|
23
|
+
import { formatMessageDateLabel, getCalendarDayKey } from './messageDate';
|
|
22
24
|
import type { ComposerAppearance } from '../composer/types';
|
|
23
25
|
|
|
24
26
|
export interface MessageListProps {
|
|
@@ -69,6 +71,18 @@ export interface MessageListProps {
|
|
|
69
71
|
* whole bubble up for full-page chat. Default `compact` (no change).
|
|
70
72
|
* Ignored when `renderItem` is set (the host owns bubble rendering). */
|
|
71
73
|
appearance?: ComposerAppearance;
|
|
74
|
+
/** Show a divider when adjacent messages fall on different calendar days. */
|
|
75
|
+
showDateSeparators?: boolean;
|
|
76
|
+
/** Locale used for divider labels. Defaults to the viewer's locale. */
|
|
77
|
+
dateSeparatorLocale?: string;
|
|
78
|
+
/**
|
|
79
|
+
* IANA timezone used for calendar grouping (for example `UTC` or
|
|
80
|
+
* `Asia/Makassar`). Defaults to the viewer's browser timezone after mount.
|
|
81
|
+
* Message timestamps remain absolute UTC epoch milliseconds.
|
|
82
|
+
*/
|
|
83
|
+
dateSeparatorTimeZone?: string;
|
|
84
|
+
/** Replace the default date divider while retaining day-grouping behavior. */
|
|
85
|
+
renderDateSeparator?: (timestamp: number, label: string) => ReactNode;
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
export interface MessageListHandle {
|
|
@@ -103,6 +117,10 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
103
117
|
atBottomThreshold = 120,
|
|
104
118
|
scrollAnchorId,
|
|
105
119
|
appearance = 'compact',
|
|
120
|
+
showDateSeparators = true,
|
|
121
|
+
dateSeparatorLocale,
|
|
122
|
+
dateSeparatorTimeZone,
|
|
123
|
+
renderDateSeparator,
|
|
106
124
|
},
|
|
107
125
|
ref,
|
|
108
126
|
) {
|
|
@@ -110,6 +128,22 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
110
128
|
const messages = messagesProp ?? ctx?.messages ?? [];
|
|
111
129
|
const isLoadingMore = isLoadingMoreProp ?? ctx?.isLoadingMore ?? false;
|
|
112
130
|
const { copyToClipboard } = useCopy();
|
|
131
|
+
// Do not guess the server timezone during SSR. When the host does not pass
|
|
132
|
+
// one explicitly, resolve it in the browser after mount; this avoids a
|
|
133
|
+
// hydration mismatch around midnight while still grouping for the viewer.
|
|
134
|
+
const [browserTimeZone, setBrowserTimeZone] = useState<string | undefined>(dateSeparatorTimeZone);
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (dateSeparatorTimeZone) {
|
|
137
|
+
setBrowserTimeZone(dateSeparatorTimeZone);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
setBrowserTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
|
|
141
|
+
}, [dateSeparatorTimeZone]);
|
|
142
|
+
const resolvedDateSeparatorTimeZone = dateSeparatorTimeZone ?? browserTimeZone;
|
|
143
|
+
// Keep relative labels stable for the lifetime of this list. Normal chat
|
|
144
|
+
// activity remounts/re-renders the shell often enough; more importantly,
|
|
145
|
+
// one render cannot label adjacent separators against different instants.
|
|
146
|
+
const dateLabelNow = useMemo(() => Date.now(), []);
|
|
113
147
|
|
|
114
148
|
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
|
115
149
|
// Virtuoso's `atBottomStateChange` only fires when the list is
|
|
@@ -210,6 +244,50 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
210
244
|
);
|
|
211
245
|
|
|
212
246
|
const itemRenderer = renderItem ?? defaultRenderItem;
|
|
247
|
+
const renderTranscriptItem = useCallback(
|
|
248
|
+
(m: ChatMessage, index: number) => {
|
|
249
|
+
const item = itemRenderer(m, index);
|
|
250
|
+
if (!showDateSeparators || !resolvedDateSeparatorTimeZone) return item;
|
|
251
|
+
|
|
252
|
+
const previous = messages[index - 1];
|
|
253
|
+
const startsNewDay =
|
|
254
|
+
!previous ||
|
|
255
|
+
getCalendarDayKey(previous.createdAt, resolvedDateSeparatorTimeZone) !==
|
|
256
|
+
getCalendarDayKey(m.createdAt, resolvedDateSeparatorTimeZone);
|
|
257
|
+
|
|
258
|
+
if (!startsNewDay) return item;
|
|
259
|
+
|
|
260
|
+
const separator = (
|
|
261
|
+
<MessageDateSeparator
|
|
262
|
+
timestamp={m.createdAt}
|
|
263
|
+
timeZone={resolvedDateSeparatorTimeZone}
|
|
264
|
+
locale={dateSeparatorLocale}
|
|
265
|
+
now={dateLabelNow}
|
|
266
|
+
/>
|
|
267
|
+
);
|
|
268
|
+
const label = formatMessageDateLabel(m.createdAt, {
|
|
269
|
+
locale: dateSeparatorLocale,
|
|
270
|
+
timeZone: resolvedDateSeparatorTimeZone,
|
|
271
|
+
now: dateLabelNow,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<>
|
|
276
|
+
{renderDateSeparator ? renderDateSeparator(m.createdAt, label) : separator}
|
|
277
|
+
{item}
|
|
278
|
+
</>
|
|
279
|
+
);
|
|
280
|
+
},
|
|
281
|
+
[
|
|
282
|
+
dateLabelNow,
|
|
283
|
+
dateSeparatorLocale,
|
|
284
|
+
itemRenderer,
|
|
285
|
+
messages,
|
|
286
|
+
renderDateSeparator,
|
|
287
|
+
resolvedDateSeparatorTimeZone,
|
|
288
|
+
showDateSeparators,
|
|
289
|
+
],
|
|
290
|
+
);
|
|
213
291
|
// Jump to bottom on the first non-empty messages batch. A SINGLE rAF +
|
|
214
292
|
// scrollToIndex is not enough when the list mounts with a long transcript
|
|
215
293
|
// (opening / switching a contact): virtuoso measures the dynamic bubble
|
|
@@ -242,9 +320,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
242
320
|
// self-cancel.
|
|
243
321
|
const scroller = scrollerRef.current;
|
|
244
322
|
const scrollerEl =
|
|
245
|
-
scroller != null && scroller !== window && scroller instanceof HTMLElement
|
|
246
|
-
? scroller
|
|
247
|
-
: null;
|
|
323
|
+
scroller != null && scroller !== window && scroller instanceof HTMLElement ? scroller : null;
|
|
248
324
|
scrollerEl?.addEventListener('wheel', cancelOnUserScroll, { passive: true });
|
|
249
325
|
scrollerEl?.addEventListener('touchstart', cancelOnUserScroll, { passive: true });
|
|
250
326
|
scrollerEl?.addEventListener('keydown', cancelOnUserScroll);
|
|
@@ -454,7 +530,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
454
530
|
</div>
|
|
455
531
|
) : null}
|
|
456
532
|
{messages.map((m, i) => (
|
|
457
|
-
<div key={m.id ?? i}>{
|
|
533
|
+
<div key={m.id ?? i}>{renderTranscriptItem(m, i)}</div>
|
|
458
534
|
))}
|
|
459
535
|
{/* Mirror the virtualized Footer spacer so the last bubble keeps
|
|
460
536
|
its breathing room above the composer in the non-virtual path. */}
|
|
@@ -474,7 +550,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
|
|
|
474
550
|
computeItemKey={computeItemKey}
|
|
475
551
|
itemContent={(index, m) => {
|
|
476
552
|
if (!m) return null;
|
|
477
|
-
const node =
|
|
553
|
+
const node = renderTranscriptItem(m, index);
|
|
478
554
|
// Wrap only the last bubble in a size-observed shell so we can
|
|
479
555
|
// re-pin to the absolute bottom as the streaming reply grows
|
|
480
556
|
// (corrects virtuoso's slight final-chunk under-scroll). The
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { formatMessageDateLabel, getCalendarDayKey } from '../messageDate';
|
|
4
|
+
|
|
5
|
+
describe('messageDate', () => {
|
|
6
|
+
it('groups an absolute timestamp by the requested timezone', () => {
|
|
7
|
+
const timestamp = Date.UTC(2026, 6, 12, 23, 30);
|
|
8
|
+
|
|
9
|
+
expect(getCalendarDayKey(timestamp, 'UTC')).toBe('2026-07-12');
|
|
10
|
+
expect(getCalendarDayKey(timestamp, 'Asia/Makassar')).toBe('2026-07-13');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('formats today and yesterday relative to the display timezone', () => {
|
|
14
|
+
const now = Date.UTC(2026, 6, 13, 12);
|
|
15
|
+
|
|
16
|
+
expect(
|
|
17
|
+
formatMessageDateLabel(Date.UTC(2026, 6, 13, 1), {
|
|
18
|
+
locale: 'en-US',
|
|
19
|
+
timeZone: 'UTC',
|
|
20
|
+
now,
|
|
21
|
+
}),
|
|
22
|
+
).toBe('Today');
|
|
23
|
+
expect(
|
|
24
|
+
formatMessageDateLabel(Date.UTC(2026, 6, 12, 23), {
|
|
25
|
+
locale: 'en-US',
|
|
26
|
+
timeZone: 'UTC',
|
|
27
|
+
now,
|
|
28
|
+
}),
|
|
29
|
+
).toBe('Yesterday');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('includes the year only when the message is outside the current year', () => {
|
|
33
|
+
const now = Date.UTC(2026, 6, 13, 12);
|
|
34
|
+
|
|
35
|
+
expect(
|
|
36
|
+
formatMessageDateLabel(Date.UTC(2026, 6, 4, 12), {
|
|
37
|
+
locale: 'en-US',
|
|
38
|
+
timeZone: 'UTC',
|
|
39
|
+
now,
|
|
40
|
+
}),
|
|
41
|
+
).toBe('Saturday, July 4');
|
|
42
|
+
expect(
|
|
43
|
+
formatMessageDateLabel(Date.UTC(2025, 6, 4, 12), {
|
|
44
|
+
locale: 'en-US',
|
|
45
|
+
timeZone: 'UTC',
|
|
46
|
+
now,
|
|
47
|
+
}),
|
|
48
|
+
).toBe('Friday, July 4, 2025');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -8,6 +8,13 @@ export {
|
|
|
8
8
|
type MessageListHandle,
|
|
9
9
|
} from './MessageList';
|
|
10
10
|
export { MessageBubble, type MessageBubbleProps } from './MessageBubble';
|
|
11
|
+
export { MessageDateSeparator, type MessageDateSeparatorProps } from './MessageDateSeparator';
|
|
12
|
+
export {
|
|
13
|
+
formatMessageDateLabel,
|
|
14
|
+
getCalendarDateParts,
|
|
15
|
+
getCalendarDayKey,
|
|
16
|
+
type CalendarDateParts,
|
|
17
|
+
} from './messageDate';
|
|
11
18
|
// The auto-detect link-preview slot — shared by MessageBubble and any forked
|
|
12
19
|
// bubble (e.g. cmdop's mention bubble) so previews never silently drift.
|
|
13
20
|
export { AutoLinkPreview, type AutoLinkPreviewProps } from './AutoLinkPreview';
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar helpers for chat timestamps.
|
|
3
|
+
*
|
|
4
|
+
* `ChatMessage.createdAt` is an epoch-millisecond value, so it represents an
|
|
5
|
+
* absolute UTC instant. Calendar grouping is deliberately performed in the
|
|
6
|
+
* viewer's display timezone: the same instant may belong to Monday in UTC and
|
|
7
|
+
* Tuesday in Asia/Makassar.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface CalendarDateParts {
|
|
11
|
+
year: number;
|
|
12
|
+
month: number;
|
|
13
|
+
day: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getCalendarDateParts(timestamp: number, timeZone: string): CalendarDateParts {
|
|
17
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
18
|
+
timeZone,
|
|
19
|
+
year: 'numeric',
|
|
20
|
+
month: '2-digit',
|
|
21
|
+
day: '2-digit',
|
|
22
|
+
}).formatToParts(new Date(timestamp));
|
|
23
|
+
|
|
24
|
+
const values = Object.fromEntries(
|
|
25
|
+
parts
|
|
26
|
+
.filter((part) => part.type === 'year' || part.type === 'month' || part.type === 'day')
|
|
27
|
+
.map((part) => [part.type, Number(part.value)]),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
year: values.year,
|
|
32
|
+
month: values.month,
|
|
33
|
+
day: values.day,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getCalendarDayKey(timestamp: number, timeZone: string): string {
|
|
38
|
+
const { year, month, day } = getCalendarDateParts(timestamp, timeZone);
|
|
39
|
+
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function calendarDayNumber(timestamp: number, timeZone: string): number {
|
|
43
|
+
const { year, month, day } = getCalendarDateParts(timestamp, timeZone);
|
|
44
|
+
return Math.floor(Date.UTC(year, month - 1, day) / 86_400_000);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function capitalizeLabel(label: string, locale?: string): string {
|
|
48
|
+
if (!label) return label;
|
|
49
|
+
return label[0].toLocaleUpperCase(locale) + label.slice(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatMessageDateLabel(
|
|
53
|
+
timestamp: number,
|
|
54
|
+
options: {
|
|
55
|
+
locale?: string;
|
|
56
|
+
timeZone: string;
|
|
57
|
+
now?: number;
|
|
58
|
+
},
|
|
59
|
+
): string {
|
|
60
|
+
const { locale, timeZone, now = Date.now() } = options;
|
|
61
|
+
const dayDelta = calendarDayNumber(timestamp, timeZone) - calendarDayNumber(now, timeZone);
|
|
62
|
+
|
|
63
|
+
if (dayDelta === 0 || dayDelta === -1) {
|
|
64
|
+
return capitalizeLabel(
|
|
65
|
+
new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(dayDelta, 'day'),
|
|
66
|
+
locale,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const messageYear = getCalendarDateParts(timestamp, timeZone).year;
|
|
71
|
+
const currentYear = getCalendarDateParts(now, timeZone).year;
|
|
72
|
+
return new Intl.DateTimeFormat(locale, {
|
|
73
|
+
timeZone,
|
|
74
|
+
weekday: 'long',
|
|
75
|
+
month: 'long',
|
|
76
|
+
day: 'numeric',
|
|
77
|
+
...(messageYear === currentYear ? {} : { year: 'numeric' as const }),
|
|
78
|
+
}).format(new Date(timestamp));
|
|
79
|
+
}
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
|
|
16
16
|
/** Bubble surface classes (background + text), keyed by message state. */
|
|
17
17
|
export const BUBBLE_SURFACE = {
|
|
18
|
-
/** User-authored bubble
|
|
19
|
-
user: 'bg-
|
|
20
|
-
/** Assistant
|
|
21
|
-
assistant: 'bg-muted text-foreground
|
|
18
|
+
/** User-authored bubble - a quiet neutral surface aligned to the right. */
|
|
19
|
+
user: 'bg-muted text-foreground',
|
|
20
|
+
/** Assistant replies use a lighter neutral surface than user messages. */
|
|
21
|
+
assistant: 'bg-muted/40 text-foreground',
|
|
22
22
|
/** Assistant bubble when the turn failed — destructive tint. */
|
|
23
23
|
error: 'bg-destructive/10 text-destructive rounded-tl-md border border-destructive/30',
|
|
24
24
|
} as const;
|
|
@@ -44,23 +44,22 @@ export const BLOCK_SURFACE =
|
|
|
44
44
|
/**
|
|
45
45
|
* Anchor (link) classes for markdown content rendered inside a bubble.
|
|
46
46
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* contrast tracks the design system automatically.
|
|
47
|
+
* User and assistant prose now share neutral surfaces, so both roles use the
|
|
48
|
+
* theme's primary link color with a restrained underline.
|
|
50
49
|
*
|
|
51
50
|
* On the neutral assistant bubble we keep the brand-primary color so links
|
|
52
51
|
* still pop without competing with the body text.
|
|
53
52
|
*/
|
|
54
53
|
export const ANCHOR = {
|
|
55
54
|
user:
|
|
56
|
-
'text-primary
|
|
57
|
-
'hover:decoration-primary
|
|
55
|
+
'text-primary underline decoration-primary/50 underline-offset-2 ' +
|
|
56
|
+
'hover:text-primary/80 hover:decoration-primary transition-colors break-all',
|
|
58
57
|
assistant: 'text-primary underline hover:text-primary/80 transition-colors break-all',
|
|
59
58
|
} as const;
|
|
60
59
|
|
|
61
60
|
/** Inline secondary action (e.g. "Show more / less"). Same logic as anchors. */
|
|
62
61
|
export const TOGGLE = {
|
|
63
|
-
user: 'text-primary
|
|
62
|
+
user: 'text-primary hover:text-primary/80',
|
|
64
63
|
assistant: 'text-primary hover:text-primary/80',
|
|
65
64
|
} as const;
|
|
66
65
|
|