feat(app): make session timelines much faster AND without flicker or scroll jumps (#32331)

This commit is contained in:
Luke Parker 2026-06-16 18:53:57 +02:00 committed by GitHub
parent e772664389
commit 3b811bd019
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 2822 additions and 843 deletions

100
bun.lock
View File

@ -53,6 +53,7 @@
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4", "@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@ -66,7 +67,6 @@
"solid-js": "catalog:", "solid-js": "catalog:",
"solid-list": "catalog:", "solid-list": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"virtua": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@happy-dom/global-registrator": "20.0.11", "@happy-dom/global-registrator": "20.0.11",
@ -828,6 +828,7 @@
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2", "@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3", "@solid-primitives/bounds": "0.1.3",
"@solid-primitives/event-listener": "2.4.5", "@solid-primitives/event-listener": "2.4.5",
@ -853,7 +854,6 @@
"solid-js": "catalog:", "solid-js": "catalog:",
"solid-list": "catalog:", "solid-list": "catalog:",
"strip-ansi": "7.1.2", "strip-ansi": "7.1.2",
"virtua": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
@ -905,25 +905,26 @@
}, },
}, },
"trustedDependencies": [ "trustedDependencies": [
"esbuild",
"tree-sitter-powershell", "tree-sitter-powershell",
"protobufjs",
"electron",
"web-tree-sitter", "web-tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
"esbuild",
"electron",
"protobufjs",
], ],
"patchedDependencies": { "patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch", "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
}, },
"overrides": { "overrides": {
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
@ -947,15 +948,17 @@
"@opentui/core": "0.3.4", "@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4", "@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4", "@opentui/solid": "0.3.4",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0", "@sentry/vite-plugin": "4.6.0",
"@shikijs/stream": "4.2.0",
"@solid-primitives/storage": "4.3.3", "@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4", "@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"@tanstack/solid-virtual": "3.13.28",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
@ -981,14 +984,13 @@
"remeda": "2.26.0", "remeda": "2.26.0",
"remend": "1.3.0", "remend": "1.3.0",
"semver": "7.7.4", "semver": "7.7.4",
"shiki": "3.20.0", "shiki": "4.2.0",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"solid-list": "0.3.0", "solid-list": "0.3.0",
"sst": "4.13.1", "sst": "4.13.1",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
"typescript": "5.8.2", "typescript": "5.8.2",
"ulid": "3.0.1", "ulid": "3.0.1",
"virtua": "0.49.1",
"vite": "7.1.4", "vite": "7.1.4",
"vite-plugin-solid": "2.11.10", "vite-plugin-solid": "2.11.10",
"zod": "4.1.8", "zod": "4.1.8",
@ -2124,9 +2126,11 @@
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="], "@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="], "@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="],
"@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
"@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="],
"@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="], "@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="],
@ -2332,13 +2336,17 @@
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="],
"@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], "@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="],
"@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], "@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="],
"@shikijs/stream": ["@shikijs/stream@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-OaMUUStdIZ+l1GJad9uVACR3Xvgwo4y+RmEuDMU62cgFMMg1IBCaIFmvzAR2HiCpGtwoc/qPfpNnP+ivgrPXZg=="],
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
"@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="], "@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="],
@ -2582,6 +2590,10 @@
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
@ -4852,7 +4864,7 @@
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], "shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="],
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="], "shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
@ -5262,8 +5274,6 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="],
"vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="], "vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],
"vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "^8.12.1", "es-module-lexer": "^1.5.4", "fast-glob": "^3.3.2", "magic-string": "^0.30.11" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="], "vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "^8.12.1", "es-module-lexer": "^1.5.4", "fast-glob": "^3.3.2", "magic-string": "^0.30.11" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="],
@ -5512,6 +5522,8 @@
"@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"@astrojs/markdown-remark/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
@ -5850,13 +5862,17 @@
"@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/langs/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/stream/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
@ -5966,6 +5982,8 @@
"astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"astro/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
"astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
"astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], "astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
@ -6210,9 +6228,9 @@
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
"shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@ -6354,6 +6372,18 @@
"@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
"@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
"@astrojs/markdown-remark/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
"@astrojs/markdown-remark/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
"@astrojs/markdown-remark/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="],
"@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
@ -6650,6 +6680,8 @@
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
@ -6716,6 +6748,18 @@
"astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"astro/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"astro/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
"astro/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
"astro/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
"astro/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
"astro/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"astro/unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "astro/unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], "astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],

View File

@ -2,7 +2,7 @@
exact = true exact = true
# Only install newly resolved package versions published at least 3 days ago. # Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200 minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
[test] [test]
root = "./do-not-run-tests-from-root" root = "./do-not-run-tests-from-root"

View File

@ -42,6 +42,8 @@
"@opentui/core": "0.3.4", "@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4", "@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4", "@opentui/solid": "0.3.4",
"@tanstack/solid-virtual": "3.13.28",
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
@ -51,7 +53,7 @@
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.2.10",
"opentui-spinner": "0.0.7", "opentui-spinner": "0.0.7",
"@solid-primitives/storage": "4.3.3", "@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
@ -76,10 +78,9 @@
"zod": "4.1.8", "zod": "4.1.8",
"remeda": "2.26.0", "remeda": "2.26.0",
"sst": "4.13.1", "sst": "4.13.1",
"shiki": "3.20.0", "shiki": "4.2.0",
"solid-list": "0.3.0", "solid-list": "0.3.0",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
"virtua": "0.49.1",
"vite": "7.1.4", "vite": "7.1.4",
"@solidjs/meta": "0.29.4", "@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
@ -145,12 +146,13 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch" "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
} }
} }

View File

