@urbicon-ui/blocks 6.34.0 → 6.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +11 -1
  2. package/dist/components/Calendar/CalendarHeader.svelte +32 -26
  3. package/dist/components/Calendar/CalendarMiniMonth.svelte +10 -10
  4. package/dist/components/Calendar/calendar.variants.js +19 -5
  5. package/dist/components/Chat/Chat/Chat.svelte +53 -0
  6. package/dist/components/Chat/Chat/Chat.svelte.d.ts +4 -0
  7. package/dist/components/Chat/Chat/chat.variants.d.ts +20 -0
  8. package/dist/components/Chat/Chat/chat.variants.js +15 -0
  9. package/dist/components/Chat/Chat/index.d.ts +47 -0
  10. package/dist/components/Chat/Chat/index.js +2 -0
  11. package/dist/components/Chat/ChatMessage/ChatMessage.svelte +342 -0
  12. package/dist/components/Chat/ChatMessage/ChatMessage.svelte.d.ts +4 -0
  13. package/dist/components/Chat/ChatMessage/chat-message.variants.d.ts +138 -0
  14. package/dist/components/Chat/ChatMessage/chat-message.variants.js +118 -0
  15. package/dist/components/Chat/ChatMessage/index.d.ts +102 -0
  16. package/dist/components/Chat/ChatMessage/index.js +2 -0
  17. package/dist/components/Chat/ChatMessageList/ChatMessageList.svelte +301 -0
  18. package/dist/components/Chat/ChatMessageList/ChatMessageList.svelte.d.ts +6 -0
  19. package/dist/components/Chat/ChatMessageList/chat-message-list.variants.d.ts +23 -0
  20. package/dist/components/Chat/ChatMessageList/chat-message-list.variants.js +32 -0
  21. package/dist/components/Chat/ChatMessageList/chat-scroll.d.ts +45 -0
  22. package/dist/components/Chat/ChatMessageList/chat-scroll.js +52 -0
  23. package/dist/components/Chat/ChatMessageList/index.d.ts +93 -0
  24. package/dist/components/Chat/ChatMessageList/index.js +3 -0
  25. package/dist/components/Chat/CitationChip/CitationChip.svelte +107 -0
  26. package/dist/components/Chat/CitationChip/CitationChip.svelte.d.ts +4 -0
  27. package/dist/components/Chat/CitationChip/citation-chip.variants.d.ts +40 -0
  28. package/dist/components/Chat/CitationChip/citation-chip.variants.js +42 -0
  29. package/dist/components/Chat/CitationChip/index.d.ts +69 -0
  30. package/dist/components/Chat/CitationChip/index.js +2 -0
  31. package/dist/components/Chat/CodeBlock/CodeBlock.svelte +120 -0
  32. package/dist/components/Chat/CodeBlock/CodeBlock.svelte.d.ts +4 -0
  33. package/dist/components/Chat/CodeBlock/code-block.variants.d.ts +40 -0
  34. package/dist/components/Chat/CodeBlock/code-block.variants.js +51 -0
  35. package/dist/components/Chat/CodeBlock/index.d.ts +55 -0
  36. package/dist/components/Chat/CodeBlock/index.js +2 -0
  37. package/dist/components/Chat/PromptInput/PromptInput.svelte +432 -0
  38. package/dist/components/Chat/PromptInput/PromptInput.svelte.d.ts +4 -0
  39. package/dist/components/Chat/PromptInput/index.d.ts +165 -0
  40. package/dist/components/Chat/PromptInput/index.js +2 -0
  41. package/dist/components/Chat/PromptInput/prompt-input.variants.d.ts +90 -0
  42. package/dist/components/Chat/PromptInput/prompt-input.variants.js +102 -0
  43. package/dist/components/Chat/ReasoningDisclosure/ReasoningDisclosure.svelte +107 -0
  44. package/dist/components/Chat/ReasoningDisclosure/ReasoningDisclosure.svelte.d.ts +4 -0
  45. package/dist/components/Chat/ReasoningDisclosure/index.d.ts +85 -0
  46. package/dist/components/Chat/ReasoningDisclosure/index.js +2 -0
  47. package/dist/components/Chat/ReasoningDisclosure/reasoning-disclosure.variants.d.ts +20 -0
  48. package/dist/components/Chat/ReasoningDisclosure/reasoning-disclosure.variants.js +31 -0
  49. package/dist/components/Chat/StreamingMarkdown/MdBlock.svelte +99 -0
  50. package/dist/components/Chat/StreamingMarkdown/MdBlock.svelte.d.ts +9 -0
  51. package/dist/components/Chat/StreamingMarkdown/MdInline.svelte +82 -0
  52. package/dist/components/Chat/StreamingMarkdown/MdInline.svelte.d.ts +9 -0
  53. package/dist/components/Chat/StreamingMarkdown/StreamingMarkdown.svelte +118 -0
  54. package/dist/components/Chat/StreamingMarkdown/StreamingMarkdown.svelte.d.ts +4 -0
  55. package/dist/components/Chat/StreamingMarkdown/index.d.ts +86 -0
  56. package/dist/components/Chat/StreamingMarkdown/index.js +2 -0
  57. package/dist/components/Chat/StreamingMarkdown/md-context.d.ts +58 -0
  58. package/dist/components/Chat/StreamingMarkdown/md-context.js +1 -0
  59. package/dist/components/Chat/StreamingMarkdown/streaming-markdown.variants.d.ts +145 -0
  60. package/dist/components/Chat/StreamingMarkdown/streaming-markdown.variants.js +73 -0
  61. package/dist/components/Chat/ToolCallCard/ToolCallCard.svelte +174 -0
  62. package/dist/components/Chat/ToolCallCard/ToolCallCard.svelte.d.ts +4 -0
  63. package/dist/components/Chat/ToolCallCard/index.d.ts +84 -0
  64. package/dist/components/Chat/ToolCallCard/index.js +2 -0
  65. package/dist/components/Chat/ToolCallCard/tool-call-card.variants.d.ts +38 -0
  66. package/dist/components/Chat/ToolCallCard/tool-call-card.variants.js +38 -0
  67. package/dist/components/Chat/chat.types.d.ts +82 -0
  68. package/dist/components/Chat/chat.types.js +7 -0
  69. package/dist/components/Chat/index.d.ts +22 -0
  70. package/dist/components/Chat/index.js +14 -0
  71. package/dist/components/Chat/markdown/blocks.d.ts +54 -0
  72. package/dist/components/Chat/markdown/blocks.js +462 -0
  73. package/dist/components/Chat/markdown/inline.d.ts +31 -0
  74. package/dist/components/Chat/markdown/inline.js +446 -0
  75. package/dist/components/Chat/markdown/repair.d.ts +23 -0
  76. package/dist/components/Chat/markdown/repair.js +337 -0
  77. package/dist/components/Chat/markdown/streaming-harness.d.ts +23 -0
  78. package/dist/components/Chat/markdown/streaming-harness.js +62 -0
  79. package/dist/components/Chat/markdown/types.d.ts +139 -0
  80. package/dist/components/Chat/markdown/types.js +13 -0
  81. package/dist/components/Chat/markdown/url-policy.d.ts +33 -0
  82. package/dist/components/Chat/markdown/url-policy.js +109 -0
  83. package/dist/components/FileUpload/FileUpload.svelte +52 -125
  84. package/dist/components/FileUpload/FileUpload.svelte.d.ts +1 -1
  85. package/dist/components/FileUpload/index.d.ts +2 -28
  86. package/dist/components/Planner/PlannerHeader.svelte +16 -13
  87. package/dist/components/Planner/planner.variants.js +14 -3
  88. package/dist/components/index.d.ts +1 -0
  89. package/dist/components/index.js +1 -0
  90. package/dist/i18n/index.d.ts +268 -382
  91. package/dist/i18n/index.js +83 -9
  92. package/dist/internal/core/CoreIconButton.svelte +66 -0
  93. package/dist/internal/core/CoreIconButton.svelte.d.ts +13 -0
  94. package/dist/internal/core/CoreSpinner.svelte +49 -0
  95. package/dist/internal/core/CoreSpinner.svelte.d.ts +8 -0
  96. package/dist/internal/core/spinner-geometry.d.ts +6 -0
  97. package/dist/internal/core/spinner-geometry.js +6 -0
  98. package/dist/mint/registry.d.ts +20 -2
  99. package/dist/mint/registry.js +20 -2
  100. package/dist/primitives/Badge/Badge.svelte +5 -5
  101. package/dist/primitives/Badge/badge.variants.js +17 -1
  102. package/dist/primitives/Button/Button.svelte +4 -2
  103. package/dist/primitives/Collapsible/Collapsible.svelte +5 -0
  104. package/dist/primitives/Dialog/Dialog.svelte +7 -5
  105. package/dist/primitives/Dialog/dialog.variants.d.ts +7 -0
  106. package/dist/primitives/Dialog/dialog.variants.js +17 -0
  107. package/dist/primitives/Drawer/Drawer.svelte +7 -5
  108. package/dist/primitives/Drawer/drawer.variants.d.ts +8 -0
  109. package/dist/primitives/Drawer/drawer.variants.js +17 -0
  110. package/dist/primitives/Spinner/Spinner.svelte +3 -1
  111. package/dist/primitives/SplitPane/SplitPane.svelte +293 -0
  112. package/dist/primitives/SplitPane/SplitPane.svelte.d.ts +4 -0
  113. package/dist/primitives/SplitPane/index.d.ts +88 -0
  114. package/dist/primitives/SplitPane/index.js +2 -0
  115. package/dist/primitives/SplitPane/split-pane.utils.d.ts +73 -0
  116. package/dist/primitives/SplitPane/split-pane.utils.js +94 -0
  117. package/dist/primitives/SplitPane/split-pane.variants.d.ts +40 -0
  118. package/dist/primitives/SplitPane/split-pane.variants.js +56 -0
  119. package/dist/primitives/Toast/Toaster.svelte +17 -2
  120. package/dist/primitives/index.d.ts +2 -0
  121. package/dist/primitives/index.js +1 -0
  122. package/dist/utils/file-intake.d.ts +78 -0
  123. package/dist/utils/file-intake.js +144 -0
  124. package/dist/utils/index.d.ts +1 -0
  125. package/dist/utils/index.js +1 -0
  126. package/dist/utils/variants.js +44 -10
  127. package/package.json +4 -6
