feat(app): make session timelines much faster AND without flicker or scroll jumps (#32331)
This commit is contained in:
parent
e772664389
commit
3b811bd019
100
bun.lock
100
bun.lock
@ -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=="],
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
12
package.json
12
package.json
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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 }))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()} />
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
30
packages/app/src/pages/session/timeline/measure.test.ts
Normal file
30
packages/app/src/pages/session/timeline/measure.test.ts
Normal 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()
|
||||||
|
})
|
||||||
5
packages/app/src/pages/session/timeline/measure.ts
Normal file
5
packages/app/src/pages/session/timeline/measure.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export function scheduleConnectedMeasure<T extends HTMLElement>(element: T, measure: (element: T) => void) {
|
||||||
|
return requestAnimationFrame(() => {
|
||||||
|
if (element.isConnected) measure(element)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
101
packages/app/src/pages/session/timeline/model.test.ts
Normal file
101
packages/app/src/pages/session/timeline/model.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
152
packages/app/src/pages/session/timeline/model.ts
Normal file
152
packages/app/src/pages/session/timeline/model.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
113
packages/app/src/pages/session/timeline/projection.ts
Normal file
113
packages/app/src/pages/session/timeline/projection.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
46
packages/app/test-browser/solid-virtual.test.ts
Normal file
46
packages/app/test-browser/solid-virtual.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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" } } },
|
||||||
|
|||||||
@ -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:"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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> = {
|
||||||
|
|||||||
32
packages/ui/src/components/markdown-code-state.test.ts
Normal file
32
packages/ui/src/components/markdown-code-state.test.ts
Normal 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)
|
||||||
|
})
|
||||||
22
packages/ui/src/components/markdown-code-state.ts
Normal file
22
packages/ui/src/components/markdown-code-state.ts
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
104
packages/ui/src/components/markdown-shiki.worker.ts
Normal file
104
packages/ui/src/components/markdown-shiki.worker.ts
Normal 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))]
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
packages/ui/src/components/markdown-worker-protocol.test.ts
Normal file
77
packages/ui/src/components/markdown-worker-protocol.test.ts
Normal 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")
|
||||||
|
})
|
||||||
48
packages/ui/src/components/markdown-worker-protocol.ts
Normal file
48
packages/ui/src/components/markdown-worker-protocol.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
49
packages/ui/src/components/markdown-worker-queue.test.ts
Normal file
49
packages/ui/src/components/markdown-worker-queue.test.ts
Normal 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"])
|
||||||
|
})
|
||||||
64
packages/ui/src/components/markdown-worker-queue.ts
Normal file
64
packages/ui/src/components/markdown-worker-queue.ts
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
56
packages/ui/src/components/markdown-worker-transport.test.ts
Normal file
56
packages/ui/src/components/markdown-worker-transport.test.ts
Normal 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)
|
||||||
|
})
|
||||||
41
packages/ui/src/components/markdown-worker-transport.ts
Normal file
41
packages/ui/src/components/markdown-worker-transport.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
122
packages/ui/src/components/markdown-worker.ts
Normal file
122
packages/ui/src/components/markdown-worker.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
45
patches/@tanstack%2Fsolid-virtual@3.13.28.patch
Normal file
45
patches/@tanstack%2Fsolid-virtual@3.13.28.patch
Normal 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
|
||||||
58
patches/@tanstack%2Fvirtual-core@3.17.0.patch
Normal file
58
patches/@tanstack%2Fvirtual-core@3.17.0.patch
Normal 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,
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user