@ -147,7 +147,8 @@ test.describe("regression: session timeline local row state", () => {
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first() const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expectAppVisible(wrapper) await expectAppVisible(wrapper)
await expectAppVisible(wrapper.locator('[data-component="file"][data-mode="diff"]').first()) const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first()
await expectAppVisible(file)
await markDiffProbe(page) await markDiffProbe(page)
events.push({ events.push({
@ -159,7 +160,15 @@ test.describe("regression: session timeline local row state", () => {
}) })
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 }) await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" }) const siblingProbe = await readDiffProbe(page)
expect(siblingProbe).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
})
await markDiffProbe(page) await markDiffProbe(page)
events.push({ events.push({
@ -173,7 +182,72 @@ test.describe("regression: session timeline local row state", () => {
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible( await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
{ timeout: 10_000 }, { timeout: 10_000 },
) )
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" }) expect(await readDiffProbe(page)).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
})
})
test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
const events: EventPayload[] = []
const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = [100, 300, 500, 700, 900].reduce(
(result, index) => result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`),
lines,
)
const part = {
...editPart,
state: {
...editPart.state,
metadata: {
...editPart.state.metadata,
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: lines,
after,
},
},
},
}
await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
const diff = wrapper.locator('[data-component="edit-content"]').first()
await expectAppVisible(diff)
await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500)
const samples = await wrapper.evaluate(async (element) => {
const root = element.closest<HTMLElement>(".scroll-view__viewport")!
element.scrollIntoView({ block: "start" })
const result = []
for (const offset of [0, 120, 240, 360, 480]) {
root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
await new Promise(requestAnimationFrame)
const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
result.push({
offset,
trigger: trigger.getBoundingClientRect().y,
diff: diff.getBoundingClientRect().y,
bottom: element.getBoundingClientRect().bottom,
})
}
return result
})
expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
}) })
}) })
@ -247,10 +321,16 @@ async function markDiffProbe(page: Page) {
.evaluate((element) => { .evaluate((element) => {
const tool = element as HTMLElement const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]') const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
const row = tool.closest<HTMLElement>("[data-timeline-key]")
const frame = tool.closest<HTMLElement>("[data-timeline-row]")
if (!file) throw new Error("missing edit diff file") if (!file) throw new Error("missing edit diff file")
if (!row) throw new Error("missing virtual timeline row")
if (!frame) throw new Error("missing timeline row frame")
tool.dataset.timelineProbe = "before" tool.dataset.timelineProbe = "before"
file.dataset.timelineProbe = "before" file.dataset.timelineProbe = "before"
row.dataset.timelineProbe = "before"
frame.dataset.timelineProbe = "before"
window.__timelineDiffProbe.reset() window.__timelineDiffProbe.reset()
}) })
} }
@ -262,10 +342,15 @@ async function readDiffProbe(page: Page) {
.evaluate((element) => { .evaluate((element) => {
const tool = element as HTMLElement const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]') const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
const row = tool.closest<HTMLElement>("[data-timeline-key]")
const frame = tool.closest<HTMLElement>("[data-timeline-row]")
return { return {
fileMarker: file?.dataset.timelineProbe, fileMarker: file?.dataset.timelineProbe,
shadowRoots: window.__timelineDiffProbe.shadowRoots(), shadowRoots: window.__timelineDiffProbe.shadowRoots(),
toolMarker: tool.dataset.timelineProbe, toolMarker: tool.dataset.timelineProbe,
rowMarker: row?.dataset.timelineProbe,
rowKey: row?.dataset.timelineKey,
frameMarker: frame?.dataset.timelineProbe,
} }
}) })
} }
@ -300,14 +385,19 @@ function readExpanded(element: Element) {
return !!content && content.getBoundingClientRect().height > 0 return !!content && content.getBoundingClientRect().height > 0
} }
async function mockServer(page: Page, events: EventPayload[]) { async function mockServer(
page: Page,
events: EventPayload[],
messages = [userMessage, assistantMessage],
) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
directory, directory,
project: project(), project: project(),
provider: provider(), provider: provider(),
sessions: [session()], sessions: [session()],
pageMessages: () => ({ items: [userMessage, assistantMessage] }), pageMessages: () => ({ items: messages }),
events: () => events.splice(0), events: () => events.splice(0, 1),
eventRetry: 16,
}) })
} }

View File

@ -32,8 +32,6 @@ test.describe("regression: session timeline context group resize", () => {
const samples = await sampleExpansion(page) const samples = await sampleExpansion(page)
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5) const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
console.log("context resize samples", JSON.stringify(samples, null, 2))
expect(samples[0]?.overlap).toBe(0) expect(samples[0]?.overlap).toBe(0)
expect(visibleOverlap).toEqual([]) expect(visibleOverlap).toEqual([])
expect(samples.at(-1)?.expanded).toBe("true") expect(samples.at(-1)?.expanded).toBe("true")
@ -115,13 +113,15 @@ async function sampleExpansion(page: Page) {
let frame = 1 let frame = 1
const tick = () => { const tick = () => {
capture(frame, "raf") setTimeout(() => {
frame += 1 capture(frame, "painted")
if (frame > 8) { frame += 1
resolve(samples) if (frame > 8) {
return resolve(samples)
} return
requestAnimationFrame(tick) }
requestAnimationFrame(tick)
}, 0)
} }
requestAnimationFrame(tick) requestAnimationFrame(tick)
}), }),

View File

@ -30,6 +30,271 @@ type SmokeWindow = Window & {
test.describe("smoke: session timeline", () => { test.describe("smoke: session timeline", () => {
test.setTimeout(240_000) test.setTimeout(240_000)
test("keeps the visible message fixed while prepending history", async ({ page }) => {
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
messageDelay: 3_000,
onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }),
})
await configureSmokePage(page, fixture.directory)
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
await waitForTimelineStable(page)
const scroller = timelineScroller(page)
await pointAtTimeline(page)
const deadline = Date.now() + 120_000
while (!requests.some((request) => request.before && request.phase === "start")) {
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
await page.mouse.wheel(0, -240)
await page.waitForTimeout(20)
}
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
for (let index = 0; index < 12; index++) {
await page.mouse.wheel(0, -120)
await page.waitForTimeout(20)
}
const keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"]
const positions = () =>
scroller.evaluate((element, keys) => {
const top = element.getBoundingClientRect().top
return Object.fromEntries(
keys.map((key) => {
const row = element.querySelector<HTMLElement>(`[data-timeline-part-id="${key}"]`)
if (!row) throw new Error(`Missing stable timeline key: ${key}`)
return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio]
}),
)
}, keys)
const before = await positions()
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true)
await waitForTimelineStable(page)
await expect.poll(positions).toEqual(before)
})
test("preserves the timeline gap above the composer", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await configureSmokePage(page, fixture.directory)
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
await waitForTimelineStable(page)
const scroller = timelineScroller(page)
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight
})
await waitForTimelineStable(page)
const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]')
await expect(spacer).toBeVisible()
expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
})
test("paints cached session tabs at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server: "http://127.0.0.1:4096",
dirBase64,
sessionId,
})),
),
)
},
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
await expectSessionTitle(page, fixture.expected.targetTitle)
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
const last = fixture.expected.targetMessageIDs.at(-1)!
await page.evaluate(
({ destination, last }) => {
const ids = new Set(destination)
const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = []
const firstPaintNodes = new WeakSet<Node>()
let firstPaint = false
let removedFirstPaintNodes = 0
let running = true
new MutationObserver((records) => {
if (!firstPaint || !running) return
records.forEach((record) =>
record.removedNodes.forEach((node) => {
if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1
if (!(node instanceof Element)) return
node.querySelectorAll("*").forEach((element) => {
if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1
})
}),
)
}).observe(document.documentElement, { childList: true, subtree: true })
const sample = () => {
if (!running) return
setTimeout(() => {
if (!running) return
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
.filter((id) => ids.has(id))
const bottom = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
firstPaint = true
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
const rect = row.getBoundingClientRect()
if (rect.bottom <= view.top || rect.top >= view.bottom) return
firstPaintNodes.add(row)
row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element))
})
}
}
requestAnimationFrame(sample)
}, 0)
}
;(window as Window & {
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
}).__sessionTabPaint = {
samples,
removed: () => removedFirstPaintNodes,
stop: () => {
running = false
},
}
requestAnimationFrame(sample)
},
{ destination, last },
)
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
await page.waitForFunction(() =>
(window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }).__sessionTabPaint?.samples.some(
(sample) => sample.ids.length > 0,
),
)
await page.waitForTimeout(200)
const first = await page.evaluate(() => {
const probe = (window as Window & {
__sessionTabPaint?: {
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
removed: () => number
stop: () => void
}
}).__sessionTabPaint!
probe.stop()
return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() }
})
expect(first.first?.last).toBe(true)
expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
expect(first.removed).toBe(0)
})
test("paints a cold session tab at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.global.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server: "http://127.0.0.1:4096",
dirBase64,
sessionId,
})),
),
)
},
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
await expectSessionTitle(page, fixture.expected.sourceTitle)
const last = fixture.expected.targetMessageIDs.at(-1)!
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
await page.evaluate(({ destination, last }) => {
const ids = new Set(destination)
const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = []
const sample = () => {
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
samples.push({
destination: messages.some((element) => ids.has(element.dataset.messageId!)),
last: messages.some((element) => element.dataset.messageId === last),
bottomError: spacer ? spacer.bottom - view.bottom : undefined,
})
}
requestAnimationFrame(() => setTimeout(sample, 0))
}
;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples
requestAnimationFrame(() => setTimeout(sample, 0))
}, { destination, last })
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
await page.waitForFunction(() =>
(window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some(
(sample) => sample.destination,
),
)
const result = await page.evaluate(() => {
const samples = (window as Window & {
__coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }>
}).__coldTabSamples!
return samples.find((sample) => sample.destination)!
})
expect(result.last).toBe(true)
expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
})
test("renders seeded timeline in order while paging through history", async ({ page }) => { test("renders seeded timeline in order while paging through history", async ({ page }) => {
const errors = trackPageErrors(page) const errors = trackPageErrors(page)
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
@ -427,6 +692,14 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
await expectSessionTitle(page, expectedTitle) await expectSessionTitle(page, expectedTitle)
} }
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
const href = `/${base64Encode(fixture.directory)}/session/${sessionID}`
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
await tab.click()
await expectSessionTitle(page, title)
}
async function expectSessionReady(page: Page) { async function expectSessionReady(page: Page) {
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i })) await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
} }

View File

@ -18,7 +18,10 @@ export interface MockServerConfig {
project: unknown project: unknown
sessions: ({ id: string } & Record<string, unknown>)[] sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string } pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
messageDelay?: number
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
events?: () => unknown[] events?: () => unknown[]
eventRetry?: number
} }
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@ -44,7 +47,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (url.port !== targetPort) return route.fallback() if (url.port !== targetPort) return route.fallback()
const path = url.pathname const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.()) if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
if (path === "/global/health") return json(route, { healthy: true }) if (path === "/global/health") return json(route, { healthy: true })
if (emptyObject.has(path)) return json(route, {}) if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, []) if (emptyList.has(path)) return json(route, [])
@ -60,9 +63,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) { if (messagesMatch) {
const limit = Number(url.searchParams.get("limit") ?? 80)
const before = url.searchParams.get("before") ?? undefined const before = url.searchParams.get("before") ?? undefined
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const limit = Number(url.searchParams.get("limit") ?? 80)
const pageData = config.pageMessages(messagesMatch[1], limit, before) const pageData = config.pageMessages(messagesMatch[1], limit, before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined) return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
} }
@ -83,10 +89,10 @@ function json(route: Route, body: unknown, headers?: Record<string, string>) {
}) })
} }
function sse(route: Route, events?: unknown[]) { function sse(route: Route, events?: unknown[], retry?: number) {
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "text/event-stream", contentType: "text/event-stream",
body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n", body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
}) })
} }

View File

@ -17,8 +17,9 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"test": "bun run test:unit", "test": "bun run test:unit && bun run test:virtualizer",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src", "test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:local": "playwright test", "test:e2e:local": "playwright test",
@ -64,6 +65,7 @@
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@tanstack/solid-query": "5.91.4", "@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@ -76,7 +78,6 @@
"shiki": "catalog:", "shiki": "catalog:",
"solid-js": "catalog:", "solid-js": "catalog:",
"solid-list": "catalog:", "solid-list": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:"
"virtua": "catalog:"
} }
} }

View File

@ -134,6 +134,26 @@ describe("applyGlobalEvent", () => {
}) })
describe("applyDirectoryEvent", () => { describe("applyDirectoryEvent", () => {
test("initializes text delta accumulation from the current part text", () => {
const part = { ...textPart("part", "session", "message"), text: "existing" }
const [store, setStore] = createStore(baseState({ part: { message: [part] } }))
applyDirectoryEvent({
event: {
type: "message.part.delta",
properties: { messageID: "message", partID: "part", field: "text", delta: " appended" },
},
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.part_text_accum_delta.part).toBe("existing appended")
expect((store.part.message?.[0] as { text: string }).text).toBe("existing appended")
})
test("preserves a Home-specific retained session limit", () => { test("preserves a Home-specific retained session limit", () => {
const [store, setStore] = createStore( const [store, setStore] = createStore(
baseState({ baseState({

View File

@ -282,7 +282,13 @@ export function applyDirectoryEvent(input: {
if (!parts) break if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id) const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break if (!result.found) break
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta) const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field]
input.setStore(
"part_text_accum_delta",
props.partID,
(existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta,
)
input.setStore( input.setStore(
"part", "part",
props.messageID, props.messageID,

View File

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { resumeStreamAfterPageShow } from "./server-sdk" import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => { describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => { test("restarts a stream only after a back-forward cache restore", () => {
@ -12,3 +13,41 @@ describe("resumeStreamAfterPageShow", () => {
expect(starts).toBe(1) expect(starts).toBe(1)
}) })
}) })
describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text") => ({
directory: "/repo",
payload: {
type: "message.part.delta",
properties: { messageID: "msg", partID: "part", field, delta: value },
} as Event,
})
test("merges adjacent deltas for the same field", () => {
const result = coalesceServerEvents([delta("hello "), delta("world")])
expect(result).toHaveLength(1)
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
})
test("preserves event boundaries and distinct fields", () => {
const status = {
directory: "/repo",
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
}
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
expect(result.map((event) => event.payload.type)).toEqual([
"message.part.delta",
"message.part.delta",
"session.status",
"message.part.delta",
])
})
test("drops stale deltas", () => {
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
expect(result).toEqual([])
})
})

View File

@ -15,6 +15,39 @@ const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: Event }
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
const output: QueuedServerEvent[] = []
const deltas = new Map<string, number>()
events.forEach((event) => {
if (stale && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
}
if (event.payload.type !== "message.part.delta") {
deltas.clear()
output.push(event)
return
}
const props = event.payload.properties
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
const index = deltas.get(id)
const existing = index === undefined ? undefined : output[index]
if (!existing || existing.payload.type !== "message.part.delta") {
deltas.set(id, output.length)
output.push({
directory: event.directory,
payload: { ...event.payload, properties: { ...props } },
})
return
}
existing.payload.properties.delta += props.delta
})
return output
}
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
if (!event.persisted) return if (!event.persisted) return
@ -45,7 +78,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
[key: string]: Event [key: string]: Event
}>() }>()
type Queued = { directory: string; payload: Event } type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16 const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8 const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250 const RECONNECT_DELAY_MS = 250
@ -57,8 +90,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let last = 0 let last = 0
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
const key = (directory: string, payload: Event) => { const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}` if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}` if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
@ -83,14 +114,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
staleDeltas.clear() staleDeltas.clear()
last = Date.now() last = Date.now()
const output = coalesceServerEvents(events, skip)
batch(() => { batch(() => {
for (const event of events) { output.forEach((event) => emitter.emit(event.directory, event.payload))
if (skip && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
}
emitter.emit(event.directory, event.payload)
}
}) })
buffer.length = 0 buffer.length = 0

View File

@ -2366,7 +2366,7 @@ export default function Layout(props: ParentProps) {
{props.children} {props.children}
</Show> </Show>
</main> </main>
{import.meta.env.DEV && <DebugBar />} {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
<HelpButton /> <HelpButton />
<ToastRegion v2={newDesign()} /> <ToastRegion v2={newDesign()} />
</div> </div>
@ -2519,7 +2519,7 @@ export default function Layout(props: ParentProps) {
</div> </div>
</div> </div>
</div> </div>
{import.meta.env.DEV && <DebugBar />} {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div> </div>
<HelpButton /> <HelpButton />
<ToastRegion v2={newDesign()} /> <ToastRegion v2={newDesign()} />

View File

@ -13,7 +13,6 @@ import {
on, on,
onMount, onMount,
untrack, untrack,
createResource,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
@ -33,7 +32,6 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router" import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session" import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
@ -54,7 +52,8 @@ import {
shouldFocusTerminalOnKeyDown, shouldFocusTerminalOnKeyDown,
shouldShowFileTree, shouldShowFileTree,
} from "@/pages/session/helpers" } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline" import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
@ -67,11 +66,9 @@ import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs" import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
const emptyUserMessages: UserMessage[] = []
type FollowupItem = FollowupDraft & { id: string } type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context"> type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = [] const emptyFollowups: FollowupItem[] = []
@ -79,110 +76,6 @@ const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn" type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch" type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
loaded: () => number
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
userScrolled: () => boolean
scroller: () => HTMLDivElement | undefined
}
function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
const historyScrollThreshold = 200
let shiftFrame: number | undefined
const [state, setState] = createStore({
shift: false,
})
const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
equals: same,
})
const cancelShiftReset = () => {
if (shiftFrame === undefined) return
cancelAnimationFrame(shiftFrame)
shiftFrame = undefined
}
const scheduleShiftReset = () => {
cancelShiftReset()
shiftFrame = requestAnimationFrame(() => {
shiftFrame = undefined
setState("shift", false)
})
}
const fetchOlderMessages = async () => {
const id = input.sessionID()
if (!id) return
if (!input.historyMore() || input.historyLoading()) return
// TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
const beforeVisible = input.visibleUserMessages().length
let loaded = input.loaded()
let growth = 0
cancelShiftReset()
setState("shift", true)
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
loaded = nextLoaded
growth = input.visibleUserMessages().length - beforeVisible
if (growth > 0) break
if (raw <= 0) break
if (!input.historyMore()) break
}
if (growth > 0) {
scheduleShiftReset()
return
}
setState("shift", false)
}
const loadAndReveal = () => fetchOlderMessages()
const onScrollerScroll = () => {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollTop >= historyScrollThreshold) return
void fetchOlderMessages()
}
createEffect(
on(
input.sessionID,
() => {
cancelShiftReset()
setState({ shift: false })
},
{ defer: true },
),
)
onCleanup(cancelShiftReset)
return {
userMessages,
shift: () => state.shift,
loadAndReveal,
onScrollerScroll,
}
}
export default function Page() { export default function Page() {
const serverSync = useServerSync() const serverSync = useServerSync()
const layout = useLayout() const layout = useLayout()
@ -323,39 +216,15 @@ export default function Page() {
const activeTab = tabState.activeTab const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab const activeFileTab = tabState.activeFileTab
const revertMessageID = createMemo(() => info()?.revert?.messageID) const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) const timeline = createTimelineModel({ sessionID: () => params.id, revertMessageID })
const messagesReady = createMemo(() => { const historyLoading = timeline.history.loading
const id = params.id const historyMore = timeline.history.more
if (!id) return true const lastUserMessage = timeline.lastUserMessage
return sync().data.message[id] !== undefined const messages = timeline.messages
}) const messagesReady = timeline.ready
const historyMore = createMemo(() => { const sessionSync = timeline.resource
const id = params.id const userMessages = timeline.userMessages
if (!id) return false const visibleUserMessages = timeline.visibleUserMessages
return sync().session.history.more(id)
})
const historyLoading = createMemo(() => {
const id = params.id
if (!id) return false
return sync().session.history.loading(id)
})
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{
equals: same,
},
)
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
createEffect(() => { createEffect(() => {
const tab = activeFileTab() const tab = activeFileTab()
@ -423,8 +292,6 @@ export default function Page() {
}, sessionKey()) }, sessionKey())
let reviewFrame: number | undefined let reviewFrame: number | undefined
let refreshFrame: number | undefined
let refreshTimer: number | undefined
let todoFrame: number | undefined let todoFrame: number | undefined
let todoTimer: number | undefined let todoTimer: number | undefined
let diffFrame: number | undefined let diffFrame: number | undefined
@ -614,6 +481,7 @@ export default function Page() {
let scroller: HTMLDivElement | undefined let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined let content: HTMLDivElement | undefined
let revealMessage = (_id: string) => {} let revealMessage = (_id: string) => {}
let scrollToEnd = () => {}
let scrollMark = 0 let scrollMark = 0
let messageMark = 0 let messageMark = 0
@ -632,39 +500,6 @@ export default function Page() {
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
const [sessionSync] = createResource(
() => [sdk().directory, params.id] as const,
([directory, id]) => {
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
refreshFrame = undefined
refreshTimer = undefined
if (!id) return
const cached = untrack(() => sync().data.message[id] !== undefined)
const stale = !cached
? false
: (() => {
const info = getSessionPrefetch(serverSDK().scope, directory, id)
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
refreshFrame = requestAnimationFrame(() => {
refreshFrame = undefined
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
if (params.id !== id) return
untrack(() => {
if (stale) void sync().session.sync(id, { force: true })
})
}, 0)
})
return sync().session.sync(id)
},
)
createEffect( createEffect(
on( on(
() => { () => {
@ -1202,8 +1037,18 @@ export default function Page() {
const autoScroll = createAutoScroll({ const autoScroll = createAutoScroll({
working: () => true, working: () => true,
overflowAnchor: "dynamic", overflowAnchor: "none",
}) })
createEffect(
on(
() => params.id,
(id, previous) => {
if (!id || !previous || id === previous) return
if (location.hash || store.messageId || ui.pendingMessage) return
autoScroll.resume()
},
),
)
let scrollStateFrame: number | undefined let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined let scrollStateTarget: HTMLDivElement | undefined
@ -1239,7 +1084,8 @@ export default function Page() {
const resumeScroll = () => { const resumeScroll = () => {
setStore("messageId", undefined) setStore("messageId", undefined)
autoScroll.forceScrollToBottom() autoScroll.resume()
scrollToEnd()
clearMessageHash() clearMessageHash()
const el = scroller const el = scroller
@ -1282,16 +1128,14 @@ export default function Page() {
}, },
) )
const historyLoader = createSessionHistoryLoader({ let captureHistoryAnchor = () => {}
sessionID: () => params.id, let restoreHistoryAnchor = (_done: boolean) => {}
loaded: () => messages().length, const loadOlder = () =>
visibleUserMessages, timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
historyMore, const onHistoryScroll = () => {
historyLoading, if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return
loadMore: (sessionID) => sync().session.history.loadMore(sessionID), void loadOlder()
userScrolled: autoScroll.userScrolled, }
scroller: () => scroller,
})
fill = () => { fill = () => {
if (fillFrame !== undefined) return if (fillFrame !== undefined) return
@ -1307,7 +1151,7 @@ export default function Page() {
if (el.scrollHeight > el.clientHeight + 1) return if (el.scrollHeight > el.clientHeight + 1) return
if (!historyMore()) return if (!historyMore()) return
void historyLoader.loadAndReveal() void loadOlder()
}) })
} }
@ -1615,7 +1459,7 @@ export default function Page() {
dockHeight = next dockHeight = next
if (stick) autoScroll.forceScrollToBottom() if (stick) scrollToEnd()
if (el) scheduleScrollState(el) if (el) scheduleScrollState(el)
fill() fill()
@ -1634,7 +1478,13 @@ export default function Page() {
pendingMessage: () => ui.pendingMessage, pendingMessage: () => ui.pendingMessage,
setPendingMessage: (value) => setUi("pendingMessage", value), setPendingMessage: (value) => setUi("pendingMessage", value),
setActiveMessage, setActiveMessage,
autoScroll, autoScroll: {
pause: autoScroll.pause,
forceScrollToBottom: () => {
autoScroll.resume()
scrollToEnd()
},
},
scroller: () => scroller, scroller: () => scroller,
anchor, anchor,
revealMessage: (id) => revealMessage(id), revealMessage: (id) => revealMessage(id),
@ -1657,8 +1507,6 @@ export default function Page() {
onCleanup(() => { onCleanup(() => {
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer) if (todoTimer !== undefined) window.clearTimeout(todoTimer)
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
@ -1791,37 +1639,45 @@ export default function Page() {
</div> </div>
</Match> </Match>
<Match when={params.id}> <Match when={params.id}>
<Show when={messagesReady()}> <Show when={messagesReady() ? params.id : undefined} keyed>
<MessageTimeline {(_id) => (
actions={actions} <MessageTimeline
scroll={ui.scroll} actions={actions}
onResumeScroll={resumeScroll} scroll={ui.scroll}
setScrollRef={setScrollRef} onResumeScroll={resumeScroll}
onScheduleScrollState={scheduleScrollState} setScrollRef={setScrollRef}
onAutoScrollHandleScroll={autoScroll.handleScroll} onScheduleScrollState={scheduleScrollState}
onMarkScrollGesture={markScrollGesture} onAutoScrollHandleScroll={autoScroll.handleScroll}
hasScrollGesture={hasScrollGesture} onMarkScrollGesture={markScrollGesture}
onUserScroll={markUserScroll} hasScrollGesture={hasScrollGesture}
onHistoryScroll={historyLoader.onScrollerScroll} onUserScroll={markUserScroll}
onAutoScrollInteraction={autoScroll.handleInteraction} onHistoryScroll={onHistoryScroll}
shouldAnchorBottom={() => onAutoScrollInteraction={autoScroll.handleInteraction}
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() shouldAnchorBottom={() =>
} !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
centered={centered()} }
setContentRef={(el) => { centered={centered()}
content = el setContentRef={(el) => {
autoScroll.contentRef(el) content = el
autoScroll.contentRef(el)
const root = scroller const root = scroller
if (root) scheduleScrollState(root) if (root) scheduleScrollState(root)
}} }}
historyShift={historyLoader.shift()} userMessages={visibleUserMessages()}
userMessages={historyLoader.userMessages()} setHistoryAnchor={(handlers) => {
anchor={anchor} captureHistoryAnchor = handlers.capture
setRevealMessage={(fn) => { restoreHistoryAnchor = handlers.restore
revealMessage = fn }}
}} anchor={anchor}
/> setRevealMessage={(fn) => {
revealMessage = fn
}}
setScrollToEnd={(fn) => {
scrollToEnd = fn
}}
/>
)}
</Show> </Show>
</Match> </Match>
<Match when={true}> <Match when={true}>

View File

@ -0,0 +1,30 @@
import { expect, test } from "bun:test"
import { scheduleConnectedMeasure } from "./measure"
test("does not measure an element detached before the frame", async () => {
const element = document.createElement("div")
document.body.append(element)
let calls = 0
scheduleConnectedMeasure(element, () => {
calls += 1
})
element.remove()
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
expect(calls).toBe(0)
})
test("measures a connected element on the next frame", async () => {
const element = document.createElement("div")
document.body.append(element)
let calls = 0
scheduleConnectedMeasure(element, () => {
calls += 1
})
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
expect(calls).toBe(1)
element.remove()
})

View File

@ -0,0 +1,5 @@
export function scheduleConnectedMeasure<T extends HTMLElement>(element: T, measure: (element: T) => void) {
return requestAnimationFrame(() => {
if (element.isConnected) measure(element)
})
}

View File

@ -6,8 +6,8 @@ import {
Index, Index,
on, on,
onCleanup, onCleanup,
onMount,
Show, Show,
mapArray,
type Accessor, type Accessor,
type JSX, type JSX,
} from "solid-js" } from "solid-js"
@ -15,7 +15,7 @@ import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid" import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { Accordion } from "@opencode-ai/ui/accordion" import { Accordion } from "@opencode-ai/ui/accordion"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Card } from "@opencode-ai/ui/card" import { Card } from "@opencode-ai/ui/card"
@ -49,7 +49,6 @@ import type {
UserMessage, UserMessage,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { Binary } from "@opencode-ai/core/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover" import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { normalize } from "@opencode-ai/ui/session-diff" import { normalize } from "@opencode-ai/ui/session-diff"
@ -69,7 +68,9 @@ import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title" import { sessionTitle } from "@/utils/session-title"
import { makeTimer } from "@solid-primitives/timer" import { makeTimer } from "@solid-primitives/timer"
import { MessageComment, SummaryDiff, Timeline, TimelineRow, TimelineRowMap } from "./message-timeline.data" import { scheduleConnectedMeasure } from "./measure"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
const emptyMessages: MessageType[] = [] const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = [] const emptyParts: PartType[] = []
@ -77,43 +78,14 @@ const emptyTools: ToolPart[] = []
const emptyAssistantMessages: AssistantMessage[] = [] const emptyAssistantMessages: AssistantMessage[] = []
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "BottomSpacer" }> type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }> type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((key, index) => key === b[index])
}
const timelineCacheLimit = 16
const timelineFallbackItemSize = 60 const timelineFallbackItemSize = 60
const timelineCache = new Map<string, { keys: readonly string[]; cache: VirtualizerHandle["cache"] }>() const timelineCache = new Map<
string,
function readTimelineCache(id: string, keys: readonly string[]) { { measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }
const entry = timelineCache.get(id) >()
if (!entry) return
if (sameKeys(entry.keys, keys)) return entry.cache
timelineCache.delete(id)
}
function writeTimelineCache(id: string, keys: readonly string[], handle: VirtualizerHandle | undefined) {
if (!handle || keys.length === 0) return
timelineCache.delete(id)
timelineCache.set(id, { keys: keys.slice(), cache: handle.cache })
while (timelineCache.size > timelineCacheLimit) timelineCache.delete(timelineCache.keys().next().value!)
}
function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
return rows.map((row) => {
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
})
}
const taskDescription = (part: PartType, sessionID: string) => { const taskDescription = (part: PartType, sessionID: string) => {
if (part.type !== "tool" || part.tool !== "task") return if (part.type !== "tool" || part.tool !== "task") return
@ -278,10 +250,11 @@ export function MessageTimeline(props: {
shouldAnchorBottom: () => boolean shouldAnchorBottom: () => boolean
centered: boolean centered: boolean
setContentRef: (el: HTMLDivElement) => void setContentRef: (el: HTMLDivElement) => void
historyShift: boolean
userMessages: UserMessage[] userMessages: UserMessage[]
anchor: (id: string) => string anchor: (id: string) => string
setRevealMessage?: (fn: (id: string) => void) => void setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
}) { }) {
let touchGesture: number | undefined let touchGesture: number | undefined
@ -293,40 +266,21 @@ export function MessageTimeline(props: {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const { params, sessionKey } = useSessionKey() const { params, sessionKey } = useSessionKey()
const ownerSessionKey = sessionKey()
const cached = timelineCache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom()
const platform = usePlatform() const platform = usePlatform()
let virtualizer: VirtualizerHandle | undefined const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const sessionID = createMemo(() => params.id) const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
const id = sessionID()
if (!id) return emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
const result = new Map<string, AssistantMessage[]>()
for (const message of sessionMessages()) {
if (message.role !== "assistant") continue
const messages = result.get(message.parentID)
if (messages) {
messages.push(message)
continue
}
result.set(message.parentID, [message])
}
return result
})
const pending = createMemo(() =>
sessionMessages().findLast(
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
const sessionStatus = createMemo(() => { const sessionStatus = createMemo(() => {
const id = sessionID() const id = sessionID()
if (!id) return idle if (!id) return idle
return sync().data.session_status[id] ?? idle return sync().data.session_status[id] ?? idle
}) })
const working = createMemo(() => sessionStatus().type !== "idle") const working = createMemo(() => sessionStatus().type !== "idle")
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync().data.agent)) const tint = createMemo(() => messageAgentColor(sessionMessages(), sync().data.agent))
const [timeoutDone, setTimeoutDone] = createSignal(true) const [timeoutDone, setTimeoutDone] = createSignal(true)
@ -344,25 +298,6 @@ export function MessageTimeline(props: {
makeTimer(() => setTimeoutDone(true), 260, setTimeout) makeTimer(() => setTimeoutDone(true), 260, setTimeout)
}) })
const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID
if (parentID) {
const messages = sessionMessages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
if (message && message.role === "user") return message.id
}
const status = sessionStatus()
if (status.type !== "idle") {
const messages = sessionMessages()
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return messages[i].id
}
}
return undefined
})
const info = createMemo(() => { const info = createMemo(() => {
const id = sessionID() const id = sessionID()
if (!id) return if (!id) return
@ -385,6 +320,7 @@ export function MessageTimeline(props: {
}) })
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new")) const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const childTaskDescription = createMemo(() => { const childTaskDescription = createMemo(() => {
const id = sessionID() const id = sessionID()
if (!id) return if (!id) return
@ -401,147 +337,217 @@ export function MessageTimeline(props: {
return language.t("command.session.new") return language.t("command.session.new")
}) })
const showHeader = createMemo(() => !!(titleValue() || parentID())) const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
})
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
const lastAssistantGroupKey = projection.lastAssistantGroupKey
const messageByID = projection.messageByID
const messageLastRowIndex = projection.messageLastRowIndex
const messageRowIndex = projection.messageRowIndex
const timelineRowByKey = projection.rowByKey
const timelineRows = projection.rows
const messageRowMemos = createMemo( let prependAnchor: { key: string; offset: number } | undefined
mapArray( let prependAnchorFrame: number | undefined
() => props.userMessages, let prependLoading = false
(userMessage, indexAccessor) => { const clearPrependAnchor = () => {
return createMemo((previous: TimelineRow.TimelineRow[] | undefined) => { prependLoading = false
const rows = Timeline.constructMessageRows( prependAnchor = undefined
userMessage, if (prependAnchorFrame === undefined) return
getMsgParts, cancelAnimationFrame(prependAnchorFrame)
assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages, prependAnchorFrame = undefined
indexAccessor(), }
settings.general.showReasoningSummaries(), const capturePrependAnchor = () => {
sessionStatus().type, prependLoading = true
activeMessageID() === userMessage.id, updatePrependAnchor()
) }
const updatePrependAnchor = () => {
const root = listRoot()
if (!root) return
const view = root.getBoundingClientRect()
const anchor = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
.sort((a, b) => a.rect.top - b.rect.top)[0]
if (!anchor) return
if (!anchor.element.dataset.timelineKey) return
prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top }
}
const restorePrependAnchor = (done: boolean) => {
if (done) prependLoading = false
applyPrependAnchor()
}
const applyPrependAnchor = () => {
const root = listRoot()
if (!root || !prependAnchor) return
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
let frames = 0
let stable = 0
const apply = () => {
prependAnchorFrame = undefined
const anchor = prependAnchor
if (!anchor) return
const element = root.querySelector<HTMLElement>(`[data-timeline-key="${CSS.escape(anchor.key)}"]`)
const delta = element
? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset
: undefined
if (delta !== undefined && Math.abs(delta) > 0.5) {
root.scrollTop += delta
stable = 0
} else {
stable += 1
}
frames += 1
if (stable >= 30 || frames >= 180) {
if (!prependLoading) prependAnchor = undefined
return
}
prependAnchorFrame = requestAnimationFrame(apply)
}
prependAnchorFrame = requestAnimationFrame(apply)
}
return reuseTimelineRows(previous, rows) const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
let resizePinnedIndexes: number[] = []
let resizePinFrame: number | undefined
let virtualContent: HTMLDivElement | undefined
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return timelineRows().length
},
getScrollElement: () => listRoot() ?? null,
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => timelineFallbackItemSize,
scrollToFn: (offset, options, instance) => {
// Expose the computed range before core writes an anchor correction so the browser does not clamp it to the old height.
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
},
get getItemKey() {
const rows = timelineRows()
return (index: number) => {
const row = rows[index]
// ResizeObserver can report a removed element after its row has left the projection.
if (!row) return `removed:${index}`
return TimelineRow.key(row)
}
},
anchorTo: "end",
followOnAppend: true,
scrollEndThreshold: 80,
get scrollMargin() {
return showHeader() ? 64 : 0
},
overscan: 50,
paddingEnd: 64,
rangeExtractor: (range) => {
const id = activeMessageID()
const active = id ? (messageLastRowIndex().get(id) ?? -1) : -1
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
return [...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b)
},
})
const resizeItem = virtualizer.resizeItem
virtualizer.resizeItem = (index, size) => {
const item = virtualizer.measurementsCache[index]
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
const root = listRoot()
if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) {
const view = root.getBoundingClientRect()
resizePinnedIndexes = [...root.querySelectorAll<HTMLElement>("[data-index]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
}) })
}, .map((element) => Number(element.dataset.index))
), if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
resizePinFrame = requestAnimationFrame(() => {
resizePinFrame = requestAnimationFrame(() => {
resizePinFrame = undefined
resizePinnedIndexes = []
})
})
}
resizeItem(index, size)
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
item.end <= instance.getLogicalScrollOffset()
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
) )
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key as string))
const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
const rows = messageRowMemos().flatMap((memo) => memo())
if (rows.length === 0) return rows
return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
})
const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys })
const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
const messageRowIndex = createMemo(() => {
const result = new Map<string, number>()
timelineRows().forEach((row, index) => {
if (!("userMessageID" in row)) return
if (result.has(row.userMessageID)) return
result.set(row.userMessageID, index)
})
return result
})
const lastAssistantGroupKey = createMemo(() => {
const result = new Map<string, string>()
timelineRows().forEach((row) => {
if (row._tag !== "AssistantPart") return
result.set(row.userMessageID, row.group.key)
})
return result
})
const keepMounted = createMemo(() => {
const id = activeMessageID()
if (!id) return
const rows = timelineRows()
const index = rows.findLastIndex((row) => "userMessageID" in row && row.userMessageID === id)
if (index < 0) return
return [index]
})
const activeAssistantMessages = createMemo(() => {
const id = activeMessageID() ?? props.userMessages[props.userMessages.length - 1]?.id
if (!id) return emptyAssistantMessages
return assistantMessagesByParent().get(id) ?? emptyAssistantMessages
})
const activeAssistantContentVersion = createMemo(() =>
activeAssistantMessages()
.flatMap((message) => [
`${message.id}:${message.time.completed ?? ""}:${message.error?.name ?? ""}`,
...getMsgParts(message.id).map((part) => {
if (part.type === "text" || part.type === "reasoning") return `${part.id}:${part.type}:${part.text.length}`
if (part.type === "tool") {
const metadata = "metadata" in part.state ? part.state.metadata : undefined
const output =
"output" in part.state && typeof part.state.output === "string" ? part.state.output.length : 0
const metadataOutput =
metadata && typeof metadata === "object" && "output" in metadata && typeof metadata.output === "string"
? metadata.output.length
: 0
return `${part.id}:${part.tool}:${part.state.status}:${output}:${metadataOutput}`
}
return `${part.id}:${part.type}`
}),
])
.join("|"),
)
createEffect(
on(
() => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const,
() => {
if (!virtualizer) return
if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return
const keys = timelineRowKeys()
if (keys.length === 0) return
virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
scheduleMeasuredBottomAnchor()
},
{ defer: true },
),
)
createEffect(() => { createEffect(() => {
props.setRevealMessage?.((id) => { props.setRevealMessage?.((id) => {
const index = messageRowIndex().get(id) const index = messageRowIndex().get(id)
if (index === undefined) return if (index === undefined) return
virtualizer?.scrollToIndex(index, { align: "center" }) virtualizer.scrollToIndex(index, { align: "center" })
})
props.setScrollToEnd?.(() => virtualizer.scrollToEnd())
props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
})
let overscanFrame: number | undefined
onMount(() => {
overscanFrame = requestAnimationFrame(() => {
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
if (renderOverscan() < 20) setRenderOverscan(20)
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
})
}) })
}) })
let cacheSessionKey = sessionKey()
let cacheRowKeys = timelineRowKeys()
let virtualizerSessionKey = cacheSessionKey
let virtualizerRowKeys = cacheRowKeys
let bottomAnchorSessionKey = "" let bottomAnchorSessionKey = ""
let bottomAnchorFrame: number | undefined
const maybeAnchorBottom = () => { const maybeAnchorBottom = () => {
const key = sessionKey() const key = sessionKey()
if (bottomAnchorSessionKey === key) return if (bottomAnchorSessionKey === key) return
if (!virtualizer) return if (timelineRows().length === 0) return
const keys = timelineRowKeys()
if (keys.length === 0) return
bottomAnchorSessionKey = key bottomAnchorSessionKey = key
if (!props.shouldAnchorBottom()) return if (!props.shouldAnchorBottom()) return
virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
bottomAnchorFrame = requestAnimationFrame(() => {
bottomAnchorFrame = undefined
if (sessionKey() !== key) return
virtualizer.scrollToEnd()
})
} }
createEffect( let measuredSessionKey = sessionKey()
on( createEffect(() => {
() => [sessionKey(), timelineRowKeys()] as const, const key = sessionKey()
(next, prev) => { timelineRows().length
if (prev && prev[0] !== next[0]) writeTimelineCache(prev[0], prev[1], virtualizer) if (measuredSessionKey !== key) {
cacheSessionKey = next[0] measuredSessionKey = key
cacheRowKeys = next[1] virtualizer.measure()
if (virtualizer) { }
virtualizerSessionKey = cacheSessionKey maybeAnchorBottom()
virtualizerRowKeys = cacheRowKeys })
maybeAnchorBottom()
}
},
{ defer: true },
),
)
onCleanup(() => { onCleanup(() => {
writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer) clearPrependAnchor()
timelineCache.delete(ownerSessionKey)
timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!)
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
props.setRevealMessage?.(() => {}) props.setRevealMessage?.(() => {})
props.setScrollToEnd?.(() => {})
props.setHistoryAnchor?.({ capture: () => {}, restore: () => {} })
}) })
const [title, setTitle] = createStore({ const [title, setTitle] = createStore({
@ -560,17 +566,8 @@ export function MessageTimeline(props: {
const [bar, setBar] = createStore({ const [bar, setBar] = createStore({
ms: pace(640), ms: pace(640),
}) })
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
let more: HTMLButtonElement | undefined let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined let head: HTMLDivElement | undefined
let listRoot: HTMLDivElement | undefined
let listFrame: number | undefined
let contentFrame: number | undefined
let bottomAnchorFrame: number | undefined
let bottomAnchorFrames = 0
let measuredBottomAnchored = true
const [scrollRoot, setScrollRoot] = createSignal<HTMLDivElement>()
const updateTitleMetrics = () => { const updateTitleMetrics = () => {
if (!head || head.clientWidth <= 0) return if (!head || head.clientWidth <= 0) return
@ -579,83 +576,14 @@ export function MessageTimeline(props: {
createResizeObserver(() => head, updateTitleMetrics) createResizeObserver(() => head, updateTitleMetrics)
const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
const measureTimeline = () => {
virtualizer?.measure()
anchorMeasuredBottom()
}
function anchorMeasuredBottom() {
if (!listRoot) return false
if (!measuredBottomAnchored) return false
listRoot.scrollTop = listRoot.scrollHeight
return true
}
function scheduleMeasuredBottomAnchor() {
// Workaround for virtua issue #301: virtua does not expose a synchronous item-resize hook for
// "stay at bottom if already at bottom". Tool rows can briefly outgrow the measured virtual
// height, so keep the scroll container bottom-locked for a few frames while measurement settles.
bottomAnchorFrames = 90
if (bottomAnchorFrame !== undefined) return
const tick = () => {
bottomAnchorFrame = undefined
if (!anchorMeasuredBottom()) {
bottomAnchorFrames = 0
return
}
bottomAnchorFrames = working() ? 12 : bottomAnchorFrames - 1
if (bottomAnchorFrames <= 0) return
bottomAnchorFrame = requestAnimationFrame(tick)
}
bottomAnchorFrame = requestAnimationFrame(tick)
}
const bindContentRoot = (root: HTMLDivElement) => {
const child = root.firstElementChild
props.setContentRef(child instanceof HTMLDivElement ? child : root)
}
const scheduleContentRoot = (root: HTMLDivElement) => {
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
contentFrame = requestAnimationFrame(() => {
contentFrame = undefined
if (listRoot !== root) return
bindContentRoot(root)
})
}
const connectListRoot = (root: HTMLDivElement) => {
if (listRoot !== root) return
if (!root.isConnected || !root.ownerDocument.defaultView) {
listFrame = requestAnimationFrame(() => {
listFrame = undefined
connectListRoot(root)
})
return
}
props.setScrollRef(root)
measuredBottomAnchored = isMeasuredBottom(root)
setScrollRoot(root)
scheduleContentRoot(root)
}
const bindListRoot = (root: HTMLDivElement) => { const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot) return if (root === listRoot()) return
setListRoot(root)
if (listFrame !== undefined) cancelAnimationFrame(listFrame) props.setScrollRef(root)
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
listRoot = root
setScrollRoot(undefined)
connectListRoot(root)
} }
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => { const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
if (!prependLoading) clearPrependAnchor()
const root = event.currentTarget const root = event.currentTarget
const delta = normalizeWheelDelta({ const delta = normalizeWheelDelta({
deltaY: event.deltaY, deltaY: event.deltaY,
@ -667,6 +595,7 @@ export function MessageTimeline(props: {
} }
const handleListTouchStart = (event: TouchEvent) => { const handleListTouchStart = (event: TouchEvent) => {
if (!prependLoading) clearPrependAnchor()
touchGesture = event.touches[0]?.clientY touchGesture = event.touches[0]?.clientY
} }
@ -692,12 +621,13 @@ export function MessageTimeline(props: {
} }
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => { const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
if (!prependLoading) clearPrependAnchor()
if (event.target !== event.currentTarget) return if (event.target !== event.currentTarget) return
props.onMarkScrollGesture(event.currentTarget) props.onMarkScrollGesture(event.currentTarget)
} }
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => { const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
measuredBottomAnchored = isMeasuredBottom(event.currentTarget) if (prependLoading) updatePrependAnchor()
props.onScheduleScrollState(event.currentTarget) props.onScheduleScrollState(event.currentTarget)
props.onHistoryScroll() props.onHistoryScroll()
if (!props.hasScrollGesture()) return if (!props.hasScrollGesture()) return
@ -707,10 +637,6 @@ export function MessageTimeline(props: {
} }
onCleanup(() => { onCleanup(() => {
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
setScrollRoot(undefined)
props.setScrollRef(undefined) props.setScrollRef(undefined)
}) })
@ -1010,9 +936,7 @@ export function MessageTimeline(props: {
} }
} }
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID) const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>, onSizeChange?: () => void) => {
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
if (row().group.type === "context") { if (row().group.type === "context") {
const parts = createMemo(() => { const parts = createMemo(() => {
const group = row().group const group = row().group
@ -1028,7 +952,7 @@ export function MessageTimeline(props: {
busy={ busy={
workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
} }
onSizeChange={measureTimeline} onSizeChange={onSizeChange}
/> />
) )
} }
@ -1062,8 +986,9 @@ export function MessageTimeline(props: {
defaultOpen={defaultOpen()} defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()} toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)} onToolOpenChange={(open) => setToolOpen(part().id, open)}
deferToolContent={false} deferToolContent
virtualizeDiff={false} virtualizeDiff={false}
onContentRendered={onSizeChange}
/> />
)} )}
</Show> </Show>
@ -1077,10 +1002,6 @@ export function MessageTimeline(props: {
const row = input.row() const row = input.row()
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor) return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
} }
const previousUserMessage = () => {
const row = input.row()
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
}
const previousAssistantPart = () => { const previousAssistantPart = () => {
const row = input.row() const row = input.row()
return row._tag === "AssistantPart" && row.previousAssistantPart return row._tag === "AssistantPart" && row.previousAssistantPart
@ -1095,7 +1016,6 @@ export function MessageTimeline(props: {
"min-w-0 w-full max-w-full": true, "min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered, "md:max-w-200 2xl:max-w-[1000px]": props.centered,
"md:mx-auto": props.centered, "md:mx-auto": props.centered,
"pt-6": previousUserMessage(),
"pt-3": previousAssistantPart(), "pt-3": previousAssistantPart(),
}} }}
> >
@ -1106,8 +1026,10 @@ export function MessageTimeline(props: {
) )
} }
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => { const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => {
switch (row()._tag) { switch (row()._tag) {
case "TurnGap":
return <div data-timeline-row="TurnGap" aria-hidden="true" class="h-6" />
case "CommentStrip": { case "CommentStrip": {
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">> const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
const comments = createMemo(() => const comments = createMemo(() =>
@ -1195,7 +1117,7 @@ export function MessageTimeline(props: {
data-slot="session-turn-assistant-content" data-slot="session-turn-assistant-content"
aria-hidden={workingTurn(assistantPartRow().userMessageID)} aria-hidden={workingTurn(assistantPartRow().userMessageID)}
> >
{renderAssistantPartGroup(assistantPartRow)} {renderAssistantPartGroup(assistantPartRow, onSizeChange)}
</div> </div>
</div> </div>
</TimelineRowFrame> </TimelineRowFrame>
@ -1246,13 +1168,74 @@ export function MessageTimeline(props: {
</TimelineRowFrame> </TimelineRowFrame>
) )
} }
case "BottomSpacer":
return <div data-timeline-row="bottom-spacer" aria-hidden="true" class="h-16" />
} }
} }
function TimelineRowView(props: { row: TimelineRow.TimelineRow }) { function TimelineRowView(props: { row: TimelineRow.TimelineRow; onSizeChange?: () => void }) {
return renderTimelineRow(() => props.row) return renderTimelineRow(() => props.row, props.onSizeChange)
}
function VirtualTimelineRow(props: { rowKey: string }) {
let element: HTMLDivElement
const initialItem = virtualItemByKey().get(props.rowKey)!
const initialRow = timelineRowByKey().get(props.rowKey)!
const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem)
const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow)
const asyncFile = () => {
const value = row()
if (value._tag !== "AssistantPart" || value.group.type !== "part") return false
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool)
}
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
let contentMeasureFrame: number | undefined
onMount(() => virtualizer.measureElement(element))
createEffect(
on(
() => item().index,
() => {
virtualizer.measureElement(element)
},
{ defer: true },
),
)
onCleanup(() => {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
})
return (
<div
data-timeline-key={props.rowKey}
style={{
position: "absolute",
top: `${item().start - (showHeader() ? 64 : 0)}px`,
left: "0",
width: "100%",
height: `${item().size}px`,
overflow: "clip",
}}
>
<div
ref={(value) => {
element = value
}}
data-index={item().index}
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
>
<TimelineRowView
row={row()}
onSizeChange={() => {
setReady(true)
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
contentMeasureFrame = scheduleConnectedMeasure(element, virtualizer.measureElement)
}}
/>
</div>
</div>
)
} }
return ( return (
@ -1581,33 +1564,28 @@ export function MessageTimeline(props: {
</div> </div>
</div> </div>
</Show> </Show>
<Show when={scrollRoot()}> <div
{(root) => ( data-timeline-virtual-content
<Virtualizer ref={(element) => {
data={timelineRows()} virtualContent = element
cache={virtualCache()} props.setContentRef(element)
itemSize={virtualCache() ? undefined : timelineFallbackItemSize} }}
scrollRef={root()} style={{
shift={props.historyShift} height: `${virtualizer.getTotalSize()}px`,
keepMounted={keepMounted()} position: "relative",
startMargin={64} width: "100%",
ref={(handle) => { }}
if (!handle) { >
writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer) <For each={virtualRowKeys()}>{(rowKey) => <VirtualTimelineRow rowKey={rowKey} />}</For>
virtualizer = undefined <Show when={timelineRows().length > 0}>
return <div
} data-timeline-row="bottom-spacer"
virtualizer = handle aria-hidden="true"
virtualizerSessionKey = cacheSessionKey class="h-16 absolute top-0 left-0 w-full"
virtualizerRowKeys = cacheRowKeys style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
maybeAnchorBottom() />
scheduleContentRoot(root()) </Show>
}} </div>
>
{(row) => <TimelineRowView row={row} />}
</Virtualizer>
)}
</Show>
</ScrollView> </ScrollView>
</div> </div>
) )

View File

@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
const user = (id: string) => ({ id, role: "user" }) as UserMessage
const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
describe("timeline model", () => {
test("selects users and applies the revert boundary", () => {
const messages: Message[] = [user("msg_1"), assistant("msg_2"), user("msg_3"), user("msg_5")]
const users = selectUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_1", "msg_3", "msg_5"])
expect(selectVisibleUserMessages(users, "msg_5").map((message) => message.id)).toEqual(["msg_1", "msg_3"])
expect(selectVisibleUserMessages(users)).toBe(users)
})
test("loads pages until a visible user turn is added", async () => {
let loaded = 10
let visible = 2
let calls = 0
const anchors: Array<string | boolean> = []
await loadOlderTimeline({
sessionID: () => "ses_test",
loaded: () => loaded,
visible: () => visible,
more: () => true,
loading: () => false,
loadMore: async () => {
calls += 1
loaded += 3
if (calls === 2) visible += 1
},
before: () => anchors.push("before"),
after: (done) => anchors.push("after", done),
})
expect(calls).toBe(2)
expect(anchors).toEqual(["before", "after", false, "after", true])
})
test("stops when a page adds no raw messages", async () => {
let calls = 0
await loadOlderTimeline({
sessionID: () => "ses_test",
loaded: () => 10,
visible: () => 2,
more: () => true,
loading: () => false,
loadMore: async () => {
calls += 1
},
})
expect(calls).toBe(1)
})
test("does not restore an anchor after the session changes", async () => {
let sessionID = "ses_old"
let restore = 0
await loadOlderTimeline({
sessionID: () => sessionID,
loaded: () => 10,
visible: () => 2,
more: () => true,
loading: () => false,
loadMore: async () => {
sessionID = "ses_new"
},
after: () => {
restore += 1
},
})
expect(restore).toBe(0)
})
test("releases the anchor when loading history fails", async () => {
let restore = 0
await expect(
loadOlderTimeline({
sessionID: () => "ses_test",
loaded: () => 10,
visible: () => 2,
more: () => true,
loading: () => false,
loadMore: async () => {
throw new Error("history failed")
},
after: () => {
restore += 1
},
}),
).rejects.toThrow("history failed")
expect(restore).toBe(1)
})
})

View File

@ -0,0 +1,152 @@
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { same } from "@/utils/same"
const emptyUserMessages: UserMessage[] = []
export function createTimelineModel(input: {
sessionID: Accessor<string | undefined>
revertMessageID: Accessor<string | undefined>
}) {
const sdk = useSDK()
const serverSDK = useServerSDK()
const sync = useSync()
let refreshFrame: number | undefined
let refreshTimer: number | undefined
const [resource] = createResource(
() => [sdk().directory, input.sessionID()] as const,
([directory, id]) => {
clearRefresh()
if (!id) return
const cached = untrack(() => sync().data.message[id] !== undefined)
const stale = cached
? (() => {
const info = getSessionPrefetch(serverSDK().scope, directory, id)
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
: false
refreshFrame = requestAnimationFrame(() => {
refreshFrame = undefined
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
if (input.sessionID() !== id) return
untrack(() => {
if (stale) void sync().session.sync(id, { force: true })
})
}, 0)
})
return sync().session.sync(id)
},
)
const messages = createMemo(() => {
const id = input.sessionID()
return id ? (sync().data.message[id] ?? []) : []
})
const ready = createMemo(() => {
const id = input.sessionID()
return !id || sync().data.message[id] !== undefined
})
const userMessages = createMemo(
() => selectUserMessages(messages()),
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
return selectVisibleUserMessages(userMessages(), input.revertMessageID())
},
emptyUserMessages,
{ equals: same },
)
const more = createMemo(() => {
const id = input.sessionID()
return id ? sync().session.history.more(id) : false
})
const loading = createMemo(() => {
const id = input.sessionID()
return id ? sync().session.history.loading(id) : false
})
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
return loadOlderTimeline({
sessionID: input.sessionID,
loaded: () => messages().length,
visible: () => visibleUserMessages().length,
more,
loading,
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
before: options?.before,
after: options?.after,
})
}
onCleanup(clearRefresh)
return {
history: { loadOlder, loading, more },
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
messages,
ready,
resource,
userMessages,
visibleUserMessages,
}
function clearRefresh() {
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
refreshFrame = undefined
refreshTimer = undefined
}
}
export function selectUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
}
export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined>
loaded: Accessor<number>
visible: Accessor<number>
more: Accessor<boolean>
loading: Accessor<boolean>
loadMore: (sessionID: string) => Promise<void>
before?: () => void
after?: (done: boolean) => void
}) {
const id = input.sessionID()
if (!id || !input.more() || input.loading()) return
// A history page may contain only assistant messages or user turns hidden by a revert boundary.
const beforeVisible = input.visible()
let loaded = input.loaded()
input.before?.()
while (true) {
await input.loadMore(id).catch((error) => {
if (input.sessionID() === id) input.after?.(true)
throw error
})
if (input.sessionID() !== id) return
const nextLoaded = input.loaded()
const growth = input.visible() - beforeVisible
const raw = nextLoaded - loaded
loaded = nextLoaded
const done = growth > 0 || raw <= 0 || !input.more()
input.after?.(done)
if (done) return
}
}

View File

@ -0,0 +1,113 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { Timeline, TimelineRow } from "./rows"
const emptyAssistantMessages: AssistantMessage[] = []
export function createTimelineProjection(input: {
messages: Accessor<Message[]>
userMessages: Accessor<UserMessage[]>
parts: (messageID: string) => Part[]
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
const result = new Map<string, AssistantMessage[]>()
input.messages().forEach((message) => {
if (message.role !== "assistant") return
const messages = result.get(message.parentID)
if (messages) {
messages.push(message)
return
}
result.set(message.parentID, [message])
})
return result
})
const activeMessageID = createMemo(() => {
const parentID = input.messages().findLast(
(message): message is AssistantMessage => message.role === "assistant" && typeof message.time.completed !== "number",
)?.parentID
if (parentID) {
const messages = input.messages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
if (message?.role === "user") return message.id
}
if (input.status().type === "idle") return
return input.messages().findLast((message) => message.role === "user")?.id
})
const messageRowMemos = createMemo(
mapArray(input.userMessages, (userMessage, indexAccessor) =>
createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(
previous,
Timeline.constructMessageRows(
userMessage,
input.parts,
assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
indexAccessor(),
input.showReasoningSummaries(),
input.status().type,
activeMessageID() === userMessage.id,
),
),
),
),
)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(
previous,
messageRowMemos().flatMap((memo) => memo()),
),
)
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
const messageRowIndex = createMemo(() => {
const result = new Map<string, number>()
rows().forEach((row, index) => {
if (!("userMessageID" in row) || result.has(row.userMessageID)) return
result.set(row.userMessageID, index)
})
return result
})
const messageLastRowIndex = createMemo(() => {
const result = new Map<string, number>()
rows().forEach((row, index) => {
if ("userMessageID" in row) result.set(row.userMessageID, index)
})
return result
})
const lastAssistantGroupKey = createMemo(() => {
const result = new Map<string, string>()
rows().forEach((row) => {
if (row._tag === "AssistantPart") result.set(row.userMessageID, row.group.key)
})
return result
})
return {
activeMessageID,
assistantMessagesByParent,
lastAssistantGroupKey,
messageByID,
messageRowIndex,
messageLastRowIndex,
rowByKey,
rows,
}
}
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
const next = rows.map((row) => {
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
})
if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
return next
}

View File

@ -6,14 +6,13 @@ import { Data, Equal } from "effect"
export type SummaryDiff = SnapshotFileDiff & { file: string } export type SummaryDiff = SnapshotFileDiff & { file: string }
export type TimelineRowMap = { export type TimelineRowMap = {
TurnGap: { userMessageID: string }
CommentStrip: { CommentStrip: {
userMessageID: string userMessageID: string
previousUserMessage: boolean
} }
UserMessage: { UserMessage: {
userMessageID: string userMessageID: string
anchor: boolean anchor: boolean
previousUserMessage: boolean
} }
TurnDivider: { TurnDivider: {
userMessageID: string userMessageID: string
@ -28,18 +27,18 @@ export type TimelineRowMap = {
Retry: { userMessageID: string } Retry: { userMessageID: string }
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] } DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
Error: { userMessageID: string; text: string } Error: { userMessageID: string; text: string }
BottomSpacer: {}
} }
export namespace TimelineRow { export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
userMessageID: string
}> {}
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{ export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
userMessageID: string userMessageID: string
previousUserMessage: boolean
}> {} }> {}
export class UserMessage extends Data.TaggedClass("UserMessage")<{ export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string userMessageID: string
anchor: boolean anchor: boolean
previousUserMessage: boolean
}> {} }> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{ export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string userMessageID: string
@ -65,9 +64,9 @@ export namespace TimelineRow {
export class Retry extends Data.TaggedClass("Retry")<{ export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string userMessageID: string
}> {} }> {}
export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {}
export type TimelineRow = export type TimelineRow =
| TurnGap
| CommentStrip | CommentStrip
| UserMessage | UserMessage
| TurnDivider | TurnDivider
@ -76,10 +75,11 @@ export namespace TimelineRow {
| DiffSummary | DiffSummary
| Error | Error
| Retry | Retry
| BottomSpacer
export const key = (row: TimelineRow) => { export const key = (row: TimelineRow) => {
switch (row._tag) { switch (row._tag) {
case "TurnGap":
return `turn-gap:${row.userMessageID}`
case "CommentStrip": case "CommentStrip":
return `comment-strip:${row.userMessageID}` return `comment-strip:${row.userMessageID}`
case "UserMessage": case "UserMessage":
@ -96,8 +96,6 @@ export namespace TimelineRow {
return `error:${row.userMessageID}` return `error:${row.userMessageID}`
case "Retry": case "Retry":
return `retry:${row.userMessageID}` return `retry:${row.userMessageID}`
case "BottomSpacer":
return "bottom-spacer"
} }
} }
@ -149,11 +147,12 @@ export namespace Timeline {
), ),
] ]
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group })) : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
if (comments.length > 0) if (comments.length > 0)
rows.push( rows.push(
new TimelineRow.CommentStrip({ new TimelineRow.CommentStrip({
userMessageID: userMessage.id, userMessageID: userMessage.id,
previousUserMessage,
}), }),
) )
@ -161,7 +160,6 @@ export namespace Timeline {
new TimelineRow.UserMessage({ new TimelineRow.UserMessage({
userMessageID: userMessage.id, userMessageID: userMessage.id,
anchor: comments.length === 0, anchor: comments.length === 0,
previousUserMessage: comments.length === 0 && previousUserMessage,
}), }),
) )

View File

@ -0,0 +1,46 @@
import { expect, test } from "bun:test"
import { createVirtualizer } from "@tanstack/solid-virtual"
import { createRoot, createSignal } from "solid-js"
test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return count()
},
getScrollElement: () => null,
estimateSize: () => 60,
initialRect: { width: 800, height: 600 },
})
expect(virtualizer.getTotalSize()).toBe(120)
virtualizer.resizeItem(0, 100)
expect(virtualizer.getTotalSize()).toBe(160)
setCount(3)
expect(virtualizer.itemSizeCache.get(0)).toBe(100)
expect(virtualizer.getTotalSize()).toBe(220)
dispose()
})
})
test("logical scroll offset includes pending measurement adjustments", () => {
createRoot((dispose) => {
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
count: 2,
getScrollElement: () => null,
estimateSize: () => 60,
initialOffset: 100,
initialRect: { width: 800, height: 60 },
})
virtualizer.getTotalSize()
virtualizer.resizeItem(0, 100)
expect(virtualizer.scrollOffset).toBe(100)
expect(virtualizer.getLogicalScrollOffset()).toBe(140)
dispose()
})
})

View File

@ -140,10 +140,10 @@ describe("ShareNext", () => {
it.live("create posts share, persists it, and returns the result", () => it.live("create posts share, persists it, and returns the result", () =>
provideTmpdirInstance( provideTmpdirInstance(
() => { () => {
const seen: HttpClientRequest.HttpClientRequest[] = [] const createRequests: HttpClientRequest.HttpClientRequest[] = []
const client = HttpClient.make((req) => { const client = HttpClient.make((req) => {
seen.push(req)
if (req.url.endsWith("/api/share")) { if (req.url.endsWith("/api/share")) {
createRequests.push(req)
return Effect.succeed( return Effect.succeed(
json(req, { json(req, {
id: "shr_abc", id: "shr_abc",
@ -168,9 +168,9 @@ describe("ShareNext", () => {
expect(row?.url).toBe("https://legacy-share.example.com/share/abc") expect(row?.url).toBe("https://legacy-share.example.com/share/abc")
expect(row?.secret).toBe("sec_123") expect(row?.secret).toBe("sec_123")
expect(seen).toHaveLength(1) expect(createRequests).toHaveLength(1)
expect(seen[0].method).toBe("POST") expect(createRequests[0].method).toBe("POST")
expect(seen[0].url).toBe("https://legacy-share.example.com/api/share") expect(createRequests[0].url).toBe("https://legacy-share.example.com/api/share")
}).pipe(Effect.provide(integrationLayer(client))) }).pipe(Effect.provide(integrationLayer(client)))
}, },
{ config: { enterprise: { url: "https://legacy-share.example.com" } } }, { config: { enterprise: { url: "https://legacy-share.example.com" } } },

View File

@ -59,6 +59,7 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"@shikijs/stream": "catalog:",
"diff": "catalog:", "diff": "catalog:",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
@ -76,7 +77,6 @@
"shiki": "catalog:", "shiki": "catalog:",
"solid-js": "catalog:", "solid-js": "catalog:",
"solid-list": "catalog:", "solid-list": "catalog:",
"strip-ansi": "7.1.2", "strip-ansi": "7.1.2"
"virtua": "catalog:"
} }
} }

View File

@ -2,6 +2,10 @@
content-visibility: auto; content-visibility: auto;
} }
[data-timeline-row] [data-component="file"] {
content-visibility: visible;
}
[data-component="file"][data-mode="text"] { [data-component="file"][data-mode="text"] {
overflow: hidden; overflow: hidden;
} }

View File

@ -52,7 +52,7 @@ const VIRTUALIZE_BYTES = 500_000
const codeMetrics = { const codeMetrics = {
...DEFAULT_VIRTUAL_FILE_METRICS, ...DEFAULT_VIRTUAL_FILE_METRICS,
lineHeight: 24, lineHeight: 24,
fileGap: 0, spacing: 0,
} satisfies Partial<VirtualFileMetrics> } satisfies Partial<VirtualFileMetrics>
type SharedProps<T> = { type SharedProps<T> = {

View File

@ -0,0 +1,32 @@
import { expect, test } from "bun:test"
import { shouldResetCodeTokens } from "./markdown-code-state"
const previous = {
language: "ts",
generation: 1,
stableCount: 3,
unstable: [],
raw: "```ts\nconst x = 1\n```",
}
test("resets tokens for a non-prefix replacement with the same generation and token count", () => {
expect(
shouldResetCodeTokens(previous, {
language: "ts",
generation: 1,
stableCount: 3,
raw: "```ts\nlet y = 2\n```",
}),
).toBe(true)
})
test("retains tokens for an append-only streaming update", () => {
expect(
shouldResetCodeTokens(previous, {
language: "ts",
generation: 1,
stableCount: 4,
raw: `${previous.raw}\nmore`,
}),
).toBe(false)
})

View File

@ -0,0 +1,22 @@
import type { MarkdownToken } from "./markdown-worker-protocol"
export type RenderedCodeState = {
language: string
generation: number
stableCount: number
unstable: MarkdownToken[]
raw: string
}
export function shouldResetCodeTokens(
previous: RenderedCodeState | undefined,
next: { language: string; generation: number; stableCount: number; raw: string },
) {
return (
!previous ||
previous.language !== next.language ||
previous.generation !== next.generation ||
next.stableCount < previous.stableCount ||
!next.raw.startsWith(previous.raw)
)
}

View File

@ -0,0 +1,104 @@
/// <reference lib="webworker" />
import { ShikiStreamTokenizer } from "@shikijs/stream"
import {
bundledLanguages,
createHighlighter,
getTokenStyleObject,
stringifyTokenStyle,
type BundledLanguage,
type ThemedToken,
} from "shiki"
import type { MarkdownToken, MarkdownWorkerRequest, MarkdownWorkerResponse } from "./markdown-worker-protocol"
import { createLatestWorkerQueue } from "./markdown-worker-queue"
type Stream = {
language: string
source: string
tokenizer: ShikiStreamTokenizer
}
const streams = new Map<string, Stream>()
let highlighter: ReturnType<typeof createHighlighter> | undefined
const queue = createLatestWorkerQueue<Extract<MarkdownWorkerRequest, { type: "highlight" }>>({
run: highlight,
supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }),
dispose: (key) => void streams.delete(key),
})
self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
if (event.data.type === "init") {
highlighter ??= createHighlighter({ themes: [event.data.theme], langs: [] })
return
}
if (event.data.type === "dispose") {
queue.dispose(event.data.key)
return
}
queue.highlight(event.data)
}
async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highlight" }>) {
try {
const instance = await highlighter
if (!instance) throw new Error("Shiki worker is not initialized")
const language = request.language in bundledLanguages ? request.language : "text"
if (!instance.getLoadedLanguages().includes(language))
await instance.loadLanguage(bundledLanguages[language as BundledLanguage])
if (request.complete) {
const result = instance.codeToTokens(request.text, { lang: language as BundledLanguage, theme: "OpenCode" })
streams.delete(request.key)
post({
type: "highlight",
id: request.id,
key: request.key,
reset: true,
stable: result.tokens
.flatMap((line, index) =>
index === result.tokens.length - 1 ? line : [...line, { content: "\n", offset: 0 }],
)
.map(token),
unstable: [],
})
return
}
const previous = streams.get(request.key)
const reset = !previous || previous.language !== language || !request.text.startsWith(previous.source)
const stream = reset
? {
language,
source: "",
tokenizer: new ShikiStreamTokenizer({ highlighter: instance, lang: language, theme: "OpenCode" }),
}
: previous
const result = await stream.tokenizer.enqueue(request.text.slice(stream.source.length))
stream.source = request.text
streams.set(request.key, stream)
post({
type: "highlight",
id: request.id,
key: request.key,
reset,
stable: result.stable.filter((token) => token.content.length > 0).map(token),
unstable: result.unstable.filter((token) => token.content.length > 0).map(token),
})
} catch (error) {
post({
type: "error",
id: request.id,
key: request.key,
message: error instanceof Error ? error.message : String(error),
})
}
}
function post(response: MarkdownWorkerResponse) {
self.postMessage(response)
}
function token(value: ThemedToken): MarkdownToken {
return [value.content, stringifyTokenStyle(value.htmlStyle ?? getTokenStyleObject(value))]
}

View File

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { stream } from "./markdown-stream" import { canReusePendingBlock, project, stream } from "./markdown-stream"
describe("markdown stream", () => { describe("markdown stream", () => {
test("heals incomplete emphasis while streaming", () => { test("heals incomplete emphasis while streaming", () => {
@ -15,8 +15,58 @@ describe("markdown stream", () => {
test("splits an unfinished trailing code fence from stable content", () => { test("splits an unfinished trailing code fence from stable content", () => {
expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([ expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([
{ raw: "before\n\n", src: "before\n\n", mode: "live" }, { raw: "before\n\n", src: "before\n\n", mode: "full" },
{ raw: "```ts\nconst x = 1", src: "```ts\nconst x = 1", mode: "live" }, { raw: "```ts\nconst x = 1", src: "const x = 1", mode: "code", language: "ts" },
])
})
test("fully parses a code fence once it closes", () => {
const text = "before\n\n```ts\nconst x = 1\n```"
expect(stream(text, true)).toEqual([
{ raw: "before\n\n", src: "before\n\n", mode: "full" },
{ raw: "```ts\nconst x = 1\n```", src: "const x = 1", mode: "code", language: "ts", complete: true },
])
})
test("keeps a completed code fence in worker-rendered code mode when prose follows", () => {
expect(stream("```ts\nconst x = 1\n```\n\nafter", true)).toEqual([
{ raw: "```ts\nconst x = 1\n```\n\n", src: "const x = 1", mode: "code", language: "ts", complete: true },
{ raw: "after", src: "after", mode: "live" },
])
})
test("freezes completed top-level blocks and only keeps the tail live", () => {
expect(stream("# Plan\n\nFinished paragraph.\n\n- live item", true)).toEqual([
{ raw: "# Plan\n\n", src: "# Plan\n\n", mode: "full" },
{ raw: "Finished paragraph.\n\n", src: "Finished paragraph.\n\n", mode: "full" },
{ raw: "- live item", src: "- live item", mode: "live" },
])
})
test("keeps a growing table together until a later block freezes it", () => {
expect(stream("| a | b |\n|---|---|\n| 1 | 2 |", true)).toEqual([
{ raw: "| a | b |\n|---|---|\n| 1 | 2 |", src: "| a | b |\n|---|---|\n| 1 | 2 |", mode: "live" },
])
})
test("reprojects non-prefix replacements from current content", () => {
expect(stream("# Replacement\n\nNew body", true)).toEqual([
{ raw: "# Replacement\n\n", src: "# Replacement\n\n", mode: "full" },
{ raw: "New body", src: "New body", mode: "live" },
])
})
test("reprojects truncation without retaining removed blocks", () => {
expect(stream("Only the restored prefix", true)).toEqual([
{ raw: "Only the restored prefix", src: "Only the restored prefix", mode: "live" },
])
})
test("shifts later blocks when an earlier block is inserted", () => {
expect(stream("# Inserted\n\nFirst body\n\nSecond body", true)).toEqual([
{ raw: "# Inserted\n\n", src: "# Inserted\n\n", mode: "full" },
{ raw: "First body\n\n", src: "First body\n\n", mode: "full" },
{ raw: "Second body", src: "Second body", mode: "live" },
]) ])
}) })
@ -29,4 +79,112 @@ describe("markdown stream", () => {
}, },
]) ])
}) })
test("keeps compact and indented reference definitions with their uses", () => {
expect(stream("[docs]\n\n [docs]:/guide", true)).toEqual([
{
raw: "[docs]\n\n [docs]:/guide",
src: "[docs]\n\n [docs]:/guide",
mode: "live",
},
])
})
test("keeps multiline reference definitions with their uses", () => {
expect(stream("[docs][id]\n\n[id]:\n /guide", true)).toEqual([
{
raw: "[docs][id]\n\n[id]:\n /guide",
src: "[docs][id]\n\n[id]:\n /guide",
mode: "live",
},
])
})
test("uses only the language portion of fence metadata", () => {
expect(stream("```ts title=example\nconst x = 1", true)).toEqual([
{
raw: "```ts title=example\nconst x = 1",
src: "const x = 1",
mode: "code",
language: "ts",
},
])
})
test("preserves trailing newlines in open code fences", () => {
expect(stream("```ts\nconst x = 1\n", true)).toEqual([
{
raw: "```ts\nconst x = 1\n",
src: "const x = 1\n",
mode: "code",
language: "ts",
},
])
})
test("only reuses pending blocks with compatible identity and content", () => {
expect(canReusePendingBlock({ mode: "full", raw: "First\n\n" }, { mode: "full", raw: "# Inserted\n\n", src: "", })).toBe(false)
expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "code", raw: "```ts\none two", src: "" })).toBe(true)
expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "live", raw: "one", src: "" })).toBe(false)
})
test("appends plain code deltas without reprojecting frozen blocks", () => {
const previous = project(undefined, "# Plan\n\n```ts\nconst one = 1\n", true)
const next = project(previous, `${previous.text}const two = 2\n`, true)
expect(next.blocks[0]).toBe(previous.blocks[0])
expect(next.blocks.at(-1)).toEqual({
raw: "```ts\nconst one = 1\nconst two = 2\n",
src: "const one = 1\nconst two = 2\n",
mode: "code",
language: "ts",
})
})
test("does not add a blank line before the first streamed code", () => {
const previous = project(undefined, "```ts\n", true)
const next = project(previous, `${previous.text}const x = 1`, true)
expect(next.blocks.at(-1)).toEqual({
raw: "```ts\nconst x = 1",
src: "const x = 1",
mode: "code",
language: "ts",
})
})
test("closes code fences split across provider deltas", () => {
const open = project(undefined, "```ts\nconst x = 1\n", true)
const one = project(open, `${open.text}\``, true)
const two = project(one, `${one.text}\``, true)
const closed = project(two, `${two.text}\``, true)
const prose = project(closed, `${closed.text}\nafter`, true)
expect(closed.blocks.at(-1)).toEqual({
raw: "```ts\nconst x = 1\n```",
src: "const x = 1",
mode: "code",
language: "ts",
complete: true,
})
expect(prose.blocks).toEqual([
{ raw: "```ts\nconst x = 1\n```\n", src: "const x = 1", mode: "code", language: "ts", complete: true },
{ raw: "after", src: "after", mode: "live" },
])
})
test("closes tilde fences split across provider deltas", () => {
const open = project(undefined, "~~~ts\nconst x = 1\n", true)
const one = project(open, `${open.text}~`, true)
const two = project(one, `${one.text}~`, true)
const closed = project(two, `${two.text}~`, true)
expect(closed.blocks.at(-1)).toEqual({
raw: "~~~ts\nconst x = 1\n~~~",
src: "const x = 1",
mode: "code",
language: "ts",
complete: true,
})
})
}) })