@@ -0,0 +1,102 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { ChatMessageData, ChatMessagePart, ChatRole } from '../chat.types.js';
4
+ import type { MarkdownUrlPolicy } from '../markdown/types.js';
5
+ import type { ChatMessageSlots, ChatMessageVariants } from './chat-message.variants.js';
6
+ /**
7
+ * Per-part snippet overrides keyed by part `type`. When a renderer exists for a
8
+ * part's type it replaces the built-in rendering for that part (one level up
9
+ * from StreamingMarkdown's node `renderers`) — this is how P3 swaps in
10
+ * ToolCallCard / ReasoningDisclosure and P4 adds A2UIView without touching this
11
+ * component. `source` is intentionally not overridable here: sources are
12
+ * collected into the citation footer, not rendered inline.
13
+ */
14
+ export type ChatPartRenderers = {
15
+ [K in Exclude<ChatMessagePart['type'], 'source'>]?: Snippet<[
16
+ Extract<ChatMessagePart, {
17
+ type: K;
18
+ }>
19
+ ]>;
20
+ };
21
+ /**
22
+ * @description Renders one `ChatMessageData` — its ordered parts (markdown text via
23
+ * StreamingMarkdown, reasoning, tool-call status lines, policy-checked attachment
24
+ * chips) plus a citation footer, streaming placeholder, error/aborted Alert, a
25
+ * hover-revealed copy/regenerate action bar and a timestamp. `layout="bubble"`
26
+ * (default) tints and aligns per role; `layout="plain"` is a document-like
27
+ * full-width Claude-style column. Untrusted URLs never render as inline media —
28
+ * only as `urlPolicy`-checked download links. Compose many of these under
29
+ * ChatMessageList; the consumer owns the `ChatMessageData[]`.
30
+ *
31
+ * @tag ai
32
+ * @tag display
33
+ * @related ChatMessageList
34
+ * @related StreamingMarkdown
35
+ * @stability experimental
36
+ *
37
+ * @example
38
+ * ```svelte
39
+ * <ChatMessage message={msg} onRegenerate={() => regenerate(msg.id)} />
40
+ * ```
41
+ */
42
+ export interface ChatMessageProps extends Omit<ChatMessageVariants, 'role'>, Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'class'> {
43
+ /** The message to render. The component never mutates it. */
44
+ message: ChatMessageData;
45
+ /**
46
+ * URL policy applied to attachment links and forwarded to StreamingMarkdown /
47
+ * CitationChip. Strict by default (untrusted LLM/server output). Keep it
48
+ * referentially stable to avoid re-parsing streamed markdown.
49
+ */
50
+ urlPolicy?: MarkdownUrlPolicy;
51
+ /** Provide to render a "Regenerate" action. Called on click. */
52
+ onRegenerate?: () => void;
53
+ /** Provide to render a "Retry" button in the error/aborted Alert. Called on click. */
54
+ onRetry?: () => void;
55
+ /** Avatar override. Receives the message role; default is the Avatar primitive with a role icon. */
56
+ avatar?: Snippet<[{
57
+ role: ChatRole;
58
+ }]>;
59
+ /** Action-bar override. Receives the message; default is copy (+ regenerate when `onRegenerate` is set). */
60
+ actions?: Snippet<[{
61
+ message: ChatMessageData;
62
+ }]>;
63
+ /** Metadata override. Receives the message; default is `createdAt` as a `<time>` element. */
64
+ metadata?: Snippet<[{
65
+ message: ChatMessageData;
66
+ }]>;
67
+ /** Per-part-type render overrides (see `ChatPartRenderers`). */
68
+ partRenderers?: ChatPartRenderers;
69
+ /** Display names per role, shown in the `plain`-layout header. */
70
+ roleLabels?: Partial<Record<ChatRole, string>>;
71
+ /** Accessible label / tooltip for the copy action. @default 'Copy message' */
72
+ copyLabel?: string;
73
+ /** Accessible label shown briefly after a successful copy. @default 'Copied' */
74
+ copiedLabel?: string;
75
+ /** Accessible label / tooltip for the regenerate action. @default 'Regenerate' */
76
+ regenerateLabel?: string;
77
+ /** Label for the retry button in the error/aborted Alert. @default 'Retry' */
78
+ retryLabel?: string;
79
+ /** Alert title for `status === 'error'`. @default 'Something went wrong' */
80
+ errorLabel?: string;
81
+ /** Alert title for `status === 'aborted'`. @default 'Generation stopped' */
82
+ abortedLabel?: string;
83
+ /** Extra classes merged onto the root element. */
84
+ class?: string;
85
+ /** Remove all default tv classes. */
86
+ unstyled?: boolean;
87
+ /**
88
+ * Per-slot class overrides. Slots: root | container | header | roleName | avatar |
89
+ * bubble | partsFlow | attachment | attachmentIcon | attachmentName | attachmentSize |
90
+ * sourcesFooter | placeholder | statusAlert | footer | actions | actionButton |
91
+ * metadata. Reasoning and tool-call parts render through ReasoningDisclosure /
92
+ * ToolCallCard, which take their own `slotClasses`.
93
+ */
94
+ slotClasses?: Partial<Record<ChatMessageSlots, string>>;
95
+ /**
96
+ * Apply a named preset registered via `<BlocksProvider presets={{ ChatMessage: {...} }}>`.
97
+ * Prefer this over `class` overrides for reusable custom looks.
98
+ */
99
+ preset?: string;
100
+ }
101
+ export { default as ChatMessage } from './ChatMessage.svelte';
102
+ export { type ChatMessageSlots, type ChatMessageVariants, chatMessageVariants } from './chat-message.variants.js';
@@ -0,0 +1,2 @@
1
+ export { default as ChatMessage } from './ChatMessage.svelte';
2
+ export { chatMessageVariants } from './chat-message.variants.js';
@@ -0,0 +1,301 @@
1
+ <script lang="ts">
2
+ import { getBlocksConfig, resolveSlotClasses } from '../../../provider';
3
+ import { resolveIcon } from '../../../icons';
4
+ import ArrowDownIconDefault from '../../../icons/ArrowDownIcon.svelte';
5
+ import MessageSquareIconDefault from '../../../icons/MessageSquareIcon.svelte';
6
+ import { Badge } from '../../../primitives/Badge';
7
+ import { EmptyState } from '../../EmptyState';
8
+ import type { ChatMessageData, ChatMessageStatus } from '../chat.types';
9
+ import ChatMessage from '../ChatMessage/ChatMessage.svelte';
10
+ import { chatMessageListVariants } from './chat-message-list.variants';
11
+ import {
12
+ appendedCount,
13
+ classifyTransition,
14
+ type ListIdSnapshot,
15
+ type ListTransition,
16
+ resolveScrollIntent
17
+ } from './chat-scroll';
18
+ import type { ChatMessageListProps } from './index';
19
+
20
+ const ArrowDownIcon = resolveIcon('arrowDown', ArrowDownIconDefault);
21
+ const MessageSquareIcon = resolveIcon('messageSquare', MessageSquareIconDefault);
22
+
23
+ let {
24
+ messages,
25
+ message: messageSnippet,
26
+ empty,
27
+ urlPolicy,
28
+ partRenderers,
29
+ layout,
30
+ density,
31
+ onRegenerate,
32
+ onRetry,
33
+ onStickChange,
34
+ listLabel = 'Conversation',
35
+ scrollToBottomLabel = 'Scroll to bottom',
36
+ newMessagesLabel = 'New messages',
37
+ generatingLabel = 'Generating response…',
38
+ errorLabel = 'Something went wrong',
39
+ abortedLabel = 'Generation stopped',
40
+ emptyTitle = 'No messages yet',
41
+ emptyDescription,
42
+ class: className = '',
43
+ unstyled: unstyledProp = false,
44
+ slotClasses: slotClassesProp = {},
45
+ preset,
46
+ ...restProps
47
+ }: ChatMessageListProps = $props();
48
+
49
+ const blocksConfig = getBlocksConfig();
50
+ const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
51
+
52
+ const styles = $derived(chatMessageListVariants({}));
53
+ const slotClasses = $derived(
54
+ resolveSlotClasses(blocksConfig, 'ChatMessageList', preset, {}, slotClassesProp)
55
+ );
56
+
57
+ function slot(name: keyof typeof styles, extra?: string | false): string {
58
+ const overrides = [slotClasses?.[name], extra].filter(Boolean).join(' ');
59
+ if (unstyled) return overrides;
60
+ return styles[name]({ class: overrides });
61
+ }
62
+
63
+ // ── Stick state ──────────────────────────────────────────────────────────
64
+ // `stuck` = the list follows growing content. See chat-scroll.ts for the
65
+ // direction/proximity model that classifies scroll events without any
66
+ // "programmatic scroll" suppression flag.
67
+ let viewportRef = $state<HTMLDivElement>();
68
+ let contentRef = $state<HTMLDivElement>();
69
+ let stuck = $state(true);
70
+ let newCount = $state(0);
71
+ let lastScrollTop = 0;
72
+
73
+ const showJumpButton = $derived(!stuck && messages.length > 0);
74
+
75
+ function scrollNow(behavior: ScrollBehavior = 'instant') {
76
+ const el = viewportRef;
77
+ if (!el) return;
78
+ el.scrollTo({ top: el.scrollHeight, behavior });
79
+ }
80
+
81
+ /**
82
+ * Scroll to the newest message and resume following. Exposed on the
83
+ * instance (`bind:this`) for consumer-driven jumps.
84
+ */
85
+ export function scrollToBottom(behavior: ScrollBehavior = 'instant') {
86
+ setStuck(true);
87
+ scrollNow(behavior);
88
+ }
89
+
90
+ function setStuck(next: boolean) {
91
+ if (stuck === next) return;
92
+ stuck = next;
93
+ if (next) newCount = 0;
94
+ onStickChange?.(next);
95
+ }
96
+
97
+ function handleScroll() {
98
+ const el = viewportRef;
99
+ if (!el) return;
100
+ const snapshot = {
101
+ scrollTop: el.scrollTop,
102
+ scrollHeight: el.scrollHeight,
103
+ clientHeight: el.clientHeight
104
+ };
105
+ const intent = resolveScrollIntent(snapshot, lastScrollTop);
106
+ lastScrollTop = snapshot.scrollTop;
107
+ if (intent === 'stick') setStuck(true);
108
+ else if (intent === 'unstick') setStuck(false);
109
+ }
110
+
111
+ // Follow growing content (streaming tokens) and viewport resizes while
112
+ // stuck. Content growth below the fold never fires scroll events, so the
113
+ // engine observes sizes instead.
114
+ $effect(() => {
115
+ const viewport = viewportRef;
116
+ const content = contentRef;
117
+ if (!viewport || typeof ResizeObserver === 'undefined') return;
118
+ const ro = new ResizeObserver(() => {
119
+ if (stuck) scrollNow();
120
+ });
121
+ ro.observe(viewport);
122
+ if (content) ro.observe(content);
123
+ return () => ro.disconnect();
124
+ });
125
+
126
+ // ── List transitions (append / prepend / replace) ────────────────────────
127
+ // The pre-effect classifies the change and snapshots the scroll metrics
128
+ // BEFORE the DOM updates; the post-effect applies the matching correction
129
+ // after. Plain (non-reactive) carriers hand the verdict across the pair.
130
+ let prevIds: ListIdSnapshot = { firstId: undefined, lastId: undefined, length: 0 };
131
+ let pendingTransition: ListTransition = 'none';
132
+ let pendingPrependHeight = 0;
133
+ let pendingPrependTop = 0;
134
+ let pendingAppended = 0;
135
+
136
+ $effect.pre(() => {
137
+ const next: ListIdSnapshot = {
138
+ firstId: messages[0]?.id,
139
+ lastId: messages[messages.length - 1]?.id,
140
+ length: messages.length
141
+ };
142
+ const transition = classifyTransition(prevIds, next);
143
+ pendingTransition = transition;
144
+ if (transition === 'append') pendingAppended = appendedCount(prevIds.length, next.length);
145
+ if (transition === 'prepend' && viewportRef) {
146
+ pendingPrependHeight = viewportRef.scrollHeight;
147
+ pendingPrependTop = viewportRef.scrollTop;
148
+ }
149
+ prevIds = next;
150
+ });
151
+
152
+ $effect(() => {
153
+ // Subscribe to the SAME signals as the pre-effect (length + boundary ids),
154
+ // not just the array reference: an in-place `messages.push(m)` — the
155
+ // idiomatic Svelte-5 stream append — changes length but not the reference,
156
+ // and `void messages` alone would miss it (review finding, P2 wave).
157
+ void messages.length;
158
+ void messages[0]?.id;
159
+ void messages[messages.length - 1]?.id;
160
+ const transition = pendingTransition;
161
+ pendingTransition = 'none';
162
+
163
+ if (transition === 'prepend' && viewportRef) {
164
+ // Keep the previously visible message visually still: shift scrollTop
165
+ // by exactly the height the prepended history added above it.
166
+ const delta = viewportRef.scrollHeight - pendingPrependHeight;
167
+ viewportRef.scrollTop = pendingPrependTop + delta;
168
+ lastScrollTop = viewportRef.scrollTop;
169
+ } else if (transition === 'initial' || transition === 'replace') {
170
+ // A conversation opens (or resets) at its newest message.
171
+ setStuck(true);
172
+ scrollNow();
173
+ } else if (transition === 'append') {
174
+ if (stuck) scrollNow();
175
+ else newCount += pendingAppended;
176
+ }
177
+ });
178
+
179
+ // ── Screen-reader strategy ───────────────────────────────────────────────
180
+ // The visible log is aria-live="off" — announcing every streamed token is
181
+ // spam. A visually hidden status region announces the start of generation
182
+ // and, when a reply settles, its full text once (a deliberate trade-off:
183
+ // the answer is what the user asked for; a short cue would force a manual
184
+ // hunt through the log). Transitions settling in the SAME tick collapse to
185
+ // the last one — accepted: parallel assistant streams are out of scope.
186
+ let announcement = $state('');
187
+ // Deliberately a plain Map, NOT SvelteMap: it is only read/written inside
188
+ // the effect below — reactivity here would re-trigger that very effect.
189
+ const announcedStatus = new Map<string, ChatMessageStatus | undefined>();
190
+ // First effect run only REGISTERS the mounted history — announcing it would
191
+ // read the newest old message aloud on every conversation open.
192
+ let announceSyncDone = false;
193
+
194
+ function messageText(msg: ChatMessageData): string {
195
+ return msg.parts.flatMap((p) => (p.type === 'text' ? [p.text] : [])).join('\n\n');
196
+ }
197
+
198
+ function settledAnnouncement(msg: ChatMessageData): string {
199
+ if (msg.status === 'error') return errorLabel;
200
+ if (msg.status === 'aborted') return abortedLabel;
201
+ return messageText(msg);
202
+ }
203
+
204
+ $effect(() => {
205
+ const seen = new Set<string>();
206
+ const announceable = announceSyncDone;
207
+ for (const msg of messages) {
208
+ seen.add(msg.id);
209
+ if (msg.role !== 'assistant') continue;
210
+ const prev = announcedStatus.get(msg.id);
211
+ const known = announcedStatus.has(msg.id);
212
+ if (msg.status === 'streaming' && prev !== 'streaming') {
213
+ if (announceable || !known) announcement = generatingLabel;
214
+ } else if (known && prev === 'streaming' && msg.status !== 'streaming') {
215
+ // Settle: text when there is any, the error/aborted label otherwise —
216
+ // never leave a stale "generating" in the region (review finding).
217
+ announcement = settledAnnouncement(msg) || '';
218
+ } else if (announceable && !known && msg.status !== 'streaming') {
219
+ // A reply that arrives already settled (fast/cached models stream
220
+ // nothing) must still be announced (review finding).
221
+ announcement = settledAnnouncement(msg) || announcement;
222
+ }
223
+ announcedStatus.set(msg.id, msg.status);
224
+ }
225
+ for (const id of announcedStatus.keys()) {
226
+ if (!seen.has(id)) announcedStatus.delete(id);
227
+ }
228
+ announceSyncDone = true;
229
+ });
230
+ </script>
231
+
232
+ <div
233
+ {...restProps}
234
+ class={unstyled
235
+ ? [slotClasses?.root, className].filter(Boolean).join(' ')
236
+ : styles.root({ class: [slotClasses?.root, className] })}
237
+ data-stuck={stuck || undefined}
238
+ >
239
+ <!-- Focusable scroll container (axe: scrollable-region-focusable) -->
240
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
241
+ <div
242
+ bind:this={viewportRef}
243
+ class={slot('viewport')}
244
+ role="region"
245
+ aria-label={listLabel}
246
+ tabindex="0"
247
+ onscroll={handleScroll}
248
+ >
249
+ {#if messages.length === 0}
250
+ <div class={slot('empty')}>
251
+ {#if empty}
252
+ {@render empty()}
253
+ {:else}
254
+ <EmptyState icon={MessageSquareIcon} title={emptyTitle} description={emptyDescription} />
255
+ {/if}
256
+ </div>
257
+ {:else}
258
+ <div bind:this={contentRef} class={slot('content')} role="log" aria-live="off">
259
+ {#each messages as msg, i (msg.id)}
260
+ {@const isLast = i === messages.length - 1}
261
+ {#if messageSnippet}
262
+ {@render messageSnippet({ message: msg, index: i, isLast })}
263
+ {:else}
264
+ <ChatMessage
265
+ message={msg}
266
+ {urlPolicy}
267
+ {partRenderers}
268
+ {layout}
269
+ {density}
270
+ onRegenerate={isLast && msg.role === 'assistant' && onRegenerate
271
+ ? () => onRegenerate(msg)
272
+ : undefined}
273
+ onRetry={onRetry ? () => onRetry(msg) : undefined}
274
+ />
275
+ {/if}
276
+ {/each}
277
+ </div>
278
+ {/if}
279
+ </div>
280
+
281
+ {#if showJumpButton}
282
+ <button
283
+ type="button"
284
+ class={slot('newButton')}
285
+ aria-label={newCount > 0 ? `${newCount} ${newMessagesLabel}` : scrollToBottomLabel}
286
+ onclick={() => {
287
+ scrollToBottom();
288
+ // Re-sticking unmounts this button — hand focus to the viewport so a
289
+ // keyboard activation does not drop focus to <body> (WCAG 2.4.3).
290
+ viewportRef?.focus();
291
+ }}
292
+ >
293
+ <ArrowDownIcon size={16} />
294
+ {#if newCount > 0}
295
+ <Badge intent="primary" size="xs" counter>{newCount}</Badge>
296
+ {/if}
297
+ </button>
298
+ {/if}
299
+
300
+ <div class="sr-only" role="status">{announcement}</div>
301
+ </div>
@@ -0,0 +1,6 @@
1
+ import type { ChatMessageListProps } from './index.js';
2
+ declare const ChatMessageList: import("svelte").Component<ChatMessageListProps, {
3
+ scrollToBottom: (behavior?: ScrollBehavior) => void;
4
+ }, "">;
5
+ type ChatMessageList = ReturnType<typeof ChatMessageList>;
6
+ export default ChatMessageList;
@@ -0,0 +1,23 @@
1
+ import { type SlotNames, type VariantProps } from '../../../utils/variants.js';
2
+ export declare const chatMessageListVariants: ((props?: {} | undefined) => {
3
+ root: (props?: ({} & {
4
+ class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
5
+ }) | undefined) => string;
6
+ viewport: (props?: ({} & {
7
+ class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
8
+ }) | undefined) => string;
9
+ content: (props?: ({} & {
10
+ class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
11
+ }) | undefined) => string;
12
+ empty: (props?: ({} & {
13
+ class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
14
+ }) | undefined) => string;
15
+ newButton: (props?: ({} & {
16
+ class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
17
+ }) | undefined) => string;
18
+ }) & {
19
+ readonly config: import("../../../utils/variants.js").TVConfig;
20
+ };
21
+ export type ChatMessageListVariants = VariantProps<typeof chatMessageListVariants>;
22
+ /** Slot names derived from the tv() config — single source of truth for slotClasses. */
23
+ export type ChatMessageListSlots = SlotNames<typeof chatMessageListVariants>;
@@ -0,0 +1,32 @@
1
+ import { tv } from '../../../utils/variants.js';
2
+ export const chatMessageListVariants = tv({
3
+ slots: {
4
+ // Positioning context for the floating new-messages button; fills the
5
+ // parent (Chat's body region) and delegates all scrolling to the viewport.
6
+ root: ['relative flex h-full min-h-0 flex-col'],
7
+ // The one scrolling element. Browser scroll anchoring is disabled — the
8
+ // engine does its own deterministic prepend anchoring (Safari has no
9
+ // overflow-anchor support, so we correct manually everywhere).
10
+ viewport: [
11
+ 'min-h-0 flex-1 overflow-y-auto overscroll-contain',
12
+ '[overflow-anchor:none]',
13
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset'
14
+ ],
15
+ // The log element whose children are the messages.
16
+ content: ['flex flex-col gap-4 p-4'],
17
+ empty: ['flex h-full items-center justify-center p-6'],
18
+ // Floating pill centered over the bottom edge of the viewport.
19
+ newButton: [
20
+ 'absolute bottom-3 left-1/2 -translate-x-1/2',
21
+ 'inline-flex items-center gap-1.5',
22
+ 'rounded-full border border-border-default bg-surface-overlay',
23
+ 'px-3 py-1.5 text-sm text-text-primary',
24
+ 'shadow-[var(--blocks-shadow-md)]',
25
+ 'cursor-pointer select-none',
26
+ 'transition-[background-color,color] duration-[var(--blocks-duration-fast)] ease-out',
27
+ 'hover:bg-surface-hover',
28
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2'
29
+ ]
30
+ },
31
+ variants: {}
32
+ });
@@ -0,0 +1,45 @@
1
+ /** Scroll metrics of the viewport at one point in time. */
2
+ export interface ScrollSnapshot {
3
+ scrollTop: number;
4
+ scrollHeight: number;
5
+ clientHeight: number;
6
+ }
7
+ /**
8
+ * Distance from the bottom edge within which the user counts as "at the
9
+ * bottom": entering it re-sticks, and tiny upward wiggles inside it (trackpad
10
+ * inertia, macOS bounce) never unstick.
11
+ */
12
+ export declare const RESTICK_PX = 24;
13
+ export declare function distanceFromBottom(snapshot: ScrollSnapshot): number;
14
+ /**
15
+ * Classify one scroll event. `lastScrollTop` is the previous event's position.
16
+ * Order matters: the bottom zone wins over direction, so bounce-back at the
17
+ * bottom edge stays stuck.
18
+ */
19
+ export declare function resolveScrollIntent(snapshot: ScrollSnapshot, lastScrollTop: number): 'stick' | 'unstick' | 'none';
20
+ /**
21
+ * How the message list changed between two renders, judged purely from ids —
22
+ * content growth inside an existing message (streaming) is deliberately NOT a
23
+ * transition (the follow behaviour reacts to it via ResizeObserver instead).
24
+ *
25
+ * - `append` — new messages at the end (feeds the new-messages counter).
26
+ * - `prepend` — older messages loaded at the top (needs the scroll anchor fix).
27
+ * - `truncate` — trailing messages removed (stable head, shorter list): NOT an
28
+ * arrival, so it must never bump the counter (review finding, P2 wave).
29
+ * - `replace` — both ends changed or the list was reset (jump to bottom).
30
+ * - `initial` — first non-empty render (open at the bottom).
31
+ * Simultaneous append+prepend degrades to `replace` — rare (history load
32
+ * during streaming) and safe: worst case is one jump to the bottom.
33
+ */
34
+ export type ListTransition = 'none' | 'initial' | 'append' | 'prepend' | 'truncate' | 'replace';
35
+ export interface ListIdSnapshot {
36
+ firstId: string | undefined;
37
+ lastId: string | undefined;
38
+ length: number;
39
+ }
40
+ export declare function classifyTransition(prev: ListIdSnapshot, next: ListIdSnapshot): ListTransition;
41
+ /**
42
+ * New messages added by an `append` transition. A same-length append (the tail
43
+ * message was swapped for a new id) still counts as one new message.
44
+ */
45
+ export declare function appendedCount(prevLength: number, nextLength: number): number;
@@ -0,0 +1,52 @@
1
+ // Pure decision logic for the ChatMessageList scroll engine — kept free of
2
+ // DOM/Svelte so the invariants are node-testable.
3
+ //
4
+ // Core idea: programmatic follow-scrolling only ever moves DOWN, so any UPWARD
5
+ // scroll movement is user intent and breaks the stick. Re-sticking happens by
6
+ // proximity: whoever scrolls back into the bottom zone follows again. This
7
+ // needs no "suppress programmatic scroll" flag and no event-source sniffing.
8
+ /**
9
+ * Distance from the bottom edge within which the user counts as "at the
10
+ * bottom": entering it re-sticks, and tiny upward wiggles inside it (trackpad
11
+ * inertia, macOS bounce) never unstick.
12
+ */
13
+ export const RESTICK_PX = 24;
14
+ /** Minimum upward movement (px) that counts as deliberate user scrolling. */
15
+ const UPWARD_INTENT_PX = 1;
16
+ export function distanceFromBottom(snapshot) {
17
+ return Math.max(0, snapshot.scrollHeight - snapshot.scrollTop - snapshot.clientHeight);
18
+ }
19
+ /**
20
+ * Classify one scroll event. `lastScrollTop` is the previous event's position.
21
+ * Order matters: the bottom zone wins over direction, so bounce-back at the
22
+ * bottom edge stays stuck.
23
+ */
24
+ export function resolveScrollIntent(snapshot, lastScrollTop) {
25
+ if (distanceFromBottom(snapshot) <= RESTICK_PX)
26
+ return 'stick';
27
+ if (snapshot.scrollTop < lastScrollTop - UPWARD_INTENT_PX)
28
+ return 'unstick';
29
+ return 'none';
30
+ }
31
+ export function classifyTransition(prev, next) {
32
+ if (next.length === 0)
33
+ return prev.length === 0 ? 'none' : 'replace';
34
+ if (prev.length === 0)
35
+ return 'initial';
36
+ const firstStable = prev.firstId === next.firstId;
37
+ const lastStable = prev.lastId === next.lastId;
38
+ if (firstStable && lastStable && next.length === prev.length)
39
+ return 'none';
40
+ if (firstStable)
41
+ return next.length < prev.length ? 'truncate' : 'append';
42
+ if (lastStable)
43
+ return 'prepend';
44
+ return 'replace';
45
+ }
46
+ /**
47
+ * New messages added by an `append` transition. A same-length append (the tail
48
+ * message was swapped for a new id) still counts as one new message.
49
+ */
50
+ export function appendedCount(prevLength, nextLength) {
51
+ return Math.max(1, nextLength - prevLength);
52
+ }