Forráskód Böngészése

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

Luke Parker 2 hónapja
szülő
commit
3b811bd019
49 módosított fájl, 2824 hozzáadás és 845 törlés
  1. 72 28
      bun.lock
  2. 1 1
      bunfig.toml
  3. 7 5
      package.json
  4. 96 6
      packages/app/e2e/regression/session-timeline-collapse-state.spec.ts
  5. 9 9
      packages/app/e2e/regression/session-timeline-context-resize.spec.ts
  6. 273 0
      packages/app/e2e/smoke/session-timeline.spec.ts
  7. 10 4
      packages/app/e2e/utils/mock-server.ts
  8. 4 3
      packages/app/package.json
  9. 20 0
      packages/app/src/context/global-sync/event-reducer.test.ts
  10. 7 1
      packages/app/src/context/global-sync/event-reducer.ts
  11. 40 1
      packages/app/src/context/server-sdk.test.ts
  12. 36 10
      packages/app/src/context/server-sdk.tsx
  13. 2 2
      packages/app/src/pages/layout.tsx
  14. 81 225
      packages/app/src/pages/session.tsx
  15. 30 0
      packages/app/src/pages/session/timeline/measure.test.ts
  16. 5 0
      packages/app/src/pages/session/timeline/measure.ts
  17. 310 332
      packages/app/src/pages/session/timeline/message-timeline.tsx
  18. 101 0
      packages/app/src/pages/session/timeline/model.test.ts
  19. 152 0
      packages/app/src/pages/session/timeline/model.ts
  20. 113 0
      packages/app/src/pages/session/timeline/projection.ts
  21. 9 11
      packages/app/src/pages/session/timeline/rows.ts
  22. 46 0
      packages/app/test-browser/solid-virtual.test.ts
  23. 5 5
      packages/opencode/test/share/share-next.test.ts
  24. 2 2
      packages/ui/package.json
  25. 4 0
      packages/ui/src/components/file.css
  26. 1 1
      packages/ui/src/components/file.tsx
  27. 32 0
      packages/ui/src/components/markdown-code-state.test.ts
  28. 22 0
      packages/ui/src/components/markdown-code-state.ts
  29. 104 0
      packages/ui/src/components/markdown-shiki.worker.ts
  30. 161 3
      packages/ui/src/components/markdown-stream.test.ts
  31. 77 16
      packages/ui/src/components/markdown-stream.ts
  32. 77 0
      packages/ui/src/components/markdown-worker-protocol.test.ts
  33. 48 0
      packages/ui/src/components/markdown-worker-protocol.ts
  34. 49 0
      packages/ui/src/components/markdown-worker-queue.test.ts
  35. 64 0
      packages/ui/src/components/markdown-worker-queue.ts
  36. 56 0
      packages/ui/src/components/markdown-worker-transport.test.ts
  37. 41 0
      packages/ui/src/components/markdown-worker-transport.ts
  38. 122 0
      packages/ui/src/components/markdown-worker.ts
  39. 8 0
      packages/ui/src/components/markdown.css
  40. 314 45
      packages/ui/src/components/markdown.tsx
  41. 25 2
      packages/ui/src/components/message-part.tsx
  42. 36 1
      packages/ui/src/components/scroll-view.test.ts
  43. 32 16
      packages/ui/src/components/scroll-view.tsx
  44. 7 4
      packages/ui/src/context/marked.tsx
  45. 9 18
      packages/ui/src/pierre/index.ts
  46. 1 1
      packages/ui/src/pierre/virtualizer.ts
  47. 45 0
      patches/@tanstack%2Fsolid-virtual@3.13.28.patch
  48. 58 0
      patches/@tanstack%2Fvirtual-core@3.17.0.patch
  49. 0 93
      patches/virtua@0.49.1.patch

+ 72 - 28
bun.lock

@@ -53,6 +53,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:",
@@ -66,7 +67,6 @@
         "solid-js": "catalog:",
         "solid-list": "catalog:",
         "tailwindcss": "catalog:",
