From c840e1db9512468bbd73170626ddef2cd99401ed Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:32:57 +0200 Subject: [PATCH] feat(app): stabilize long session timelines --- bun.lock | 101 ++-- bunfig.toml | 2 +- package.json | 11 +- .../session-timeline-collapse-state.spec.ts | 102 +++- .../session-timeline-context-resize.spec.ts | 18 +- .../app/e2e/smoke/session-timeline.spec.ts | 70 +++ packages/app/e2e/utils/mock-server.ts | 14 +- packages/app/package.json | 4 +- .../context/global-sync/event-reducer.test.ts | 20 + .../src/context/global-sync/event-reducer.ts | 8 +- packages/app/src/pages/session.tsx | 78 ++- .../pages/session/message-timeline.data.ts | 20 +- .../src/pages/session/message-timeline.tsx | 451 +++++++++--------- packages/ui/package.json | 4 +- packages/ui/src/components/file.tsx | 2 +- .../src/components/markdown-shiki.worker.ts | 101 ++++ .../ui/src/components/markdown-stream.test.ts | 72 ++- packages/ui/src/components/markdown-stream.ts | 68 ++- .../markdown-worker-protocol.test.ts | 51 ++ .../components/markdown-worker-protocol.ts | 37 ++ packages/ui/src/components/markdown-worker.ts | 81 ++++ packages/ui/src/components/markdown.css | 6 + packages/ui/src/components/markdown.tsx | 399 ++++++++++++++-- packages/ui/src/components/message-part.tsx | 15 +- .../ui/src/components/scroll-view.test.ts | 37 +- packages/ui/src/components/scroll-view.tsx | 48 +- packages/ui/src/context/marked.tsx | 11 +- packages/ui/src/pierre/index.ts | 27 +- packages/ui/src/pierre/virtualizer.ts | 2 +- .../@tanstack%2Fsolid-virtual@3.13.28.patch | 45 ++ patches/virtua@0.49.1.patch | 93 ---- 31 files changed, 1436 insertions(+), 562 deletions(-) create mode 100644 packages/ui/src/components/markdown-shiki.worker.ts create mode 100644 packages/ui/src/components/markdown-worker-protocol.test.ts create mode 100644 packages/ui/src/components/markdown-worker-protocol.ts create mode 100644 packages/ui/src/components/markdown-worker.ts create mode 100644 patches/@tanstack%2Fsolid-virtual@3.13.28.patch delete mode 100644 patches/virtua@0.49.1.patch diff --git a/bun.lock b/bun.lock index bc43f72c737..7c8c2bbb3ca 100644 --- a/bun.lock +++ b/bun.lock @@ -52,6 +52,7 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@tanstack/solid-query": "5.91.4", + "@tanstack/solid-virtual": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "effect": "catalog:", @@ -65,7 +66,6 @@ "solid-js": "catalog:", "solid-list": "catalog:", "tailwindcss": "catalog:", - "virtua": "catalog:", }, "devDependencies": { "@happy-dom/global-registrator": "20.0.11", @@ -827,6 +827,7 @@ "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@pierre/diffs": "catalog:", + "@shikijs/stream": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", "@solid-primitives/event-listener": "2.4.5", @@ -852,7 +853,6 @@ "solid-js": "catalog:", "solid-list": "catalog:", "strip-ansi": "7.1.2", - "virtua": "catalog:", }, "devDependencies": { "@tailwindcss/vite": "catalog:", @@ -904,23 +904,23 @@ }, }, "trustedDependencies": [ - "esbuild", "tree-sitter-powershell", - "protobufjs", - "electron", "web-tree-sitter", "tree-sitter-bash", + "esbuild", + "electron", + "protobufjs", ], "patchedDependencies": { + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", + "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", + "pacote@21.5.0": "patches/pacote@21.5.0.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "pacote@21.5.0": "patches/pacote@21.5.0.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", }, "overrides": { "@opentui/core": "catalog:", @@ -944,15 +944,17 @@ "@opentui/core": "0.3.4", "@opentui/keymap": "0.3.4", "@opentui/solid": "0.3.4", - "@pierre/diffs": "1.1.0-beta.18", + "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", "@sentry/vite-plugin": "4.6.0", + "@shikijs/stream": "4.2.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@tailwindcss/vite": "4.1.11", + "@tanstack/solid-virtual": "3.13.28", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.13", @@ -978,14 +980,13 @@ "remeda": "2.26.0", "remend": "1.3.0", "semver": "7.7.4", - "shiki": "3.20.0", + "shiki": "4.2.0", "solid-js": "1.9.10", "solid-list": "0.3.0", "sst": "4.13.1", "tailwindcss": "4.1.11", "typescript": "5.8.2", "ulid": "3.0.1", - "virtua": "0.49.1", "vite": "7.1.4", "vite-plugin-solid": "2.11.10", "zod": "4.1.8", @@ -2121,9 +2122,11 @@ "@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="], - "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="], + "@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="], - "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], + "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], + + "@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], @@ -2327,13 +2330,17 @@ "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], - "@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], + "@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], - "@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], + "@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], + + "@shikijs/stream": ["@shikijs/stream@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-OaMUUStdIZ+l1GJad9uVACR3Xvgwo4y+RmEuDMU62cgFMMg1IBCaIFmvzAR2HiCpGtwoc/qPfpNnP+ivgrPXZg=="], + + "@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], "@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="], @@ -2577,6 +2584,10 @@ "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="], + "@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], @@ -4843,7 +4854,7 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], + "shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="], @@ -5253,8 +5264,6 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="], - "vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="], "vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "^8.12.1", "es-module-lexer": "^1.5.4", "fast-glob": "^3.3.2", "magic-string": "^0.30.11" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="], @@ -5503,6 +5512,8 @@ "@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@astrojs/markdown-remark/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], + "@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], "@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -5841,13 +5852,17 @@ "@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/langs/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + + "@shikijs/stream/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], + + "@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], @@ -5957,6 +5972,8 @@ "astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "astro/shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], + "astro/unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], "astro/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], @@ -6201,9 +6218,9 @@ "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + "shiki/@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], - "shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -6345,6 +6362,18 @@ "@astrojs/markdown-remark/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + + "@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], + + "@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], + + "@astrojs/markdown-remark/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], + + "@astrojs/markdown-remark/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], + + "@astrojs/markdown-remark/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], @@ -6641,6 +6670,8 @@ "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -6707,6 +6738,18 @@ "astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "astro/shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + + "astro/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], + + "astro/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], + + "astro/shiki/@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], + + "astro/shiki/@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], + + "astro/shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "astro/unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "astro/unstorage/h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], diff --git a/bunfig.toml b/bunfig.toml index c086c75bfac..c506ff57c4b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -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" diff --git a/package.json b/package.json index 66b86e39f58..86c8738bda4 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "@opentui/core": "0.3.4", "@opentui/keymap": "0.3.4", "@opentui/solid": "0.3.4", + "@tanstack/solid-virtual": "3.13.28", + "@shikijs/stream": "4.2.0", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -51,7 +53,7 @@ "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/diffs": "1.1.0-beta.18", + "@pierre/diffs": "1.2.10", "opentui-spinner": "0.0.7", "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", @@ -76,10 +78,9 @@ "zod": "4.1.8", "remeda": "2.26.0", "sst": "4.13.1", - "shiki": "3.20.0", + "shiki": "4.2.0", "solid-list": "0.3.0", "tailwindcss": "4.1.11", - "virtua": "0.49.1", "vite": "7.1.4", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", @@ -145,10 +146,10 @@ "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch" + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch" } } diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index 8b31b8cc273..c902e7faf38 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -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(".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('[data-slot="collapsible-trigger"]')! + const diff = element.querySelector('[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('[data-component="file"][data-mode="diff"]') + const row = tool.closest("[data-timeline-key]") + const frame = tool.closest("[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('[data-component="file"][data-mode="diff"]') + const row = tool.closest("[data-timeline-key]") + const frame = tool.closest("[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, }) } diff --git a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts index d2b18275734..745dee10e53 100644 --- a/packages/app/e2e/regression/session-timeline-context-resize.spec.ts +++ b/packages/app/e2e/regression/session-timeline-context-resize.spec.ts @@ -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) }), diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 5d7425ea5bb..8fef4f7ba34 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -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(`[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, { diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 9a03a9d5adb..c4ef9f6cc84 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -18,7 +18,10 @@ export interface MockServerConfig { project: unknown sessions: ({ id: string } & Record)[] 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) { }) } -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"}`, }) } diff --git a/packages/app/package.json b/packages/app/package.json index 3d99650f83d..d21b4ee6b9e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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:" } } diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index f884af2bf23..2811e6e3c6c 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -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({ diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index f398aa995eb..9ca8f8bdad1 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -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, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c44d31bbdc2..6215207b7c6 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -88,34 +88,17 @@ type SessionHistoryWindowInput = { loadMore: (sessionID: string) => Promise 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 + }} /> diff --git a/packages/app/src/pages/session/message-timeline.data.ts b/packages/app/src/pages/session/message-timeline.data.ts index 0643a424ea1..bc5719bdcf8 100644 --- a/packages/app/src/pages/session/message-timeline.data.ts +++ b/packages/app/src/pages/session/message-timeline.data.ts @@ -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, }), ) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index e4ae4a23e36..6e4470a81ce 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -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 +type FramedTimelineRow = Exclude type TimelineRowByTag = Extract -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() - -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() 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("[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(`[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>({}) + const virtualizer = createVirtualizer({ + 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() 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>({}) - 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() 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) => { + const renderAssistantPartGroup = (row: Accessor, 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} /> )} @@ -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) => { + const renderTimelineRow = (row: Accessor, onSizeChange?: () => void) => { switch (row()._tag) { + case "TurnGap": + return @@ -1246,13 +1178,68 @@ export function MessageTimeline(props: { ) } - case "BottomSpacer": - return - - {(root) => ( - { - if (!handle) { - writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer) - virtualizer = undefined - return - } - virtualizer = handle - virtualizerSessionKey = cacheSessionKey - virtualizerRowKeys = cacheRowKeys - maybeAnchorBottom() - scheduleContentRoot(root()) - }} - > - {(row) => } - - )} - +
{ + props.setContentRef(element) + }} + style={{ + height: `${virtualizer.getTotalSize()}px`, + position: "relative", + width: "100%", + }} + > + {(rowKey) => } + 0}> +
) diff --git a/packages/ui/package.json b/packages/ui/package.json index cd9ec9275a3..6f34cc98644 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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" } } diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index bfcc05b40ac..8c8096375a3 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -52,7 +52,7 @@ const VIRTUALIZE_BYTES = 500_000 const codeMetrics = { ...DEFAULT_VIRTUAL_FILE_METRICS, lineHeight: 24, - fileGap: 0, + spacing: 0, } satisfies Partial type SharedProps = { diff --git a/packages/ui/src/components/markdown-shiki.worker.ts b/packages/ui/src/components/markdown-shiki.worker.ts new file mode 100644 index 00000000000..99ad00849da --- /dev/null +++ b/packages/ui/src/components/markdown-shiki.worker.ts @@ -0,0 +1,101 @@ +/// + +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() +let highlighter: ReturnType | undefined +let queue = Promise.resolve() + +self.onmessage = (event: MessageEvent) => { + 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) { + 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))] +} diff --git a/packages/ui/src/components/markdown-stream.test.ts b/packages/ui/src/components/markdown-stream.test.ts index 1ee63fc62e4..3ef9c0f3387 100644 --- a/packages/ui/src/components/markdown-stream.test.ts +++ b/packages/ui/src/components/markdown-stream.test.ts @@ -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", + }) + }) }) diff --git a/packages/ui/src/components/markdown-stream.ts b/packages/ui/src/components/markdown-stream.ts index ea35b0c140d..e260262c380 100644 --- a/packages/ui/src/components/markdown-stream.ts +++ b/packages/ui/src/components/markdown-stream.ts @@ -4,11 +4,19 @@ import remend from "remend" export type Block = { raw: string src: string - mode: "full" | "live" + mode: "full" | "live" | "code" + language?: string + complete?: boolean +} + +export type Projection = { + text: string + blocks: Block[] } function refs(text: string) { - return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text) + if (!text.includes("]:")) return false + return /^[ \t]{0,3}\[[^\]]+\]:[ \t]*\S+/m.test(text) } function open(raw: string) { @@ -26,24 +34,52 @@ function heal(text: string) { return remend(text, { linkMode: "text-only" }) } -export function stream(text: string, live: boolean) { +export function stream(text: string, live: boolean): Block[] { if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[] - const src = heal(text) - if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[] + if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[] const tokens = marked.lexer(text) const tail = tokens.findLastIndex((token) => token.type !== "space") - if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[] + if (tail < 0) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[] const last = tokens[tail] - if (!last || last.type !== "code") return [{ raw: text, src, mode: "live" }] satisfies Block[] - const code = last as Tokens.Code - if (!open(code.raw)) return [{ raw: text, src, mode: "live" }] satisfies Block[] - const head = tokens - .slice(0, tail) + if (!last) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[] + + const result: Block[] = [] + for (let index = 0; index < tail; index++) { + const token = tokens[index] + if (!token || token.type === "space") continue + let raw = token.raw + while (tokens[index + 1]?.type === "space" && index + 1 < tail) raw += tokens[++index]!.raw + result.push({ raw, src: raw, mode: "full" }) + } + + const raw = tokens + .slice(tail) .map((token) => token.raw) .join("") - if (!head) return [{ raw: code.raw, src: code.raw, mode: "live" }] satisfies Block[] - return [ - { raw: head, src: heal(head), mode: "live" }, - { raw: code.raw, src: code.raw, mode: "live" }, - ] satisfies Block[] + if (last.type !== "code") return [...result, { raw, src: heal(raw), mode: "live" }] + + const code = last as Tokens.Code + if (!open(code.raw)) + return [...result, { raw, src: code.text, mode: "code", language: code.lang, complete: true }] + return [...result, { raw, src: code.text, mode: "code", language: code.lang }] +} + +export function project(previous: Projection | undefined, text: string, live: boolean): Projection { + if (!live || !previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) } + const tail = previous.blocks.at(-1) + const suffix = text.slice(previous.text.length) + if (!suffix || tail?.mode !== "code" || tail.complete || suffix.includes("```") || suffix.includes("~~~")) + return { text, blocks: stream(text, live) } + const separator = previous.text.endsWith("\n") && !tail.src.endsWith("\n") ? "\n" : "" + return { + text, + blocks: [ + ...previous.blocks.slice(0, -1), + { + ...tail, + raw: tail.raw + suffix, + src: tail.src + separator + suffix, + }, + ], + } } diff --git a/packages/ui/src/components/markdown-worker-protocol.test.ts b/packages/ui/src/components/markdown-worker-protocol.test.ts new file mode 100644 index 00000000000..cc0b9374604 --- /dev/null +++ b/packages/ui/src/components/markdown-worker-protocol.test.ts @@ -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"]) +}) diff --git a/packages/ui/src/components/markdown-worker-protocol.ts b/packages/ui/src/components/markdown-worker-protocol.ts new file mode 100644 index 00000000000..d63e1a32281 --- /dev/null +++ b/packages/ui/src/components/markdown-worker-protocol.ts @@ -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, +) { + if (state && response.id <= state.id) return state + return { + id: response.id, + stable: response.reset ? response.stable : [...(state?.stable ?? []), ...response.stable], + unstable: response.unstable, + } +} diff --git a/packages/ui/src/components/markdown-worker.ts b/packages/ui/src/components/markdown-worker.ts new file mode 100644 index 00000000000..0a7c33ce348 --- /dev/null +++ b/packages/ui/src/components/markdown-worker.ts @@ -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() +const states = new Map() +const keys = new Set() + +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((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) => { + 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 +} diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 26c9efd475e..2c79e172b66 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -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, diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index e12b661dba6..0010572dc32 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -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 }) + | { + 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() +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 + } +>() 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, "
") } +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() + const activeCodeKeys = new Set() + 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, + 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 +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 35912e119d4..e2eddd73e33 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -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} /> @@ -2021,7 +2025,13 @@ ToolRegistry.register({ } >
- +
@@ -2080,6 +2090,7 @@ ToolRegistry.register({ cacheKey: checksum(props.input.content), }} overflow="scroll" + onRendered={props.onContentRendered} /> @@ -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} /> @@ -2283,6 +2295,7 @@ ToolRegistry.register({ mode="diff" virtualize={props.virtualizeDiff} fileDiff={single()!.view.fileDiff} + onRendered={props.onContentRendered} /> diff --git a/packages/ui/src/components/scroll-view.test.ts b/packages/ui/src/components/scroll-view.test.ts index d28b51fea8b..370d025aa69 100644 --- a/packages/ui/src/components/scroll-view.test.ts +++ b/packages/ui/src/components/scroll-view.test.ts @@ -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) + }) +}) diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 3ff00f117d2..f8a4f18b7ea 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -27,6 +27,24 @@ export const scrollKey = (event: Pick { 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 diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 46f4993babd..762daaf29e1 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -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 diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index 22586f0f583..7ef3ffb0334 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -17,24 +17,15 @@ export type DiffProps = FileDiffOptions & { 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; diff --git a/packages/ui/src/pierre/virtualizer.ts b/packages/ui/src/pierre/virtualizer.ts index 31862cc4931..235a3fd6773 100644 --- a/packages/ui/src/pierre/virtualizer.ts +++ b/packages/ui/src/pierre/virtualizer.ts @@ -16,7 +16,7 @@ const cache = new WeakMap() export const virtualMetrics: Partial = { lineHeight: 24, hunkSeparatorHeight: 24, - fileGap: 0, + spacing: 0, } function scrollable(value: string) { diff --git a/patches/@tanstack%2Fsolid-virtual@3.13.28.patch b/patches/@tanstack%2Fsolid-virtual@3.13.28.patch new file mode 100644 index 00000000000..3b1cda91e1c --- /dev/null +++ b/patches/@tanstack%2Fsolid-virtual@3.13.28.patch @@ -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 diff --git a/patches/virtua@0.49.1.patch b/patches/virtua@0.49.1.patch deleted file mode 100644 index d8064d3d010..00000000000 --- a/patches/virtua@0.49.1.patch +++ /dev/null @@ -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; - }