View File

@ -4,11 +4,28 @@ import remend from "remend"
export type Block = { export type Block = {
raw: string raw: string
src: string src: string
mode: "full" | "live" mode: "full" | "live" | "code"
language?: string
complete?: boolean
}
export type Projection = {
text: string
blocks: Block[]
} }
function refs(text: string) { function refs(text: string) {
return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text) if (!text.includes("]:")) return false
return /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(?:\S+|\r?\n[ \t]+\S+)/m.test(text)
}
function language(value: string | undefined) {
return value?.trim().split(/\s+/, 1)[0] || undefined
}
function openCode(raw: string) {
const newline = raw.indexOf("\n")
return newline < 0 ? "" : raw.slice(newline + 1)
} }
function open(raw: string) { function open(raw: string) {
@ -22,28 +39,72 @@ function open(raw: string) {
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last) return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
} }
function closesFence(raw: string, suffix: string) {
const mark = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)?.[1]
if (!mark) return suffix.includes("```") || suffix.includes("~~~")
return `${raw.slice(-(mark.length - 1))}${suffix}`.includes(mark)
}
function heal(text: string) { function heal(text: string) {
return remend(text, { linkMode: "text-only" }) return remend(text, { linkMode: "text-only" })
} }
export function stream(text: string, live: boolean) { export function stream(text: string, live: boolean): Block[] {
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[] if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
const src = heal(text) if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
const tokens = marked.lexer(text) const tokens = marked.lexer(text)
const tail = tokens.findLastIndex((token) => token.type !== "space") const tail = tokens.findLastIndex((token) => token.type !== "space")
if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[] if (tail < 0) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
const last = tokens[tail] const last = tokens[tail]
if (!last || last.type !== "code") return [{ raw: text, src, mode: "live" }] satisfies Block[] if (!last) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
const code = last as Tokens.Code
if (!open(code.raw)) return [{ raw: text, src, mode: "live" }] satisfies Block[] const result: Block[] = []
const head = tokens for (let index = 0; index < tail; index++) {
.slice(0, tail) const token = tokens[index]
if (!token || token.type === "space") continue
let raw = token.raw
while (tokens[index + 1]?.type === "space" && index + 1 < tail) raw += tokens[++index]!.raw
if (token.type === "code") {
const code = token as Tokens.Code
result.push({ raw, src: code.text, mode: "code", language: language(code.lang), complete: true })
continue
}
result.push({ raw, src: raw, mode: "full" })
}
const raw = tokens
.slice(tail)
.map((token) => token.raw) .map((token) => token.raw)
.join("") .join("")
if (!head) return [{ raw: code.raw, src: code.raw, mode: "live" }] satisfies Block[] if (last.type !== "code") return [...result, { raw, src: heal(raw), mode: "live" }]
return [
{ raw: head, src: heal(head), mode: "live" }, const code = last as Tokens.Code
{ raw: code.raw, src: code.raw, mode: "live" }, if (!open(code.raw))
] satisfies Block[] return [...result, { raw, src: code.text, mode: "code", language: language(code.lang), complete: true }]
return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }]
}
export function canReusePendingBlock(current: Pick<Block, "mode" | "raw"> | undefined, next: Block) {
if (!current || current.mode !== next.mode) return false
if (next.mode === "code") return next.raw.startsWith(current.raw)
return current.raw === next.raw
}
export function project(previous: Projection | undefined, text: string, live: boolean): Projection {
if (!live || !previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) }
const tail = previous.blocks.at(-1)
const suffix = text.slice(previous.text.length)
if (!suffix || tail?.mode !== "code" || tail.complete || closesFence(tail.raw, suffix))
return { text, blocks: stream(text, live) }
return {
text,
blocks: [
...previous.blocks.slice(0, -1),
{
...tail,
raw: tail.raw + suffix,
src: tail.src + suffix,
},
],
}
} }