-        "virtua": "catalog:",
       },
       "devDependencies": {
         "@happy-dom/global-registrator": "20.0.11",
@@ -828,6 +828,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",
@@ -853,7 +854,6 @@
         "solid-js": "catalog:",
         "solid-list": "catalog:",
         "strip-ansi": "7.1.2",
-        "virtua": "catalog:",
       },
       "devDependencies": {
         "@tailwindcss/vite": "catalog:",
@@ -905,25 +905,26 @@
     },
   },
   "trustedDependencies": [
-    "esbuild",
     "tree-sitter-powershell",
-    "protobufjs",
-    "electron",
     "web-tree-sitter",
     "tree-sitter-bash",
+    "esbuild",
+    "electron",
+    "protobufjs",
   ],
   "patchedDependencies": {
     "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
     "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
-    "virtua@0.49.1": "patches/virtua@0.49.1.patch",
+    "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
+    "pacote@21.5.0": "patches/pacote@21.5.0.patch",
+    "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
     "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
     "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",
+    "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
+    "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
+    "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
   },
   "overrides": {
     "@opentui/core": "catalog:",
@@ -947,15 +948,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",
@@ -981,14 +984,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",
@@ -2124,9 +2126,11 @@
 
     "@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
 
-    "@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@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
 
-    "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="],
+    "@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="],
 
     "@pierre/trees": ["@pierre/trees@1.0.0-beta.4", "", { "dependencies": { "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag=="],
 
@@ -2332,13 +2336,17 @@
 
     "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
 
-    "@shikijs/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=="],
 
@@ -2582,6 +2590,10 @@
 
     "@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
 
+    "@tanstack/solid-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=="],
@@ -4852,7 +4864,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=="],
 
@@ -5262,8 +5274,6 @@
 
     "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
 
-    "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=="],
@@ -5512,6 +5522,8 @@
 
     "@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
 
+    "@astrojs/markdown-remark/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=="],
@@ -5850,13 +5862,17 @@
 
     "@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
 
-    "@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@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/langs/@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/primitive/@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/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=="],
 
@@ -5966,6 +5982,8 @@
 
     "astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
 
+    "astro/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=="],
@@ -6210,9 +6228,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=="],
 
@@ -6354,6 +6372,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=="],
@@ -6650,6 +6680,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=="],
@@ -6716,6 +6748,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=="],

+ 1 - 1
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"

+ 7 - 5
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,12 +146,13 @@
     "@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",
+    "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
     "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
-    "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch"
+    "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
+    "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
   }
 }

+ 96 - 6
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<HTMLElement>(".scroll-view__viewport")!
+      element.scrollIntoView({ block: "start" })
+      const result = []
+      for (const offset of [0, 120, 240, 360, 480]) {
+        root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
+        await new Promise(requestAnimationFrame)
+        const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
+        const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
+        result.push({
+          offset,
+          trigger: trigger.getBoundingClientRect().y,
+          diff: diff.getBoundingClientRect().y,
+          bottom: element.getBoundingClientRect().bottom,
+        })
+      }
+      return result
+    })
+
+    expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
+    expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
+    expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
   })
 })
 
@@ -247,10 +321,16 @@ async function markDiffProbe(page: Page) {
     .evaluate((element) => {
       const tool = element as HTMLElement
       const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
+      const row = tool.closest<HTMLElement>("[data-timeline-key]")
+      const frame = tool.closest<HTMLElement>("[data-timeline-row]")
       if (!file) throw new Error("missing edit diff file")
+      if (!row) throw new Error("missing virtual timeline row")
+      if (!frame) throw new Error("missing timeline row frame")
 
       tool.dataset.timelineProbe = "before"
       file.dataset.timelineProbe = "before"
+      row.dataset.timelineProbe = "before"
+      frame.dataset.timelineProbe = "before"
       window.__timelineDiffProbe.reset()
     })
 }
@@ -262,10 +342,15 @@ async function readDiffProbe(page: Page) {
     .evaluate((element) => {
       const tool = element as HTMLElement
       const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
+      const row = tool.closest<HTMLElement>("[data-timeline-key]")
+      const frame = tool.closest<HTMLElement>("[data-timeline-row]")
       return {
         fileMarker: file?.dataset.timelineProbe,
         shadowRoots: window.__timelineDiffProbe.shadowRoots(),
         toolMarker: tool.dataset.timelineProbe,
+        rowMarker: row?.dataset.timelineProbe,
+        rowKey: row?.dataset.timelineKey,
+        frameMarker: frame?.dataset.timelineProbe,
       }
     })
 }
@@ -300,14 +385,19 @@ function readExpanded(element: Element) {
   return !!content && content.getBoundingClientRect().height > 0
 }
 
