@lapismd/design-core 0.1.2 → 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 (65) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE.md +3 -3
  3. package/README.md +20 -0
  4. package/package.json +24 -9
  5. package/spec/src/styling-and-themes.md +2 -0
  6. package/src/shared/ai/chat.css +23 -0
  7. package/src/shared/ai/chat.ts +12 -0
  8. package/src/shared/ai/context.svelte.ts +2 -0
  9. package/src/shared/ai/layout/Layout.svelte +116 -10
  10. package/src/shared/ai/message-list/MessageList.svelte +123 -43
  11. package/src/shared/ai/message-list/MessageListHistoryFixture.svelte +112 -0
  12. package/src/shared/ai/message-list/MessageListLegacyFixture.svelte +52 -0
  13. package/src/shared/ai/message-list/VirtualizedMessageRows.svelte +299 -0
  14. package/src/shared/ai/message-list/position.ts +7 -0
  15. package/src/shared/ai/new-messages.svelte.ts +24 -5
  16. package/src/shared/ai/stream-scroll.svelte.ts +135 -6
  17. package/src/shared/ai/tool-call-detail/ToolCallDetail.svelte +71 -0
  18. package/src/shared/ai/tool-call-detail/tool-call-detail.ts +209 -0
  19. package/src/shared/diff/core/merge/actions.ts +4 -3
  20. package/src/shared/diff/core/merge/render-model.ts +14 -10
  21. package/src/shared/diff/file-diff/FileDiff.css +68 -1
  22. package/src/shared/diff/file-diff/FileDiff.svelte +22 -6
  23. package/src/shared/diff/file-diff/FileDiffComposer.svelte +98 -16
  24. package/src/shared/diff/file-diff/index.ts +2 -0
  25. package/src/shared/diff/file-diff/types.ts +13 -0
  26. package/src/shared/diff/index.ts +2 -0
  27. package/src/shared/forms/confirm-button/ConfirmButton.svelte +38 -0
  28. package/src/shared/forms/confirm-button/ConfirmButtonExample.svelte +14 -0
  29. package/src/shared/forms/confirm-button/index.ts +1 -0
  30. package/src/shared/forms/confirm-dialog/ConfirmDialog.css +21 -0
  31. package/src/shared/forms/confirm-dialog/ConfirmDialog.svelte +116 -0
  32. package/src/shared/forms/confirm-dialog/ConfirmDialogExample.svelte +17 -0
  33. package/src/shared/forms/confirm-dialog/index.ts +2 -0
  34. package/src/shared/forms/dialog-frame/DialogFrame.css +130 -0
  35. package/src/shared/forms/dialog-frame/DialogFrame.svelte +108 -0
  36. package/src/shared/forms/dialog-frame/DialogFrameExample.svelte +37 -0
  37. package/src/shared/forms/dialog-frame/index.ts +6 -0
  38. package/src/shared/forms/dialog.ts +8 -0
  39. package/src/shared/forms/index.ts +11 -0
  40. package/src/shared/shadcn/button/button.svelte +4 -0
  41. package/src/shared/shadcn/code-block/highlightRanges.ts +10 -6
  42. package/src/shared/shadcn/code-block/syntax/defineSyntaxTheme.ts +4 -2
  43. package/src/shared/shadcn/code-block/tokenizer.ts +3 -3
  44. package/src/shared/shadcn/dialog/dialog-content.svelte +3 -4
  45. package/src/shared/shadcn/dialog/dialog-portal.svelte +10 -2
  46. package/src/shared/shadcn/tooltip/tooltip-content.svelte +10 -4
  47. package/src/shared/shadcn/tooltip/tooltip.tokens.ts +4 -0
  48. package/src/shared/shell/app-shell/AppShell.css +112 -32
  49. package/src/shared/shell/app-shell/AppShellMain.svelte +12 -1
  50. package/src/shared/shell/app-shell/AppShellRoot.svelte +27 -13
  51. package/src/shared/shell/app-shell/AppShellSidebar.svelte +121 -19
  52. package/src/shared/shell/app-shell/AppShellSidebarClose.svelte +17 -17
  53. package/src/shared/shell/app-shell/AppShellSidebarToggle.svelte +38 -58
  54. package/src/shared/shell/app-shell/AppShellSurfaceLayer.svelte +173 -0
  55. package/src/shared/shell/app-shell/AppShellSurfaceLayerBody.svelte +32 -0
  56. package/src/shared/shell/app-shell/AppShellSurfaceLayerHeader.svelte +26 -0
  57. package/src/shared/shell/app-shell/app-shell-context.svelte.ts +6 -3
  58. package/src/shared/shell/app-shell/app-shell-controller.svelte.ts +246 -2
  59. package/src/shared/shell/app-shell/app-shell-sidebar-context.svelte.ts +1 -0
  60. package/src/shared/shell/app-shell/examples/AppShellExamples.css +103 -0
  61. package/src/shared/shell/app-shell/examples/AppShellMultiPanelDemo.svelte +187 -0
  62. package/src/shared/shell/app-shell/index.ts +14 -0
  63. package/src/shared/shell/shell.tokens.css +2 -0
  64. package/src/shared/shell/shell.tokens.ts +5 -0
  65. package/src/theme.css +10 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @lapismd/design-core
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#7](https://github.com/lapismd/design-core/pull/7) [`737c657`](https://github.com/lapismd/design-core/commit/737c65727a6beaeb6955a652aba67c42b882b222) Thanks [@stevejuma](https://github.com/stevejuma)! - Add composable same-side App Shell panels, responsive main-surface replacement, transparent rails, external controller toggles, and structural surface layers.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#7](https://github.com/lapismd/design-core/pull/7) [`80937e0`](https://github.com/lapismd/design-core/commit/80937e0f01e8dda50cbbed45da4948736ec3035b) Thanks [@stevejuma](https://github.com/stevejuma)! - Relicense the published Design Core package to Apache 2.0.
12
+
13
+ - [#7](https://github.com/lapismd/design-core/pull/7) [`80937e0`](https://github.com/lapismd/design-core/commit/80937e0f01e8dda50cbbed45da4948736ec3035b) Thanks [@stevejuma](https://github.com/stevejuma)! - Tighten shared Tooltip padding and type so compact overlays no longer inherit oversized content chrome.
14
+
15
+ - [#7](https://github.com/lapismd/design-core/pull/7) [`80937e0`](https://github.com/lapismd/design-core/commit/80937e0f01e8dda50cbbed45da4948736ec3035b) Thanks [@stevejuma](https://github.com/stevejuma)! - Expose controlled multi-file review state, host-owned line annotations, leading file headers, and gutter line actions. Diff helpers stay compatible with strict TypeScript consumers.
16
+
17
+ - [#7](https://github.com/lapismd/design-core/pull/7) [`80937e0`](https://github.com/lapismd/design-core/commit/80937e0f01e8dda50cbbed45da4948736ec3035b) Thanks [@stevejuma](https://github.com/stevejuma)! - Keep mobile App Shell panels visible while focus moves between the trigger and the opened surface.
18
+
19
+ - [#7](https://github.com/lapismd/design-core/pull/7) [`80937e0`](https://github.com/lapismd/design-core/commit/80937e0f01e8dda50cbbed45da4948736ec3035b) Thanks [@stevejuma](https://github.com/stevejuma)! - Add opt-in `MessageList` virtualization while keeping the default unvirtualized chat layout. History scrolling now preserves user intent across measurements, state saves, and parent updates.
20
+
21
+ - [#7](https://github.com/lapismd/design-core/pull/7) [`80937e0`](https://github.com/lapismd/design-core/commit/80937e0f01e8dda50cbbed45da4948736ec3035b) Thanks [@stevejuma](https://github.com/stevejuma)! - Add shared dialog frames, inset large frames, and retryable confirmations. Portal updates keep dialog controls mounted, and dialog headings stay out of document landmarks.
22
+
23
+ - [#7](https://github.com/lapismd/design-core/pull/7) [`80937e0`](https://github.com/lapismd/design-core/commit/80937e0f01e8dda50cbbed45da4948736ec3035b) Thanks [@stevejuma](https://github.com/stevejuma)! - Export shared `ToolCallDetail` presentation so hosts can render tool payloads, summaries, and expandable detail without forking chat markup.
24
+
3
25
  ## 0.1.2
4
26
 
5
27
  ### Patch Changes
package/LICENSE.md CHANGED
@@ -1,4 +1,4 @@
1
- AGPL-3.0-or-later
1
+ Apache-2.0
2
2
 
3
- This project is licensed under the GNU Affero General Public License v3.0 or later.
4
- See <https://www.gnu.org/licenses/agpl-3.0.en.html>.
3
+ This project is licensed under the Apache License 2.0.
4
+ See <https://www.apache.org/licenses/LICENSE-2.0>.
package/README.md CHANGED
@@ -224,6 +224,26 @@ pnpm spec:search -- "tokens"
224
224
  pnpm spec:check
225
225
  ```
226
226
 
227
+ ### CI and local parity
228
+
229
+ `pnpm checks` runs the nonvisual governance, quality, unit, Storybook, pointer,
230
+ and AI browser lanes through Turbo. Governance, quality, and unit work executes
231
+ concurrently with half the available processors, capped at four. Resource-heavy
232
+ Storybook and browser suites run one at a time locally so catalog startup and
233
+ interaction readiness remain deterministic; set `TURBO_CONCURRENCY` to a
234
+ positive number or percentage to override the general task bound.
235
+
236
+ Local task output is cached under `.turbo/`. The same tasks use the signed
237
+ organization cache in CI and fail open to normal execution when it is
238
+ unavailable. To use the remote cache locally, copy `.env.example` to the ignored
239
+ root `.env` and provide `TURBO_TOKEN` and
240
+ `TURBO_REMOTE_CACHE_SIGNATURE_KEY`. Never commit those credentials.
241
+
242
+ CI fans the nine nonvisual lanes out in parallel, including separate Workspace,
243
+ Shell, Shadcn, and AI browser runners. It runs full and production dependency
244
+ audits after they pass and reports one stable `Validate` result. Visual
245
+ comparison remains an explicit, separate command.
246
+
227
247
  ## Release and validation
228
248
 
229
249
  Common validation commands:
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@lapismd/design-core",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Shared Lapis design system, forms, shell, and workspace primitives",
5
- "license": "AGPL-3.0-or-later",
5
+ "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/lapismd/design-core.git"
@@ -58,6 +58,7 @@
58
58
  "@lezer/highlight": "^1.2.3",
59
59
  "@lezer/lr": "^1.4.2",
60
60
  "@lucide/svelte": "^1.21.0",
61
+ "@tanstack/svelte-virtual": "^3.13.39",
61
62
  "@types/luxon": "^3.4.2",
62
63
  "bits-ui": "^2.18.1",
63
64
  "chrono-node": "^2.10.1",
@@ -100,8 +101,8 @@
100
101
  "@types/pngjs": "^6.0.5",
101
102
  "@types/react": "^19.2.17",
102
103
  "@types/react-dom": "^19.2.3",
103
- "@vitest/browser": "^4.1.10",
104
- "@vitest/browser-playwright": "^4.1.10",
104
+ "@vitest/browser": "^4.1.11",
105
+ "@vitest/browser-playwright": "^4.1.11",
105
106
  "aria-query": "5.3.2",
106
107
  "execa": "^10.0.0",
107
108
  "highlight.js": "^11.11.1",
@@ -124,9 +125,10 @@
124
125
  "svelte-check": "^4.6.0",
125
126
  "tailwindcss": "^4.3.0",
126
127
  "tsx": "^4.23.1",
128
+ "turbo": "^2.5.4",
127
129
  "typescript": "^5.7.2",
128
130
  "vite": "^6.2.2",
129
- "vitest": "^4.1.10",
131
+ "vitest": "^4.1.11",
130
132
  "zod": "^4.4.3"
131
133
  },
132
134
  "exports": {
@@ -226,7 +228,8 @@
226
228
  "./workspace/view-header": "./src/shared/workspace/view-header/index.ts",
227
229
  "./workspace/tokens": "./src/shared/workspace/workspace.tokens.ts",
228
230
  "./workspace/view-host": "./src/shared/workspace/view-host/index.ts",
229
- "./workspace/workspace.tokens.css": "./src/shared/workspace/workspace.tokens.css"
231
+ "./workspace/workspace.tokens.css": "./src/shared/workspace/workspace.tokens.css",
232
+ "./forms/dialog": "./src/shared/forms/dialog.ts"
230
233
  },
231
234
  "scripts": {
232
235
  "storybook": "WATCHPACK_POLLING=250 node scripts/with-storybook-env.mjs node scripts/storybook-run.mjs",
@@ -234,9 +237,11 @@
234
237
  "storybook:stop": "node scripts/with-storybook-env.mjs bash scripts/storybook-stop.sh",
235
238
  "storybook:restart": "node scripts/with-storybook-env.mjs bash scripts/storybook-restart.sh",
236
239
  "storybook:check": "pnpm test:storybook && pnpm test:workspace:pointer && pnpm test:shell:pointer && pnpm test:shadcn:pointer && pnpm build-storybook && pnpm workspace:visual:audit",
237
- "build-storybook": "node -e \"require('node:fs').mkdirSync('.visual-delta/cache',{recursive:true})\" && node --max-old-space-size=6144 ./node_modules/storybook/dist/bin/dispatcher.js build --stats-json .visual-delta/cache",
240
+ "build-storybook": "node --env-file-if-exists=.env scripts/run-turbo.mjs build:storybook",
241
+ "build:storybook": "node -e \"require('node:fs').mkdirSync('.visual-delta/cache',{recursive:true})\" && node --max-old-space-size=6144 ./node_modules/storybook/dist/bin/dispatcher.js build --stats-json .visual-delta/cache",
238
242
  "changeset": "changeset",
239
243
  "test:unit": "vitest run --project unit",
244
+ "test:turbo": "node --test scripts/concurrency.test.mjs scripts/turbo-cache-report.test.mjs",
240
245
  "test:storybook": "vitest run --project storybook",
241
246
  "test:storybook:watch": "vitest --project storybook",
242
247
  "spec:build": "spec-validator build",
@@ -278,14 +283,24 @@
278
283
  "ui:visual:tag": "tsx scripts/ui-generator/cli.ts visual:tag",
279
284
  "test-unit": "pnpm test:unit",
280
285
  "test-storybook": "pnpm test:storybook",
281
- "check": "pnpm spec:validate && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings && pnpm check:shell-consumer && pnpm check:filter-consumer && pnpm check:source-consumer",
286
+ "check": "pnpm spec:validate && pnpm check:types",
287
+ "check:types": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings && pnpm check:shell-consumer && pnpm check:filter-consumer && pnpm check:source-consumer",
282
288
  "check:filter-consumer": "svelte-check --tsconfig ./tsconfig.filter-consumer.json --fail-on-warnings",
283
289
  "check:shell-consumer": "svelte-check --tsconfig ./tsconfig.shell-consumer.json --fail-on-warnings",
284
290
  "check:source-consumer": "svelte-check --tsconfig ./tsconfig.source-consumer.json --fail-on-warnings",
285
291
  "fmt": "prettier . --write",
286
292
  "fmt:check": "prettier . --check",
287
293
  "check:no-tailwind": "tsx scripts/check-no-tailwind-utilities.ts",
288
- "checks:nonvisual": "pnpm fmt:check && pnpm spec:check && pnpm check && pnpm check:no-tailwind && pnpm test:unit && pnpm test:storybook && pnpm test:workspace:pointer && pnpm test:shell:pointer && pnpm test:shadcn:pointer && pnpm build-storybook && pnpm workspace:visual:audit && pnpm test:ai-chat-browser",
294
+ "ci:governance": "pnpm spec:check",
295
+ "ci:quality": "pnpm fmt:check && pnpm check:types && pnpm check:no-tailwind",
296
+ "ci:unit": "pnpm test:unit && pnpm test:turbo",
297
+ "ci:storybook-static": "pnpm workspace:visual:audit -- --index=storybook-static/index.json",
298
+ "ci:storybook-interaction": "pnpm test:storybook",
299
+ "ci:browser-workspace": "pnpm test:workspace:pointer",
300
+ "ci:browser-shell": "pnpm test:shell:pointer",
301
+ "ci:browser-shadcn": "pnpm test:shadcn:pointer",
302
+ "ci:browser-ai": "pnpm test:ai-chat-browser",
303
+ "checks:nonvisual": "node --env-file-if-exists=.env scripts/run-turbo.mjs ci:governance ci:quality ci:unit --summarize && node --env-file-if-exists=.env scripts/run-turbo.mjs ci:storybook-static --summarize && node --env-file-if-exists=.env scripts/run-turbo.mjs ci:storybook-interaction --summarize && node --env-file-if-exists=.env scripts/run-turbo.mjs ci:browser-workspace --summarize && node --env-file-if-exists=.env scripts/run-turbo.mjs ci:browser-shell --summarize && node --env-file-if-exists=.env scripts/run-turbo.mjs ci:browser-ai --summarize && node --env-file-if-exists=.env scripts/run-turbo.mjs ci:browser-shadcn --summarize",
289
304
  "checks:release": "pnpm spec:check && pnpm check && pnpm check:no-tailwind && pnpm test:unit && pnpm build-storybook && pnpm workspace:visual:audit",
290
305
  "checks": "pnpm checks:nonvisual",
291
306
  "checks:visual": "pnpm checks:nonvisual && pnpm test:visual",
@@ -186,3 +186,5 @@ refuses leftover utilities after native-CSS conversion.
186
186
  4. Stamp `data-ui-component` and `data-ui-part`.
187
187
  5. Document the tokens the component reads.
188
188
  6. Run `pnpm check:no-tailwind`, focused story checks, and the repository gate.
189
+
190
+ Dialog recipes consume runtime container and text tokens explicitly defined in the theme so packaged consumers retain geometry without generating Tailwind utilities.
@@ -1550,3 +1550,26 @@
1550
1550
  animation: none;
1551
1551
  }
1552
1552
  }
1553
+
1554
+ [data-ui-component="ai-chat-message-list"] [data-ui-part="virtual-content"] {
1555
+ position: relative;
1556
+ flex: 0 0 auto;
1557
+ width: 100%;
1558
+ overflow-anchor: none;
1559
+ }
1560
+ [data-ui-component="ai-chat-message-list"] [data-ui-part="virtual-row"] {
1561
+ position: absolute;
1562
+ top: 0;
1563
+ left: 0;
1564
+ width: 100%;
1565
+ padding-bottom: var(--ui-ai-chat-balanced-gap);
1566
+ overflow-anchor: none;
1567
+ }
1568
+ [data-ui-component="ai-chat-message-list"][data-density="compact"]
1569
+ [data-ui-part="virtual-row"] {
1570
+ padding-bottom: var(--ui-ai-chat-compact-gap);
1571
+ }
1572
+ [data-ui-component="ai-chat-message-list"][data-density="spacious"]
1573
+ [data-ui-part="virtual-row"] {
1574
+ padding-bottom: var(--ui-ai-chat-spacious-gap);
1575
+ }
@@ -12,6 +12,15 @@ export { default as SendButton } from "./send-button/SendButton.svelte";
12
12
  export { default as ComposerToken } from "./composer-token/ComposerToken.svelte";
13
13
  export { default as TokenizedText } from "./tokenized-text/TokenizedText.svelte";
14
14
  export { default as ToolCalls } from "./tool-calls/ToolCalls.svelte";
15
+ export { default as ToolCallDetail } from "./tool-call-detail/ToolCallDetail.svelte";
16
+ export {
17
+ isOneLineToolAlert,
18
+ presentToolPayload,
19
+ toolCallTarget,
20
+ type PresentedToolPayload,
21
+ type ToolPayloadHint,
22
+ type ToolPayloadLanguage,
23
+ } from "./tool-call-detail/tool-call-detail.js";
15
24
  export { default as DictationButton } from "./dictation-button/DictationButton.svelte";
16
25
 
17
26
  export {
@@ -19,6 +28,7 @@ export {
19
28
  type ScrollBehavior,
20
29
  type ScrollToBottomOptions,
21
30
  type StreamScrollController,
31
+ type PositionedStreamScrollController,
22
32
  type StreamScrollOptions,
23
33
  } from "./stream-scroll.svelte.js";
24
34
  export {
@@ -68,3 +78,5 @@ export type {
68
78
  ToolCallStatus,
69
79
  } from "./types.js";
70
80
  export type { ComposerToken as ComposerTokenValue } from "./types.js";
81
+
82
+ export type { MessageListPosition } from "./message-list/position.js";
@@ -12,6 +12,8 @@ export type LayoutContextValue = {
12
12
  getScrollContainer: () => HTMLElement | null;
13
13
  setScrollContainer: (element: HTMLElement | null) => void;
14
14
  setContent: (element: HTMLElement | null) => void;
15
+ /** A virtual list owns row resize anchoring; the layout still owns scroll writes. */
16
+ setVirtualized?: (active: boolean) => void;
15
17
  streamScroll: StreamScrollController;
16
18
  newMessages: NewMessagesController;
17
19
  };
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import SparklesIcon from "@lucide/svelte/icons/sparkles";
3
- import type { Snippet } from "svelte";
3
+ import { untrack, type Snippet } from "svelte";
4
4
  import type { HTMLAttributes } from "svelte/elements";
5
5
  import * as Empty from "@lapismd/design-core/shadcn/empty";
6
6
  import { ScrollArea } from "@lapismd/design-core/shadcn/scroll-area";
@@ -16,6 +16,12 @@
16
16
  scrollRef = $bindable(null),
17
17
  density = "balanced",
18
18
  isEmpty = false,
19
+ scrollMode = "stream",
20
+ conversationKey,
21
+ hasNewMessages: controlledNewMessages,
22
+ hasNewer = false,
23
+ onLoadLatest,
24
+ onViewportChange,
19
25
  emptyState,
20
26
  scrollButton,
21
27
  composer,
@@ -27,6 +33,13 @@
27
33
  scrollRef?: HTMLElement | null;
28
34
  density?: Density;
29
35
  isEmpty?: boolean;
36
+ /** Keep streaming scroll behavior by default; opt in to anchored cached-history navigation. */
37
+ scrollMode?: "stream" | "history";
38
+ conversationKey?: string;
39
+ hasNewMessages?: boolean;
40
+ hasNewer?: boolean;
41
+ onLoadLatest?: () => Promise<void>;
42
+ onViewportChange?: (state: { atLatest: boolean; active: boolean }) => void;
30
43
  emptyState?: Snippet;
31
44
  scrollButton?: Snippet<
32
45
  [
@@ -43,12 +56,20 @@
43
56
 
44
57
  let messageAreaRef = $state<HTMLElement | null>(null);
45
58
  let contentRef = $state<HTMLElement | null>(null);
46
- const streamScroll = createStreamScroll();
59
+ let virtualized = false;
60
+ const streamScroll = createStreamScroll({
61
+ anchorOnResize: () => scrollMode === "history",
62
+ });
47
63
  const newMessages = createNewMessages({
64
+ deferResize: () => scrollMode === "history",
48
65
  isLocked: () => streamScroll.isLocked,
49
66
  onResize: () => {
50
- streamScroll.scrollIfLocked();
51
- streamScroll.update();
67
+ if (scrollMode === "history")
68
+ streamScroll.contentResized({ restoreAnchor: !virtualized });
69
+ else {
70
+ streamScroll.scrollIfLocked();
71
+ streamScroll.update();
72
+ }
52
73
  },
53
74
  });
54
75
 
@@ -57,10 +78,89 @@
57
78
  newMessages.attach(element);
58
79
  }
59
80
 
81
+ let pendingJump = false;
82
+ let jumpVersion = 0;
83
+ let priorConversationKey: string | undefined;
84
+ let conversationInitialized = false;
85
+ let jumpError = $state<string | undefined>();
86
+ let documentActive = $state(true);
87
+ const unread = $derived(controlledNewMessages ?? newMessages.hasNewMessages);
88
+ const showNewMessages = $derived(
89
+ unread && (!streamScroll.isLocked || hasNewer || !documentActive),
90
+ );
91
+ const showScrollButton = $derived(
92
+ streamScroll.isScrolledUp || showNewMessages || hasNewer,
93
+ );
60
94
  function scrollToBottom(): void {
61
- streamScroll.scrollToBottom();
62
- newMessages.dismiss();
95
+ if (pendingJump) return;
96
+ if (scrollMode === "stream" && !onLoadLatest) {
97
+ newMessages.dismiss();
98
+ streamScroll.scrollToBottom();
99
+ return;
100
+ }
101
+ const key = conversationKey;
102
+ const version = ++jumpVersion;
103
+ const revision = streamScroll.positionRevision;
104
+ pendingJump = true;
105
+ jumpError = undefined;
106
+ void (async () => {
107
+ try {
108
+ await onLoadLatest?.();
109
+ if (
110
+ key === conversationKey &&
111
+ version === jumpVersion &&
112
+ revision === streamScroll.positionRevision
113
+ )
114
+ streamScroll.scrollToBottom({
115
+ behavior: scrollMode === "history" ? "instant" : "spring",
116
+ });
117
+ } catch (cause) {
118
+ if (key === conversationKey && version === jumpVersion)
119
+ jumpError =
120
+ cause instanceof Error
121
+ ? cause.message
122
+ : "Could not load latest messages";
123
+ } finally {
124
+ if (version === jumpVersion) pendingJump = false;
125
+ }
126
+ })();
63
127
  }
128
+ $effect(() => {
129
+ const key = conversationKey;
130
+ untrack(() => {
131
+ if (conversationInitialized && key === priorConversationKey) return;
132
+ conversationInitialized = true;
133
+ priorConversationKey = key;
134
+ jumpVersion++;
135
+ pendingJump = false;
136
+ if (key === undefined) return;
137
+ newMessages.attach(contentRef ?? messageAreaRef);
138
+ streamScroll.attach(null);
139
+ streamScroll.attach(scrollRef);
140
+ jumpError = undefined;
141
+ });
142
+ return () => {
143
+ void key;
144
+ };
145
+ });
146
+ $effect(() => {
147
+ if (streamScroll.isLocked && !hasNewer && documentActive)
148
+ newMessages.dismiss();
149
+ const observation = {
150
+ atLatest: streamScroll.isLocked && !hasNewer,
151
+ active: documentActive,
152
+ };
153
+ const notify = onViewportChange;
154
+ untrack(() => notify?.(observation));
155
+ });
156
+ $effect(() => {
157
+ const update = () => {
158
+ documentActive = document.visibilityState !== "hidden";
159
+ };
160
+ update();
161
+ document.addEventListener("visibilitychange", update);
162
+ return () => document.removeEventListener("visibilitychange", update);
163
+ });
64
164
 
65
165
  setLayoutContext({
66
166
  getScrollContainer: () => scrollRef,
@@ -69,6 +169,9 @@
69
169
  streamScroll.attach(element);
70
170
  },
71
171
  setContent,
172
+ setVirtualized(active) {
173
+ virtualized = active;
174
+ },
72
175
  streamScroll,
73
176
  newMessages,
74
177
  });
@@ -118,16 +221,19 @@
118
221
  {/if}
119
222
  </div>
120
223
  </ScrollArea>
224
+ {#if jumpError}<p role="alert" data-ui-part="history-error">
225
+ {jumpError}
226
+ </p>{/if}
121
227
  {#if scrollButton}
122
228
  {@render scrollButton({
123
- isVisible: streamScroll.isScrolledUp || newMessages.hasNewMessages,
124
- hasNewMessages: newMessages.hasNewMessages,
229
+ isVisible: showScrollButton,
230
+ hasNewMessages: showNewMessages,
125
231
  scrollToBottom,
126
232
  })}
127
233
  {:else}
128
234
  <LayoutScrollButton
129
- isVisible={streamScroll.isScrolledUp || newMessages.hasNewMessages}
130
- hasNewMessages={newMessages.hasNewMessages}
235
+ isVisible={showScrollButton}
236
+ hasNewMessages={showNewMessages}
131
237
  onClick={scrollToBottom}
132
238
  />
133
239
  {/if}
@@ -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>