@lapismd/design-core 0.1.0 → 0.2.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/CHANGELOG.md +44 -0
- package/LICENSE.md +3 -3
- package/README.md +21 -1
- package/package.json +34 -15
- package/spec/src/styling-and-themes.md +11 -9
- package/src/shared/ai/chat.css +25 -1
- package/src/shared/ai/chat.ts +12 -0
- package/src/shared/ai/composer-input/ComposerInput.svelte +3 -2
- package/src/shared/ai/context.svelte.ts +2 -0
- package/src/shared/ai/experimental/reaction-bar/ReactionBar.svelte +1 -1
- package/src/shared/ai/layout/Layout.svelte +116 -10
- package/src/shared/ai/message-list/MessageList.svelte +123 -43
- package/src/shared/ai/message-list/MessageListHistoryFixture.svelte +112 -0
- package/src/shared/ai/message-list/MessageListLegacyFixture.svelte +52 -0
- package/src/shared/ai/message-list/VirtualizedMessageRows.svelte +299 -0
- package/src/shared/ai/message-list/position.ts +7 -0
- package/src/shared/ai/new-messages.svelte.ts +24 -5
- package/src/shared/ai/stream-scroll.svelte.ts +135 -6
- package/src/shared/ai/tool-call-detail/ToolCallDetail.svelte +71 -0
- package/src/shared/ai/tool-call-detail/tool-call-detail.ts +209 -0
- package/src/shared/ai/tool-calls/ToolCalls.svelte +1 -1
- package/src/shared/ai/trigger-menu.ts +2 -3
- package/src/shared/diff/core/merge/actions.ts +4 -3
- package/src/shared/diff/core/merge/render-model.ts +14 -10
- package/src/shared/diff/file-diff/FileDiff.css +68 -1
- package/src/shared/diff/file-diff/FileDiff.svelte +27 -14
- package/src/shared/diff/file-diff/FileDiffComposer.svelte +98 -16
- package/src/shared/diff/file-diff/index.ts +2 -0
- package/src/shared/diff/file-diff/types.ts +13 -0
- package/src/shared/diff/file-listing/FileListingViewModeToggle.css +42 -5
- package/src/shared/diff/index.ts +2 -0
- package/src/shared/diff/merge-editor/MergeEditor.svelte +2 -7
- package/src/shared/filter/filter-query/filters.ts +2 -2
- package/src/shared/filter/filter-query/parser.ts +9 -8
- package/src/shared/filter/power-search/PowerSearch.svelte +1 -1
- package/src/shared/filter/search-filter-bar/SearchFilterBar.svelte +2 -2
- package/src/shared/filter/search-filter-bar/SearchFilterInput.svelte +1 -1
- package/src/shared/filter/search-filter-bar/SearchFilterPredicateEditor.svelte +1 -1
- package/src/shared/filter/search-filter-bar/search-filter-syntax.ts +1 -1
- package/src/shared/forms/autocomplete-input/AutocompleteInput.svelte +19 -7
- package/src/shared/forms/confirm-button/ConfirmButton.svelte +38 -0
- package/src/shared/forms/confirm-button/ConfirmButtonExample.svelte +14 -0
- package/src/shared/forms/confirm-button/index.ts +1 -0
- package/src/shared/forms/confirm-dialog/ConfirmDialog.css +21 -0
- package/src/shared/forms/confirm-dialog/ConfirmDialog.svelte +116 -0
- package/src/shared/forms/confirm-dialog/ConfirmDialogExample.svelte +17 -0
- package/src/shared/forms/confirm-dialog/index.ts +2 -0
- package/src/shared/forms/dialog-frame/DialogFrame.css +130 -0
- package/src/shared/forms/dialog-frame/DialogFrame.svelte +108 -0
- package/src/shared/forms/dialog-frame/DialogFrameExample.svelte +37 -0
- package/src/shared/forms/dialog-frame/index.ts +6 -0
- package/src/shared/forms/dialog.ts +8 -0
- package/src/shared/forms/index.ts +11 -0
- package/src/shared/shadcn/button/button.svelte +4 -0
- package/src/shared/shadcn/code-block/highlightRanges.ts +10 -6
- package/src/shared/shadcn/code-block/syntax/defineSyntaxTheme.ts +4 -2
- package/src/shared/shadcn/code-block/tokenizer.ts +3 -3
- package/src/shared/shadcn/command/command-dialog.svelte +1 -1
- package/src/shared/shadcn/command/command-group.svelte +3 -1
- package/src/shared/shadcn/command-view/command-view.svelte +1 -6
- package/src/shared/shadcn/dialog/dialog-content.svelte +3 -4
- package/src/shared/shadcn/dialog/dialog-portal.svelte +10 -2
- package/src/shared/shadcn/dropdown-menu/dropdown-menu-checkbox-item.svelte +0 -691
- package/src/shared/shadcn/dropdown-menu/dropdown-menu-content.svelte +691 -0
- package/src/shared/shadcn/dropdown-menu/dropdown-menu-radio-group.svelte +1 -1
- package/src/shared/shadcn/scroll-area/scroll-area.svelte +2 -0
- package/src/shared/shadcn/spinner/spinner.svelte +3 -3
- package/src/shared/shadcn/tooltip/tooltip-content.svelte +10 -4
- package/src/shared/shadcn/tooltip/tooltip.tokens.ts +4 -0
- package/src/shared/shell/app-shell/AppShell.css +112 -32
- package/src/shared/shell/app-shell/AppShell.demo.css +37 -0
- package/src/shared/shell/app-shell/AppShellBodyContent.svelte +5 -1
- package/src/shared/shell/app-shell/AppShellBodySidebar.svelte +5 -1
- package/src/shared/shell/app-shell/AppShellMain.svelte +12 -1
- package/src/shared/shell/app-shell/AppShellMobilePanelSelector.svelte +1 -1
- package/src/shared/shell/app-shell/AppShellRoot.svelte +27 -13
- package/src/shared/shell/app-shell/AppShellSidebar.svelte +121 -19
- package/src/shared/shell/app-shell/AppShellSidebarClose.svelte +17 -17
- package/src/shared/shell/app-shell/AppShellSidebarToggle.svelte +38 -58
- package/src/shared/shell/app-shell/AppShellSurfaceLayer.svelte +173 -0
- package/src/shared/shell/app-shell/AppShellSurfaceLayerBody.svelte +32 -0
- package/src/shared/shell/app-shell/AppShellSurfaceLayerHeader.svelte +26 -0
- package/src/shared/shell/app-shell/app-shell-context.svelte.ts +6 -3
- package/src/shared/shell/app-shell/app-shell-controller.svelte.ts +269 -11
- package/src/shared/shell/app-shell/app-shell-sidebar-context.svelte.ts +1 -0
- package/src/shared/shell/app-shell/examples/AppShellBodyDemo.svelte +4 -0
- package/src/shared/shell/app-shell/examples/AppShellCompleteDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellConversationDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellExamples.css +624 -0
- package/src/shared/shell/app-shell/examples/AppShellFilesSidebarDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellMarkdownDocumentDemo.svelte +6 -1
- package/src/shared/shell/app-shell/examples/AppShellMarkdownFilesDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellModeSwitchDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellMultiPanelDemo.svelte +187 -0
- package/src/shared/shell/app-shell/examples/AppShellProjectSidebarDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellSidebarDemo.svelte +1 -0
- package/src/shared/shell/app-shell/examples/AppShellToolbarDemo.svelte +1 -0
- package/src/shared/shell/app-shell/index.ts +14 -0
- package/src/shared/shell/shell.tokens.css +2 -0
- package/src/shared/shell/shell.tokens.ts +5 -0
- package/src/shared/workspace/menu/WorkspaceContextMenuItems.svelte +2 -1
- package/src/shared/workspace/menu/WorkspaceMenuItems.svelte +2 -1
- package/src/shared/workspace/problems/ProblemsQuickFix.svelte +1 -4
- package/src/shared/workspace/settings/AppSettingsContent.svelte +6 -0
- package/src/shared/workspace/settings/AppSettingsNavigation.svelte +7 -0
- package/src/shared/workspace/settings/CopyableValue.svelte +81 -0
- package/src/shared/workspace/settings/DeclarativeSettingsSection.svelte +20 -0
- package/src/shared/workspace/settings/WorkspaceSettingField.svelte +110 -41
- package/src/shared/workspace/settings/WorkspaceSettingMultiSelect.svelte +7 -6
- package/src/shared/workspace/settings/WorkspaceSettingObjectTable.svelte +187 -23
- package/src/shared/workspace/settings/WorkspaceSettingSelect.svelte +29 -29
- package/src/shared/workspace/settings/WorkspaceSettings.css +190 -4
- package/src/shared/workspace/settings/WorkspaceSettingsStoryCustomField.svelte +2 -2
- package/src/shared/workspace/settings/WorkspaceSettingsStoryCustomPage.svelte +13 -0
- package/src/shared/workspace/settings/WorkspaceSettingsSurface.svelte +50 -0
- package/src/shared/workspace/settings/configuration.ts +5 -0
- package/src/shared/workspace/settings/copyable-value.ts +10 -0
- package/src/shared/workspace/settings/index.ts +5 -0
- package/src/shared/workspace/settings/settings-controller.svelte.ts +349 -15
- package/src/shared/workspace/settings/types.ts +90 -8
- package/src/storybook/app-shell-reference/AppShellTwoExpandedSidebarsReference.css +25 -0
- package/src/storybook/app-shell-reference/AppShellTwoExpandedSidebarsReference.svelte +48 -0
- package/src/storybook/app-shell-reference/index.ts +1 -0
- package/src/theme.css +10 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
<script lang="ts">
|
|
1
|
+
<script lang="ts" generics="T extends { id: string }">
|
|
2
2
|
import SparklesIcon from "@lucide/svelte/icons/sparkles";
|
|
3
3
|
import { Spinner } from "@lapismd/design-core/shadcn/spinner";
|
|
4
4
|
import { Button } from "@lapismd/design-core/shadcn/button";
|
|
5
5
|
import * as Empty from "@lapismd/design-core/shadcn/empty";
|
|
6
|
-
import
|
|
6
|
+
import VirtualizedMessageRows from "./VirtualizedMessageRows.svelte";
|
|
7
|
+
import { tick, untrack, type Snippet } from "svelte";
|
|
7
8
|
import type { HTMLAttributes } from "svelte/elements";
|
|
8
9
|
import { setListContext, useLayoutContext } from "../context.svelte.js";
|
|
9
10
|
import type { Density } from "../types.js";
|
|
11
|
+
import type { MessageListPosition } from "./position.js";
|
|
10
12
|
import "../chat.css";
|
|
11
13
|
|
|
12
14
|
let {
|
|
@@ -19,6 +21,19 @@
|
|
|
19
21
|
scrollToTopAction,
|
|
20
22
|
latestMessageId,
|
|
21
23
|
children,
|
|
24
|
+
header,
|
|
25
|
+
footer,
|
|
26
|
+
items = [],
|
|
27
|
+
row,
|
|
28
|
+
virtualize = false,
|
|
29
|
+
overscan = 6,
|
|
30
|
+
estimateSize = 112,
|
|
31
|
+
conversationKey,
|
|
32
|
+
initialPosition,
|
|
33
|
+
onPositionChange,
|
|
34
|
+
retainRowIds = [],
|
|
35
|
+
hasOlder = true,
|
|
36
|
+
loading = false,
|
|
22
37
|
"aria-label": ariaLabel = "Messages",
|
|
23
38
|
...restProps
|
|
24
39
|
}: HTMLAttributes<HTMLDivElement> & {
|
|
@@ -30,13 +45,36 @@
|
|
|
30
45
|
emptyState?: Snippet;
|
|
31
46
|
scrollToTopAction?: () => Promise<void>;
|
|
32
47
|
latestMessageId?: string;
|
|
33
|
-
children
|
|
48
|
+
children?: Snippet;
|
|
49
|
+
/** Content before and after the rows, including in a virtualized list. */
|
|
50
|
+
header?: Snippet;
|
|
51
|
+
footer?: Snippet;
|
|
52
|
+
items?: readonly T[];
|
|
53
|
+
row?: Snippet<[T, number]>;
|
|
54
|
+
/** Opt in to bounded row rendering and automatic older-page loading; defaults to false. */
|
|
55
|
+
virtualize?: boolean;
|
|
56
|
+
overscan?: number;
|
|
57
|
+
estimateSize?: number;
|
|
58
|
+
/** Scope restoration and arrivals to a conversation; existing unkeyed lists retain explicit arrival handling. */
|
|
59
|
+
conversationKey?: string;
|
|
60
|
+
initialPosition?: MessageListPosition;
|
|
61
|
+
onPositionChange?: (position: MessageListPosition) => void;
|
|
62
|
+
retainRowIds?: readonly string[];
|
|
63
|
+
hasOlder?: boolean;
|
|
64
|
+
loading?: boolean;
|
|
34
65
|
} = $props();
|
|
35
66
|
|
|
36
67
|
const layout = useLayoutContext();
|
|
68
|
+
const componentId = $props.id();
|
|
69
|
+
const errorId = `${componentId}-older-error`;
|
|
37
70
|
let innerRef = $state<HTMLDivElement | null>(null);
|
|
38
71
|
let loadingOlder = $state(false);
|
|
72
|
+
let loadVersion = 0;
|
|
39
73
|
let loadError = $state<string | null>(null);
|
|
74
|
+
let arrivalKey: string | undefined;
|
|
75
|
+
let previousMessageId: string | undefined;
|
|
76
|
+
let historyKey: string | undefined;
|
|
77
|
+
let historyInitialized = false;
|
|
40
78
|
|
|
41
79
|
setListContext({ getDensity: () => density });
|
|
42
80
|
|
|
@@ -44,28 +82,64 @@
|
|
|
44
82
|
layout?.setContent(innerRef);
|
|
45
83
|
return () => layout?.setContent(null);
|
|
46
84
|
});
|
|
85
|
+
$effect(() => {
|
|
86
|
+
const key = conversationKey,
|
|
87
|
+
latest = latestMessageId;
|
|
88
|
+
if (
|
|
89
|
+
latest &&
|
|
90
|
+
(key === undefined ||
|
|
91
|
+
(arrivalKey === key && latest !== previousMessageId))
|
|
92
|
+
)
|
|
93
|
+
untrack(() => layout?.newMessages.notify(latest));
|
|
94
|
+
arrivalKey = key;
|
|
95
|
+
previousMessageId = latest;
|
|
96
|
+
});
|
|
47
97
|
|
|
48
98
|
$effect(() => {
|
|
49
|
-
|
|
99
|
+
const key = conversationKey;
|
|
100
|
+
if (historyInitialized && key === historyKey) return;
|
|
101
|
+
historyInitialized = true;
|
|
102
|
+
historyKey = key;
|
|
103
|
+
loadVersion++;
|
|
104
|
+
loadingOlder = false;
|
|
105
|
+
loadError = null;
|
|
50
106
|
});
|
|
51
107
|
|
|
52
108
|
async function loadOlder(): Promise<void> {
|
|
53
|
-
if (!scrollToTopAction || loadingOlder) return;
|
|
109
|
+
if (!scrollToTopAction || loadingOlder || loading || !hasOlder) return;
|
|
110
|
+
const key = conversationKey;
|
|
111
|
+
const version = loadVersion;
|
|
54
112
|
loadingOlder = true;
|
|
55
113
|
loadError = null;
|
|
56
|
-
const viewport = layout?.getScrollContainer()
|
|
114
|
+
const viewport = layout?.getScrollContainer();
|
|
57
115
|
const previousHeight = viewport?.scrollHeight ?? 0;
|
|
116
|
+
const revision = layout?.streamScroll.positionRevision;
|
|
117
|
+
const restore =
|
|
118
|
+
!virtualize && conversationKey !== undefined
|
|
119
|
+
? layout?.streamScroll.captureAnchor?.()
|
|
120
|
+
: undefined;
|
|
58
121
|
try {
|
|
59
122
|
await scrollToTopAction();
|
|
60
123
|
await tick();
|
|
61
|
-
if (
|
|
62
|
-
|
|
124
|
+
if (key === conversationKey && version === loadVersion) {
|
|
125
|
+
if (restore) restore();
|
|
126
|
+
else if (
|
|
127
|
+
!virtualize &&
|
|
128
|
+
viewport &&
|
|
129
|
+
revision === layout?.streamScroll.positionRevision
|
|
130
|
+
)
|
|
131
|
+
layout?.streamScroll.scrollToOffset?.(
|
|
132
|
+
viewport.scrollTop + viewport.scrollHeight - previousHeight,
|
|
133
|
+
true,
|
|
134
|
+
);
|
|
63
135
|
}
|
|
64
136
|
} catch (error) {
|
|
65
|
-
|
|
66
|
-
|
|
137
|
+
if (key === conversationKey && version === loadVersion)
|
|
138
|
+
loadError =
|
|
139
|
+
error instanceof Error ? error.message : "Could not load messages";
|
|
67
140
|
} finally {
|
|
68
|
-
|
|
141
|
+
if (key === conversationKey && version === loadVersion)
|
|
142
|
+
loadingOlder = false;
|
|
69
143
|
}
|
|
70
144
|
}
|
|
71
145
|
</script>
|
|
@@ -80,54 +154,60 @@
|
|
|
80
154
|
aria-label={ariaLabel}
|
|
81
155
|
aria-live="polite"
|
|
82
156
|
aria-relevant="additions text"
|
|
83
|
-
aria-busy={isStreaming || loadingOlder}
|
|
157
|
+
aria-busy={isStreaming || loadingOlder || loading}
|
|
84
158
|
>
|
|
85
159
|
<div bind:this={innerRef} data-ui-part="list-inner" style:gap>
|
|
86
|
-
{#if scrollToTopAction}
|
|
160
|
+
{#if scrollToTopAction && hasOlder}
|
|
87
161
|
<div data-ui-part="top-sentinel">
|
|
88
162
|
<Button
|
|
89
163
|
type="button"
|
|
90
164
|
size="xs"
|
|
91
165
|
variant="ghost"
|
|
92
|
-
disabled={loadingOlder}
|
|
93
|
-
aria-describedby={loadError ?
|
|
166
|
+
disabled={loadingOlder || loading}
|
|
167
|
+
aria-describedby={loadError ? errorId : undefined}
|
|
94
168
|
onclick={loadOlder}
|
|
95
169
|
>
|
|
96
|
-
{#if loadingOlder}
|
|
97
|
-
|
|
98
|
-
Loading older messages
|
|
99
|
-
{:else}
|
|
100
|
-
Load older messages
|
|
101
|
-
{/if}
|
|
170
|
+
{#if loadingOlder || loading}<Spinner /> Loading older messages{:else if loadError}Retry
|
|
171
|
+
older messages{:else}Load older messages{/if}
|
|
102
172
|
</Button>
|
|
103
173
|
</div>
|
|
104
|
-
{#if loadError}
|
|
105
|
-
<p id="ai-chat-load-older-error" role="alert">{loadError}</p>
|
|
106
|
-
{/if}
|
|
174
|
+
{#if loadError}<p id={errorId} role="alert">{loadError}</p>{/if}
|
|
107
175
|
{/if}
|
|
108
|
-
|
|
109
176
|
<div data-ui-part="list-spacer" aria-hidden="true"></div>
|
|
110
|
-
|
|
177
|
+
{#if header}{@render header()}{/if}
|
|
111
178
|
{#if isEmpty}
|
|
112
179
|
<div data-ui-part="empty">
|
|
113
|
-
{#if emptyState}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
The conversation will appear here.
|
|
124
|
-
</Empty.Description>
|
|
125
|
-
</Empty.Header>
|
|
126
|
-
</Empty.Root>
|
|
180
|
+
{#if emptyState}{@render emptyState()}{:else}
|
|
181
|
+
<Empty.Root
|
|
182
|
+
><Empty.Header
|
|
183
|
+
><Empty.Media variant="icon"
|
|
184
|
+
><SparklesIcon aria-hidden="true" /></Empty.Media
|
|
185
|
+
><Empty.Title>No messages yet</Empty.Title><Empty.Description
|
|
186
|
+
>The conversation will appear here.</Empty.Description
|
|
187
|
+
></Empty.Header
|
|
188
|
+
></Empty.Root
|
|
189
|
+
>
|
|
127
190
|
{/if}
|
|
128
191
|
</div>
|
|
129
|
-
{:else}
|
|
130
|
-
|
|
131
|
-
|
|
192
|
+
{:else if virtualize && row}
|
|
193
|
+
<VirtualizedMessageRows
|
|
194
|
+
{items}
|
|
195
|
+
{row}
|
|
196
|
+
{overscan}
|
|
197
|
+
{estimateSize}
|
|
198
|
+
conversationKey={conversationKey ?? ""}
|
|
199
|
+
{initialPosition}
|
|
200
|
+
{onPositionChange}
|
|
201
|
+
{retainRowIds}
|
|
202
|
+
{hasOlder}
|
|
203
|
+
loading={loadingOlder || loading}
|
|
204
|
+
onLoadOlder={loadOlder}
|
|
205
|
+
/>
|
|
206
|
+
{:else if row}
|
|
207
|
+
{#each items as item, index (item.id)}<div data-message-id={item.id}>
|
|
208
|
+
{@render row(item, index)}
|
|
209
|
+
</div>{/each}
|
|
210
|
+
{:else if children}{@render children()}{/if}
|
|
211
|
+
{#if footer}{@render footer()}{/if}
|
|
132
212
|
</div>
|
|
133
213
|
</div>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from "@lapismd/design-core/shadcn/button";
|
|
3
|
+
import Layout from "../layout/Layout.svelte";
|
|
4
|
+
import MessageList from "./MessageList.svelte";
|
|
5
|
+
import Message from "../message/Message.svelte";
|
|
6
|
+
import MessageBubble from "../message-bubble/MessageBubble.svelte";
|
|
7
|
+
import type { MessageListPosition } from "./position.js";
|
|
8
|
+
let items = $state(
|
|
9
|
+
Array.from({ length: 1000 }, (_, index) => ({
|
|
10
|
+
id: `message-${index}`,
|
|
11
|
+
text: `Message ${index}: ${"Variable-height cached conversation content. ".repeat((index % 7) + 1)}`,
|
|
12
|
+
})),
|
|
13
|
+
);
|
|
14
|
+
let unread = $state(false);
|
|
15
|
+
let expanded = $state(false);
|
|
16
|
+
let position = $state<MessageListPosition>();
|
|
17
|
+
let presentation = $state<{ key: string; position?: MessageListPosition }>({
|
|
18
|
+
key: "channel-a",
|
|
19
|
+
});
|
|
20
|
+
let loads = $state(0);
|
|
21
|
+
let key = $state("channel-a");
|
|
22
|
+
let delay = $state(false);
|
|
23
|
+
let statusUpdates = $state(0);
|
|
24
|
+
let positionReports = $state(0);
|
|
25
|
+
function append(incoming: boolean) {
|
|
26
|
+
items = [
|
|
27
|
+
...items,
|
|
28
|
+
{
|
|
29
|
+
id: `message-${items.length}`,
|
|
30
|
+
text: incoming ? "Incoming message" : "My own message",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
if (incoming) unread = true;
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<div data-story="history-controls">
|
|
38
|
+
<Button onclick={() => append(true)}>Receive message</Button>
|
|
39
|
+
<Button onclick={() => append(false)}>Send own message</Button>
|
|
40
|
+
<Button onclick={() => (expanded = !expanded)}>Resize content</Button>
|
|
41
|
+
<Button
|
|
42
|
+
onclick={() => (key = key === "channel-a" ? "channel-b" : "channel-a")}
|
|
43
|
+
>Switch conversation</Button
|
|
44
|
+
>
|
|
45
|
+
<Button onclick={() => (delay = !delay)}>Toggle delayed history</Button>
|
|
46
|
+
<Button onclick={() => statusUpdates++}>Update unrelated status</Button>
|
|
47
|
+
<output aria-label="Position reports">{positionReports}</output>
|
|
48
|
+
<output aria-label="History loads">{loads}</output>
|
|
49
|
+
<output aria-label="Reading anchor">{position?.messageId ?? "none"}</output>
|
|
50
|
+
</div>
|
|
51
|
+
<div
|
|
52
|
+
data-story="virtual-history-frame"
|
|
53
|
+
style:width={expanded ? "360px" : "640px"}
|
|
54
|
+
style="height: 420px; max-width: 100%; display: flex; flex-direction: column;"
|
|
55
|
+
>
|
|
56
|
+
<Layout
|
|
57
|
+
scrollMode="history"
|
|
58
|
+
conversationKey={presentation.key === key ? presentation.key : key}
|
|
59
|
+
hasNewMessages={unread}
|
|
60
|
+
onViewportChange={({ atLatest, active }) => {
|
|
61
|
+
if (atLatest && active) unread = false;
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
{#snippet composer()}<span>Conversation composer</span>{/snippet}
|
|
65
|
+
<MessageList
|
|
66
|
+
{...{
|
|
67
|
+
items,
|
|
68
|
+
overscan: 6,
|
|
69
|
+
estimateSize: 112,
|
|
70
|
+
retainRowIds: [],
|
|
71
|
+
"data-status": statusUpdates,
|
|
72
|
+
"data-position-reports": positionReports,
|
|
73
|
+
}}
|
|
74
|
+
virtualize
|
|
75
|
+
conversationKey={presentation.key === key ? presentation.key : key}
|
|
76
|
+
onPositionChange={(next) => {
|
|
77
|
+
positionReports++;
|
|
78
|
+
position = next;
|
|
79
|
+
if (
|
|
80
|
+
JSON.stringify(presentation.position) !== JSON.stringify(next) ||
|
|
81
|
+
presentation.key !== key
|
|
82
|
+
)
|
|
83
|
+
presentation = { key, position: next };
|
|
84
|
+
}}
|
|
85
|
+
scrollToTopAction={async () => {
|
|
86
|
+
loads++;
|
|
87
|
+
if (delay) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
88
|
+
const prefix = `older-${loads}`;
|
|
89
|
+
items = [
|
|
90
|
+
...Array.from({ length: 50 }, (_, index) => ({
|
|
91
|
+
id: `${prefix}-${index}`,
|
|
92
|
+
text: `Earlier message ${index}`,
|
|
93
|
+
})),
|
|
94
|
+
...items,
|
|
95
|
+
];
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
{#snippet row(item)}
|
|
99
|
+
<Message sender="user"
|
|
100
|
+
><MessageBubble>
|
|
101
|
+
<p>{item.text}</p>
|
|
102
|
+
{#if expanded}<p>
|
|
103
|
+
Additional image or expanded text changes this row's height
|
|
104
|
+
without adding a message.
|
|
105
|
+
</p>{/if}
|
|
106
|
+
<Button size="xs" variant="ghost">Action for {item.id}</Button>
|
|
107
|
+
</MessageBubble></Message
|
|
108
|
+
>
|
|
109
|
+
{/snippet}
|
|
110
|
+
</MessageList>
|
|
111
|
+
</Layout>
|
|
112
|
+
</div>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button } from "@lapismd/design-core/shadcn/button";
|
|
3
|
+
import Layout from "../layout/Layout.svelte";
|
|
4
|
+
import MessageList from "./MessageList.svelte";
|
|
5
|
+
import Message from "../message/Message.svelte";
|
|
6
|
+
import MessageBubble from "../message-bubble/MessageBubble.svelte";
|
|
7
|
+
let items = $state(
|
|
8
|
+
Array.from({ length: 100 }, (_, index) => ({
|
|
9
|
+
id: `legacy-${index}`,
|
|
10
|
+
text: `Existing message ${index}`,
|
|
11
|
+
})),
|
|
12
|
+
);
|
|
13
|
+
let loads = $state(0);
|
|
14
|
+
let expanded = $state(false);
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<Button
|
|
18
|
+
onclick={() =>
|
|
19
|
+
(items = [...items, { id: "incoming", text: "New incoming message" }])}
|
|
20
|
+
>Receive explicit arrival</Button
|
|
21
|
+
>
|
|
22
|
+
<Button onclick={() => (expanded = !expanded)}>Reflow existing content</Button>
|
|
23
|
+
<output aria-label="Legacy history requests">{loads}</output>
|
|
24
|
+
<div
|
|
25
|
+
data-story="legacy-history-frame"
|
|
26
|
+
style="height: 420px; width: 540px; display: flex; flex-direction: column;"
|
|
27
|
+
>
|
|
28
|
+
<Layout>
|
|
29
|
+
{#snippet composer()}<span>Existing composer snippet</span>{/snippet}
|
|
30
|
+
<MessageList
|
|
31
|
+
density="compact"
|
|
32
|
+
gap="8px"
|
|
33
|
+
latestMessageId={items.at(-1)?.id}
|
|
34
|
+
scrollToTopAction={async () => {
|
|
35
|
+
loads++;
|
|
36
|
+
items = [
|
|
37
|
+
{ id: `earlier-${loads}`, text: `Earlier context ${loads}` },
|
|
38
|
+
...items,
|
|
39
|
+
];
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{#each items as item (item.id)}
|
|
43
|
+
<Message sender="user"
|
|
44
|
+
><MessageBubble>
|
|
45
|
+
{item.text}
|
|
46
|
+
{#if expanded}<p>Reflow without a new message</p>{/if}
|
|
47
|
+
</MessageBubble></Message
|
|
48
|
+
>
|
|
49
|
+
{/each}
|
|
50
|
+
</MessageList>
|
|
51
|
+
</Layout>
|
|
52
|
+
</div>
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
<script lang="ts" generics="T extends { id: string }">
|
|
2
|
+
import {
|
|
3
|
+
createVirtualizer,
|
|
4
|
+
defaultRangeExtractor,
|
|
5
|
+
} from "@tanstack/svelte-virtual";
|
|
6
|
+
import { get } from "svelte/store";
|
|
7
|
+
import { onDestroy, tick, untrack, type Snippet } from "svelte";
|
|
8
|
+
import { useLayoutContext } from "../context.svelte.js";
|
|
9
|
+
import type { MessageListPosition } from "./position.js";
|
|
10
|
+
|
|
11
|
+
let {
|
|
12
|
+
items,
|
|
13
|
+
row,
|
|
14
|
+
overscan,
|
|
15
|
+
estimateSize,
|
|
16
|
+
conversationKey,
|
|
17
|
+
initialPosition,
|
|
18
|
+
onPositionChange,
|
|
19
|
+
retainRowIds,
|
|
20
|
+
hasOlder,
|
|
21
|
+
loading,
|
|
22
|
+
onLoadOlder,
|
|
23
|
+
}: {
|
|
24
|
+
items: readonly T[];
|
|
25
|
+
row: Snippet<[T, number]>;
|
|
26
|
+
overscan: number;
|
|
27
|
+
estimateSize: number;
|
|
28
|
+
conversationKey: string;
|
|
29
|
+
initialPosition?: MessageListPosition | undefined;
|
|
30
|
+
onPositionChange?: ((position: MessageListPosition) => void) | undefined;
|
|
31
|
+
retainRowIds: readonly string[];
|
|
32
|
+
hasOlder: boolean;
|
|
33
|
+
loading: boolean;
|
|
34
|
+
onLoadOlder?: () => Promise<void>;
|
|
35
|
+
} = $props();
|
|
36
|
+
const layout = useLayoutContext();
|
|
37
|
+
const stableKey = $derived(conversationKey);
|
|
38
|
+
const stableItems = $derived(items);
|
|
39
|
+
let virtualRef = $state<HTMLDivElement | null>(null);
|
|
40
|
+
let activeRowId = $state<string>();
|
|
41
|
+
let margin = $state(0);
|
|
42
|
+
let restoredKey: string | undefined;
|
|
43
|
+
let priorKey: string | undefined;
|
|
44
|
+
let priorItems: readonly T[] = [];
|
|
45
|
+
let paginationArmed = false;
|
|
46
|
+
let lastTop = 0;
|
|
47
|
+
let alive = true;
|
|
48
|
+
let priorConfiguration:
|
|
49
|
+
| {
|
|
50
|
+
key: string;
|
|
51
|
+
items: readonly T[];
|
|
52
|
+
margin: number;
|
|
53
|
+
size: number;
|
|
54
|
+
overscan: number;
|
|
55
|
+
retained: string;
|
|
56
|
+
}
|
|
57
|
+
| undefined;
|
|
58
|
+
onDestroy(() => {
|
|
59
|
+
alive = false;
|
|
60
|
+
});
|
|
61
|
+
const virtualizer = createVirtualizer<HTMLElement, HTMLDivElement>({
|
|
62
|
+
count: 0,
|
|
63
|
+
getScrollElement: () => layout?.getScrollContainer() ?? null,
|
|
64
|
+
estimateSize: () => estimateSize,
|
|
65
|
+
enabled: false,
|
|
66
|
+
useAnimationFrameWithResizeObserver: true,
|
|
67
|
+
scrollToFn: (offset, options) =>
|
|
68
|
+
layout?.streamScroll.scrollToOffset?.(
|
|
69
|
+
offset + (options.adjustments ?? 0),
|
|
70
|
+
true,
|
|
71
|
+
),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
$effect(() => {
|
|
75
|
+
layout?.setVirtualized?.(true);
|
|
76
|
+
return () => layout?.setVirtualized?.(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function readPosition(): MessageListPosition | undefined {
|
|
80
|
+
const instance = get(virtualizer);
|
|
81
|
+
const viewport = layout?.getScrollContainer();
|
|
82
|
+
if (!viewport || !items.length) return;
|
|
83
|
+
const first = instance
|
|
84
|
+
.getVirtualItems()
|
|
85
|
+
.find((item) => item.end > viewport.scrollTop);
|
|
86
|
+
const item = first && items[first.index];
|
|
87
|
+
if (!first || !item) return;
|
|
88
|
+
return {
|
|
89
|
+
messageId: item.id,
|
|
90
|
+
index: first.index,
|
|
91
|
+
offset: viewport.scrollTop - first.start,
|
|
92
|
+
atLatest: layout?.streamScroll.isLocked ?? false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function reportPosition() {
|
|
97
|
+
if (
|
|
98
|
+
restoredKey !== conversationKey ||
|
|
99
|
+
document.visibilityState === "hidden"
|
|
100
|
+
)
|
|
101
|
+
return;
|
|
102
|
+
const position = readPosition();
|
|
103
|
+
if (position) untrack(() => onPositionChange?.(position));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function measure(node: HTMLDivElement) {
|
|
107
|
+
untrack(() => get(virtualizer).measureElement(node));
|
|
108
|
+
return {
|
|
109
|
+
destroy() {
|
|
110
|
+
untrack(() => get(virtualizer).measureElement(null));
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Inputs alone invalidate the virtualizer. Its returned ranges must never restart this effect.
|
|
116
|
+
$effect(() => {
|
|
117
|
+
const nextItems = stableItems,
|
|
118
|
+
key = stableKey,
|
|
119
|
+
enabled = true,
|
|
120
|
+
scrollMargin = margin;
|
|
121
|
+
const keep = [...retainRowIds, ...(activeRowId ? [activeRowId] : [])];
|
|
122
|
+
const extra = keep
|
|
123
|
+
.map((id) => nextItems.findIndex((item) => item.id === id))
|
|
124
|
+
.filter((index) => index >= 0);
|
|
125
|
+
const size = estimateSize,
|
|
126
|
+
scan = overscan;
|
|
127
|
+
untrack(() => {
|
|
128
|
+
const retained = JSON.stringify(extra);
|
|
129
|
+
if (
|
|
130
|
+
priorConfiguration?.key === key &&
|
|
131
|
+
priorConfiguration.items === nextItems &&
|
|
132
|
+
priorConfiguration.margin === scrollMargin &&
|
|
133
|
+
priorConfiguration.size === size &&
|
|
134
|
+
priorConfiguration.overscan === scan &&
|
|
135
|
+
priorConfiguration.retained === retained
|
|
136
|
+
)
|
|
137
|
+
return;
|
|
138
|
+
priorConfiguration = {
|
|
139
|
+
key,
|
|
140
|
+
items: nextItems,
|
|
141
|
+
margin: scrollMargin,
|
|
142
|
+
size,
|
|
143
|
+
overscan: scan,
|
|
144
|
+
retained,
|
|
145
|
+
};
|
|
146
|
+
const instance = get(virtualizer);
|
|
147
|
+
const viewport = layout?.getScrollContainer();
|
|
148
|
+
const oldRow = instance
|
|
149
|
+
.getVirtualItems()
|
|
150
|
+
.find((item) => item.end > (viewport?.scrollTop ?? 0));
|
|
151
|
+
const oldItem = oldRow && priorItems[oldRow.index];
|
|
152
|
+
const offset = oldRow ? (viewport?.scrollTop ?? 0) - oldRow.start : 0;
|
|
153
|
+
const revision = layout?.streamScroll.positionRevision;
|
|
154
|
+
const sameKey = priorKey === key;
|
|
155
|
+
const restore = restoredKey !== key ? initialPosition : undefined;
|
|
156
|
+
const preserve =
|
|
157
|
+
sameKey &&
|
|
158
|
+
nextItems !== priorItems &&
|
|
159
|
+
!layout?.streamScroll.isLocked &&
|
|
160
|
+
oldItem;
|
|
161
|
+
priorKey = key;
|
|
162
|
+
priorItems = nextItems;
|
|
163
|
+
if (!sameKey) {
|
|
164
|
+
restoredKey = undefined;
|
|
165
|
+
paginationArmed = false;
|
|
166
|
+
activeRowId = undefined;
|
|
167
|
+
}
|
|
168
|
+
instance.setOptions({
|
|
169
|
+
enabled,
|
|
170
|
+
count: nextItems.length,
|
|
171
|
+
scrollMargin,
|
|
172
|
+
getScrollElement: () => layout?.getScrollContainer() ?? null,
|
|
173
|
+
getItemKey: (index) => nextItems[index]!.id,
|
|
174
|
+
estimateSize: () => size,
|
|
175
|
+
overscan: scan,
|
|
176
|
+
rangeExtractor: (range) =>
|
|
177
|
+
[...new Set([...defaultRangeExtractor(range), ...extra])].sort(
|
|
178
|
+
(a, b) => a - b,
|
|
179
|
+
),
|
|
180
|
+
});
|
|
181
|
+
void tick().then(() => {
|
|
182
|
+
if (!alive) return;
|
|
183
|
+
if (
|
|
184
|
+
key !== conversationKey ||
|
|
185
|
+
revision !== layout?.streamScroll.positionRevision
|
|
186
|
+
)
|
|
187
|
+
return;
|
|
188
|
+
if (enabled && nextItems.length) {
|
|
189
|
+
const position =
|
|
190
|
+
restore ??
|
|
191
|
+
(preserve
|
|
192
|
+
? {
|
|
193
|
+
messageId: oldItem.id,
|
|
194
|
+
index: oldRow!.index,
|
|
195
|
+
offset,
|
|
196
|
+
atLatest: false,
|
|
197
|
+
}
|
|
198
|
+
: undefined);
|
|
199
|
+
if (position && !position.atLatest) {
|
|
200
|
+
const found = nextItems.findIndex(
|
|
201
|
+
(item) => item.id === position.messageId,
|
|
202
|
+
);
|
|
203
|
+
const index =
|
|
204
|
+
found < 0
|
|
205
|
+
? Math.min(position.index ?? 0, nextItems.length - 1)
|
|
206
|
+
: found;
|
|
207
|
+
const start = get(virtualizer).getOffsetForIndex(
|
|
208
|
+
index,
|
|
209
|
+
"start",
|
|
210
|
+
)?.[0];
|
|
211
|
+
if (start !== undefined)
|
|
212
|
+
layout?.streamScroll.scrollToOffset?.(
|
|
213
|
+
start + position.offset,
|
|
214
|
+
Boolean(preserve),
|
|
215
|
+
);
|
|
216
|
+
} else if (!sameKey || layout?.streamScroll.isLocked)
|
|
217
|
+
layout?.streamScroll.scrollIfLocked();
|
|
218
|
+
}
|
|
219
|
+
if (nextItems.length) {
|
|
220
|
+
restoredKey = key;
|
|
221
|
+
reportPosition();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
$effect(() => {
|
|
227
|
+
const content = virtualRef;
|
|
228
|
+
const viewport = layout?.getScrollContainer();
|
|
229
|
+
if (!content || !viewport) return;
|
|
230
|
+
const measureMargin = () => {
|
|
231
|
+
const next =
|
|
232
|
+
content.getBoundingClientRect().top -
|
|
233
|
+
viewport.getBoundingClientRect().top +
|
|
234
|
+
viewport.scrollTop;
|
|
235
|
+
if (Math.abs(next - margin) > 0.5) margin = next;
|
|
236
|
+
};
|
|
237
|
+
measureMargin();
|
|
238
|
+
const observer = new ResizeObserver(measureMargin);
|
|
239
|
+
observer.observe(content);
|
|
240
|
+
return () => observer.disconnect();
|
|
241
|
+
});
|
|
242
|
+
$effect(() => {
|
|
243
|
+
const viewport = layout?.getScrollContainer();
|
|
244
|
+
if (!viewport) return;
|
|
245
|
+
const onScroll = () => {
|
|
246
|
+
const top = viewport.scrollTop;
|
|
247
|
+
if (top > 600) paginationArmed = true;
|
|
248
|
+
if (
|
|
249
|
+
top <= 600 &&
|
|
250
|
+
top < lastTop &&
|
|
251
|
+
paginationArmed &&
|
|
252
|
+
hasOlder &&
|
|
253
|
+
!loading
|
|
254
|
+
) {
|
|
255
|
+
paginationArmed = false;
|
|
256
|
+
void onLoadOlder?.();
|
|
257
|
+
}
|
|
258
|
+
lastTop = top;
|
|
259
|
+
reportPosition();
|
|
260
|
+
};
|
|
261
|
+
const rememberInteraction = (event: Event) => {
|
|
262
|
+
const target = event.target;
|
|
263
|
+
if (target instanceof Element)
|
|
264
|
+
activeRowId =
|
|
265
|
+
target.closest<HTMLElement>("[data-message-id]")?.dataset.messageId;
|
|
266
|
+
};
|
|
267
|
+
viewport.addEventListener("scroll", onScroll, { passive: true });
|
|
268
|
+
viewport.addEventListener("focusin", rememberInteraction);
|
|
269
|
+
viewport.addEventListener("pointerdown", rememberInteraction, {
|
|
270
|
+
passive: true,
|
|
271
|
+
});
|
|
272
|
+
return () => {
|
|
273
|
+
viewport.removeEventListener("scroll", onScroll);
|
|
274
|
+
viewport.removeEventListener("focusin", rememberInteraction);
|
|
275
|
+
viewport.removeEventListener("pointerdown", rememberInteraction);
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
</script>
|
|
279
|
+
|
|
280
|
+
<div
|
|
281
|
+
bind:this={virtualRef}
|
|
282
|
+
data-ui-part="virtual-content"
|
|
283
|
+
style:height={`${$virtualizer.getTotalSize()}px`}
|
|
284
|
+
>
|
|
285
|
+
{#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)}
|
|
286
|
+
{@const item = items[virtualRow.index]}
|
|
287
|
+
{#if item}
|
|
288
|
+
<div
|
|
289
|
+
data-ui-part="virtual-row"
|
|
290
|
+
data-message-id={item.id}
|
|
291
|
+
data-index={virtualRow.index}
|
|
292
|
+
style:transform={`translateY(${virtualRow.start - margin}px)`}
|
|
293
|
+
use:measure
|
|
294
|
+
>
|
|
295
|
+
{@render row(item, virtualRow.index)}
|
|
296
|
+
</div>
|
|
297
|
+
{/if}
|
|
298
|
+
{/each}
|
|
299
|
+
</div>
|