@urbicon-ui/blocks 6.43.0 → 6.43.2
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/README.md +1 -1
- package/dist/components/Calendar/calendar.variants.d.ts +63 -63
- package/dist/components/Chat/ChatMessage/ChatMessage.svelte +93 -67
- package/dist/components/Chat/ChatMessage/chat-message.variants.d.ts +7 -0
- package/dist/components/Chat/ChatMessage/chat-message.variants.js +45 -12
- package/dist/components/Chat/ChatMessage/index.d.ts +20 -5
- package/dist/components/Chat/CodeBlock/CodeBlock.svelte +59 -33
- package/dist/components/Chat/CodeBlock/code-block.variants.d.ts +7 -0
- package/dist/components/Chat/CodeBlock/code-block.variants.js +28 -13
- package/dist/components/Chat/CodeBlock/index.d.ts +39 -4
- package/dist/components/Chat/PromptInput/PromptInput.svelte +9 -16
- package/dist/components/Chat/PromptInput/prompt-input.variants.js +16 -15
- package/dist/components/Chat/ToolCallCard/ToolCallCard.svelte +9 -4
- package/dist/components/Chat/ToolCallCard/index.d.ts +4 -3
- package/dist/components/Chat/ToolCallCard/tool-call-card.variants.d.ts +0 -3
- package/dist/components/Chat/ToolCallCard/tool-call-card.variants.js +4 -4
- package/dist/components/CopyButton/CopyButton.svelte +9 -19
- package/dist/components/CopyButton/copy-button.variants.d.ts +4 -4
- package/dist/i18n/index.d.ts +398 -2
- package/dist/internal/copy-state.svelte.d.ts +60 -0
- package/dist/internal/copy-state.svelte.js +76 -0
- package/dist/internal/field-chrome.js +1 -1
- package/dist/primitives/Alert/alert.variants.d.ts +8 -8
- package/dist/primitives/Combobox/combobox.variants.js +11 -4
- package/dist/primitives/JourneyTimeline/journey-timeline.variants.d.ts +22 -22
- package/dist/primitives/RadioGroup/radioGroup.variants.js +6 -1
- package/dist/primitives/Select/select.variants.js +1 -1
- package/dist/primitives/Spinner/spinner.variants.d.ts +16 -16
- package/dist/primitives/Stepper/stepper.variants.d.ts +11 -11
- package/dist/primitives/Stepper/stepper.variants.js +2 -1
- package/dist/primitives/Tab/tab.variants.js +4 -1
- package/dist/primitives/Textarea/textarea.variants.js +1 -1
- package/dist/primitives/Toast/toast.variants.d.ts +12 -12
- package/dist/primitives/Toggle/toggle.variants.js +9 -7
- package/dist/style/foundation.css +19 -9
- package/dist/style/semantic.css +49 -2
- package/dist/style/themes/forest.css +2 -2
- package/dist/style/themes/neutral.css +2 -2
- package/dist/style/themes/ocean.css +2 -2
- package/dist/style/themes/rose.css +2 -2
- package/dist/style/themes/sunset.css +2 -2
- package/dist/utils/figma-token-export.js +7 -3
- package/package.json +3 -3
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { useBlocksI18n } from '../../../i18n';
|
|
2
3
|
import { getBlocksConfig, resolveSlotClasses } from '../../../provider';
|
|
4
|
+
import { createCopyState } from '../../../internal/copy-state.svelte';
|
|
5
|
+
import CoreIconButton from '../../../internal/core/CoreIconButton.svelte';
|
|
3
6
|
import { Alert, Avatar, Button, Skeleton, Tooltip } from '../../../primitives';
|
|
4
7
|
import { resolveIcon } from '../../../icons';
|
|
5
8
|
import SparklesIconDefault from '../../../icons/SparklesIcon.svelte';
|
|
@@ -34,8 +37,9 @@
|
|
|
34
37
|
layout = 'bubble',
|
|
35
38
|
density = 'comfortable',
|
|
36
39
|
roleLabels,
|
|
37
|
-
copyLabel
|
|
38
|
-
copiedLabel
|
|
40
|
+
copyLabel,
|
|
41
|
+
copiedLabel,
|
|
42
|
+
copyFailedLabel,
|
|
39
43
|
regenerateLabel = 'Regenerate',
|
|
40
44
|
retryLabel = 'Retry',
|
|
41
45
|
errorLabel = 'Something went wrong',
|
|
@@ -47,9 +51,15 @@
|
|
|
47
51
|
...restProps
|
|
48
52
|
}: ChatMessageProps = $props();
|
|
49
53
|
|
|
54
|
+
const bt = useBlocksI18n();
|
|
50
55
|
const blocksConfig = getBlocksConfig();
|
|
51
56
|
const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
|
|
52
57
|
|
|
58
|
+
// Localised by default instead of hardcoded English in the prop signature.
|
|
59
|
+
const copyText = $derived(copyLabel ?? bt('accessibility.copy'));
|
|
60
|
+
const copiedText = $derived(copiedLabel ?? bt('accessibility.copied'));
|
|
61
|
+
const copyFailedText = $derived(copyFailedLabel ?? bt('accessibility.copyFailed'));
|
|
62
|
+
|
|
53
63
|
const SparklesIcon = resolveIcon('sparkles', SparklesIconDefault);
|
|
54
64
|
const UserIcon = resolveIcon('user', UserIconDefault);
|
|
55
65
|
const InfoIcon = resolveIcon('info', InfoIconDefault);
|
|
@@ -93,7 +103,7 @@
|
|
|
93
103
|
});
|
|
94
104
|
|
|
95
105
|
// Concatenated text parts — what the copy action writes to the clipboard.
|
|
96
|
-
const
|
|
106
|
+
const messageText = $derived(
|
|
97
107
|
message.parts.flatMap((p) => (p.type === 'text' ? [p.text] : [])).join('\n\n')
|
|
98
108
|
);
|
|
99
109
|
|
|
@@ -114,27 +124,22 @@
|
|
|
114
124
|
);
|
|
115
125
|
|
|
116
126
|
// ── Copy interaction ─────────────────────────────────────────────────────
|
|
117
|
-
|
|
118
|
-
|
|
127
|
+
const copyState = createCopyState();
|
|
128
|
+
|
|
129
|
+
// Reports all three outcomes. A failed copy used to be console-only, so the
|
|
130
|
+
// action bar looked identical whether the clipboard write worked or not.
|
|
131
|
+
const copyButtonText = $derived(
|
|
132
|
+
copyState.phase === 'copied'
|
|
133
|
+
? copiedText
|
|
134
|
+
: copyState.phase === 'error'
|
|
135
|
+
? copyFailedText
|
|
136
|
+
: copyText
|
|
137
|
+
);
|
|
119
138
|
|
|
120
139
|
async function copyMessage() {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
copied = true;
|
|
124
|
-
if (resetTimer) clearTimeout(resetTimer);
|
|
125
|
-
resetTimer = setTimeout(() => {
|
|
126
|
-
copied = false;
|
|
127
|
-
resetTimer = undefined;
|
|
128
|
-
}, 2000);
|
|
129
|
-
} catch (err) {
|
|
130
|
-
// Never confirm a copy that failed (e.g. denied clipboard permission).
|
|
131
|
-
console.error('ChatMessage: failed to copy message', err);
|
|
132
|
-
}
|
|
140
|
+
const result = await copyState.copy(messageText);
|
|
141
|
+
if (!result.ok) console.error('ChatMessage: failed to copy message', result.error);
|
|
133
142
|
}
|
|
134
|
-
|
|
135
|
-
$effect(() => () => {
|
|
136
|
-
if (resetTimer) clearTimeout(resetTimer);
|
|
137
|
-
});
|
|
138
143
|
</script>
|
|
139
144
|
|
|
140
145
|
{#snippet defaultAvatar(role: ChatRole)}
|
|
@@ -249,65 +254,43 @@
|
|
|
249
254
|
<div class={cls('actions')}>
|
|
250
255
|
<!-- No copy for text-less messages (tool-call/reasoning-only): copying an
|
|
251
256
|
empty string and confirming "Copied" would be a lie (review finding). -->
|
|
252
|
-
{#if
|
|
253
|
-
<Tooltip label={
|
|
254
|
-
<
|
|
255
|
-
type="button"
|
|
257
|
+
{#if messageText}
|
|
258
|
+
<Tooltip label={copyButtonText}>
|
|
259
|
+
<CoreIconButton
|
|
256
260
|
class={cls('actionButton')}
|
|
257
261
|
onclick={copyMessage}
|
|
258
|
-
aria-label={
|
|
262
|
+
aria-label={copyButtonText}
|
|
259
263
|
>
|
|
260
|
-
{#if copied}
|
|
264
|
+
{#if copyState.phase === 'copied'}
|
|
261
265
|
<CheckIcon size={16} />
|
|
262
266
|
{:else}
|
|
263
267
|
<CopyIcon size={16} />
|
|
264
268
|
{/if}
|
|
265
|
-
</
|
|
269
|
+
</CoreIconButton>
|
|
266
270
|
</Tooltip>
|
|
267
271
|
{/if}
|
|
268
272
|
{#if onRegenerate}
|
|
269
273
|
<Tooltip label={regenerateLabel}>
|
|
270
|
-
<
|
|
271
|
-
type="button"
|
|
274
|
+
<CoreIconButton
|
|
272
275
|
class={cls('actionButton')}
|
|
273
276
|
onclick={onRegenerate}
|
|
274
277
|
aria-label={regenerateLabel}
|
|
275
278
|
>
|
|
276
279
|
<RefreshIcon size={16} />
|
|
277
|
-
</
|
|
280
|
+
</CoreIconButton>
|
|
278
281
|
</Tooltip>
|
|
279
282
|
{/if}
|
|
280
283
|
</div>
|
|
281
284
|
{/snippet}
|
|
282
285
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
<span class={cls('roleName')}>{roleLabel}</span>
|
|
292
|
-
</div>
|
|
293
|
-
<div class={cls('bubble')}>
|
|
294
|
-
{@render body()}
|
|
295
|
-
</div>
|
|
296
|
-
{:else}
|
|
297
|
-
<div class={cls('container')}>
|
|
298
|
-
{#if message.role !== 'user'}
|
|
299
|
-
{#if avatar}
|
|
300
|
-
{@render avatar({ role: message.role })}
|
|
301
|
-
{:else}
|
|
302
|
-
{@render defaultAvatar(message.role)}
|
|
303
|
-
{/if}
|
|
304
|
-
{/if}
|
|
305
|
-
<div class={cls('bubble')}>
|
|
306
|
-
{@render body()}
|
|
307
|
-
</div>
|
|
308
|
-
</div>
|
|
309
|
-
{/if}
|
|
310
|
-
|
|
286
|
+
<!--
|
|
287
|
+
`column` wraps the bubble together with everything that hangs under it —
|
|
288
|
+
citations, the status alert, the footer. They must be siblings of the bubble
|
|
289
|
+
INSIDE the column, not of `container`: the column is what carries the
|
|
290
|
+
role-dependent side (`items-end` for a user message), so the timestamp ends up
|
|
291
|
+
under the bubble's own edge instead of at the left margin.
|
|
292
|
+
-->
|
|
293
|
+
{#snippet belowBubble()}
|
|
311
294
|
{#if sources.length}
|
|
312
295
|
<div class={cls('sourcesFooter')}>
|
|
313
296
|
{#each sources as source, i (source.id)}
|
|
@@ -326,20 +309,63 @@
|
|
|
326
309
|
</div>
|
|
327
310
|
{/if}
|
|
328
311
|
|
|
312
|
+
<!--
|
|
313
|
+
Metadata first in the DOM, actions second — and the footer reverses for a
|
|
314
|
+
user message (see the variants). That ordering is what puts the timestamp
|
|
315
|
+
against the bubble's own edge on BOTH sides: the action bar is `opacity-0`
|
|
316
|
+
until hover, so whichever element comes first at the edge is the only one
|
|
317
|
+
visible there. Actions-first left the timestamp floating one button-width in
|
|
318
|
+
from the edge, attached to nothing.
|
|
319
|
+
-->
|
|
329
320
|
<div class={cls('footer')}>
|
|
330
|
-
{#if actions}
|
|
331
|
-
{@render actions({ message })}
|
|
332
|
-
{:else}
|
|
333
|
-
{@render defaultActions()}
|
|
334
|
-
{/if}
|
|
335
321
|
{#if metadata}
|
|
336
322
|
{@render metadata({ message })}
|
|
337
323
|
{:else if createdAtTime}
|
|
338
324
|
<time class={cls('metadata')} datetime={createdAtIso}>{createdAtTime}</time>
|
|
339
325
|
{/if}
|
|
326
|
+
{#if actions}
|
|
327
|
+
{@render actions({ message })}
|
|
328
|
+
{:else}
|
|
329
|
+
{@render defaultActions()}
|
|
330
|
+
{/if}
|
|
340
331
|
</div>
|
|
332
|
+
{/snippet}
|
|
333
|
+
|
|
334
|
+
<div class={cls('root', className)} data-role={message.role} data-status={status} {...restProps}>
|
|
335
|
+
{#if layout === 'plain'}
|
|
336
|
+
<div class={cls('header')}>
|
|
337
|
+
{#if avatar}
|
|
338
|
+
{@render avatar({ role: message.role })}
|
|
339
|
+
{:else}
|
|
340
|
+
{@render defaultAvatar(message.role)}
|
|
341
|
+
{/if}
|
|
342
|
+
<span class={cls('roleName')}>{roleLabel}</span>
|
|
343
|
+
</div>
|
|
344
|
+
<div class={cls('column')}>
|
|
345
|
+
<div class={cls('bubble')}>
|
|
346
|
+
{@render body()}
|
|
347
|
+
</div>
|
|
348
|
+
{@render belowBubble()}
|
|
349
|
+
</div>
|
|
350
|
+
{:else}
|
|
351
|
+
<div class={cls('container')}>
|
|
352
|
+
{#if message.role !== 'user'}
|
|
353
|
+
{#if avatar}
|
|
354
|
+
{@render avatar({ role: message.role })}
|
|
355
|
+
{:else}
|
|
356
|
+
{@render defaultAvatar(message.role)}
|
|
357
|
+
{/if}
|
|
358
|
+
{/if}
|
|
359
|
+
<div class={cls('column')}>
|
|
360
|
+
<div class={cls('bubble')}>
|
|
361
|
+
{@render body()}
|
|
362
|
+
</div>
|
|
363
|
+
{@render belowBubble()}
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
{/if}
|
|
341
367
|
|
|
342
|
-
<!-- Copy
|
|
343
|
-
|
|
344
|
-
<span class="sr-only" role="status">{
|
|
368
|
+
<!-- Copy outcome for screen readers — a live status region, present before the
|
|
369
|
+
phase flips so it always announces the change. -->
|
|
370
|
+
<span class="sr-only" role="status">{copyState.phase === 'idle' ? '' : copyButtonText}</span>
|
|
345
371
|
</div>
|
|
@@ -39,6 +39,13 @@ export declare const chatMessageVariants: ((props?: {
|
|
|
39
39
|
} & {
|
|
40
40
|
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;
|
|
41
41
|
}) | undefined) => string;
|
|
42
|
+
column: (props?: ({
|
|
43
|
+
layout?: "bubble" | "plain" | undefined;
|
|
44
|
+
role?: "user" | "system" | "assistant" | undefined;
|
|
45
|
+
density?: "compact" | "comfortable" | undefined;
|
|
46
|
+
} & {
|
|
47
|
+
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;
|
|
48
|
+
}) | undefined) => string;
|
|
42
49
|
bubble: (props?: ({
|
|
43
50
|
layout?: "bubble" | "plain" | undefined;
|
|
44
51
|
role?: "user" | "system" | "assistant" | undefined;
|
|
@@ -11,6 +11,13 @@ export const chatMessageVariants = tv({
|
|
|
11
11
|
header: ['flex min-w-0 items-center gap-2'],
|
|
12
12
|
roleName: ['text-sm font-medium text-text-secondary select-none'],
|
|
13
13
|
avatar: ['shrink-0'],
|
|
14
|
+
// Everything that belongs UNDER the bubble — citations, the error alert, the
|
|
15
|
+
// footer — lives in this column, not as a sibling of `container`. It is what
|
|
16
|
+
// makes those rows inherit the bubble's side: a user bubble sits right, so
|
|
17
|
+
// its timestamp and citations must too. `items-*` per role does the aligning
|
|
18
|
+
// (see the compounds), and the width cap lives here rather than on `bubble`
|
|
19
|
+
// so an alert spans the column while the bubble still hugs its text.
|
|
20
|
+
column: ['flex min-w-0 flex-col'],
|
|
14
21
|
// The content bubble (bubble layout) / the full-width column (plain layout).
|
|
15
22
|
bubble: ['min-w-0 max-w-full'],
|
|
16
23
|
// Vertical stack of the rendered parts.
|
|
@@ -29,21 +36,29 @@ export const chatMessageVariants = tv({
|
|
|
29
36
|
sourcesFooter: ['flex flex-wrap items-center gap-1.5 pt-1'],
|
|
30
37
|
// streaming placeholder (zero parts)
|
|
31
38
|
placeholder: ['py-1'],
|
|
32
|
-
// status alert
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
// status alert — stretches across the column (the bubble hugs its text, an
|
|
40
|
+
// error does not).
|
|
41
|
+
statusAlert: ['mt-1 w-full'],
|
|
42
|
+
// Footer row holding actions + metadata. Its height is set by the metadata
|
|
43
|
+
// line (`min-h-5`), NOT by the action buttons: those are `opacity-0` until
|
|
44
|
+
// hover/focus, so letting them size the row reserved ~28px of blank strip
|
|
45
|
+
// under every single message. `-my-1` below lets them overhang the shorter
|
|
46
|
+
// row instead of growing it.
|
|
47
|
+
footer: ['flex min-h-5 items-center gap-2'],
|
|
36
48
|
actions: [
|
|
37
|
-
'flex items-center gap-1 opacity-0',
|
|
49
|
+
'flex items-center gap-1 -my-1 opacity-0',
|
|
38
50
|
'transition-opacity duration-[var(--blocks-duration-fast)] ease-out',
|
|
39
51
|
'group-hover/message:opacity-100 group-focus-within/message:opacity-100'
|
|
40
52
|
],
|
|
53
|
+
// Renders through CoreIconButton, so this carries only what the core does not
|
|
54
|
+
// supply — no flex centring, cursor or `focus-visible:outline-none` here (the
|
|
55
|
+
// core has no variant engine, so a repeat would resolve by stylesheet order).
|
|
41
56
|
actionButton: [
|
|
42
|
-
'
|
|
43
|
-
'text-text-tertiary
|
|
57
|
+
'rounded-modify p-1.5',
|
|
58
|
+
'text-text-tertiary',
|
|
44
59
|
'transition-[color,background-color] duration-[var(--blocks-duration-fast)] ease-out',
|
|
45
60
|
'hover:bg-surface-hover hover:text-text-primary',
|
|
46
|
-
'focus-visible:
|
|
61
|
+
'focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1'
|
|
47
62
|
],
|
|
48
63
|
metadata: ['text-xs text-text-tertiary']
|
|
49
64
|
},
|
|
@@ -52,6 +67,7 @@ export const chatMessageVariants = tv({
|
|
|
52
67
|
bubble: {},
|
|
53
68
|
// Document-like: full-width content under an avatar + role header row.
|
|
54
69
|
plain: {
|
|
70
|
+
column: 'w-full items-stretch',
|
|
55
71
|
bubble: 'w-full',
|
|
56
72
|
partsFlow: 'gap-3'
|
|
57
73
|
}
|
|
@@ -64,30 +80,46 @@ export const chatMessageVariants = tv({
|
|
|
64
80
|
density: {
|
|
65
81
|
comfortable: {
|
|
66
82
|
root: 'gap-1.5',
|
|
83
|
+
column: 'gap-1.5',
|
|
67
84
|
partsFlow: 'gap-2'
|
|
68
85
|
},
|
|
69
86
|
compact: {
|
|
70
87
|
root: 'gap-1',
|
|
88
|
+
column: 'gap-1',
|
|
71
89
|
partsFlow: 'gap-1.5'
|
|
72
90
|
}
|
|
73
91
|
}
|
|
74
92
|
},
|
|
75
93
|
compoundVariants: [
|
|
76
94
|
// ── bubble layout: role tint + alignment ────────────────────────────────
|
|
95
|
+
// Bubbles ride the BRIDGE tier, not `contain`. `contain` (2 px) is the
|
|
96
|
+
// architectural-panel radius — correct on a 600 px Card, where it reads as
|
|
97
|
+
// a precise edge, but on a ~200 px bubble the same 2 px reads as a plain
|
|
98
|
+
// rectangle. Optical radius scales with the area it turns. `bridge` (6 px)
|
|
99
|
+
// is the middle tier and stays brand-themeable, so a consumer retunes
|
|
100
|
+
// bubbles via `--radius-bridge` instead of overriding this slot.
|
|
77
101
|
{
|
|
78
102
|
layout: 'bubble',
|
|
79
103
|
role: 'user',
|
|
80
104
|
class: {
|
|
81
|
-
// Reverse so the (avatar-less)
|
|
105
|
+
// Reverse so the (avatar-less) column packs to the right edge, and
|
|
106
|
+
// right-align its rows so timestamp + citations hang off the same edge
|
|
107
|
+
// as the bubble instead of drifting to the left margin.
|
|
82
108
|
container: 'flex-row-reverse',
|
|
83
|
-
|
|
109
|
+
column: 'max-w-[85%] items-end',
|
|
110
|
+
bubble: 'rounded-bridge bg-primary-subtle',
|
|
111
|
+
// Metadata comes first in the DOM so it can sit against the bubble edge;
|
|
112
|
+
// reversing here puts that edge on the right for a user message while
|
|
113
|
+
// keeping the action bar on the inside.
|
|
114
|
+
footer: 'flex-row-reverse'
|
|
84
115
|
}
|
|
85
116
|
},
|
|
86
117
|
{
|
|
87
118
|
layout: 'bubble',
|
|
88
119
|
role: 'assistant',
|
|
89
120
|
class: {
|
|
90
|
-
|
|
121
|
+
column: 'max-w-[85%] items-start',
|
|
122
|
+
bubble: 'rounded-bridge bg-surface-elevated'
|
|
91
123
|
}
|
|
92
124
|
},
|
|
93
125
|
{
|
|
@@ -95,7 +127,8 @@ export const chatMessageVariants = tv({
|
|
|
95
127
|
role: 'system',
|
|
96
128
|
class: {
|
|
97
129
|
container: 'justify-center',
|
|
98
|
-
|
|
130
|
+
column: 'max-w-[90%] items-center',
|
|
131
|
+
bubble: 'rounded-bridge border border-border-subtle bg-surface-base text-sm text-text-secondary'
|
|
99
132
|
}
|
|
100
133
|
},
|
|
101
134
|
// ── bubble layout: density padding ──────────────────────────────────────
|
|
@@ -68,10 +68,22 @@ export interface ChatMessageProps extends Omit<ChatMessageVariants, 'role'>, Omi
|
|
|
68
68
|
partRenderers?: ChatPartRenderers;
|
|
69
69
|
/** Display names per role, shown in the `plain`-layout header. */
|
|
70
70
|
roleLabels?: Partial<Record<ChatRole, string>>;
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Accessible label / tooltip for the copy action.
|
|
73
|
+
* @default the `accessibility.copy` translation
|
|
74
|
+
*/
|
|
72
75
|
copyLabel?: string;
|
|
73
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Label shown briefly after a successful copy.
|
|
78
|
+
* @default the `accessibility.copied` translation
|
|
79
|
+
*/
|
|
74
80
|
copiedLabel?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Label shown briefly after a FAILED copy — e.g. a denied clipboard permission
|
|
83
|
+
* or a non-secure context.
|
|
84
|
+
* @default the `accessibility.copyFailed` translation
|
|
85
|
+
*/
|
|
86
|
+
copyFailedLabel?: string;
|
|
75
87
|
/** Accessible label / tooltip for the regenerate action. @default 'Regenerate' */
|
|
76
88
|
regenerateLabel?: string;
|
|
77
89
|
/** Label for the retry button in the error/aborted Alert. @default 'Retry' */
|
|
@@ -86,9 +98,12 @@ export interface ChatMessageProps extends Omit<ChatMessageVariants, 'role'>, Omi
|
|
|
86
98
|
unstyled?: boolean;
|
|
87
99
|
/**
|
|
88
100
|
* Per-slot class overrides. Slots: root | container | header | roleName | avatar |
|
|
89
|
-
* bubble | partsFlow | attachment | attachmentIcon | attachmentName |
|
|
90
|
-
* sourcesFooter | placeholder | statusAlert | footer | actions |
|
|
91
|
-
* metadata.
|
|
101
|
+
* column | bubble | partsFlow | attachment | attachmentIcon | attachmentName |
|
|
102
|
+
* attachmentSize | sourcesFooter | placeholder | statusAlert | footer | actions |
|
|
103
|
+
* actionButton | metadata. `column` wraps the bubble with everything under it
|
|
104
|
+
* (citations, status alert, footer) and carries the role-dependent alignment —
|
|
105
|
+
* override it to re-align a message's whole stack, not just the bubble.
|
|
106
|
+
* Reasoning and tool-call parts render through ReasoningDisclosure /
|
|
92
107
|
* ToolCallCard, which take their own `slotClasses`.
|
|
93
108
|
*/
|
|
94
109
|
slotClasses?: Partial<Record<ChatMessageSlots, string>>;
|
|
@@ -3,17 +3,22 @@
|
|
|
3
3
|
import { resolveIcon } from '../../../icons';
|
|
4
4
|
import CopyIconDefault from '../../../icons/CopyIcon.svelte';
|
|
5
5
|
import CheckIconDefault from '../../../icons/CheckIcon.svelte';
|
|
6
|
+
import { createCopyState } from '../../../internal/copy-state.svelte';
|
|
6
7
|
import { codeBlockVariants, type CodeBlockVariants } from './code-block.variants';
|
|
7
8
|
import type { CodeBlockProps } from './index';
|
|
8
9
|
|
|
9
10
|
let {
|
|
10
11
|
code,
|
|
11
12
|
lang,
|
|
13
|
+
label,
|
|
14
|
+
variant = 'card',
|
|
12
15
|
showCopy = true,
|
|
13
16
|
wrap = false,
|
|
14
|
-
copyLabel = 'Copy
|
|
17
|
+
copyLabel = 'Copy',
|
|
15
18
|
copiedLabel = 'Copied',
|
|
19
|
+
copyFailedLabel = 'Copy failed',
|
|
16
20
|
onCopy,
|
|
21
|
+
onCopyError,
|
|
17
22
|
actions,
|
|
18
23
|
class: className,
|
|
19
24
|
unstyled: unstyledProp = false,
|
|
@@ -28,7 +33,16 @@
|
|
|
28
33
|
const CopyIcon = resolveIcon('copy', CopyIconDefault);
|
|
29
34
|
const CheckIcon = resolveIcon('check', CheckIconDefault);
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
// Deliberately NOT routed through useBlocksI18n(). CodeBlock is a leaf with no
|
|
37
|
+
// other reason to pull the i18n registry, and doing so for three strings costs
|
|
38
|
+
// +5 KB gz here — which StreamingMarkdown and ReasoningDisclosure then inherit
|
|
39
|
+
// by embedding it (+25% each, measured against bundle-size.baseline.json). A
|
|
40
|
+
// consumer localises through the three label props; ChatMessage, which already
|
|
41
|
+
// carries the registry via Alert/Avatar/Button/Tooltip, does use the
|
|
42
|
+
// translations because there it is free. See technical-debt.md — splitting
|
|
43
|
+
// translations/en.ts per area would remove the trade-off.
|
|
44
|
+
|
|
45
|
+
const variantProps: CodeBlockVariants = $derived({ variant, wrap });
|
|
32
46
|
const styles = $derived(codeBlockVariants(variantProps));
|
|
33
47
|
const slotClasses = $derived(
|
|
34
48
|
resolveSlotClasses(blocksConfig, 'CodeBlock', preset, variantProps, slotClassesProp)
|
|
@@ -45,54 +59,66 @@
|
|
|
45
59
|
});
|
|
46
60
|
}
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
// `label` wins over `lang` in the header: an embedding parent (ToolCallCard)
|
|
63
|
+
// passes what the payload *is* ("Input"), which says more than the language it
|
|
64
|
+
// happens to be serialised in — and showing both is the same fact twice.
|
|
65
|
+
const headerLabel = $derived(label ?? lang);
|
|
66
|
+
const showHeader = $derived(!!headerLabel || showCopy || !!actions);
|
|
67
|
+
// The accessible name of the scroll region keeps naming the language when
|
|
68
|
+
// there is one: "Input code" is vaguer than "json code" for a screen reader.
|
|
69
|
+
const regionLabel = $derived(lang ? `${lang} code` : label ? `${label} code` : 'Code');
|
|
50
70
|
|
|
51
|
-
|
|
52
|
-
let resetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
71
|
+
const copyState = createCopyState();
|
|
53
72
|
|
|
54
|
-
async function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
copied = true;
|
|
58
|
-
if (resetTimer) clearTimeout(resetTimer);
|
|
59
|
-
resetTimer = setTimeout(() => {
|
|
60
|
-
copied = false;
|
|
61
|
-
resetTimer = undefined;
|
|
62
|
-
}, 2000);
|
|
73
|
+
async function handleCopy() {
|
|
74
|
+
const result = await copyState.copy(code);
|
|
75
|
+
if (result.ok) {
|
|
63
76
|
onCopy?.(code);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
} else if (onCopyError) {
|
|
78
|
+
onCopyError(result.error);
|
|
79
|
+
} else {
|
|
80
|
+
// Never silent: a copy that did nothing is the outcome most worth
|
|
81
|
+
// reporting, and the consumer opted out of handling it.
|
|
82
|
+
console.error('CodeBlock: failed to copy code', result.error);
|
|
68
83
|
}
|
|
69
84
|
}
|
|
70
85
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
// The button reports the outcome, all three of them. A denied clipboard
|
|
87
|
+
// permission used to leave it looking untouched (console-only).
|
|
88
|
+
const buttonText = $derived(
|
|
89
|
+
copyState.phase === 'copied'
|
|
90
|
+
? copiedLabel
|
|
91
|
+
: copyState.phase === 'error'
|
|
92
|
+
? copyFailedLabel
|
|
93
|
+
: copyLabel
|
|
94
|
+
);
|
|
75
95
|
</script>
|
|
76
96
|
|
|
77
97
|
<div class={cls('root', className)} {...restProps}>
|
|
78
98
|
{#if showHeader}
|
|
79
99
|
<div class={cls('header')}>
|
|
80
|
-
<span class={cls('langLabel')}>{
|
|
100
|
+
<span class={cls('langLabel')}>{headerLabel ?? ''}</span>
|
|
81
101
|
<div class="flex items-center gap-1">
|
|
82
102
|
{@render actions?.()}
|
|
83
103
|
{#if showCopy}
|
|
104
|
+
<!--
|
|
105
|
+
Icon-only: the button sits in a header whose other half already names
|
|
106
|
+
the payload, and a text button there outweighed the caption it was
|
|
107
|
+
supposed to serve. `title` carries the label for pointer users, the
|
|
108
|
+
live region below announces the outcome.
|
|
109
|
+
-->
|
|
84
110
|
<button
|
|
85
111
|
type="button"
|
|
86
112
|
class={cls('copyButton')}
|
|
87
|
-
onclick={
|
|
88
|
-
aria-label={
|
|
113
|
+
onclick={handleCopy}
|
|
114
|
+
aria-label={buttonText}
|
|
115
|
+
title={buttonText}
|
|
89
116
|
>
|
|
90
|
-
{#if copied}
|
|
117
|
+
{#if copyState.phase === 'copied'}
|
|
91
118
|
<CheckIcon size={14} />
|
|
92
119
|
{:else}
|
|
93
120
|
<CopyIcon size={14} />
|
|
94
121
|
{/if}
|
|
95
|
-
<span>{copied ? copiedLabel : copyLabel}</span>
|
|
96
122
|
</button>
|
|
97
123
|
{/if}
|
|
98
124
|
</div>
|
|
@@ -111,10 +137,10 @@
|
|
|
111
137
|
></pre>
|
|
112
138
|
|
|
113
139
|
<!--
|
|
114
|
-
Copy
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
140
|
+
Copy outcome for screen readers. A label change on the button the user just
|
|
141
|
+
activated is not a reliable announcement — a live status region is. It must
|
|
142
|
+
exist in the DOM before the phase flips, so it always renders and only its
|
|
143
|
+
text content changes.
|
|
118
144
|
-->
|
|
119
|
-
<span class="sr-only" role="status">{
|
|
145
|
+
<span class="sr-only" role="status">{copyState.phase === 'idle' ? '' : buttonText}</span>
|
|
120
146
|
</div>
|
|
@@ -1,33 +1,40 @@
|
|
|
1
1
|
import { type SlotNames, type VariantProps } from '../../../utils/variants.js';
|
|
2
2
|
export declare const codeBlockVariants: ((props?: {
|
|
3
|
+
variant?: "card" | "plain" | undefined;
|
|
3
4
|
wrap?: boolean | undefined;
|
|
4
5
|
} | undefined) => {
|
|
5
6
|
root: (props?: ({
|
|
7
|
+
variant?: "card" | "plain" | undefined;
|
|
6
8
|
wrap?: boolean | undefined;
|
|
7
9
|
} & {
|
|
8
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;
|
|
9
11
|
}) | undefined) => string;
|
|
10
12
|
header: (props?: ({
|
|
13
|
+
variant?: "card" | "plain" | undefined;
|
|
11
14
|
wrap?: boolean | undefined;
|
|
12
15
|
} & {
|
|
13
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;
|
|
14
17
|
}) | undefined) => string;
|
|
15
18
|
langLabel: (props?: ({
|
|
19
|
+
variant?: "card" | "plain" | undefined;
|
|
16
20
|
wrap?: boolean | undefined;
|
|
17
21
|
} & {
|
|
18
22
|
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;
|
|
19
23
|
}) | undefined) => string;
|
|
20
24
|
copyButton: (props?: ({
|
|
25
|
+
variant?: "card" | "plain" | undefined;
|
|
21
26
|
wrap?: boolean | undefined;
|
|
22
27
|
} & {
|
|
23
28
|
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;
|
|
24
29
|
}) | undefined) => string;
|
|
25
30
|
pre: (props?: ({
|
|
31
|
+
variant?: "card" | "plain" | undefined;
|
|
26
32
|
wrap?: boolean | undefined;
|
|
27
33
|
} & {
|
|
28
34
|
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;
|
|
29
35
|
}) | undefined) => string;
|
|
30
36
|
code: (props?: ({
|
|
37
|
+
variant?: "card" | "plain" | undefined;
|
|
31
38
|
wrap?: boolean | undefined;
|
|
32
39
|
} & {
|
|
33
40
|
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;
|