-async function mockServer(page: Page, events: EventPayload[]) {
+async function mockServer(
+  page: Page,
+  events: EventPayload[],
+  messages = [userMessage, assistantMessage],
+) {
   await mockOpenCodeServer(page, {
     directory,
     project: project(),
     provider: provider(),
     sessions: [session()],
-    pageMessages: () => ({ items: [userMessage, assistantMessage] }),
-    events: () => events.splice(0),
+    pageMessages: () => ({ items: messages }),
+    events: () => events.splice(0, 1),
+    eventRetry: 16,
   })
 }
 

+ 9 - 9
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)
       }),

+ 273 - 0
packages/app/e2e/smoke/session-timeline.spec.ts

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

+ 10 - 4
packages/app/e2e/utils/mock-server.ts

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

+ 4 - 3
packages/app/package.json

@@ -17,8 +17,9 @@
     "dev": "vite",
     "build": "vite build",
     "serve": "vite preview",
-    "test": "bun run test:unit",
+    "test": "bun run test:unit && bun run test:virtualizer",
     "test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
+    "test:virtualizer": "bun test --conditions=browser --preload ./happydom.ts ./test-browser/solid-virtual.test.ts",
     "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
     "test:e2e": "playwright test",
     "test:e2e:local": "playwright test",
@@ -64,6 +65,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:",
@@ -76,7 +78,6 @@
     "shiki": "catalog:",
     "solid-js": "catalog:",
     "solid-list": "catalog:",
-    "tailwindcss": "catalog:",
-    "virtua": "catalog:"
+    "tailwindcss": "catalog:"
   }
 }

+ 20 - 0
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({

+ 7 - 1
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,

+ 40 - 1
packages/app/src/context/server-sdk.test.ts

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

+ 36 - 10
packages/app/src/context/server-sdk.tsx

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

+ 2 - 2
packages/app/src/pages/layout.tsx

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

+ 81 - 225
packages/app/src/pages/session.tsx

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

+ 30 - 0
packages/app/src/pages/session/timeline/measure.test.ts

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

+ 5 - 0
packages/app/src/pages/session/timeline/measure.ts

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

+ 310 - 332
packages/app/src/pages/session/message-timeline.tsx → packages/app/src/pages/session/timeline/message-timeline.tsx

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

+ 101 - 0
packages/app/src/pages/session/timeline/model.test.ts

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

+ 152 - 0
packages/app/src/pages/session/timeline/model.ts

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

+ 113 - 0
packages/app/src/pages/session/timeline/projection.ts

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

+ 9 - 11
packages/app/src/pages/session/message-timeline.data.ts → packages/app/src/pages/session/timeline/rows.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,
       }),
     )
 

+ 46 - 0
packages/app/test-browser/solid-virtual.test.ts

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

+ 5 - 5
packages/opencode/test/share/share-next.test.ts

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

+ 2 - 2
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"
   }
 }

+ 4 - 0
packages/ui/src/components/file.css

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

+ 1 - 1
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<VirtualFileMetrics>
 
 type SharedProps<T> = {

+ 32 - 0
packages/ui/src/components/markdown-code-state.test.ts

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

+ 22 - 0
packages/ui/src/components/markdown-code-state.ts

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

+ 104 - 0
packages/ui/src/components/markdown-shiki.worker.ts

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

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

+ 77 - 16
packages/ui/src/components/markdown-stream.ts

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

+ 77 - 0
packages/ui/src/components/markdown-worker-protocol.test.ts

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

+ 48 - 0
packages/ui/src/components/markdown-worker-protocol.ts

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

+ 49 - 0
packages/ui/src/components/markdown-worker-queue.test.ts

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

+ 64 - 0
packages/ui/src/components/markdown-worker-queue.ts

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

+ 56 - 0
packages/ui/src/components/markdown-worker-transport.test.ts

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

+ 41 - 0
packages/ui/src/components/markdown-worker-transport.ts

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

+ 122 - 0
packages/ui/src/components/markdown-worker.ts

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

+ 8 - 0
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,
@@ -121,6 +127,8 @@
   }
 
   .shiki {
+    background: var(--color-background-stronger);
+    color: var(--text-base);
     font-size: 13px;
     padding: 12px;
     border-radius: 6px;

+ 314 - 45
packages/ui/src/components/markdown.tsx

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

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

+ 36 - 1
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)
+  })
+})

+ 32 - 16
packages/ui/src/components/scroll-view.tsx

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

+ 7 - 4
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

+ 9 - 18
packages/ui/src/pierre/index.ts

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

+ 1 - 1
packages/ui/src/pierre/virtualizer.ts

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

+ 45 - 0
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

+ 58 - 0
patches/@tanstack%2Fvirtual-core@3.17.0.patch

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

+ 0 - 93
patches/virtua@0.49.1.patch

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