@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.
- package/README.md +11 -1
- package/dist/components/Calendar/CalendarHeader.svelte +32 -26
- package/dist/components/Calendar/CalendarMiniMonth.svelte +10 -10
- package/dist/components/Calendar/calendar.variants.js +19 -5
- package/dist/components/Chat/Chat/Chat.svelte +53 -0
- package/dist/components/Chat/Chat/Chat.svelte.d.ts +4 -0
- package/dist/components/Chat/Chat/chat.variants.d.ts +20 -0
- package/dist/components/Chat/Chat/chat.variants.js +15 -0
- package/dist/components/Chat/Chat/index.d.ts +47 -0
- package/dist/components/Chat/Chat/index.js +2 -0
- package/dist/components/Chat/ChatMessage/ChatMessage.svelte +342 -0
- package/dist/components/Chat/ChatMessage/ChatMessage.svelte.d.ts +4 -0
- package/dist/components/Chat/ChatMessage/chat-message.variants.d.ts +138 -0
- package/dist/components/Chat/ChatMessage/chat-message.variants.js +118 -0
- package/dist/components/Chat/ChatMessage/index.d.ts +102 -0
- package/dist/components/Chat/ChatMessage/index.js +2 -0
- package/dist/components/Chat/ChatMessageList/ChatMessageList.svelte +301 -0
- package/dist/components/Chat/ChatMessageList/ChatMessageList.svelte.d.ts +6 -0
- package/dist/components/Chat/ChatMessageList/chat-message-list.variants.d.ts +23 -0
- package/dist/components/Chat/ChatMessageList/chat-message-list.variants.js +32 -0
- package/dist/components/Chat/ChatMessageList/chat-scroll.d.ts +45 -0
- package/dist/components/Chat/ChatMessageList/chat-scroll.js +52 -0
- package/dist/components/Chat/ChatMessageList/index.d.ts +93 -0
- package/dist/components/Chat/ChatMessageList/index.js +3 -0
- package/dist/components/Chat/CitationChip/CitationChip.svelte +107 -0
- package/dist/components/Chat/CitationChip/CitationChip.svelte.d.ts +4 -0
- package/dist/components/Chat/CitationChip/citation-chip.variants.d.ts +40 -0
- package/dist/components/Chat/CitationChip/citation-chip.variants.js +42 -0
- package/dist/components/Chat/CitationChip/index.d.ts +69 -0
- package/dist/components/Chat/CitationChip/index.js +2 -0
- package/dist/components/Chat/CodeBlock/CodeBlock.svelte +120 -0
- package/dist/components/Chat/CodeBlock/CodeBlock.svelte.d.ts +4 -0
- package/dist/components/Chat/CodeBlock/code-block.variants.d.ts +40 -0
- package/dist/components/Chat/CodeBlock/code-block.variants.js +51 -0
- package/dist/components/Chat/CodeBlock/index.d.ts +55 -0
- package/dist/components/Chat/CodeBlock/index.js +2 -0
- package/dist/components/Chat/PromptInput/PromptInput.svelte +432 -0
- package/dist/components/Chat/PromptInput/PromptInput.svelte.d.ts +4 -0
- package/dist/components/Chat/PromptInput/index.d.ts +165 -0
- package/dist/components/Chat/PromptInput/index.js +2 -0
- package/dist/components/Chat/PromptInput/prompt-input.variants.d.ts +90 -0
- package/dist/components/Chat/PromptInput/prompt-input.variants.js +102 -0
- package/dist/components/Chat/ReasoningDisclosure/ReasoningDisclosure.svelte +107 -0
- package/dist/components/Chat/ReasoningDisclosure/ReasoningDisclosure.svelte.d.ts +4 -0
- package/dist/components/Chat/ReasoningDisclosure/index.d.ts +85 -0
- package/dist/components/Chat/ReasoningDisclosure/index.js +2 -0
- package/dist/components/Chat/ReasoningDisclosure/reasoning-disclosure.variants.d.ts +20 -0
- package/dist/components/Chat/ReasoningDisclosure/reasoning-disclosure.variants.js +31 -0
- package/dist/components/Chat/StreamingMarkdown/MdBlock.svelte +99 -0
- package/dist/components/Chat/StreamingMarkdown/MdBlock.svelte.d.ts +9 -0
- package/dist/components/Chat/StreamingMarkdown/MdInline.svelte +82 -0
- package/dist/components/Chat/StreamingMarkdown/MdInline.svelte.d.ts +9 -0
- package/dist/components/Chat/StreamingMarkdown/StreamingMarkdown.svelte +118 -0
- package/dist/components/Chat/StreamingMarkdown/StreamingMarkdown.svelte.d.ts +4 -0
- package/dist/components/Chat/StreamingMarkdown/index.d.ts +86 -0
- package/dist/components/Chat/StreamingMarkdown/index.js +2 -0
- package/dist/components/Chat/StreamingMarkdown/md-context.d.ts +58 -0
- package/dist/components/Chat/StreamingMarkdown/md-context.js +1 -0
- package/dist/components/Chat/StreamingMarkdown/streaming-markdown.variants.d.ts +145 -0
- package/dist/components/Chat/StreamingMarkdown/streaming-markdown.variants.js +73 -0
- package/dist/components/Chat/ToolCallCard/ToolCallCard.svelte +174 -0
- package/dist/components/Chat/ToolCallCard/ToolCallCard.svelte.d.ts +4 -0
- package/dist/components/Chat/ToolCallCard/index.d.ts +84 -0
- package/dist/components/Chat/ToolCallCard/index.js +2 -0
- package/dist/components/Chat/ToolCallCard/tool-call-card.variants.d.ts +38 -0
- package/dist/components/Chat/ToolCallCard/tool-call-card.variants.js +38 -0
- package/dist/components/Chat/chat.types.d.ts +82 -0
- package/dist/components/Chat/chat.types.js +7 -0
- package/dist/components/Chat/index.d.ts +22 -0
- package/dist/components/Chat/index.js +14 -0
- package/dist/components/Chat/markdown/blocks.d.ts +54 -0
- package/dist/components/Chat/markdown/blocks.js +462 -0
- package/dist/components/Chat/markdown/inline.d.ts +31 -0
- package/dist/components/Chat/markdown/inline.js +446 -0
- package/dist/components/Chat/markdown/repair.d.ts +23 -0
- package/dist/components/Chat/markdown/repair.js +337 -0
- package/dist/components/Chat/markdown/streaming-harness.d.ts +23 -0
- package/dist/components/Chat/markdown/streaming-harness.js +62 -0
- package/dist/components/Chat/markdown/types.d.ts +139 -0
- package/dist/components/Chat/markdown/types.js +13 -0
- package/dist/components/Chat/markdown/url-policy.d.ts +33 -0
- package/dist/components/Chat/markdown/url-policy.js +109 -0
- package/dist/components/FileUpload/FileUpload.svelte +52 -125
- package/dist/components/FileUpload/FileUpload.svelte.d.ts +1 -1
- package/dist/components/FileUpload/index.d.ts +2 -28
- package/dist/components/Planner/PlannerHeader.svelte +16 -13
- package/dist/components/Planner/planner.variants.js +14 -3
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/i18n/index.d.ts +268 -382
- package/dist/i18n/index.js +83 -9
- package/dist/internal/core/CoreIconButton.svelte +66 -0
- package/dist/internal/core/CoreIconButton.svelte.d.ts +13 -0
- package/dist/internal/core/CoreSpinner.svelte +49 -0
- package/dist/internal/core/CoreSpinner.svelte.d.ts +8 -0
- package/dist/internal/core/spinner-geometry.d.ts +6 -0
- package/dist/internal/core/spinner-geometry.js +6 -0
- package/dist/mint/registry.d.ts +20 -2
- package/dist/mint/registry.js +20 -2
- package/dist/primitives/Badge/Badge.svelte +5 -5
- package/dist/primitives/Badge/badge.variants.js +17 -1
- package/dist/primitives/Button/Button.svelte +4 -2
- package/dist/primitives/Collapsible/Collapsible.svelte +5 -0
- package/dist/primitives/Dialog/Dialog.svelte +7 -5
- package/dist/primitives/Dialog/dialog.variants.d.ts +7 -0
- package/dist/primitives/Dialog/dialog.variants.js +17 -0
- package/dist/primitives/Drawer/Drawer.svelte +7 -5
- package/dist/primitives/Drawer/drawer.variants.d.ts +8 -0
- package/dist/primitives/Drawer/drawer.variants.js +17 -0
- package/dist/primitives/Spinner/Spinner.svelte +3 -1
- package/dist/primitives/SplitPane/SplitPane.svelte +293 -0
- package/dist/primitives/SplitPane/SplitPane.svelte.d.ts +4 -0
- package/dist/primitives/SplitPane/index.d.ts +88 -0
- package/dist/primitives/SplitPane/index.js +2 -0
- package/dist/primitives/SplitPane/split-pane.utils.d.ts +73 -0
- package/dist/primitives/SplitPane/split-pane.utils.js +94 -0
- package/dist/primitives/SplitPane/split-pane.variants.d.ts +40 -0
- package/dist/primitives/SplitPane/split-pane.variants.js +56 -0
- package/dist/primitives/Toast/Toaster.svelte +17 -2
- package/dist/primitives/index.d.ts +2 -0
- package/dist/primitives/index.js +1 -0
- package/dist/utils/file-intake.d.ts +78 -0
- package/dist/utils/file-intake.js +144 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/variants.js +44 -10
- 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,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
|
+
}
|