View File

@ -0,0 +1,77 @@
import { expect, test } from "bun:test"
import { applyMarkdownWorkerResponse, markdownBlockKey, shouldReleaseMarkdownWorkerState } from "./markdown-worker-protocol"
const token = (content: string): [string, string] => [content, ""]
const response = (id: number, reset: boolean, stable: [string, string][], unstable: [string, string][]) => ({
type: "highlight" as const,
id,
key: "code",
reset,
stable,
unstable,
})
test("accumulates stable worker tokens and replaces the unstable tail", () => {
const first = applyMarkdownWorkerResponse(undefined, {
type: "highlight",
id: 1,
key: "code",
reset: true,
stable: [token("one\n")],
unstable: [token("tw")],
})
const second = applyMarkdownWorkerResponse(first, {
type: "highlight",
id: 2,
key: "code",
reset: false,
stable: [token("two\n")],
unstable: [token("three")],
})
expect(second.stable.map((item) => item[0])).toEqual(["one\n", "two\n"])
expect(second.unstable.map((item) => item[0])).toEqual(["three"])
})
test("increments generation only when the worker resets token identity", () => {
const first = applyMarkdownWorkerResponse(undefined, response(1, true, [["const", ""]], []))
const append = applyMarkdownWorkerResponse(first, response(2, false, [[" x", ""]], []))
const replacement = applyMarkdownWorkerResponse(append, response(3, true, [["let y", ""]], []))
expect([first.generation, append.generation, replacement.generation]).toEqual([1, 1, 2])
})
test("ignores stale worker responses and resets replacement streams", () => {
const current = { id: 2, generation: 1, stable: [token("current")], unstable: [] }
expect(
applyMarkdownWorkerResponse(current, {
type: "highlight",
id: 1,
key: "code",
reset: false,
stable: [token("stale")],
unstable: [],
}),
).toBe(current)
expect(
applyMarkdownWorkerResponse(current, {
type: "highlight",
id: 3,
key: "code",
reset: true,
stable: [token("replacement")],
unstable: [],
}).stable.map((item) => item[0]),
).toEqual(["replacement"])
})
test("releases only the latest completed worker state", () => {
expect(shouldReleaseMarkdownWorkerState(true, 4, 4)).toBe(true)
expect(shouldReleaseMarkdownWorkerState(true, 5, 4)).toBe(false)
expect(shouldReleaseMarkdownWorkerState(false, 4, 4)).toBe(false)
})
test("prefixes pending and dispatched block keys with the component owner", () => {
expect(markdownBlockKey("owner", "message", 2, "code")).toBe("owner:message:2:code")
expect(markdownBlockKey("owner", undefined, 2, "code")).toBe("owner:block:2")
})

