mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 10:03:25 +00:00
feat(app): stabilize long session timelines
This commit is contained in:
parent
45e4606fa4
commit
c840e1db95
31 changed files with 1436 additions and 562 deletions
101
bun.lock
101
bun.lock
|
|
@ -52,6 +52,7 @@
|
|||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
|
|
@ -65,7 +66,6 @@
|
|||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"virtua": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
|
|
@ -827,6 +827,7 @@
|
|||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/stream": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
|
|
@ -852,7 +853,6 @@
|
|||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"virtua": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
|
|
@ -904,23 +904,23 @@
|
|||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"protobufjs",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"esbuild",
|
||||
"electron",
|
||||
"protobufjs",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.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",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.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/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",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
|
|
@ -944,15 +944,17 @@
|
|||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "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",
|
||||
"@sentry/solid": "10.36.0",
|
||||
"@sentry/vite-plugin": "4.6.0",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.13",
|
||||
|
|
@ -978,14 +980,13 @@
|
|||
"remeda": "2.26.0",
|
||||
"remend": "1.3.0",
|
||||
"semver": "7.7.4",
|
||||
"shiki": "3.20.0",
|
||||
"shiki": "4.2.0",
|
||||
"solid-js": "1.9.10",
|
||||
"solid-list": "0.3.0",
|
||||
"sst": "4.13.1",
|
||||
"tailwindcss": "4.1.11",
|
||||
"typescript": "5.8.2",
|
||||
"ulid": "3.0.1",
|
||||
"virtua": "0.49.1",
|
||||
"vite": "7.1.4",
|
||||
"vite-plugin-solid": "2.11.10",
|
||||
"zod": "4.1.8",
|
||||
|
|
@ -2121,9 +2122,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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
|
|
@ -2327,13 +2330,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/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=="],
|
||||
|
||||
|
|
@ -2577,6 +2584,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-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/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=="],
|
||||
|
|
@ -4843,7 +4854,7 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -5253,8 +5264,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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
|
@ -5503,6 +5512,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/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/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
|
@ -5841,13 +5852,17 @@
|
|||
|
||||
"@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=="],
|
||||
|
||||
|
|
@ -5957,6 +5972,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/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/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=="],
|
||||
|
|
@ -6201,9 +6218,9 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -6345,6 +6362,18 @@
|
|||
|
||||
"@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/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="],
|
||||
|
|
@ -6641,6 +6670,8 @@
|
|||
|
||||
"@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/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
|
@ -6707,6 +6738,18 @@
|
|||
|
||||
"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/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
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
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]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -42,6 +42,8 @@
|
|||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
|
|
@ -51,7 +53,7 @@
|
|||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"opentui-spinner": "0.0.7",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
|
|
@ -76,10 +78,9 @@
|
|||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"sst": "4.13.1",
|
||||
"shiki": "3.20.0",
|
||||
"shiki": "4.2.0",
|
||||
"solid-list": "0.3.0",
|
||||
"tailwindcss": "4.1.11",
|
||||
"virtua": "0.49.1",
|
||||
"vite": "7.1.4",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.4",
|
||||
|
|
@ -145,10 +146,10 @@
|
|||
"@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",
|
||||
"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",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,7 +147,8 @@ test.describe("regression: session timeline local row state", () => {
|
|||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||
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)
|
||||
|
||||
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 })
|
||||
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)
|
||||
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(
|
||||
{ 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) => {
|
||||
const tool = element as HTMLElement
|
||||
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 (!row) throw new Error("missing virtual timeline row")
|
||||
if (!frame) throw new Error("missing timeline row frame")
|
||||
|
||||
tool.dataset.timelineProbe = "before"
|
||||
file.dataset.timelineProbe = "before"
|
||||
row.dataset.timelineProbe = "before"
|
||||
frame.dataset.timelineProbe = "before"
|
||||
window.__timelineDiffProbe.reset()
|
||||
})
|
||||
}
|
||||
|
|
@ -262,10 +342,15 @@ async function readDiffProbe(page: Page) {
|
|||
.evaluate((element) => {
|
||||
const tool = element as HTMLElement
|
||||
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 {
|
||||
fileMarker: file?.dataset.timelineProbe,
|
||||
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
|
||||
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
|
||||
}
|
||||
|
||||
async function mockServer(page: Page, events: EventPayload[]) {
|
||||
async function mockServer(
|
||||
page: Page,
|
||||
events: EventPayload[],
|
||||
messages = [userMessage, assistantMessage],
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions: [session()],
|
||||
pageMessages: () => ({ items: [userMessage, assistantMessage] }),
|
||||
events: () => events.splice(0),
|
||||
pageMessages: () => ({ items: messages }),
|
||||
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 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(visibleOverlap).toEqual([])
|
||||
expect(samples.at(-1)?.expanded).toBe("true")
|
||||
|
|
@ -115,13 +113,15 @@ async function sampleExpansion(page: Page) {
|
|||
|
||||
let frame = 1
|
||||
const tick = () => {
|
||||
capture(frame, "raf")
|
||||
frame += 1
|
||||
if (frame > 8) {
|
||||
resolve(samples)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
setTimeout(() => {
|
||||
capture(frame, "painted")
|
||||
frame += 1
|
||||
if (frame > 8) {
|
||||
resolve(samples)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}, 0)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,76 @@ type SmokeWindow = Window & {
|
|||
test.describe("smoke: session timeline", () => {
|
||||
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)
|
||||
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("renders seeded timeline in order while paging through history", async ({ page }) => {
|
||||
const errors = trackPageErrors(page)
|
||||
await mockOpenCodeServer(page, {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ export interface MockServerConfig {
|
|||
project: unknown
|
||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||
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[]
|
||||
eventRetry?: number
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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 (emptyObject.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$/)
|
||||
if (messagesMatch) {
|
||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||
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)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
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({
|
||||
status: 200,
|
||||
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"}`,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
|
|
@ -75,7 +76,6 @@
|
|||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"virtua": "catalog:"
|
||||
"tailwindcss": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,26 @@ describe("applyGlobalEvent", () => {
|
|||
})
|
||||
|
||||
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", () => {
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
|
|
|
|||
|
|
@ -282,7 +282,13 @@ export function applyDirectoryEvent(input: {
|
|||
if (!parts) break
|
||||
const result = Binary.search(parts, props.partID, (p) => p.id)
|
||||
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(
|
||||
"part",
|
||||
props.messageID,
|
||||
|
|
|
|||
|
|
@ -88,34 +88,17 @@ type SessionHistoryWindowInput = {
|
|||
loadMore: (sessionID: string) => Promise<void>
|
||||
userScrolled: () => boolean
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
onBeforeLoad?: () => void
|
||||
onAfterLoad?: () => void
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -124,31 +107,22 @@ function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
|
|||
// 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)
|
||||
input.onBeforeLoad?.()
|
||||
|
||||
while (true) {
|
||||
await input.loadMore(id)
|
||||
input.onAfterLoad?.()
|
||||
if (input.sessionID() !== id) return
|
||||
|
||||
const nextLoaded = input.loaded()
|
||||
const raw = nextLoaded - loaded
|
||||
loaded = nextLoaded
|
||||
growth = input.visibleUserMessages().length - beforeVisible
|
||||
const 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()
|
||||
|
|
@ -162,22 +136,8 @@ function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
|
|||
void fetchOlderMessages()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
input.sessionID,
|
||||
() => {
|
||||
cancelShiftReset()
|
||||
setState({ shift: false })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(cancelShiftReset)
|
||||
|
||||
return {
|
||||
userMessages,
|
||||
shift: () => state.shift,
|
||||
loadAndReveal,
|
||||
onScrollerScroll,
|
||||
}
|
||||
|
|
@ -613,6 +573,7 @@ export default function Page() {
|
|||
let scroller: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
let revealMessage = (_id: string) => {}
|
||||
let scrollToEnd = () => {}
|
||||
let scrollMark = 0
|
||||
let messageMark = 0
|
||||
|
||||
|
|
@ -1199,7 +1160,7 @@ export default function Page() {
|
|||
|
||||
const autoScroll = createAutoScroll({
|
||||
working: () => true,
|
||||
overflowAnchor: "dynamic",
|
||||
overflowAnchor: "none",
|
||||
})
|
||||
|
||||
let scrollStateFrame: number | undefined
|
||||
|
|
@ -1236,7 +1197,8 @@ export default function Page() {
|
|||
|
||||
const resumeScroll = () => {
|
||||
setStore("messageId", undefined)
|
||||
autoScroll.forceScrollToBottom()
|
||||
autoScroll.resume()
|
||||
scrollToEnd()
|
||||
clearMessageHash()
|
||||
|
||||
const el = scroller
|
||||
|
|
@ -1279,6 +1241,8 @@ export default function Page() {
|
|||
},
|
||||
)
|
||||
|
||||
let captureHistoryAnchor = () => {}
|
||||
let restoreHistoryAnchor = () => {}
|
||||
const historyLoader = createSessionHistoryLoader({
|
||||
sessionID: () => params.id,
|
||||
loaded: () => messages().length,
|
||||
|
|
@ -1288,6 +1252,8 @@ export default function Page() {
|
|||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
userScrolled: autoScroll.userScrolled,
|
||||
scroller: () => scroller,
|
||||
onBeforeLoad: () => captureHistoryAnchor(),
|
||||
onAfterLoad: () => restoreHistoryAnchor(),
|
||||
})
|
||||
|
||||
fill = () => {
|
||||
|
|
@ -1608,7 +1574,7 @@ export default function Page() {
|
|||
|
||||
dockHeight = next
|
||||
|
||||
if (stick) autoScroll.forceScrollToBottom()
|
||||
if (stick) scrollToEnd()
|
||||
|
||||
if (el) scheduleScrollState(el)
|
||||
fill()
|
||||
|
|
@ -1627,7 +1593,13 @@ export default function Page() {
|
|||
pendingMessage: () => ui.pendingMessage,
|
||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||
setActiveMessage,
|
||||
autoScroll,
|
||||
autoScroll: {
|
||||
pause: autoScroll.pause,
|
||||
forceScrollToBottom: () => {
|
||||
autoScroll.resume()
|
||||
scrollToEnd()
|
||||
},
|
||||
},
|
||||
scroller: () => scroller,
|
||||
anchor,
|
||||
revealMessage: (id) => revealMessage(id),
|
||||
|
|
@ -1808,12 +1780,18 @@ export default function Page() {
|
|||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
historyShift={historyLoader.shift()}
|
||||
userMessages={historyLoader.userMessages()}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
}}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
setScrollToEnd={(fn) => {
|
||||
scrollToEnd = fn
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
|
|
|
|||
|
|
@ -6,14 +6,13 @@ import { Data, Equal } from "effect"
|
|||
export type SummaryDiff = SnapshotFileDiff & { file: string }
|
||||
|
||||
export type TimelineRowMap = {
|
||||
TurnGap: { userMessageID: string }
|
||||
CommentStrip: {
|
||||
userMessageID: string
|
||||
previousUserMessage: boolean
|
||||
}
|
||||
UserMessage: {
|
||||
userMessageID: string
|
||||
anchor: boolean
|
||||
previousUserMessage: boolean
|
||||
}
|
||||
TurnDivider: {
|
||||
userMessageID: string
|
||||
|
|
@ -28,18 +27,18 @@ export type TimelineRowMap = {
|
|||
Retry: { userMessageID: string }
|
||||
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
|
||||
Error: { userMessageID: string; text: string }
|
||||
BottomSpacer: {}
|
||||
}
|
||||
|
||||
export namespace TimelineRow {
|
||||
export class TurnGap extends Data.TaggedClass("TurnGap")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
|
||||
userMessageID: string
|
||||
previousUserMessage: boolean
|
||||
}> {}
|
||||
export class UserMessage extends Data.TaggedClass("UserMessage")<{
|
||||
userMessageID: string
|
||||
anchor: boolean
|
||||
previousUserMessage: boolean
|
||||
}> {}
|
||||
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
|
||||
userMessageID: string
|
||||
|
|
@ -65,9 +64,9 @@ export namespace TimelineRow {
|
|||
export class Retry extends Data.TaggedClass("Retry")<{
|
||||
userMessageID: string
|
||||
}> {}
|
||||
export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {}
|
||||
|
||||
export type TimelineRow =
|
||||
| TurnGap
|
||||
| CommentStrip
|
||||
| UserMessage
|
||||
| TurnDivider
|
||||
|
|
@ -76,10 +75,11 @@ export namespace TimelineRow {
|
|||
| DiffSummary
|
||||
| Error
|
||||
| Retry
|
||||
| BottomSpacer
|
||||
|
||||
export const key = (row: TimelineRow) => {
|
||||
switch (row._tag) {
|
||||
case "TurnGap":
|
||||
return `turn-gap:${row.userMessageID}`
|
||||
case "CommentStrip":
|
||||
return `comment-strip:${row.userMessageID}`
|
||||
case "UserMessage":
|
||||
|
|
@ -96,8 +96,6 @@ export namespace TimelineRow {
|
|||
return `error:${row.userMessageID}`
|
||||
case "Retry":
|
||||
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 }))
|
||||
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
|
||||
|
||||
if (comments.length > 0)
|
||||
rows.push(
|
||||
new TimelineRow.CommentStrip({
|
||||
userMessageID: userMessage.id,
|
||||
previousUserMessage,
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -161,7 +160,6 @@ export namespace Timeline {
|
|||
new TimelineRow.UserMessage({
|
||||
userMessageID: userMessage.id,
|
||||
anchor: comments.length === 0,
|
||||
previousUserMessage: comments.length === 0 && previousUserMessage,
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
Index,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
mapArray,
|
||||
type Accessor,
|
||||
|
|
@ -15,7 +16,7 @@ import { createStore, produce } from "solid-js/store"
|
|||
import { Dynamic } from "solid-js/web"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
|
|
@ -77,42 +78,21 @@ const emptyTools: ToolPart[] = []
|
|||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
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 }>
|
||||
|
||||
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 timelineCache = new Map<string, { keys: readonly string[]; cache: VirtualizerHandle["cache"] }>()
|
||||
|
||||
function readTimelineCache(id: string, keys: readonly string[]) {
|
||||
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 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
|
||||
}
|
||||
|
||||
const taskDescription = (part: PartType, sessionID: string) => {
|
||||
|
|
@ -278,10 +258,11 @@ export function MessageTimeline(props: {
|
|||
shouldAnchorBottom: () => boolean
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
historyShift: boolean
|
||||
userMessages: UserMessage[]
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
setScrollToEnd?: (fn: () => void) => void
|
||||
setHistoryAnchor?: (handlers: { capture: () => void; restore: () => void }) => void
|
||||
}) {
|
||||
let touchGesture: number | undefined
|
||||
|
||||
|
|
@ -295,7 +276,7 @@ export function MessageTimeline(props: {
|
|||
const { params, sessionKey } = useSessionKey()
|
||||
const platform = usePlatform()
|
||||
|
||||
let virtualizer: VirtualizerHandle | undefined
|
||||
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const sessionMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
|
|
@ -423,13 +404,96 @@ export function MessageTimeline(props: {
|
|||
),
|
||||
)
|
||||
|
||||
let prependAnchor: { key: string; offset: number } | undefined
|
||||
let prependAnchorFrame: number | undefined
|
||||
const clearPrependAnchor = () => {
|
||||
prependAnchor = undefined
|
||||
if (prependAnchorFrame === undefined) return
|
||||
cancelAnimationFrame(prependAnchorFrame)
|
||||
prependAnchorFrame = undefined
|
||||
}
|
||||
const capturePrependAnchor = () => {
|
||||
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 = () => {
|
||||
applyPrependAnchor()
|
||||
}
|
||||
const applyPrependAnchor = () => {
|
||||
const anchor = prependAnchor
|
||||
const root = listRoot()
|
||||
if (!root || !anchor) return
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
let frames = 0
|
||||
let stable = 0
|
||||
const apply = () => {
|
||||
prependAnchorFrame = undefined
|
||||
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) {
|
||||
prependAnchor = undefined
|
||||
return
|
||||
}
|
||||
prependAnchorFrame = requestAnimationFrame(apply)
|
||||
}
|
||||
prependAnchorFrame = requestAnimationFrame(apply)
|
||||
}
|
||||
|
||||
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()])
|
||||
return reuseTimelineRows(previous, rows)
|
||||
})
|
||||
const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys })
|
||||
const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
get count() {
|
||||
return timelineRows().length
|
||||
},
|
||||
getScrollElement: () => listRoot() ?? null,
|
||||
estimateSize: () => timelineFallbackItemSize,
|
||||
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,
|
||||
overscan: 40,
|
||||
paddingEnd: 64,
|
||||
rangeExtractor: (range) => {
|
||||
const id = activeMessageID()
|
||||
const active = id ? timelineRows().findLastIndex((row) => "userMessageID" in row && row.userMessageID === id) : -1
|
||||
return [...new Set([...defaultRangeExtractor(range), ...(active < 0 ? [] : [active])])].sort((a, b) => a - b)
|
||||
},
|
||||
})
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
|
||||
item.end <= (instance.scrollOffset ?? 0)
|
||||
const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)))
|
||||
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 messageRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
timelineRows().forEach((row, index) => {
|
||||
|
|
@ -447,101 +511,51 @@ export function MessageTimeline(props: {
|
|||
})
|
||||
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(() => {
|
||||
props.setRevealMessage?.((id) => {
|
||||
const index = messageRowIndex().get(id)
|
||||
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 cacheSessionKey = sessionKey()
|
||||
let cacheRowKeys = timelineRowKeys()
|
||||
let virtualizerSessionKey = cacheSessionKey
|
||||
let virtualizerRowKeys = cacheRowKeys
|
||||
let bottomAnchorSessionKey = ""
|
||||
let bottomAnchorFrame: number | undefined
|
||||
|
||||
const maybeAnchorBottom = () => {
|
||||
const key = sessionKey()
|
||||
if (bottomAnchorSessionKey === key) return
|
||||
if (!virtualizer) return
|
||||
const keys = timelineRowKeys()
|
||||
if (keys.length === 0) return
|
||||
if (timelineRows().length === 0) return
|
||||
bottomAnchorSessionKey = key
|
||||
if (!props.shouldAnchorBottom()) return
|
||||
virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
clearPrependAnchor()
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
bottomAnchorFrame = requestAnimationFrame(() => {
|
||||
bottomAnchorFrame = undefined
|
||||
if (sessionKey() !== key) return
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [sessionKey(), timelineRowKeys()] as const,
|
||||
(next, prev) => {
|
||||
if (prev && prev[0] !== next[0]) writeTimelineCache(prev[0], prev[1], virtualizer)
|
||||
cacheSessionKey = next[0]
|
||||
cacheRowKeys = next[1]
|
||||
if (virtualizer) {
|
||||
virtualizerSessionKey = cacheSessionKey
|
||||
virtualizerRowKeys = cacheRowKeys
|
||||
maybeAnchorBottom()
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
let measuredSessionKey = sessionKey()
|
||||
createEffect(() => {
|
||||
const key = sessionKey()
|
||||
timelineRows().length
|
||||
if (measuredSessionKey !== key) {
|
||||
measuredSessionKey = key
|
||||
virtualizer.measure()
|
||||
}
|
||||
maybeAnchorBottom()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
props.setRevealMessage?.(() => {})
|
||||
props.setScrollToEnd?.(() => {})
|
||||
props.setHistoryAnchor?.({ capture: () => {}, restore: () => {} })
|
||||
})
|
||||
|
||||
const [title, setTitle] = createStore({
|
||||
|
|
@ -560,17 +574,8 @@ export function MessageTimeline(props: {
|
|||
const [bar, setBar] = createStore({
|
||||
ms: pace(640),
|
||||
})
|
||||
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
|
||||
|
||||
let more: HTMLButtonElement | 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 = () => {
|
||||
if (!head || head.clientWidth <= 0) return
|
||||
|
|
@ -579,83 +584,15 @@ export function MessageTimeline(props: {
|
|||
|
||||
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) => {
|
||||
if (root === listRoot) return
|
||||
|
||||
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
|
||||
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
|
||||
listRoot = root
|
||||
setScrollRoot(undefined)
|
||||
connectListRoot(root)
|
||||
if (root === listRoot()) return
|
||||
setListRoot(root)
|
||||
props.setScrollRef(root)
|
||||
}
|
||||
|
||||
|
||||
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
|
||||
clearPrependAnchor()
|
||||
const root = event.currentTarget
|
||||
const delta = normalizeWheelDelta({
|
||||
deltaY: event.deltaY,
|
||||
|
|
@ -667,6 +604,7 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const handleListTouchStart = (event: TouchEvent) => {
|
||||
clearPrependAnchor()
|
||||
touchGesture = event.touches[0]?.clientY
|
||||
}
|
||||
|
||||
|
|
@ -692,12 +630,12 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
clearPrependAnchor()
|
||||
if (event.target !== event.currentTarget) return
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
measuredBottomAnchored = isMeasuredBottom(event.currentTarget)
|
||||
props.onScheduleScrollState(event.currentTarget)
|
||||
props.onHistoryScroll()
|
||||
if (!props.hasScrollGesture()) return
|
||||
|
|
@ -707,10 +645,6 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
|
||||
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
setScrollRoot(undefined)
|
||||
props.setScrollRef(undefined)
|
||||
})
|
||||
|
||||
|
|
@ -1012,7 +946,7 @@ export function MessageTimeline(props: {
|
|||
|
||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||
|
||||
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
|
||||
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>, onSizeChange?: () => void) => {
|
||||
if (row().group.type === "context") {
|
||||
const parts = createMemo(() => {
|
||||
const group = row().group
|
||||
|
|
@ -1028,7 +962,7 @@ export function MessageTimeline(props: {
|
|||
busy={
|
||||
workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
|
||||
}
|
||||
onSizeChange={measureTimeline}
|
||||
onSizeChange={onSizeChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1064,6 +998,7 @@ export function MessageTimeline(props: {
|
|||
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
||||
deferToolContent={false}
|
||||
virtualizeDiff={false}
|
||||
onContentRendered={onSizeChange}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
|
@ -1077,10 +1012,6 @@ export function MessageTimeline(props: {
|
|||
const row = input.row()
|
||||
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 row = input.row()
|
||||
return row._tag === "AssistantPart" && row.previousAssistantPart
|
||||
|
|
@ -1095,7 +1026,6 @@ export function MessageTimeline(props: {
|
|||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
"md:mx-auto": props.centered,
|
||||
"pt-6": previousUserMessage(),
|
||||
"pt-3": previousAssistantPart(),
|
||||
}}
|
||||
>
|
||||
|
|
@ -1106,8 +1036,10 @@ export function MessageTimeline(props: {
|
|||
)
|
||||
}
|
||||
|
||||
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
|
||||
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => {
|
||||
switch (row()._tag) {
|
||||
case "TurnGap":
|
||||
return <div data-timeline-row="TurnGap" aria-hidden="true" class="h-6" />
|
||||
case "CommentStrip": {
|
||||
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
|
||||
const comments = createMemo(() =>
|
||||
|
|
@ -1195,7 +1127,7 @@ export function MessageTimeline(props: {
|
|||
data-slot="session-turn-assistant-content"
|
||||
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
|
||||
>
|
||||
{renderAssistantPartGroup(assistantPartRow)}
|
||||
{renderAssistantPartGroup(assistantPartRow, onSizeChange)}
|
||||
</div>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
|
|
@ -1246,13 +1178,68 @@ export function MessageTimeline(props: {
|
|||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "BottomSpacer":
|
||||
return <div data-timeline-row="bottom-spacer" aria-hidden="true" class="h-16" />
|
||||
}
|
||||
}
|
||||
|
||||
function TimelineRowView(props: { row: TimelineRow.TimelineRow }) {
|
||||
return renderTimelineRow(() => props.row)
|
||||
function TimelineRowView(props: { row: TimelineRow.TimelineRow; onSizeChange?: () => void }) {
|
||||
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())
|
||||
|
||||
onMount(() => virtualizer.measureElement(element))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => item().index,
|
||||
() => {
|
||||
virtualizer.measureElement(element)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-timeline-key={props.rowKey}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${item().start}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)
|
||||
requestAnimationFrame(() => virtualizer.measureElement(element))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -1581,33 +1568,27 @@ export function MessageTimeline(props: {
|
|||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={scrollRoot()}>
|
||||
{(root) => (
|
||||
<Virtualizer
|
||||
data={timelineRows()}
|
||||
cache={virtualCache()}
|
||||
itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
|
||||
scrollRef={root()}
|
||||
shift={props.historyShift}
|
||||
keepMounted={keepMounted()}
|
||||
startMargin={64}
|
||||
ref={(handle) => {
|
||||
if (!handle) {
|
||||
writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
|
||||
virtualizer = undefined
|
||||
return
|
||||
}
|
||||
virtualizer = handle
|
||||
virtualizerSessionKey = cacheSessionKey
|
||||
virtualizerRowKeys = cacheRowKeys
|
||||
maybeAnchorBottom()
|
||||
scheduleContentRoot(root())
|
||||
}}
|
||||
>
|
||||
{(row) => <TimelineRowView row={row} />}
|
||||
</Virtualizer>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
data-timeline-virtual-content
|
||||
ref={(element) => {
|
||||
props.setContentRef(element)
|
||||
}}
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<For each={virtualRowKeys()}>{(rowKey) => <VirtualTimelineRow rowKey={rowKey} />}</For>
|
||||
<Show when={timelineRows().length > 0}>
|
||||
<div
|
||||
data-timeline-row="bottom-spacer"
|
||||
aria-hidden="true"
|
||||
class="h-16 absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@shikijs/stream": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "catalog:",
|
||||
|
|
@ -76,7 +77,6 @@
|
|||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"virtua": "catalog:"
|
||||
"strip-ansi": "7.1.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const VIRTUALIZE_BYTES = 500_000
|
|||
const codeMetrics = {
|
||||
...DEFAULT_VIRTUAL_FILE_METRICS,
|
||||
lineHeight: 24,
|
||||
fileGap: 0,
|
||||
spacing: 0,
|
||||
} satisfies Partial<VirtualFileMetrics>
|
||||
|
||||
type SharedProps<T> = {
|
||||
|
|
|
|||
101
packages/ui/src/components/markdown-shiki.worker.ts
Normal file
101
packages/ui/src/components/markdown-shiki.worker.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/// <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"
|
||||
|
||||
type Stream = {
|
||||
language: string
|
||||
source: string
|
||||
tokenizer: ShikiStreamTokenizer
|
||||
}
|
||||
|
||||
const streams = new Map<string, Stream>()
|
||||
let highlighter: ReturnType<typeof createHighlighter> | undefined
|
||||
let queue = Promise.resolve()
|
||||
|
||||
self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
|
||||
if (event.data.type === "init") {
|
||||
highlighter ??= createHighlighter({ themes: [event.data.theme], langs: [] })
|
||||
return
|
||||
}
|
||||
if (event.data.type === "dispose") {
|
||||
const key = event.data.key
|
||||
queue = queue.then(() => void streams.delete(key)).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const request = event.data
|
||||
queue = queue.then(() => highlight(request)).catch(() => {})
|
||||
}
|
||||
|
||||
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 { stream } from "./markdown-stream"
|
||||
import { project, stream } from "./markdown-stream"
|
||||
|
||||
describe("markdown stream", () => {
|
||||
test("heals incomplete emphasis while streaming", () => {
|
||||
|
|
@ -15,8 +15,51 @@ describe("markdown stream", () => {
|
|||
|
||||
test("splits an unfinished trailing code fence from stable content", () => {
|
||||
expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([
|
||||
{ raw: "before\n\n", src: "before\n\n", mode: "live" },
|
||||
{ raw: "```ts\nconst x = 1", src: "```ts\nconst x = 1", mode: "live" },
|
||||
{ raw: "before\n\n", src: "before\n\n", mode: "full" },
|
||||
{ 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("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 +72,27 @@ 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("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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,11 +4,19 @@ import remend from "remend"
|
|||
export type Block = {
|
||||
raw: 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) {
|
||||
return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text)
|
||||
if (!text.includes("]:")) return false
|
||||
return /^[ \t]{0,3}\[[^\]]+\]:[ \t]*\S+/m.test(text)
|
||||
}
|
||||
|
||||
function open(raw: string) {
|
||||
|
|
@ -26,24 +34,52 @@ function heal(text: string) {
|
|||
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[]
|
||||
const src = heal(text)
|
||||
if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||
if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
||||
const tokens = marked.lexer(text)
|
||||
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]
|
||||
if (!last || last.type !== "code") return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||
const code = last as Tokens.Code
|
||||
if (!open(code.raw)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
|
||||
const head = tokens
|
||||
.slice(0, tail)
|
||||
if (!last) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
||||
|
||||
const result: Block[] = []
|
||||
for (let index = 0; index < tail; index++) {
|
||||
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
|
||||
result.push({ raw, src: raw, mode: "full" })
|
||||
}
|
||||
|
||||
const raw = tokens
|
||||
.slice(tail)
|
||||
.map((token) => token.raw)
|
||||
.join("")
|
||||
if (!head) return [{ raw: code.raw, src: code.raw, mode: "live" }] satisfies Block[]
|
||||
return [
|
||||
{ raw: head, src: heal(head), mode: "live" },
|
||||
{ raw: code.raw, src: code.raw, mode: "live" },
|
||||
] satisfies Block[]
|
||||
if (last.type !== "code") return [...result, { raw, src: heal(raw), mode: "live" }]
|
||||
|
||||
const code = last as Tokens.Code
|
||||
if (!open(code.raw))
|
||||
return [...result, { raw, src: code.text, mode: "code", language: code.lang, complete: true }]
|
||||
return [...result, { raw, src: code.text, mode: "code", language: code.lang }]
|
||||
}
|
||||
|
||||
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 || suffix.includes("```") || suffix.includes("~~~"))
|
||||
return { text, blocks: stream(text, live) }
|
||||
const separator = previous.text.endsWith("\n") && !tail.src.endsWith("\n") ? "\n" : ""
|
||||
return {
|
||||
text,
|
||||
blocks: [
|
||||
...previous.blocks.slice(0, -1),
|
||||
{
|
||||
...tail,
|
||||
raw: tail.raw + suffix,
|
||||
src: tail.src + separator + suffix,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
packages/ui/src/components/markdown-worker-protocol.test.ts
Normal file
51
packages/ui/src/components/markdown-worker-protocol.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { applyMarkdownWorkerResponse } from "./markdown-worker-protocol"
|
||||
|
||||
const token = (content: string): [string, string] => [content, ""]
|
||||
|
||||
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("ignores stale worker responses and resets replacement streams", () => {
|
||||
const current = { id: 2, 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"])
|
||||
})
|
||||
37
packages/ui/src/components/markdown-worker-protocol.ts
Normal file
37
packages/ui/src/components/markdown-worker-protocol.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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 }
|
||||
|
||||
export type MarkdownWorkerState = {
|
||||
id: number
|
||||
stable: MarkdownToken[]
|
||||
unstable: MarkdownToken[]
|
||||
}
|
||||
|
||||
export function applyMarkdownWorkerResponse(
|
||||
state: MarkdownWorkerState | undefined,
|
||||
response: Extract<MarkdownWorkerResponse, { type: "highlight" }>,
|
||||
) {
|
||||
if (state && response.id <= state.id) return state
|
||||
return {
|
||||
id: response.id,
|
||||
stable: response.reset ? response.stable : [...(state?.stable ?? []), ...response.stable],
|
||||
unstable: response.unstable,
|
||||
}
|
||||
}
|
||||
81
packages/ui/src/components/markdown-worker.ts
Normal file
81
packages/ui/src/components/markdown-worker.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import MarkdownShikiWorkerUrl from "./markdown-shiki.worker.ts?worker&url"
|
||||
import { OpenCodeTheme } from "../context/marked"
|
||||
import {
|
||||
applyMarkdownWorkerResponse,
|
||||
type MarkdownWorkerRequest,
|
||||
type MarkdownWorkerResponse,
|
||||
type MarkdownWorkerState,
|
||||
} from "./markdown-worker-protocol"
|
||||
|
||||
type Pending = {
|
||||
key: string
|
||||
complete: boolean
|
||||
resolve: (state: MarkdownWorkerState) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let worker: Worker | undefined
|
||||
let nextID = 0
|
||||
const pending = new Map<number, Pending>()
|
||||
const states = new Map<string, MarkdownWorkerState>()
|
||||
const keys = new Set<string>()
|
||||
|
||||
export function highlightStreamingCode(key: string, text: string, language: string, complete = false) {
|
||||
const instance = getWorker()
|
||||
const id = ++nextID
|
||||
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 })
|
||||
instance.postMessage({ type: "highlight", id, key, text, language, complete } satisfies MarkdownWorkerRequest)
|
||||
})
|
||||
}
|
||||
|
||||
export function disposeStreamingCode(key: string) {
|
||||
keys.delete(key)
|
||||
states.delete(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 {}
|
||||
|
||||
function getWorker() {
|
||||
if (worker) return worker
|
||||
worker = new Worker(MarkdownShikiWorkerUrl, { type: "module" })
|
||||
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
const result = pending.get(event.data.id)
|
||||
if (!result) return
|
||||
pending.delete(event.data.id)
|
||||
if (!keys.has(event.data.key)) {
|
||||
result.reject(new MarkdownWorkerDisposedError())
|
||||
return
|
||||
}
|
||||
if (event.data.type === "error") {
|
||||
result.reject(new Error(event.data.message))
|
||||
return
|
||||
}
|
||||
const state = applyMarkdownWorkerResponse(states.get(event.data.key), event.data)
|
||||
if (result.complete) {
|
||||
states.delete(event.data.key)
|
||||
keys.delete(event.data.key)
|
||||
} else states.set(event.data.key, state)
|
||||
result.resolve(state)
|
||||
}
|
||||
worker.onerror = (event) => {
|
||||
const error = new Error(event.message || "Markdown highlighting worker failed")
|
||||
pending.forEach((request) => request.reject(error))
|
||||
pending.clear()
|
||||
states.clear()
|
||||
keys.clear()
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
}
|
||||
worker.postMessage({ type: "init", theme: OpenCodeTheme } satisfies MarkdownWorkerRequest)
|
||||
return worker
|
||||
}
|
||||
|
|
@ -15,6 +15,12 @@
|
|||
> *:last-child {
|
||||
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 */
|
||||
h1,
|
||||
|
|
|
|||
|
|
@ -3,17 +3,62 @@ import { useI18n } from "../context/i18n"
|
|||
import DOMPurify from "dompurify"
|
||||
import morphdom from "morphdom"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
|
||||
import { getSharedHighlighter } from "@pierre/diffs"
|
||||
import { ShikiStreamTokenizer } from "@shikijs/stream"
|
||||
import { ComponentProps, createEffect, createMemo, createResource, createSignal, onCleanup, splitProps } from "solid-js"
|
||||
import { isServer } from "solid-js/web"
|
||||
import { stream } from "./markdown-stream"
|
||||
import {
|
||||
bundledLanguages,
|
||||
getTokenStyleObject,
|
||||
stringifyTokenStyle,
|
||||
type BundledLanguage,
|
||||
type ThemedToken,
|
||||
} from "shiki"
|
||||
import { project, type Block, type Projection } from "./markdown-stream"
|
||||
import { disposeStreamingCode, highlightStreamingCode, MarkdownWorkerDisposedError } from "./markdown-worker"
|
||||
import type { MarkdownToken } from "./markdown-worker-protocol"
|
||||
|
||||
type Entry = {
|
||||
raw: string
|
||||
hash: 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
|
||||
stable: MarkdownToken[]
|
||||
unstable: MarkdownToken[]
|
||||
}
|
||||
|
||||
type RenderResult = {
|
||||
text: string
|
||||
blocks: RenderedBlock[]
|
||||
}
|
||||
|
||||
const max = 200
|
||||
const cache = new Map<string, Entry>()
|
||||
const renderedCodeTokens = new WeakMap<
|
||||
HTMLDivElement,
|
||||
{ language: string; stableCount: number; unstable: MarkdownToken[] }
|
||||
>()
|
||||
const codeCache = new Map<
|
||||
string,
|
||||
{
|
||||
language: string
|
||||
source: string
|
||||
tokenizer: ShikiStreamTokenizer
|
||||
stable: ThemedToken[]
|
||||
unstable: ThemedToken[]
|
||||
pending: Promise<void>
|
||||
}
|
||||
>()
|
||||
|
||||
if (typeof window !== "undefined" && DOMPurify.isSupported) {
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
|
||||
|
|
@ -60,6 +105,66 @@ function fallback(markdown: string) {
|
|||
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"
|
||||
if (typeof Worker !== "undefined") {
|
||||
try {
|
||||
const result = await highlightStreamingCode(key, text, name, complete)
|
||||
return { language: name, stable: result.stable, unstable: result.unstable }
|
||||
} catch (error) {
|
||||
if (error instanceof MarkdownWorkerDisposedError)
|
||||
return { language: name, stable: [], unstable: [[text, ""] as MarkdownToken] }
|
||||
// Keep highlighting available in runtimes where the worker cannot start.
|
||||
}
|
||||
}
|
||||
return codeOnMainThread(text, name, key)
|
||||
}
|
||||
|
||||
async function codeOnMainThread(text: string, name: string, key: string) {
|
||||
const existing = codeCache.get(key)
|
||||
const entry =
|
||||
existing && existing.language === name && text.startsWith(existing.source)
|
||||
? existing
|
||||
: {
|
||||
language: name,
|
||||
source: "",
|
||||
tokenizer: new ShikiStreamTokenizer({
|
||||
highlighter: await getSharedHighlighter({
|
||||
themes: ["OpenCode"],
|
||||
langs: [],
|
||||
preferredHighlighter: "shiki-wasm",
|
||||
}),
|
||||
lang: name,
|
||||
theme: "OpenCode",
|
||||
}),
|
||||
stable: [],
|
||||
unstable: [],
|
||||
pending: Promise.resolve(),
|
||||
}
|
||||
if (!entry.tokenizer.options.highlighter.getLoadedLanguages().includes(name))
|
||||
await entry.tokenizer.options.highlighter.loadLanguage(bundledLanguages[name as BundledLanguage])
|
||||
const suffix = text.slice(entry.source.length)
|
||||
entry.source = text
|
||||
entry.pending = entry.pending.then(async () => {
|
||||
const result = await entry.tokenizer.enqueue(suffix)
|
||||
entry.stable.push(...result.stable.filter((token) => token.content.length > 0))
|
||||
entry.unstable = result.unstable.filter((token) => token.content.length > 0)
|
||||
})
|
||||
codeCache.delete(key)
|
||||
codeCache.set(key, entry)
|
||||
if (codeCache.size > max) codeCache.delete(codeCache.keys().next().value!)
|
||||
await entry.pending
|
||||
return {
|
||||
language: name,
|
||||
stable: entry.stable.map(token),
|
||||
unstable: entry.unstable.map(token),
|
||||
}
|
||||
}
|
||||
|
||||
function token(value: ThemedToken): MarkdownToken {
|
||||
return [value.content, stringifyTokenStyle(value.htmlStyle ?? getTokenStyleObject(value))]
|
||||
}
|
||||
|
||||
type CopyLabels = {
|
||||
copy: string
|
||||
copied: string
|
||||
|
|
@ -93,7 +198,7 @@ function createIcon(path: string, slot: string) {
|
|||
return icon
|
||||
}
|
||||
|
||||
function createCopyButton(labels: CopyLabels) {
|
||||
function createCopyButton(labels: CopyLabels, icons = true) {
|
||||
const button = document.createElement("button")
|
||||
button.type = "button"
|
||||
button.setAttribute("data-component", "icon-button")
|
||||
|
|
@ -102,8 +207,10 @@ function createCopyButton(labels: CopyLabels) {
|
|||
button.setAttribute("data-slot", "markdown-copy-button")
|
||||
button.setAttribute("aria-label", labels.copy)
|
||||
button.setAttribute("data-tooltip", labels.copy)
|
||||
button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
|
||||
button.appendChild(createIcon(iconPaths.check, "check-icon"))
|
||||
if (icons) {
|
||||
button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
|
||||
button.appendChild(createIcon(iconPaths.check, "check-icon"))
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
|
|
@ -119,7 +226,7 @@ function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boo
|
|||
button.setAttribute("data-tooltip", labels.copy)
|
||||
}
|
||||
|
||||
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
||||
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels, shallow: boolean) {
|
||||
const parent = block.parentElement
|
||||
if (!parent) return
|
||||
const wrapped = parent.getAttribute("data-component") === "markdown-code"
|
||||
|
|
@ -128,7 +235,7 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
|||
wrapper.setAttribute("data-component", "markdown-code")
|
||||
parent.replaceChild(wrapper, block)
|
||||
wrapper.appendChild(block)
|
||||
wrapper.appendChild(createCopyButton(labels))
|
||||
wrapper.appendChild(createCopyButton(labels, !shallow))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -175,10 +282,10 @@ function markCodeLinks(root: HTMLDivElement) {
|
|||
}
|
||||
}
|
||||
|
||||
function decorate(root: HTMLDivElement, labels: CopyLabels) {
|
||||
function decorate(root: HTMLDivElement, labels: CopyLabels, shallow = false) {
|
||||
const blocks = Array.from(root.querySelectorAll("pre"))
|
||||
for (const block of blocks) {
|
||||
ensureCodeWrapper(block, labels)
|
||||
ensureCodeWrapper(block, labels, shallow)
|
||||
}
|
||||
markCodeLinks(root)
|
||||
}
|
||||
|
|
@ -251,51 +358,109 @@ export function Markdown(
|
|||
const marked = useMarked()
|
||||
const i18n = useI18n()
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const activeCodeKeys = new Set<string>()
|
||||
const projection = createMemo((previous: Projection | undefined) =>
|
||||
project(previous, local.text, local.streaming ?? false),
|
||||
)
|
||||
const [html] = createResource(
|
||||
() => ({
|
||||
text: local.text,
|
||||
key: local.cacheKey,
|
||||
streaming: local.streaming ?? false,
|
||||
}),
|
||||
() => {
|
||||
return {
|
||||
text: local.text,
|
||||
key: local.cacheKey,
|
||||
projection: projection(),
|
||||
}
|
||||
},
|
||||
async (src) => {
|
||||
if (isServer) return fallback(src.text)
|
||||
if (!src.text) return ""
|
||||
if (isServer)
|
||||
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)
|
||||
return Promise.all(
|
||||
stream(src.text, src.streaming).map(async (block, index) => {
|
||||
const hash = checksum(block.raw)
|
||||
const key = base ? `${base}:${index}:${block.mode}` : hash
|
||||
src.projection.blocks.map(async (block, index) => {
|
||||
const key = base ? `${base}:${index}:${block.mode}` : undefined
|
||||
const blockKey = key ?? `block:${index}`
|
||||
|
||||
if (key && hash) {
|
||||
const cached = cache.get(key)
|
||||
if (cached && cached.hash === hash) {
|
||||
touch(key, cached)
|
||||
return cached.html
|
||||
if (block.mode === "code") {
|
||||
const result = await code(block.src, block.language, blockKey, block.complete)
|
||||
return {
|
||||
key: blockKey,
|
||||
mode: block.mode,
|
||||
raw: block.raw,
|
||||
hash: String(block.raw.length),
|
||||
complete: !!block.complete,
|
||||
...result,
|
||||
}
|
||||
}
|
||||
|
||||
const next = await Promise.resolve(marked.parse(block.src))
|
||||
const safe = sanitize(next)
|
||||
if (key && hash) touch(key, { hash, html: safe })
|
||||
return safe
|
||||
if (key) {
|
||||
const cached = cache.get(key)
|
||||
if (cached?.raw === block.raw) {
|
||||
touch(key, cached)
|
||||
return { key: blockKey, mode: block.mode, ...cached }
|
||||
}
|
||||
}
|
||||
|
||||
const hash = checksum(block.raw)
|
||||
const safe = sanitize(await Promise.resolve(marked.parse(block.src)))
|
||||
if (key && hash) touch(key, { raw: block.raw, hash, html: safe })
|
||||
return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
|
||||
}),
|
||||
)
|
||||
.then((list) => list.join(""))
|
||||
.catch(() => fallback(src.text))
|
||||
.then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult)
|
||||
.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: {
|
||||
text: local.text,
|
||||
blocks: [
|
||||
{
|
||||
key: "initial",
|
||||
mode: "full" as const,
|
||||
raw: local.text,
|
||||
hash: checksum(local.text) ?? "",
|
||||
html: fallback(local.text),
|
||||
},
|
||||
],
|
||||
} satisfies RenderResult,
|
||||
},
|
||||
{ initialValue: fallback(local.text) },
|
||||
)
|
||||
|
||||
let copyCleanup: (() => void) | undefined
|
||||
|
||||
createEffect(() => {
|
||||
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) : []
|
||||
if (!container) return
|
||||
if (isServer) return
|
||||
|
||||
if (!content) {
|
||||
if (content.length === 0) {
|
||||
container.innerHTML = ""
|
||||
return
|
||||
}
|
||||
|
|
@ -304,26 +469,14 @@ export function Markdown(
|
|||
copy: i18n.t("ui.message.copy"),
|
||||
copied: i18n.t("ui.message.copied"),
|
||||
}
|
||||
const temp = document.createElement("div")
|
||||
temp.innerHTML = content
|
||||
decorate(temp, labels)
|
||||
|
||||
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
|
||||
},
|
||||
const nextCodeKeys = new Set(content.filter((block) => block.mode === "code").map((block) => block.key))
|
||||
activeCodeKeys.forEach((key) => {
|
||||
if (!nextCodeKeys.has(key)) disposeCode(key)
|
||||
})
|
||||
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()
|
||||
|
||||
if (!copyCleanup)
|
||||
copyCleanup = setupCodeCopy(container, () => ({
|
||||
|
|
@ -334,6 +487,7 @@ export function Markdown(
|
|||
|
||||
onCleanup(() => {
|
||||
if (copyCleanup) copyCleanup()
|
||||
activeCodeKeys.forEach(disposeCode)
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
@ -348,3 +502,142 @@ export function Markdown(
|
|||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function pendingBlocks(result: RenderResult | undefined, projection: Projection | undefined, cacheKey?: 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) return current
|
||||
const key = cacheKey ? `${cacheKey}:${index}:${block.mode}` : `block:${index}`
|
||||
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: [],
|
||||
unstable: [[block.src, ""] as MarkdownToken],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function disposeCode(key: string) {
|
||||
codeCache.delete(key)
|
||||
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,
|
||||
current instanceof HTMLDivElement && current.querySelector('[data-slot="markdown-copy-button"]') !== null,
|
||||
)
|
||||
|
||||
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 = !previous || previous.language !== block.language || block.stable.length < previous.stableCount
|
||||
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()
|
||||
code.append(...tail.slice(keep - stableCount).map(createTokenSpan))
|
||||
renderedCodeTokens.set(next, {
|
||||
language: block.language,
|
||||
stableCount: block.stable.length,
|
||||
unstable: block.unstable,
|
||||
})
|
||||
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}`
|
||||
codeElement.append(...block.stable.map(createTokenSpan), ...block.unstable.map(createTokenSpan))
|
||||
pre.appendChild(codeElement)
|
||||
wrapper.append(pre, createCopyButton(labels))
|
||||
next.appendChild(wrapper)
|
||||
renderedCodeTokens.set(next, {
|
||||
language: block.language,
|
||||
stableCount: block.stable.length,
|
||||
unstable: block.unstable,
|
||||
})
|
||||
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
|
||||
deferToolContent?: boolean
|
||||
virtualizeDiff?: boolean
|
||||
onContentRendered?: () => void
|
||||
showAssistantCopyPartID?: string | null
|
||||
turnDurationMs?: number
|
||||
}
|
||||
|
|
@ -1272,6 +1273,7 @@ export function Part(props: MessagePartProps) {
|
|||
onToolOpenChange={props.onToolOpenChange}
|
||||
deferToolContent={props.deferToolContent}
|
||||
virtualizeDiff={props.virtualizeDiff}
|
||||
onContentRendered={props.onContentRendered}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
turnDurationMs={props.turnDurationMs}
|
||||
/>
|
||||
|
|
@ -1292,6 +1294,7 @@ export interface ToolProps {
|
|||
onOpenChange?: (open: boolean) => void
|
||||
deferContent?: boolean
|
||||
virtualizeDiff?: boolean
|
||||
onContentRendered?: () => void
|
||||
forceOpen?: boolean
|
||||
locked?: boolean
|
||||
}
|
||||
|
|
@ -1438,6 +1441,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
|||
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
|
||||
deferContent={props.deferToolContent}
|
||||
virtualizeDiff={props.virtualizeDiff}
|
||||
onContentRendered={props.onContentRendered}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
@ -2021,7 +2025,13 @@ ToolRegistry.register({
|
|||
}
|
||||
>
|
||||
<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>
|
||||
</ToolFileAccordion>
|
||||
</Show>
|
||||
|
|
@ -2080,6 +2090,7 @@ ToolRegistry.register({
|
|||
cacheKey: checksum(props.input.content),
|
||||
}}
|
||||
overflow="scroll"
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
|
|
@ -2208,6 +2219,7 @@ ToolRegistry.register({
|
|||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={file.view.fileDiff}
|
||||
hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -2283,6 +2295,7 @@ ToolRegistry.register({
|
|||
mode="diff"
|
||||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={single()!.view.fileDiff}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { scrollKey } from "./scroll-view"
|
||||
import { scrollKey, scrollTopFromThumbPointer } from "./scroll-view"
|
||||
|
||||
describe("scrollKey", () => {
|
||||
test("maps plain navigation keys", () => {
|
||||
|
|
@ -17,3 +17,38 @@ describe("scrollKey", () => {
|
|||
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) {
|
||||
const i18n = useI18n()
|
||||
const merged = mergeProps({ orientation: "vertical" }, props)
|
||||
|
|
@ -103,39 +121,37 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
updateThumb()
|
||||
})
|
||||
|
||||
let startY = 0
|
||||
let startScrollTop = 0
|
||||
|
||||
const onThumbPointerDown = (e: PointerEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setState("isDragging", true)
|
||||
startY = e.clientY
|
||||
startScrollTop = viewportRef.scrollTop
|
||||
const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top
|
||||
|
||||
thumbRef.setPointerCapture(e.pointerId)
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
const deltaY = e.clientY - startY
|
||||
const { scrollHeight, clientHeight } = viewportRef
|
||||
const maxScrollTop = scrollHeight - clientHeight
|
||||
const maxThumbTop = clientHeight - thumbHeight()
|
||||
|
||||
if (maxThumbTop > 0) {
|
||||
const scrollDelta = deltaY * (maxScrollTop / maxThumbTop)
|
||||
viewportRef.scrollTop = startScrollTop + scrollDelta
|
||||
}
|
||||
viewportRef.scrollTop = scrollTopFromThumbPointer({
|
||||
pointer: e.clientY,
|
||||
viewportTop: viewportRef.getBoundingClientRect().top,
|
||||
grabOffset,
|
||||
clientHeight,
|
||||
scrollHeight,
|
||||
thumbHeight: thumbHeight(),
|
||||
})
|
||||
}
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
const done = (e: PointerEvent) => {
|
||||
setState("isDragging", false)
|
||||
thumbRef.releasePointerCapture(e.pointerId)
|
||||
thumbRef.removeEventListener("pointermove", onPointerMove)
|
||||
thumbRef.removeEventListener("pointerup", onPointerUp)
|
||||
thumbRef.removeEventListener("pointerup", done)
|
||||
thumbRef.removeEventListener("pointercancel", done)
|
||||
}
|
||||
|
||||
thumbRef.addEventListener("pointermove", onPointerMove)
|
||||
thumbRef.addEventListener("pointerup", onPointerUp)
|
||||
thumbRef.addEventListener("pointerup", done)
|
||||
thumbRef.addEventListener("pointercancel", done)
|
||||
}
|
||||
|
||||
// Keybinds implementation
|
||||
|
|
|
|||
|
|
@ -6,14 +6,16 @@ import { bundledLanguages, type BundledLanguage } from "shiki"
|
|||
import { createSimpleContext } from "./helper"
|
||||
import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs"
|
||||
|
||||
registerCustomTheme("OpenCode", () => {
|
||||
return Promise.resolve({
|
||||
export const OpenCodeTheme = {
|
||||
name: "OpenCode",
|
||||
bg: "var(--color-background-stronger)",
|
||||
fg: "var(--text-base)",
|
||||
colors: {
|
||||
"editor.background": "var(--color-background-stronger)",
|
||||
"editor.foreground": "var(--text-base)",
|
||||
"gitDecoration.addedResourceForeground": "var(--syntax-diff-add)",
|
||||
"gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)",
|
||||
"gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)",
|
||||
// "gitDecoration.conflictingResourceForeground": "#ffca00",
|
||||
// "gitDecoration.modifiedResourceForeground": "#1a76d4",
|
||||
// "gitDecoration.untrackedResourceForeground": "#00cab1",
|
||||
|
|
@ -373,8 +375,9 @@ registerCustomTheme("OpenCode", () => {
|
|||
"variable.constant": "var(--syntax-constant)",
|
||||
"variable.defaultLibrary": "var(--syntax-unknown)",
|
||||
},
|
||||
} as unknown as ThemeRegistrationResolved)
|
||||
})
|
||||
} as unknown as ThemeRegistrationResolved
|
||||
|
||||
registerCustomTheme("OpenCode", () => Promise.resolve(OpenCodeTheme))
|
||||
|
||||
function renderMathInText(text: string): string {
|
||||
let result = text
|
||||
|
|
|
|||
|
|
@ -17,24 +17,15 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
|||
const unsafeCSS = `
|
||||
[data-diff],
|
||||
[data-file] {
|
||||
--diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg));
|
||||
--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-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))));
|
||||
--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))));
|
||||
--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-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))));
|
||||
--diffs-deletion-base: var(--syntax-diff-delete);
|
||||
--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)));
|
||||
/* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */
|
||||
--diffs-bg-deletion-override: light-dark(
|
||||
color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-deletion-base)),
|
||||
color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-deletion-base))
|
||||
);
|
||||
--diffs-bg-addition-override: light-dark(
|
||||
color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-addition-base)),
|
||||
color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-addition-base))
|
||||
);
|
||||
--diffs-selection-base: var(--surface-warning-strong);
|
||||
--diffs-selection-border: var(--border-warning-base);
|
||||
--diffs-selection-number-fg: #1c1917;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const cache = new WeakMap<Document | HTMLElement, Entry>()
|
|||
export const virtualMetrics: Partial<VirtualFileMetrics> = {
|
||||
lineHeight: 24,
|
||||
hunkSeparatorHeight: 24,
|
||||
fileGap: 0,
|
||||
spacing: 0,
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -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…
Add table
Add a link
Reference in a new issue