@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.
- package/CHANGELOG.md +22 -0
- package/LICENSE.md +3 -3
- package/README.md +20 -0
- package/package.json +24 -9
- package/spec/src/styling-and-themes.md +2 -0
- package/src/shared/ai/chat.css +23 -0
- package/src/shared/ai/chat.ts +12 -0
- package/src/shared/ai/context.svelte.ts +2 -0
- 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/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 +22 -6
- 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/index.ts +2 -0
- 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/dialog/dialog-content.svelte +3 -4
- package/src/shared/shadcn/dialog/dialog-portal.svelte +10 -2
- 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/AppShellMain.svelte +12 -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 +246 -2
- package/src/shared/shell/app-shell/app-shell-sidebar-context.svelte.ts +1 -0
- package/src/shared/shell/app-shell/examples/AppShellExamples.css +103 -0
- package/src/shared/shell/app-shell/examples/AppShellMultiPanelDemo.svelte +187 -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/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
|
-
|
|
1
|
+
Apache-2.0
|
|
2
2
|
|
|
3
|
-
This project is licensed under the
|
|
4
|
-
See <https://www.
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Shared Lapis design system, forms, shell, and workspace primitives",
|
|
5
|
-
"license": "
|
|
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.
|
|
104
|
-
"@vitest/browser-playwright": "^4.1.
|
|
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.
|
|
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
|
|
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 &&
|
|
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
|
-
"
|
|
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.
|
package/src/shared/ai/chat.css
CHANGED
|
@@ -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
|
+
}
|
package/src/shared/ai/chat.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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:
|
|
124
|
-
hasNewMessages:
|
|
229
|
+
isVisible: showScrollButton,
|
|
230
|
+
hasNewMessages: showNewMessages,
|
|
125
231
|
scrollToBottom,
|
|
126
232
|
})}
|
|
127
233
|
{:else}
|
|
128
234
|
<LayoutScrollButton
|
|
129
|
-
isVisible={
|
|
130
|
-
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
|
|
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>
|