View File

@ -0,0 +1,48 @@
import type { ThemeRegistrationResolved } from "shiki"
export type MarkdownToken = [content: string, style: string]
export type MarkdownWorkerRequest =
| { type: "init"; theme: ThemeRegistrationResolved }
| { type: "highlight"; id: number; key: string; text: string; language: string; complete?: boolean }
| { type: "dispose"; key: string }
export type MarkdownWorkerResponse =
| {
type: "highlight"
id: number
key: string
reset: boolean
stable: MarkdownToken[]
unstable: MarkdownToken[]
}
| { type: "error"; id: number; key: string; message: string }
| { type: "superseded"; id: number; key: string }
export type MarkdownWorkerState = {
id: number
generation: number
stable: MarkdownToken[]
unstable: MarkdownToken[]
}
export function shouldReleaseMarkdownWorkerState(complete: boolean, latestID: number | undefined, responseID: number) {
return complete && latestID === responseID
}
export function markdownBlockKey(owner: string, cacheKey: string | undefined, index: number, mode: string) {
return `${owner}:${cacheKey ? `${cacheKey}:${index}:${mode}` : `block:${index}`}`
}
export function applyMarkdownWorkerResponse(
state: MarkdownWorkerState | undefined,
response: Extract<MarkdownWorkerResponse, { type: "highlight" }>,
) {
if (state && response.id <= state.id) return state
return {
id: response.id,
generation: (state?.generation ?? 0) + (response.reset ? 1 : 0),
stable: response.reset ? response.stable : [...(state?.stable ?? []), ...response.stable],
unstable: response.unstable,
}
}

