@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.
Files changed (124) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/LICENSE.md +3 -3
  3. package/README.md +21 -1
  4. package/package.json +34 -15
  5. package/spec/src/styling-and-themes.md +11 -9
  6. package/src/shared/ai/chat.css +25 -1
  7. package/src/shared/ai/chat.ts +12 -0
  8. package/src/shared/ai/composer-input/ComposerInput.svelte +3 -2
  9. package/src/shared/ai/context.svelte.ts +2 -0
  10. package/src/shared/ai/experimental/reaction-bar/ReactionBar.svelte +1 -1
  11. package/src/shared/ai/layout/Layout.svelte +116 -10
  12. package/src/shared/ai/message-list/MessageList.svelte +123 -43
  13. package/src/shared/ai/message-list/MessageListHistoryFixture.svelte +112 -0
  14. package/src/shared/ai/message-list/MessageListLegacyFixture.svelte +52 -0
  15. package/src/shared/ai/message-list/VirtualizedMessageRows.svelte +299 -0
  16. package/src/shared/ai/message-list/position.ts +7 -0
  17. package/src/shared/ai/new-messages.svelte.ts +24 -5
  18. package/src/shared/ai/stream-scroll.svelte.ts +135 -6
  19. package/src/shared/ai/tool-call-detail/ToolCallDetail.svelte +71 -0
  20. package/src/shared/ai/tool-call-detail/tool-call-detail.ts +209 -0
  21. package/src/shared/ai/tool-calls/ToolCalls.svelte +1 -1
  22. package/src/shared/ai/trigger-menu.ts +2 -3
  23. package/src/shared/diff/core/merge/actions.ts +4 -3
  24. package/src/shared/diff/core/merge/render-model.ts +14 -10
  25. package/src/shared/diff/file-diff/FileDiff.css +68 -1
  26. package/src/shared/diff/file-diff/FileDiff.svelte +27 -14
  27. package/src/shared/diff/file-diff/FileDiffComposer.svelte +98 -16
  28. package/src/shared/diff/file-diff/index.ts +2 -0
  29. package/src/shared/diff/file-diff/types.ts +13 -0
  30. package/src/shared/diff/file-listing/FileListingViewModeToggle.css +42 -5
  31. package/src/shared/diff/index.ts +2 -0
  32. package/src/shared/diff/merge-editor/MergeEditor.svelte +2 -7
  33. package/src/shared/filter/filter-query/filters.ts +2 -2
  34. package/src/shared/filter/filter-query/parser.ts +9 -8
  35. package/src/shared/filter/power-search/PowerSearch.svelte +1 -1
  36. package/src/shared/filter/search-filter-bar/SearchFilterBar.svelte +2 -2
  37. package/src/shared/filter/search-filter-bar/SearchFilterInput.svelte +1 -1
  38. package/src/shared/filter/search-filter-bar/SearchFilterPredicateEditor.svelte +1 -1
  39. package/src/shared/filter/search-filter-bar/search-filter-syntax.ts +1 -1
  40. package/src/shared/forms/autocomplete-input/AutocompleteInput.svelte +19 -7
  41. package/src/shared/forms/confirm-button/ConfirmButton.svelte +38 -0
  42. package/src/shared/forms/confirm-button/ConfirmButtonExample.svelte +14 -0
  43. package/src/shared/forms/confirm-button/index.ts +1 -0
  44. package/src/shared/forms/confirm-dialog/ConfirmDialog.css +21 -0
  45. package/src/shared/forms/confirm-dialog/ConfirmDialog.svelte +116 -0
  46. package/src/shared/forms/confirm-dialog/ConfirmDialogExample.svelte +17 -0
  47. package/src/shared/forms/confirm-dialog/index.ts +2 -0
  48. package/src/shared/forms/dialog-frame/DialogFrame.css +130 -0
  49. package/src/shared/forms/dialog-frame/DialogFrame.svelte +108 -0
  50. package/src/shared/forms/dialog-frame/DialogFrameExample.svelte +37 -0
  51. package/src/shared/forms/dialog-frame/index.ts +6 -0
  52. package/src/shared/forms/dialog.ts +8 -0
  53. package/src/shared/forms/index.ts +11 -0
  54. package/src/shared/shadcn/button/button.svelte +4 -0
  55. package/src/shared/shadcn/code-block/highlightRanges.ts +10 -6
  56. package/src/shared/shadcn/code-block/syntax/defineSyntaxTheme.ts +4 -2
  57. package/src/shared/shadcn/code-block/tokenizer.ts +3 -3
  58. package/src/shared/shadcn/command/command-dialog.svelte +1 -1
  59. package/src/shared/shadcn/command/command-group.svelte +3 -1
  60. package/src/shared/shadcn/command-view/command-view.svelte +1 -6
  61. package/src/shared/shadcn/dialog/dialog-content.svelte +3 -4
  62. package/src/shared/shadcn/dialog/dialog-portal.svelte +10 -2
  63. package/src/shared/shadcn/dropdown-menu/dropdown-menu-checkbox-item.svelte +0 -691
  64. package/src/shared/shadcn/dropdown-menu/dropdown-menu-content.svelte +691 -0
  65. package/src/shared/shadcn/dropdown-menu/dropdown-menu-radio-group.svelte +1 -1
  66. package/src/shared/shadcn/scroll-area/scroll-area.svelte +2 -0
  67. package/src/shared/shadcn/spinner/spinner.svelte +3 -3
  68. package/src/shared/shadcn/tooltip/tooltip-content.svelte +10 -4
  69. package/src/shared/shadcn/tooltip/tooltip.tokens.ts +4 -0
  70. package/src/shared/shell/app-shell/AppShell.css +112 -32
  71. package/src/shared/shell/app-shell/AppShell.demo.css +37 -0
  72. package/src/shared/shell/app-shell/AppShellBodyContent.svelte +5 -1
  73. package/src/shared/shell/app-shell/AppShellBodySidebar.svelte +5 -1
  74. package/src/shared/shell/app-shell/AppShellMain.svelte +12 -1
  75. package/src/shared/shell/app-shell/AppShellMobilePanelSelector.svelte +1 -1
  76. package/src/shared/shell/app-shell/AppShellRoot.svelte +27 -13
  77. package/src/shared/shell/app-shell/AppShellSidebar.svelte +121 -19
  78. package/src/shared/shell/app-shell/AppShellSidebarClose.svelte +17 -17
  79. package/src/shared/shell/app-shell/AppShellSidebarToggle.svelte +38 -58
  80. package/src/shared/shell/app-shell/AppShellSurfaceLayer.svelte +173 -0
  81. package/src/shared/shell/app-shell/AppShellSurfaceLayerBody.svelte +32 -0
  82. package/src/shared/shell/app-shell/AppShellSurfaceLayerHeader.svelte +26 -0
  83. package/src/shared/shell/app-shell/app-shell-context.svelte.ts +6 -3
  84. package/src/shared/shell/app-shell/app-shell-controller.svelte.ts +269 -11
  85. package/src/shared/shell/app-shell/app-shell-sidebar-context.svelte.ts +1 -0
  86. package/src/shared/shell/app-shell/examples/AppShellBodyDemo.svelte +4 -0
  87. package/src/shared/shell/app-shell/examples/AppShellCompleteDemo.svelte +1 -0
  88. package/src/shared/shell/app-shell/examples/AppShellConversationDemo.svelte +1 -0
  89. package/src/shared/shell/app-shell/examples/AppShellExamples.css +624 -0
  90. package/src/shared/shell/app-shell/examples/AppShellFilesSidebarDemo.svelte +1 -0
  91. package/src/shared/shell/app-shell/examples/AppShellMarkdownDocumentDemo.svelte +6 -1
  92. package/src/shared/shell/app-shell/examples/AppShellMarkdownFilesDemo.svelte +1 -0
  93. package/src/shared/shell/app-shell/examples/AppShellModeSwitchDemo.svelte +1 -0
  94. package/src/shared/shell/app-shell/examples/AppShellMultiPanelDemo.svelte +187 -0
  95. package/src/shared/shell/app-shell/examples/AppShellProjectSidebarDemo.svelte +1 -0
  96. package/src/shared/shell/app-shell/examples/AppShellSidebarDemo.svelte +1 -0
  97. package/src/shared/shell/app-shell/examples/AppShellToolbarDemo.svelte +1 -0
  98. package/src/shared/shell/app-shell/index.ts +14 -0
  99. package/src/shared/shell/shell.tokens.css +2 -0
  100. package/src/shared/shell/shell.tokens.ts +5 -0
  101. package/src/shared/workspace/menu/WorkspaceContextMenuItems.svelte +2 -1
  102. package/src/shared/workspace/menu/WorkspaceMenuItems.svelte +2 -1
  103. package/src/shared/workspace/problems/ProblemsQuickFix.svelte +1 -4
  104. package/src/shared/workspace/settings/AppSettingsContent.svelte +6 -0
  105. package/src/shared/workspace/settings/AppSettingsNavigation.svelte +7 -0
  106. package/src/shared/workspace/settings/CopyableValue.svelte +81 -0
  107. package/src/shared/workspace/settings/DeclarativeSettingsSection.svelte +20 -0
  108. package/src/shared/workspace/settings/WorkspaceSettingField.svelte +110 -41
  109. package/src/shared/workspace/settings/WorkspaceSettingMultiSelect.svelte +7 -6
  110. package/src/shared/workspace/settings/WorkspaceSettingObjectTable.svelte +187 -23
  111. package/src/shared/workspace/settings/WorkspaceSettingSelect.svelte +29 -29
  112. package/src/shared/workspace/settings/WorkspaceSettings.css +190 -4
  113. package/src/shared/workspace/settings/WorkspaceSettingsStoryCustomField.svelte +2 -2
  114. package/src/shared/workspace/settings/WorkspaceSettingsStoryCustomPage.svelte +13 -0
  115. package/src/shared/workspace/settings/WorkspaceSettingsSurface.svelte +50 -0
  116. package/src/shared/workspace/settings/configuration.ts +5 -0
  117. package/src/shared/workspace/settings/copyable-value.ts +10 -0
  118. package/src/shared/workspace/settings/index.ts +5 -0
  119. package/src/shared/workspace/settings/settings-controller.svelte.ts +349 -15
  120. package/src/shared/workspace/settings/types.ts +90 -8
  121. package/src/storybook/app-shell-reference/AppShellTwoExpandedSidebarsReference.css +25 -0
  122. package/src/storybook/app-shell-reference/AppShellTwoExpandedSidebarsReference.svelte +48 -0
  123. package/src/storybook/app-shell-reference/index.ts +1 -0
  124. 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 { tick, type Snippet } from "svelte";
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: Snippet;
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
- if (latestMessageId) layout?.newMessages.notify(latestMessageId);
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() ?? null;
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 (viewport) {
62
- viewport.scrollTop += viewport.scrollHeight - previousHeight;
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
- loadError =
66
- error instanceof Error ? error.message : "Could not load messages";
137
+ if (key === conversationKey && version === loadVersion)
138
+ loadError =
139
+ error instanceof Error ? error.message : "Could not load messages";
67
140
  } finally {
68
- loadingOlder = false;
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 ? "ai-chat-load-older-error" : undefined}
166
+ disabled={loadingOlder || loading}
167
+ aria-describedby={loadError ? errorId : undefined}
94
168
  onclick={loadOlder}
95
169
  >
96
- {#if loadingOlder}
97
- <Spinner />
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
- {@render emptyState()}
115
- {:else}
116
- <Empty.Root>
117
- <Empty.Header>
118
- <Empty.Media variant="icon">
119
- <SparklesIcon aria-hidden="true" />
120
- </Empty.Media>
121
- <Empty.Title>No messages yet</Empty.Title>
122
- <Empty.Description>
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
- {@render children()}
131
- {/if}
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>