feat(app): stabilize long session timelines

This commit is contained in:
LukeParkerDev 2026-06-14 17:32:57 +02:00
parent 45e4606fa4
commit c840e1db95
31 changed files with 1436 additions and 562 deletions

101
bun.lock
View file

@ -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=="],

View file

@ -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"

View file

@ -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"
}
}

View file

@ -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,
})
}

View file

@ -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)
}),

View file

@ -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, {

View file

@ -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"}`,
})
}

View file

@ -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:"
}
}

View file

@ -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({

View file

@ -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,

View file

@ -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>

View file

@ -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,
}),
)

View file

@ -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>
)

View file

@ -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"
}
}

View file

@ -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> = {

View 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))]
}

View file

@ -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",
})
})
})

View file

@ -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,
},
],
}
}

View 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"])
})

View 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,
}
}

View 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
}

View file

@ -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,

View file

@ -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
}

View file

@ -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>

View file

@ -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)
})
})

View file

@ -27,6 +27,24 @@ export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey
}
}
export function scrollTopFromThumbPointer(input: {
pointer: number
viewportTop: number
grabOffset: number
clientHeight: number
scrollHeight: number
thumbHeight: number
}) {
const padding = 8
const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight
if (maxThumbTop <= 0) return 0
const thumbTop = Math.max(
0,
Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop),
)
return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - input.clientHeight)
}
export function ScrollView(props: ScrollViewProps) {
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

View file

@ -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

View file

@ -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;

View file

@ -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) {

View file

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

View file

@ -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;
}