View File

@ -0,0 +1,49 @@
import { expect, test } from "bun:test"
import { createLatestWorkerQueue } from "./markdown-worker-queue"
test("keeps only the latest queued request for each key", async () => {
const processed: number[] = []
const superseded: number[] = []
let release = () => {}
const blocked = new Promise<void>((resolve) => {
release = resolve
})
const queue = createLatestWorkerQueue<{ id: number; key: string }>({
run: async (request) => {
processed.push(request.id)
if (request.id === 1) await blocked
},
supersede: (request) => superseded.push(request.id),
dispose: () => {},
})
queue.highlight({ id: 1, key: "code" })
await Promise.resolve()
queue.highlight({ id: 2, key: "code" })
queue.highlight({ id: 3, key: "code" })
queue.highlight({ id: 4, key: "code" })
expect(queue.pending()).toBe(1)
expect(superseded).toEqual([2, 3])
release()
await queue.idle()
expect(processed).toEqual([1, 4])
})
test("serializes disposal before a later request for the same key", async () => {
const events: string[] = []
const queue = createLatestWorkerQueue<{ id: number; key: string }>({
run: async (request) => {
events.push(`highlight:${request.id}`)
},
supersede: (request) => events.push(`supersede:${request.id}`),
dispose: (key) => events.push(`dispose:${key}`),
})
queue.highlight({ id: 1, key: "code" })
queue.dispose("code")
queue.highlight({ id: 2, key: "code" })
await queue.idle()
expect(events).toEqual(["supersede:1", "dispose:code", "highlight:2"])
})

View File

@ -0,0 +1,64 @@
export function createLatestWorkerQueue<T extends { key: string }>(input: {
run: (request: T) => Promise<void>
supersede: (request: T) => void
dispose: (key: string) => void
}) {
type Slot = { type: "highlight"; key: string; request?: T }
const jobs: Array<Slot | { type: "dispose"; key: string }> = []
const slots = new Map<string, Slot>()
let running: Promise<void> | undefined
let cursor = 0
const schedule = () => {
if (running) return
running = Promise.resolve()
.then(async () => {
while (cursor < jobs.length) {
const job = jobs[cursor++]!
if (job.type === "dispose") {
input.dispose(job.key)
continue
}
if (slots.get(job.key) === job) slots.delete(job.key)
const request = job.request
job.request = undefined
if (request) await input.run(request)
}
})
.finally(() => {
jobs.splice(0, cursor)
cursor = 0
running = undefined
if (jobs.length > 0) schedule()
})
}
return {
highlight(request: T) {
const slot = slots.get(request.key)
if (slot) {
if (slot.request) input.supersede(slot.request)
slot.request = request
return
}
const next: Slot = { type: "highlight", key: request.key, request }
slots.set(request.key, next)
jobs.push(next)
schedule()
},
dispose(key: string) {
const slot = slots.get(key)
if (slot?.request) input.supersede(slot.request)
if (slot) {
slot.request = undefined
slots.delete(key)
}
jobs.push({ type: "dispose", key })
schedule()
},
pending: () => slots.size,
async idle() {
while (running) await running
},
}
}

View File

@ -0,0 +1,56 @@
import { expect, test } from "bun:test"
import { createWorkerTransport } from "./markdown-worker-transport"
test("posts one request and retains only the latest queued snapshot per key", () => {
const posted: number[] = []
const superseded: number[] = []
const transport = createWorkerTransport<{ id: number; key: string }>({
post: (request) => posted.push(request.id),
supersede: (request) => superseded.push(request.id),
})
transport.send({ id: 1, key: "code" })
transport.send({ id: 2, key: "code" })
transport.send({ id: 3, key: "code" })
expect(posted).toEqual([1])
expect(superseded).toEqual([2])
expect(transport.queued()).toBe(1)
transport.complete("code", 1)
expect(posted).toEqual([1, 3])
expect(transport.queued()).toBe(0)
})
test("ignores a disposed request response after the key is reused", () => {
const posted: number[] = []
const transport = createWorkerTransport<{ id: number; key: string }>({
post: (request) => posted.push(request.id),
supersede: () => {},
})
transport.send({ id: 1, key: "code" })
transport.dispose("code")
transport.send({ id: 2, key: "code" })
transport.send({ id: 3, key: "code" })
transport.complete("code", 1)
expect(posted).toEqual([1, 2])
expect(transport.queued()).toBe(1)
transport.complete("code", 2)
expect(posted).toEqual([1, 2, 3])
})
test("drops queued snapshots when a key is disposed", () => {
const superseded: number[] = []
const transport = createWorkerTransport<{ id: number; key: string }>({
post: () => {},
supersede: (request) => superseded.push(request.id),
})
transport.send({ id: 1, key: "code" })
transport.send({ id: 2, key: "code" })
transport.dispose("code")
expect(superseded).toEqual([2])
expect(transport.queued()).toBe(0)
})

View File

@ -0,0 +1,41 @@
export function createWorkerTransport<T extends { id: number; key: string }>(input: {
post: (request: T) => void
supersede: (request: T) => void
}) {
const active = new Map<string, T>()
const queued = new Map<string, T>()
return {
send(request: T) {
if (!active.has(request.key)) {
active.set(request.key, request)
input.post(request)
return
}
const previous = queued.get(request.key)
if (previous) input.supersede(previous)
queued.set(request.key, request)
},
complete(key: string, id: number) {
if (active.get(key)?.id !== id) return
active.delete(key)
const next = queued.get(key)
if (!next) return
queued.delete(key)
active.set(key, next)
input.post(next)
},
dispose(key: string) {
active.delete(key)
const request = queued.get(key)
if (request) input.supersede(request)
queued.delete(key)
},
reset() {
queued.forEach(input.supersede)
queued.clear()
active.clear()
},
queued: () => queued.size,
}
}

View File

@ -0,0 +1,122 @@
import MarkdownShikiWorkerUrl from "./markdown-shiki.worker.ts?worker&url"
import { OpenCodeTheme } from "../context/marked"
import {
applyMarkdownWorkerResponse,
shouldReleaseMarkdownWorkerState,
type MarkdownWorkerRequest,
type MarkdownWorkerResponse,
type MarkdownWorkerState,
} from "./markdown-worker-protocol"
import { createWorkerTransport } from "./markdown-worker-transport"
type Pending = {
key: string
complete: boolean
resolve: (state: MarkdownWorkerState) => void
reject: (error: Error) => void
}
let worker: Worker | undefined
let disabled: Error | undefined
let nextID = 0
const pending = new Map<number, Pending>()
const states = new Map<string, MarkdownWorkerState>()
const keys = new Set<string>()
const latest = new Map<string, number>()
const transport = createWorkerTransport<Extract<MarkdownWorkerRequest, { type: "highlight" }>>({
post: (request) => worker!.postMessage(request),
supersede: (request) => {
const result = pending.get(request.id)
if (!result) return
pending.delete(request.id)
result.reject(new MarkdownWorkerSupersededError())
},
})
export function highlightStreamingCode(key: string, text: string, language: string, complete = false) {
const instance = getWorker()
const id = ++nextID
latest.set(key, id)
keys.delete(key)
keys.add(key)
if (keys.size > 200) disposeStreamingCode(keys.values().next().value!)
return new Promise<MarkdownWorkerState>((resolve, reject) => {
pending.set(id, { key, complete, resolve, reject })
transport.send({ type: "highlight", id, key, text, language, complete })
})
}
export function disposeStreamingCode(key: string) {
keys.delete(key)
latest.delete(key)
states.delete(key)
transport.dispose(key)
pending.forEach((request, id) => {
if (request.key !== key) return
pending.delete(id)
request.reject(new MarkdownWorkerDisposedError())
})
worker?.postMessage({ type: "dispose", key } satisfies MarkdownWorkerRequest)
}
export class MarkdownWorkerDisposedError extends Error {}
export class MarkdownWorkerSupersededError extends Error {}
export class MarkdownWorkerUnavailableError extends Error {}
function getWorker() {
if (worker) return worker
if (disabled) throw new MarkdownWorkerUnavailableError(disabled.message)
try {
worker = new Worker(MarkdownShikiWorkerUrl, { type: "module" })
} catch (error) {
disabled = error instanceof Error ? error : new Error(String(error))
throw new MarkdownWorkerUnavailableError(disabled.message)
}
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
const result = pending.get(event.data.id)
if (!result) {
transport.complete(event.data.key, event.data.id)
return
}
pending.delete(event.data.id)
if (!keys.has(event.data.key)) {
result.reject(new MarkdownWorkerDisposedError())
transport.complete(event.data.key, event.data.id)
return
}
if (event.data.type === "superseded") {
result.reject(new MarkdownWorkerSupersededError())
transport.complete(event.data.key, event.data.id)
return
}
if (event.data.type === "error") {
result.reject(new Error(event.data.message))
transport.complete(event.data.key, event.data.id)
return
}
const state = applyMarkdownWorkerResponse(states.get(event.data.key), event.data)
if (shouldReleaseMarkdownWorkerState(result.complete, latest.get(event.data.key), event.data.id)) {
states.delete(event.data.key)
keys.delete(event.data.key)
latest.delete(event.data.key)
} else states.set(event.data.key, state)
result.resolve(state)
transport.complete(event.data.key, event.data.id)
}
const fail = (message: string) => {
const error = new Error(message)
disabled = error
transport.reset()
pending.forEach((request) => request.reject(error))
pending.clear()
states.clear()
keys.clear()
latest.clear()
worker?.terminate()
worker = undefined
}
worker.onerror = (event) => fail(event.message || "Markdown highlighting worker failed")
worker.onmessageerror = () => fail("Markdown worker response failed")
worker.postMessage({ type: "init", theme: OpenCodeTheme } satisfies MarkdownWorkerRequest)
return worker
}

View File

