@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.466",
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.466",
338
- "@djangocfg/ui-core": "^2.1.466",
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.466",
422
- "@djangocfg/typescript-config": "^2.1.466",
423
- "@djangocfg/ui-core": "^2.1.466",
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
- // Roomy ChatGPT-style bubble: larger text with generous line-height
162
- // and padding so a full-page chat reads spacious, not just bigger.
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 = true,
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}>{itemRenderer(m, i)}</div>
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 = itemRenderer(m, index);
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 solid brand color. */
19
- user: 'bg-primary text-primary-foreground rounded-tr-md',
20
- /** Assistant bubble in normal state neutral muted surface. */
21
- assistant: 'bg-muted text-foreground rounded-tl-md',
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
- * On `bg-primary` the link MUST stay legible against the cyan/brand fill
48
- * `text-primary-foreground` matches the bubble's foreground token, so
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-foreground underline decoration-primary-foreground/60 underline-offset-2 ' +
57
- 'hover:decoration-primary-foreground transition-colors break-all',
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-foreground/80 hover:text-primary-foreground',
62
+ user: 'text-primary hover:text-primary/80',
64
63
  assistant: 'text-primary hover:text-primary/80',
65
64
  } as const;
66
65