@ -15,6 +15,12 @@
> *:last-child { > *:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
> [data-markdown-block]:first-child > *:first-child {
margin-top: 0;
}
> [data-markdown-block]:last-child > *:last-child {
margin-bottom: 0;
}
/* Headings: Same size, distinguished by color and spacing */ /* Headings: Same size, distinguished by color and spacing */
h1, h1,
@ -121,6 +127,8 @@
} }
.shiki { .shiki {
background: var(--color-background-stronger);
color: var(--text-base);
font-size: 13px; font-size: 13px;
padding: 12px; padding: 12px;
border-radius: 6px; border-radius: 6px;

View File

@ -3,17 +3,48 @@ import { useI18n } from "../context/i18n"
import DOMPurify from "dompurify" import DOMPurify from "dompurify"
import morphdom from "morphdom" import morphdom from "morphdom"
import { checksum } from "@opencode-ai/core/util/encode" import { checksum } from "@opencode-ai/core/util/encode"
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" import { ComponentProps, createEffect, createMemo, createResource, createSignal, createUniqueId, onCleanup, splitProps } from "solid-js"
import { isServer } from "solid-js/web" import { isServer } from "solid-js/web"
import { stream } from "./markdown-stream" import { bundledLanguages } from "shiki"
import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream"
import {
disposeStreamingCode,
highlightStreamingCode,
MarkdownWorkerDisposedError,
MarkdownWorkerSupersededError,
MarkdownWorkerUnavailableError,
} from "./markdown-worker"
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
type Entry = { type Entry = {
raw: string
hash: string hash: string
html: string html: string
} }
type RenderedBlock =
| (Entry & { key: string; mode: Exclude<Block["mode"], "code"> })
| {
key: string
mode: "code"
raw: string
hash: string
language: string
complete: boolean
generation: number
stable: MarkdownToken[]
unstable: MarkdownToken[]
}
type RenderResult = {
text: string
blocks: RenderedBlock[]
}
const max = 200 const max = 200
const cache = new Map<string, Entry>() const cache = new Map<string, Entry>()
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
if (typeof window !== "undefined" && DOMPurify.isSupported) { if (typeof window !== "undefined" && DOMPurify.isSupported) {
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => { DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
@ -60,6 +91,22 @@ function fallback(markdown: string) {
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>") return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
} }
async function code(text: string, language: string | undefined, key: string, complete = false) {
const name = language && language in bundledLanguages ? language : "text"
try {
const result = await highlightStreamingCode(key, text, name, complete)
return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable }
} catch (error) {
if (
!(error instanceof MarkdownWorkerDisposedError) &&
!(error instanceof MarkdownWorkerSupersededError) &&
!(error instanceof MarkdownWorkerUnavailableError)
)
console.error("Markdown highlighting worker failed", error)
return { language: name, generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] }
}
}
type CopyLabels = { type CopyLabels = {
copy: string copy: string
copied: string copied: string
@ -238,6 +285,33 @@ function touch(key: string, value: Entry) {
cache.delete(first) cache.delete(first)
} }
function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult {
if (!text) return { text, blocks: [] }
const base = key ?? checksum(text)
if (base) {
const blocks = projection.blocks.flatMap((block, index) => {
if (block.mode === "code") return []
const cacheKey = `${base}:${index}:${block.mode}`
const cached = cache.get(cacheKey)
if (cached?.raw !== block.raw) return []
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
})
if (blocks.length === projection.blocks.length) return { text, blocks }
}
return {
text,
blocks: [
{
key: "initial",
mode: "full",
raw: text,
hash: checksum(text) ?? "",
html: fallback(text),
},
],
}
}
export function Markdown( export function Markdown(
props: ComponentProps<"div"> & { props: ComponentProps<"div"> & {
text: string text: string
@ -251,51 +325,104 @@ export function Markdown(
const marked = useMarked() const marked = useMarked()
const i18n = useI18n() const i18n = useI18n()
const [root, setRoot] = createSignal<HTMLDivElement>() const [root, setRoot] = createSignal<HTMLDivElement>()
const owner = createUniqueId()
const activeCodeKeys = new Set<string>()
const completedCode = new Map<string, Extract<RenderedBlock, { mode: "code" }>>()
const projection = createMemo((previous: Projection | undefined) =>
project(previous, local.text, local.streaming ?? false),
)
const [html] = createResource( const [html] = createResource(
() => ({ () => {
text: local.text, return {
key: local.cacheKey, text: local.text,
streaming: local.streaming ?? false, key: local.cacheKey,
}), projection: projection(),
}
},
async (src) => { async (src) => {
if (isServer) return fallback(src.text) if (isServer)
if (!src.text) return "" return {
text: src.text,
blocks: [
{
key: "server",
mode: "full" as const,
raw: src.text,
hash: checksum(src.text) ?? "",
html: fallback(src.text),
},
],
} satisfies RenderResult
if (!src.text) return { text: src.text, blocks: [] } satisfies RenderResult
const base = src.key ?? checksum(src.text) const base = src.key ?? checksum(src.text)
return Promise.all( return Promise.all(
stream(src.text, src.streaming).map(async (block, index) => { src.projection.blocks.map(async (block, index) => {
const hash = checksum(block.raw) const key = base ? `${base}:${index}:${block.mode}` : undefined
const key = base ? `${base}:${index}:${block.mode}` : hash const blockKey = markdownBlockKey(owner, src.key, index, block.mode)
if (key && hash) { if (block.mode === "code") {
const cached = completedCode.get(blockKey)
if (block.complete && cached?.raw === block.raw) return cached
const result = await code(block.src, block.language, blockKey, block.complete)
const rendered = {
key: blockKey,
mode: block.mode,
raw: block.raw,
hash: String(block.raw.length),
complete: !!block.complete,
...result,
}
if (block.complete) completedCode.set(blockKey, rendered)
return rendered
}
if (key) {
const cached = cache.get(key) const cached = cache.get(key)
if (cached && cached.hash === hash) { if (cached?.raw === block.raw) {
touch(key, cached) touch(key, cached)
return cached.html return { key: blockKey, mode: block.mode, ...cached }
} }
} }
const next = await Promise.resolve(marked.parse(block.src)) const hash = checksum(block.raw)
const safe = sanitize(next) const safe = sanitize(await Promise.resolve(marked.parse(block.src)))
if (key && hash) touch(key, { hash, html: safe }) if (key && hash) touch(key, { raw: block.raw, hash, html: safe })
return safe return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
}), }),
) )
.then((list) => list.join("")) .then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult)
.catch(() => fallback(src.text)) .catch(
() =>
({
text: src.text,
blocks: [
{
key: base ?? "fallback",
mode: "full" as const,
raw: src.text,
hash: checksum(src.text) ?? "",
html: fallback(src.text),
},
],
}) satisfies RenderResult,
)
},
{
initialValue: initialResult(local.text, local.cacheKey, projection(), owner),
}, },
{ initialValue: fallback(local.text) },
) )
let copyCleanup: (() => void) | undefined let copyCleanup: (() => void) | undefined
createEffect(() => { createEffect(() => {
const container = root() const container = root()
const content = local.text ? (html.latest ?? html() ?? "") : "" const result = html.latest ?? html()
const projected = projection()
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
if (!container) return if (!container) return
if (isServer) return if (isServer) return
if (content.length === 0) {
if (!content) {
container.innerHTML = "" container.innerHTML = ""
return return
} }
@ -304,27 +431,17 @@ export function Markdown(
copy: i18n.t("ui.message.copy"), copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"), copied: i18n.t("ui.message.copied"),
} }
const temp = document.createElement("div") const nextCodeKeys = new Set(content.filter((block) => block.mode === "code").map((block) => block.key))
temp.innerHTML = content activeCodeKeys.forEach((key) => {
decorate(temp, labels) if (!nextCodeKeys.has(key)) disposeCode(key)
morphdom(container, temp, {
childrenOnly: true,
onBeforeElUpdated: (fromEl, toEl) => {
if (
fromEl instanceof HTMLButtonElement &&
toEl instanceof HTMLButtonElement &&
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
toEl.getAttribute("data-slot") === "markdown-copy-button" &&
fromEl.getAttribute("data-copied") === "true"
) {
setCopyState(toEl, labels, true)
}
if (fromEl.isEqualNode(toEl)) return false
return true
},
}) })
activeCodeKeys.clear()
nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
content.forEach((block, index) => updateBlock(container, index, block, labels))
while (container.children.length > content.length) container.lastElementChild?.remove()
container.querySelectorAll<HTMLButtonElement>('[data-slot="markdown-copy-button"]').forEach((button) =>
setCopyState(button, labels, button.dataset.copied === "true"),
)
if (!copyCleanup) if (!copyCleanup)
copyCleanup = setupCodeCopy(container, () => ({ copyCleanup = setupCodeCopy(container, () => ({
copy: i18n.t("ui.message.copy"), copy: i18n.t("ui.message.copy"),
@ -334,6 +451,8 @@ export function Markdown(
onCleanup(() => { onCleanup(() => {
if (copyCleanup) copyCleanup() if (copyCleanup) copyCleanup()
activeCodeKeys.forEach(disposeCode)
completedCode.clear()
}) })
return ( return (
@ -348,3 +467,153 @@ export function Markdown(
/> />
) )
} }
function pendingBlocks(
result: RenderResult | undefined,
projection: Projection | undefined,
cacheKey: string | undefined,
owner: string,
) {
if (!result) return []
if (!projection || result.text === projection.text) return result.blocks
const initial = result.blocks.length === 1 && result.blocks[0]?.key === "initial"
return projection.blocks.map((block, index) => {
const current = initial ? undefined : result.blocks[index]
if (current && canReusePendingBlock(current, block)) return current
const key = markdownBlockKey(owner, cacheKey, index, block.mode)
if (block.mode !== "code")
return { key, mode: block.mode, raw: block.raw, hash: String(block.raw.length), html: fallback(block.src) }
return {
key,
mode: block.mode,
raw: block.raw,
hash: String(block.raw.length),
language: block.language ?? "text",
complete: !!block.complete,
stable: [],
generation: 0,
unstable: [[block.src, ""] as MarkdownToken],
}
})
}
function disposeCode(key: string) {
disposeStreamingCode(key)
}
function updateBlock(container: HTMLDivElement, index: number, block: RenderedBlock, labels: CopyLabels) {
const current = container.children[index]
if (block.mode === "code") {
updateCodeBlock(container, current, block, labels)
return
}
if (
current instanceof HTMLDivElement &&
current.dataset.markdownKey === block.key &&
current.dataset.markdownHash === block.hash
)
return
const next = document.createElement("div")
next.dataset.markdownBlock = ""
next.dataset.markdownKey = block.key
next.dataset.markdownHash = block.hash
next.style.display = "contents"
next.innerHTML = block.html
decorate(next, labels)
if (!(current instanceof HTMLDivElement)) {
container.appendChild(next)
return
}
morphdom(current, next, {
onBeforeElUpdated: (fromEl, toEl) => {
if (
fromEl instanceof HTMLButtonElement &&
toEl instanceof HTMLButtonElement &&
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
toEl.getAttribute("data-slot") === "markdown-copy-button"
) {
return false
}
if (fromEl.isEqualNode(toEl)) return false
return true
},
})
}
function updateCodeBlock(
container: HTMLDivElement,
current: Element | undefined,
block: Extract<RenderedBlock, { mode: "code" }>,
labels: CopyLabels,
) {
const existing =
current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined
const next = existing ?? document.createElement("div")
next.dataset.markdownBlock = ""
next.dataset.markdownKey = block.key
next.dataset.markdownHash = block.hash
next.dataset.markdownComplete = block.complete ? "true" : "false"
next.style.display = "contents"
const code = existing?.querySelector("code")
if (code instanceof HTMLElement) {
code.className = `language-${block.language}`
const previous = renderedCodeTokens.get(next)
const reset = shouldResetCodeTokens(previous, {
language: block.language,
generation: block.generation,
stableCount: block.stable.length,
raw: block.raw,
})
const stableCount = reset ? 0 : previous!.stableCount
const tail = [...block.stable.slice(stableCount), ...block.unstable]
const prior = reset ? [] : previous!.unstable
const prefix = prior.findIndex((token, index) => !sameToken(token, tail[index]))
const keep = stableCount + (prefix < 0 ? Math.min(prior.length, tail.length) : prefix)
while (code.children.length > keep) code.lastElementChild?.remove()
tail.slice(keep - stableCount).map(createTokenSpan).forEach((span) => code.appendChild(span))
renderedCodeTokens.set(next, {
language: block.language,
generation: block.generation,
stableCount: block.stable.length,
unstable: block.unstable,
raw: block.raw,
})
return
}
const wrapper = document.createElement("div")
wrapper.setAttribute("data-component", "markdown-code")
const pre = document.createElement("pre")
pre.className = "shiki OpenCode"
const codeElement = document.createElement("code")
codeElement.className = `language-${block.language}`
;[...block.stable, ...block.unstable].map(createTokenSpan).forEach((span) => codeElement.appendChild(span))
pre.appendChild(codeElement)
wrapper.appendChild(pre)
wrapper.appendChild(createCopyButton(labels))
next.appendChild(wrapper)
renderedCodeTokens.set(next, {
language: block.language,
generation: block.generation,
stableCount: block.stable.length,
unstable: block.unstable,
raw: block.raw,
})
if (current) current.replaceWith(next)
else container.appendChild(next)
}
function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) {
return !!right && left[0] === right[0] && left[1] === right[1]
}
function createTokenSpan(token: MarkdownToken) {
const span = document.createElement("span")
span.setAttribute("style", token[1])
span.textContent = token[0]
return span
}

View File

@ -179,6 +179,7 @@ export interface MessagePartProps {
onToolOpenChange?: (open: boolean) => void onToolOpenChange?: (open: boolean) => void
deferToolContent?: boolean deferToolContent?: boolean
virtualizeDiff?: boolean virtualizeDiff?: boolean
onContentRendered?: () => void
showAssistantCopyPartID?: string | null showAssistantCopyPartID?: string | null
turnDurationMs?: number turnDurationMs?: number
} }
@ -188,13 +189,14 @@ export type PartComponent = Component<MessagePartProps>
export const PART_MAPPING: Record<string, PartComponent | undefined> = {} export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
const TEXT_RENDER_PACE_MS = 24 const TEXT_RENDER_PACE_MS = 24
const TEXT_RENDER_IMMEDIATE = 512
const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/ const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/
function step(size: number) { function step(size: number) {
if (size <= 12) return 2 if (size <= 12) return 2
if (size <= 48) return 4 if (size <= 48) return 4
if (size <= 96) return 8 if (size <= 96) return 8
return Math.min(24, Math.ceil(size / 8)) return Math.min(256, Math.ceil(size / 4))
} }
function next(text: string, start: number) { function next(text: string, start: number) {
@ -233,6 +235,10 @@ function createPacedValue(getValue: () => string, live?: () => boolean) {
sync(text) sync(text)
return return
} }
if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) {
sync(text)
return
}
const end = next(text, shown.length) const end = next(text, shown.length)
sync(text.slice(0, end)) sync(text.slice(0, end))
if (end < text.length) timeout = setTimeout(run, TEXT_RENDER_PACE_MS) if (end < text.length) timeout = setTimeout(run, TEXT_RENDER_PACE_MS)
@ -250,6 +256,11 @@ function createPacedValue(getValue: () => string, live?: () => boolean) {
sync(text) sync(text)
return return
} }
if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) {
clear()
sync(text)
return
}
if (text.length === shown.length || timeout) return if (text.length === shown.length || timeout) return
timeout = setTimeout(run, TEXT_RENDER_PACE_MS) timeout = setTimeout(run, TEXT_RENDER_PACE_MS)
}) })
@ -1272,6 +1283,7 @@ export function Part(props: MessagePartProps) {
onToolOpenChange={props.onToolOpenChange} onToolOpenChange={props.onToolOpenChange}
deferToolContent={props.deferToolContent} deferToolContent={props.deferToolContent}
virtualizeDiff={props.virtualizeDiff} virtualizeDiff={props.virtualizeDiff}
onContentRendered={props.onContentRendered}
showAssistantCopyPartID={props.showAssistantCopyPartID} showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs} turnDurationMs={props.turnDurationMs}
/> />
@ -1292,6 +1304,7 @@ export interface ToolProps {
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
deferContent?: boolean deferContent?: boolean
virtualizeDiff?: boolean virtualizeDiff?: boolean
onContentRendered?: () => void
forceOpen?: boolean forceOpen?: boolean
locked?: boolean locked?: boolean
} }
@ -1438,6 +1451,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined} onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
deferContent={props.deferToolContent} deferContent={props.deferToolContent}
virtualizeDiff={props.virtualizeDiff} virtualizeDiff={props.virtualizeDiff}
onContentRendered={props.onContentRendered}
/> />
</Match> </Match>
</Switch> </Switch>
@ -2021,7 +2035,13 @@ ToolRegistry.register({
} }
> >
<div data-component="edit-content"> <div data-component="edit-content">
<Dynamic component={fileComponent} mode="diff" virtualize={props.virtualizeDiff} {...fileCompProps()} /> <Dynamic
component={fileComponent}
mode="diff"
virtualize={props.virtualizeDiff}
onRendered={props.onContentRendered}
{...fileCompProps()}
/>
</div> </div>
</ToolFileAccordion> </ToolFileAccordion>
</Show> </Show>
@ -2080,6 +2100,7 @@ ToolRegistry.register({
cacheKey: checksum(props.input.content), cacheKey: checksum(props.input.content),
}} }}
overflow="scroll" overflow="scroll"
onRendered={props.onContentRendered}
/> />
</div> </div>
</ToolFileAccordion> </ToolFileAccordion>
@ -2208,6 +2229,7 @@ ToolRegistry.register({
virtualize={props.virtualizeDiff} virtualize={props.virtualizeDiff}
fileDiff={file.view.fileDiff} fileDiff={file.view.fileDiff}
hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"} hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
onRendered={props.onContentRendered}
/> />
</div> </div>
</Show> </Show>
@ -2283,6 +2305,7 @@ ToolRegistry.register({
mode="diff" mode="diff"
virtualize={props.virtualizeDiff} virtualize={props.virtualizeDiff}
fileDiff={single()!.view.fileDiff} fileDiff={single()!.view.fileDiff}
onRendered={props.onContentRendered}
/> />
</div> </div>
</ToolFileAccordion> </ToolFileAccordion>

View File

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { scrollKey } from "./scroll-view" import { scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
describe("scrollKey", () => { describe("scrollKey", () => {
test("maps plain navigation keys", () => { test("maps plain navigation keys", () => {
@ -17,3 +17,38 @@ describe("scrollKey", () => {
expect(scrollKey({ key: "End", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBeUndefined() expect(scrollKey({ key: "End", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBeUndefined()
}) })
}) })
describe("scrollTopFromThumbPointer", () => {
test("keeps downward thumb movement monotonic when content height changes", () => {
const first = scrollTopFromThumbPointer({
pointer: 300,
viewportTop: 100,
grabOffset: 12,
clientHeight: 600,
scrollHeight: 6_000,
thumbHeight: 60,
})
const second = scrollTopFromThumbPointer({
pointer: 320,
viewportTop: 100,
grabOffset: 12,
clientHeight: 600,
scrollHeight: 60_000,
thumbHeight: 32,
})
expect(second).toBeGreaterThan(first)
})
test("clamps pointer positions to the scroll range", () => {
const input = {
viewportTop: 100,
grabOffset: 12,
clientHeight: 600,
scrollHeight: 6_000,
thumbHeight: 60,
}
expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0)
expect(scrollTopFromThumbPointer({ ...input, pointer: 1_000 })).toBe(5_400)
})
})

View File

@ -27,6 +27,24 @@ export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey
} }
} }
export function scrollTopFromThumbPointer(input: {
pointer: number
viewportTop: number
grabOffset: number
clientHeight: number
scrollHeight: number
thumbHeight: number
}) {
const padding = 8
const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight
if (maxThumbTop <= 0) return 0
const thumbTop = Math.max(
0,
Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop),
)
return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - input.clientHeight)
}
export function ScrollView(props: ScrollViewProps) { export function ScrollView(props: ScrollViewProps) {
const i18n = useI18n() const i18n = useI18n()
const merged = mergeProps({ orientation: "vertical" }, props) const merged = mergeProps({ orientation: "vertical" }, props)
@ -103,39 +121,37 @@ export function ScrollView(props: ScrollViewProps) {
updateThumb() updateThumb()
}) })
let startY = 0
let startScrollTop = 0
const onThumbPointerDown = (e: PointerEvent) => { const onThumbPointerDown = (e: PointerEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setState("isDragging", true) setState("isDragging", true)
startY = e.clientY const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top
startScrollTop = viewportRef.scrollTop
thumbRef.setPointerCapture(e.pointerId) thumbRef.setPointerCapture(e.pointerId)
const onPointerMove = (e: PointerEvent) => { const onPointerMove = (e: PointerEvent) => {
const deltaY = e.clientY - startY
const { scrollHeight, clientHeight } = viewportRef const { scrollHeight, clientHeight } = viewportRef
const maxScrollTop = scrollHeight - clientHeight viewportRef.scrollTop = scrollTopFromThumbPointer({
const maxThumbTop = clientHeight - thumbHeight() pointer: e.clientY,
viewportTop: viewportRef.getBoundingClientRect().top,
if (maxThumbTop > 0) { grabOffset,
const scrollDelta = deltaY * (maxScrollTop / maxThumbTop) clientHeight,
viewportRef.scrollTop = startScrollTop + scrollDelta scrollHeight,
} thumbHeight: thumbHeight(),
})
} }
const onPointerUp = (e: PointerEvent) => { const done = (e: PointerEvent) => {
setState("isDragging", false) setState("isDragging", false)
thumbRef.releasePointerCapture(e.pointerId) thumbRef.releasePointerCapture(e.pointerId)
thumbRef.removeEventListener("pointermove", onPointerMove) thumbRef.removeEventListener("pointermove", onPointerMove)
thumbRef.removeEventListener("pointerup", onPointerUp) thumbRef.removeEventListener("pointerup", done)
thumbRef.removeEventListener("pointercancel", done)
} }
thumbRef.addEventListener("pointermove", onPointerMove) thumbRef.addEventListener("pointermove", onPointerMove)
thumbRef.addEventListener("pointerup", onPointerUp) thumbRef.addEventListener("pointerup", done)
thumbRef.addEventListener("pointercancel", done)
} }
// Keybinds implementation // Keybinds implementation

View File

@ -6,14 +6,16 @@ import { bundledLanguages, type BundledLanguage } from "shiki"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs" import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs"
registerCustomTheme("OpenCode", () => { export const OpenCodeTheme = {
return Promise.resolve({
name: "OpenCode", name: "OpenCode",
bg: "var(--color-background-stronger)",
fg: "var(--text-base)",
colors: { colors: {
"editor.background": "var(--color-background-stronger)", "editor.background": "var(--color-background-stronger)",
"editor.foreground": "var(--text-base)", "editor.foreground": "var(--text-base)",
"gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)",
"gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)",
"gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)",
// "gitDecoration.conflictingResourceForeground": "#ffca00", // "gitDecoration.conflictingResourceForeground": "#ffca00",
// "gitDecoration.modifiedResourceForeground": "#1a76d4", // "gitDecoration.modifiedResourceForeground": "#1a76d4",
// "gitDecoration.untrackedResourceForeground": "#00cab1", // "gitDecoration.untrackedResourceForeground": "#00cab1",
@ -373,8 +375,9 @@ registerCustomTheme("OpenCode", () => {
"variable.constant": "var(--syntax-constant)", "variable.constant": "var(--syntax-constant)",
"variable.defaultLibrary": "var(--syntax-unknown)", "variable.defaultLibrary": "var(--syntax-unknown)",
}, },
} as unknown as ThemeRegistrationResolved) } as unknown as ThemeRegistrationResolved
})
registerCustomTheme("OpenCode", () => Promise.resolve(OpenCodeTheme))
function renderMathInText(text: string): string { function renderMathInText(text: string): string {
let result = text let result = text

View File

@ -17,24 +17,15 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
const unsafeCSS = ` const unsafeCSS = `
[data-diff], [data-diff],
[data-file] { [data-file] {
--diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg)); /* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */
--diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)))); --diffs-bg-deletion-override: light-dark(
--diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer)))); color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-deletion-base)),
--diffs-bg-context: var(--diffs-bg-context-override, light-dark( color-mix(in lab, var(--diffs-bg) 98.5%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92.5%, var(--diffs-mixer)))); color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-deletion-base))
--diffs-bg-separator: var(--diffs-bg-separator-override, light-dark( color-mix(in lab, var(--diffs-bg) 96%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-mixer)))); );
--diffs-fg: light-dark(var(--diffs-light), var(--diffs-dark)); --diffs-bg-addition-override: light-dark(
--diffs-fg-number: var(--diffs-fg-number-override, light-dark(color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg)), color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg)))); color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-addition-base)),
--diffs-deletion-base: var(--syntax-diff-delete); color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-addition-base))
--diffs-addition-base: var(--syntax-diff-add); );
--diffs-modified-base: var(--syntax-diff-unknown);
--diffs-bg-deletion: var(--diffs-bg-deletion-override, light-dark( color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-deletion-base))));
--diffs-bg-deletion-number: var(--diffs-bg-deletion-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-deletion-base))));
--diffs-bg-deletion-hover: var(--diffs-bg-deletion-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-deletion-base))));
--diffs-bg-deletion-emphasis: var(--diffs-bg-deletion-emphasis-override, light-dark(rgb(from var(--diffs-deletion-base) r g b / 0.7), rgb(from var(--diffs-deletion-base) r g b / 0.1)));
--diffs-bg-addition: var(--diffs-bg-addition-override, light-dark( color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-addition-base))));
--diffs-bg-addition-number: var(--diffs-bg-addition-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-addition-base))));
--diffs-bg-addition-hover: var(--diffs-bg-addition-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 70%, var(--diffs-addition-base))));
--diffs-bg-addition-emphasis: var(--diffs-bg-addition-emphasis-override, light-dark(rgb(from var(--diffs-addition-base) r g b / 0.07), rgb(from var(--diffs-addition-base) r g b / 0.1)));
--diffs-selection-base: var(--surface-warning-strong); --diffs-selection-base: var(--surface-warning-strong);
--diffs-selection-border: var(--border-warning-base); --diffs-selection-border: var(--border-warning-base);
--diffs-selection-number-fg: #1c1917; --diffs-selection-number-fg: #1c1917;

View File

@ -16,7 +16,7 @@ const cache = new WeakMap<Document | HTMLElement, Entry>()
export const virtualMetrics: Partial<VirtualFileMetrics> = { export const virtualMetrics: Partial<VirtualFileMetrics> = {
lineHeight: 24, lineHeight: 24,
hunkSeparatorHeight: 24, hunkSeparatorHeight: 24,
fileGap: 0, spacing: 0,
} }
function scrollable(value: string) { function scrollable(value: string) {

View File

@ -0,0 +1,45 @@
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
index 7e97823ea769398ccd9cf449b178c77675ed252c..d75183f11421af0e20e4e8a996af99c300ad936d 100644
--- a/dist/cjs/index.cjs
+++ b/dist/cjs/index.cjs
@@ -39,7 +39,9 @@ function createVirtualizerBase(options) {
(_a = options.onChange) == null ? void 0 : _a.call(options, instance2, sync);
}
}));
- virtualizer.measure();
+ virtualizer._willUpdate();
+ setVirtualItems(store.reconcile(instance.getVirtualItems(), { key: "index" }));
+ setTotalSize(instance.getTotalSize());
});
return virtualizer;
}
diff --git a/dist/esm/index.js b/dist/esm/index.js
index 1d525463775fef3e8ece6ab191061ef9d0a36d73..14c680a2088c49a33959d8118cf32ee599ab83c2 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -38,7 +38,9 @@ function createVirtualizerBase(options) {
(_a = options.onChange) == null ? void 0 : _a.call(options, instance2, sync);
}
}));
- virtualizer.measure();
+ virtualizer._willUpdate();
+ setVirtualItems(reconcile(instance.getVirtualItems(), { key: "index" }));
+ setTotalSize(instance.getTotalSize());
});
return virtualizer;
}
diff --git a/src/index.tsx b/src/index.tsx
index 69ac34fd70753b9bd00683c2540be7f62630f8f2..9f16672aa0f4a044aa2b35754d385d7d8031f743 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -81,7 +81,9 @@ function createVirtualizerBase<
},
}),
)
- virtualizer.measure()
+ virtualizer._willUpdate()
+ setVirtualItems(reconcile(instance.getVirtualItems(), { key: 'index' }))
+ setTotalSize(instance.getTotalSize())
})
return virtualizer

View File

@ -0,0 +1,58 @@
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
index df75d0cf0347b62906e04e454d4f4ef062ed5c48..58913f2d30e0beee9d09dffa5ebcaab4601a2c22 100644
--- a/dist/cjs/index.cjs
+++ b/dist/cjs/index.cjs
@@ -526,6 +526,7 @@ class Virtualizer {
this.scrollOffset = this.scrollOffset ?? (typeof this.options.initialOffset === "function" ? this.options.initialOffset() : this.options.initialOffset);
return this.scrollOffset;
};
+ this.getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments;
this.getFurthestMeasurement = (measurements, index) => {
const furthestMeasurementsFound = /* @__PURE__ */ new Map();
const furthestMeasurements = /* @__PURE__ */ new Map();
diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts
index c61ee17752565253f795c7fc7d57e86237ecbb52..705bb7e3a121b040fb1a3e7890179eaa3e9b219e 100644
--- a/dist/cjs/index.d.cts
+++ b/dist/cjs/index.d.cts
@@ -108,6 +108,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
scrollRect: Rect | null;
scrollOffset: number | null;
scrollDirection: ScrollDirection | null;
+ getLogicalScrollOffset: () => number;
private scrollAdjustments;
private _iosDeferredAdjustment;
private _iosTouching;
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
index b03abab604eb6578f6f56ff92c489259cfaf8f19..0495f372ea000dffc416c4f56809946f7ba73099 100644
--- a/dist/esm/index.d.ts
+++ b/dist/esm/index.d.ts
@@ -108,6 +108,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
scrollRect: Rect | null;
scrollOffset: number | null;
scrollDirection: ScrollDirection | null;
+ getLogicalScrollOffset: () => number;
private scrollAdjustments;
private _iosDeferredAdjustment;
private _iosTouching;
diff --git a/dist/esm/index.js b/dist/esm/index.js
index e384cf7541978a2782b9dca68146e869b16ac3f2..53b15b7a36247ea4aa5b70b00d08b6d7dec3ea79 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -524,6 +524,7 @@ class Virtualizer {
this.scrollOffset = this.scrollOffset ?? (typeof this.options.initialOffset === "function" ? this.options.initialOffset() : this.options.initialOffset);
return this.scrollOffset;
};
+ this.getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments;
this.getFurthestMeasurement = (measurements, index) => {
const furthestMeasurementsFound = /* @__PURE__ */ new Map();
const furthestMeasurements = /* @__PURE__ */ new Map();
diff --git a/src/index.ts b/src/index.ts
index d35b3e0695a9c85b261bc1a4fbe23c0a60d5b204..d516a9d00f1233173f9a3e2144666c8159552f91 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1050,3 +1050,5 @@ export class Virtualizer<
+ getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments
+
private getFurthestMeasurement = (
measurements: Array<VirtualItem>,
index: number,

View File

@ -1,93 +0,0 @@
diff --git a/lib/solid/Virtualizer.d.ts b/lib/solid/Virtualizer.d.ts
index 144dd7f..819aab9 100644
--- a/lib/solid/Virtualizer.d.ts
+++ b/lib/solid/Virtualizer.d.ts
@@ -38,6 +38,10 @@ export interface VirtualizerHandle {
* @param index index of item
*/
getItemSize(index: number): number;
+ /**
+ * Synchronously measure currently mounted items and update cached item sizes.
+ */
+ measure(): void;
/**
* Scroll to the item specified by index.
* @param index index of item
diff --git a/lib/solid/index.jsx b/lib/solid/index.jsx
index 029201a..3949cd4 100644
--- a/lib/solid/index.jsx
+++ b/lib/solid/index.jsx
@@ -1085,6 +1085,7 @@ const createResizer = (store, isHorizontal) => {
let viewportElement;
const sizeKey = isHorizontal ? "width" : "height";
const mountedIndexes = new WeakMap();
+ const mountedItems = new Map();
const resizeObserver = createResizeObserver((entries) => {
const resizes = [];
for (const { target, contentRect } of entries) {
@@ -1111,12 +1112,27 @@ const createResizer = (store, isHorizontal) => {
},
$observeItem: (el, i) => {
mountedIndexes.set(el, i);
+ mountedItems.set(i, el);
resizeObserver._observe(el);
return () => {
mountedIndexes.delete(el);
+ if (mountedItems.get(i) === el) {
+ mountedItems.delete(i);
+ }
resizeObserver._unobserve(el);
};
},
+ $measureItems: () => {
+ const resizes = [];
+ mountedItems.forEach((el, index) => {
+ if (!el.offsetParent)
+ return;
+ resizes.push([index, el.getBoundingClientRect()[sizeKey]]);
+ });
+ if (resizes.length) {
+ store.$update(ACTION_ITEM_RESIZE, resizes);
+ }
+ },
$dispose: resizeObserver._dispose,
};
};
@@ -1354,6 +1370,8 @@ const Virtualizer = (props) => {
const range = createMemo((prev) => {
stateVersion();
const next = store.$getRange(props.bufferSize);
+ next[0] = Math.max(0, next[0]);
+ next[1] = Math.min(props.data.length - 1, next[1]);
if (prev && isSameRange(prev, next)) {
return prev;
}
@@ -1380,6 +1398,7 @@ const Virtualizer = (props) => {
findItemIndex: store.$findItemIndex,
getItemOffset: store.$getItemOffset,
getItemSize: store.$getItemSize,
+ measure: resizer.$measureItems,
scrollToIndex: scroller.$scrollToIndex,
scrollTo: scroller.$scrollTo,
scrollBy: scroller.$scrollBy,
@@ -1417,6 +1436,11 @@ const Virtualizer = (props) => {
const indexes = [];
if (props.keepMounted) {
const mounted = new Set(props.keepMounted);
+ mounted.forEach((index) => {
+ if (index < 0 || index >= count) {
+ mounted.delete(index);
+ }
+ });
for (let [i, j] = range(); i <= j; i++) {
mounted.add(i);
}
@@ -1528,6 +1552,8 @@ const WindowVirtualizer = (props) => {
const range = createMemo((prev) => {
stateVersion();
const next = store.$getRange(props.bufferSize);
+ next[0] = Math.max(0, next[0]);
+ next[1] = Math.min(props.data.length - 1, next[1]);
if (prev && isSameRange(prev, next)) {
return prev;
}