Browse Source

Merge branch 'dev' into effect-sync-event

Kit Langton 3 weeks ago
parent
commit
44e96fd358
73 changed files with 407 additions and 331 deletions
  1. 14 2
      bun.lock
  2. 4 4
      nix/hashes.json
  3. 2 1
      packages/app/package.json
  4. 2 2
      packages/app/src/components/debug-bar.tsx
  5. 5 10
      packages/app/src/components/prompt-input/attachments.ts
  6. 3 6
      packages/app/src/components/server/server-row.tsx
  7. 2 2
      packages/app/src/components/settings-keybinds.tsx
  8. 2 5
      packages/app/src/context/command.tsx
  9. 10 14
      packages/app/src/context/global-sdk.tsx
  10. 3 4
      packages/app/src/context/layout.tsx
  11. 7 14
      packages/app/src/pages/layout.tsx
  12. 4 5
      packages/app/src/pages/session.tsx
  13. 3 6
      packages/app/src/pages/session/composer/session-composer-region.tsx
  14. 2 2
      packages/app/src/pages/session/composer/session-composer-state.ts
  15. 5 6
      packages/app/src/pages/session/composer/session-question-dock.tsx
  16. 2 3
      packages/app/src/pages/session/composer/session-todo-dock.tsx
  17. 16 24
      packages/app/src/pages/session/file-tabs.tsx
  18. 4 8
      packages/app/src/pages/session/helpers.ts
  19. 7 13
      packages/app/src/pages/session/review-tab.tsx
  20. 3 6
      packages/app/src/pages/session/terminal-panel.tsx
  21. 2 0
      packages/console/app/src/i18n/ar.ts
  22. 2 0
      packages/console/app/src/i18n/br.ts
  23. 2 0
      packages/console/app/src/i18n/da.ts
  24. 2 0
      packages/console/app/src/i18n/de.ts
  25. 2 0
      packages/console/app/src/i18n/en.ts
  26. 2 0
      packages/console/app/src/i18n/es.ts
  27. 2 0
      packages/console/app/src/i18n/fr.ts
  28. 2 0
      packages/console/app/src/i18n/it.ts
  29. 2 0
      packages/console/app/src/i18n/ja.ts
  30. 2 0
      packages/console/app/src/i18n/ko.ts
  31. 2 0
      packages/console/app/src/i18n/no.ts
  32. 2 0
      packages/console/app/src/i18n/pl.ts
  33. 2 0
      packages/console/app/src/i18n/ru.ts
  34. 2 0
      packages/console/app/src/i18n/th.ts
  35. 2 0
      packages/console/app/src/i18n/tr.ts
  36. 1 0
      packages/console/app/src/i18n/zh.ts
  37. 1 0
      packages/console/app/src/i18n/zht.ts
  38. 8 0
      packages/console/app/src/routes/zen/util/handler.ts
  39. 1 0
      packages/console/core/src/model.ts
  40. 1 0
      packages/opencode/package.json
  41. 2 0
      packages/opencode/script/build-node.ts
  42. 6 2
      packages/opencode/src/account/index.ts
  43. 1 0
      packages/opencode/src/agent/prompt/compaction.txt
  44. 53 4
      packages/opencode/src/cli/ui.ts
  45. 24 4
      packages/opencode/src/index.ts
  46. 2 0
      packages/opencode/src/provider/provider.ts
  47. 1 0
      packages/opencode/src/session/compaction.ts
  48. 11 1
      packages/opencode/src/session/prompt.ts
  49. 1 1
      packages/opencode/src/session/retry.ts
  50. 7 4
      packages/opencode/test/account/service.test.ts
  51. 31 2
      packages/opencode/test/session/prompt-effect.test.ts
  52. 2 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  53. 1 0
      packages/sdk/js/src/v2/gen/types.gen.ts
  54. 4 0
      packages/sdk/openapi.json
  55. 1 0
      packages/ui/package.json
  56. 5 11
      packages/ui/src/components/file.tsx
  57. 0 5
      packages/ui/src/components/line-comment-annotations.tsx
  58. 6 13
      packages/ui/src/components/line-comment.tsx
  59. 3 3
      packages/ui/src/components/list.tsx
  60. 23 8
      packages/ui/src/components/message-part.tsx
  61. 5 19
      packages/ui/src/components/popover.tsx
  62. 3 14
      packages/ui/src/components/scroll-view.tsx
  63. 1 4
      packages/ui/src/components/session-turn.tsx
  64. 3 8
      packages/ui/src/components/text-strikethrough.stories.tsx
  65. 3 9
      packages/ui/src/components/text-strikethrough.tsx
  66. 2 2
      packages/ui/src/context/dialog.tsx
  67. 3 15
      packages/ui/src/hooks/create-auto-scroll.tsx
  68. 14 15
      packages/ui/src/pierre/file-find.ts
  69. 5 8
      packages/ui/src/theme/context.tsx
  70. 1 0
      packages/web/package.json
  71. 3 11
      packages/web/src/components/Share.tsx
  72. 10 12
      packages/web/src/components/share/common.tsx
  73. 28 19
      packages/web/src/components/share/content-diff.tsx

+ 14 - 2
bun.lock

@@ -36,9 +36,10 @@
         "@solid-primitives/active-element": "2.1.3",
         "@solid-primitives/audio": "1.4.2",
         "@solid-primitives/event-bus": "1.1.2",
+        "@solid-primitives/event-listener": "2.4.5",
         "@solid-primitives/i18n": "2.2.1",
         "@solid-primitives/media": "2.3.3",
-        "@solid-primitives/resize-observer": "2.1.3",
+        "@solid-primitives/resize-observer": "2.1.5",
         "@solid-primitives/scroll": "2.1.3",
         "@solid-primitives/storage": "catalog:",
         "@solid-primitives/timer": "1.4.4",
@@ -382,6 +383,7 @@
         "tree-sitter-powershell": "0.25.10",
         "turndown": "7.2.0",
         "ulid": "catalog:",
+        "venice-ai-sdk-provider": "2.0.1",
         "vscode-jsonrpc": "8.2.1",
         "web-tree-sitter": "0.25.10",
         "which": "6.0.1",
@@ -513,6 +515,7 @@
         "@pierre/diffs": "catalog:",
         "@shikijs/transformers": "3.9.2",
         "@solid-primitives/bounds": "0.1.3",
+        "@solid-primitives/event-listener": "2.4.5",
         "@solid-primitives/media": "2.3.3",
         "@solid-primitives/resize-observer": "2.1.3",
         "@solidjs/meta": "catalog:",
@@ -571,6 +574,7 @@
         "@astrojs/starlight": "0.34.3",
         "@fontsource/ibm-plex-mono": "5.2.5",
         "@shikijs/transformers": "3.20.0",
+        "@solid-primitives/resize-observer": "2.1.5",
         "@types/luxon": "catalog:",
         "ai": "catalog:",
         "astro": "5.7.13",
@@ -1933,7 +1937,7 @@
 
     "@solid-primitives/refs": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="],
 
-    "@solid-primitives/resize-observer": ["@solid-primitives/[email protected].3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
+    "@solid-primitives/resize-observer": ["@solid-primitives/[email protected].5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw=="],
 
     "@solid-primitives/rootless": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="],
 
@@ -4755,6 +4759,8 @@
 
     "vary": ["[email protected]", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
 
+    "venice-ai-sdk-provider": ["[email protected]", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.37", "@ai-sdk/provider": "^3.0.8", "@ai-sdk/provider-utils": "^4.0.21" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-6SxA8a4MoA6Q/c+D3q7My0Hfog76enN3n0MXhwosM+tso66rXBEGeBRD/0lravRDVzL2Q1w5QJPc86rAVJtfXg=="],
+
     "verror": ["[email protected]", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
 
     "vfile": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
@@ -5209,6 +5215,8 @@
 
     "@jsx-email/doiuse-email/htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
 
+    "@kobalte/core/@solid-primitives/resize-observer": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
+
     "@malept/flatpak-bundler/fs-extra": ["[email protected]", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
 
     "@mdx-js/mdx/source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
@@ -5301,6 +5309,8 @@
 
     "@opencode-ai/desktop-electron/typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
 
+    "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
+
     "@opencode-ai/web/@shikijs/transformers": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
 
     "@opentui/solid/@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
@@ -5363,6 +5373,8 @@
 
     "@smithy/util-stream/@smithy/util-utf8": ["@smithy/[email protected]", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
 
+    "@solid-primitives/bounds/@solid-primitives/resize-observer": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
+
     "@solidjs/start/path-to-regexp": ["[email protected]", "", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="],
 
     "@solidjs/start/shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-bjfe8/aD0hvUQQEfaNdmKV/Y3dzpf8oz1OUJdgf61WI=",
-    "aarch64-linux": "sha256-iU9v+ekSCB/qTUG+pOOpSMhPh+0hWnWU5jzDNllEkxU=",
-    "aarch64-darwin": "sha256-SgNydQLeAjbX0J49f2VKcgKg2Y30pK826R2qQJBMWE4=",
-    "x86_64-darwin": "sha256-/rzwNuI9x55qi0UcU7QvPUTupErmkt62T09g1omXkQk="
+    "x86_64-linux": "sha256-SQVfq41OQdGCgWuWqyqIN6aggL0r3Hzn2hJ9BwPJN+I=",
+    "aarch64-linux": "sha256-4w/1HhxsTzPFTHNf4JlnKle6Boz1gVTEedWG64T8E/M=",
+    "aarch64-darwin": "sha256-uMd+pU1u1yqP4OP/9461Tyy3zwwv/llr+rlllLjM98A=",
+    "x86_64-darwin": "sha256-BhIW3FPqKkM2vGfCrxXUvj5tarey33Q7dxCuaj5A+yU="
   }
 }

+ 2 - 1
packages/app/package.json

@@ -46,9 +46,10 @@
     "@solid-primitives/active-element": "2.1.3",
     "@solid-primitives/audio": "1.4.2",
     "@solid-primitives/event-bus": "1.1.2",
+    "@solid-primitives/event-listener": "2.4.5",
     "@solid-primitives/i18n": "2.2.1",
     "@solid-primitives/media": "2.3.3",
-    "@solid-primitives/resize-observer": "2.1.3",
+    "@solid-primitives/resize-observer": "2.1.5",
     "@solid-primitives/scroll": "2.1.3",
     "@solid-primitives/storage": "catalog:",
     "@solid-primitives/timer": "1.4.4",

+ 2 - 2
packages/app/src/components/debug-bar.tsx

@@ -1,6 +1,7 @@
 import { useIsRouting, useLocation } from "@solidjs/router"
 import { batch, createEffect, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { useLanguage } from "@/context/language"
 
@@ -349,13 +350,12 @@ export function DebugBar() {
 
     syncHeap()
     start()
-    document.addEventListener("visibilitychange", vis)
+    makeEventListener(document, "visibilitychange", vis)
 
     onCleanup(() => {
       if (one !== 0) cancelAnimationFrame(one)
       if (two !== 0) cancelAnimationFrame(two)
       stop()
-      document.removeEventListener("visibilitychange", vis)
       for (const ob of obs) ob.disconnect()
     })
   })

+ 5 - 10
packages/app/src/components/prompt-input/attachments.ts

@@ -1,4 +1,5 @@
-import { onCleanup, onMount } from "solid-js"
+import { onMount } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { showToast } from "@opencode-ai/ui/toast"
 import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
 import { useLanguage } from "@/context/language"
@@ -181,15 +182,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
   }
 
   onMount(() => {
-    document.addEventListener("dragover", handleGlobalDragOver)
-    document.addEventListener("dragleave", handleGlobalDragLeave)
-    document.addEventListener("drop", handleGlobalDrop)
-  })
-
-  onCleanup(() => {
-    document.removeEventListener("dragover", handleGlobalDragOver)
-    document.removeEventListener("dragleave", handleGlobalDragLeave)
-    document.removeEventListener("drop", handleGlobalDrop)
+    makeEventListener(document, "dragover", handleGlobalDragOver)
+    makeEventListener(document, "dragleave", handleGlobalDragLeave)
+    makeEventListener(document, "drop", handleGlobalDrop)
   })
 
   return {

+ 3 - 6
packages/app/src/components/server/server-row.tsx

@@ -1,11 +1,11 @@
 import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
 import {
   children,
   createEffect,
   createMemo,
   createSignal,
   type JSXElement,
-  onCleanup,
   onMount,
   type ParentProps,
   Show,
@@ -46,12 +46,9 @@ export function ServerRow(props: ServerRowProps) {
   })
 
   onMount(() => {
-    check()
     if (typeof ResizeObserver !== "function") return
-    const observer = new ResizeObserver(check)
-    if (nameRef) observer.observe(nameRef)
-    if (versionRef) observer.observe(versionRef)
-    onCleanup(() => observer.disconnect())
+    createResizeObserver([nameRef, versionRef], check)
+    check()
   })
 
   const tooltipValue = () => (

+ 2 - 2
packages/app/src/components/settings-keybinds.tsx

@@ -1,5 +1,6 @@
 import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -250,8 +251,7 @@ function useKeyCapture(input: {
       input.stop()
     }
 
-    document.addEventListener("keydown", handle, true)
-    onCleanup(() => document.removeEventListener("keydown", handle, true))
+    makeEventListener(document, "keydown", handle, { capture: true })
   })
 }
 

+ 2 - 5
packages/app/src/context/command.tsx

@@ -2,6 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
 import { dict as en } from "@/i18n/en"
@@ -378,11 +379,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     }
 
     onMount(() => {
-      document.addEventListener("keydown", handleKeyDown)
-    })
-
-    onCleanup(() => {
-      document.removeEventListener("keydown", handleKeyDown)
+      makeEventListener(document, "keydown", handleKeyDown)
     })
 
     function register(cb: () => CommandOption[]): void

+ 10 - 14
packages/app/src/context/global-sdk.tsx

@@ -1,7 +1,8 @@
 import type { Event } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
-import { batch, onCleanup } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
+import { batch, onCleanup, onMount } from "solid-js"
 import z from "zod"
 import { createSdkForServer } from "@/utils/server"
 import { useLanguage } from "./language"
@@ -206,21 +207,16 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
       clearHeartbeat()
     }
 
-    const onVisibility = () => {
-      if (typeof document === "undefined") return
-      if (document.visibilityState !== "visible") return
-      if (!started) return
-      if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
-      attempt?.abort()
-    }
-    if (typeof document !== "undefined") {
-      document.addEventListener("visibilitychange", onVisibility)
-    }
+    onMount(() => {
+      makeEventListener(document, "visibilitychange", () => {
+        if (document.visibilityState !== "visible") return
+        if (!started) return
+        if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
+        attempt?.abort()
+      })
+    })
 
     onCleanup(() => {
-      if (typeof document !== "undefined") {
-        document.removeEventListener("visibilitychange", onVisibility)
-      }
       stop()
       abort.abort()
       flush()

+ 3 - 4
packages/app/src/context/layout.tsx

@@ -1,6 +1,7 @@
 import { createStore, produce } from "solid-js/store"
 import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSDK } from "./global-sdk"
 import { useServer } from "./server"
@@ -366,12 +367,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         flush()
       }
 
-      window.addEventListener("pagehide", flush)
-      document.addEventListener("visibilitychange", handleVisibility)
+      makeEventListener(window, "pagehide", flush)
+      makeEventListener(document, "visibilitychange", handleVisibility)
 
       onCleanup(() => {
-        window.removeEventListener("pagehide", flush)
-        document.removeEventListener("visibilitychange", handleVisibility)
         scroll.dispose()
       })
     })

+ 7 - 14
packages/app/src/pages/layout.tsx

@@ -12,6 +12,7 @@ import {
   untrack,
   type Accessor,
 } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { useNavigate, useParams } from "@solidjs/router"
 import { useLayout, LocalProject } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
@@ -215,18 +216,11 @@ export default function Layout(props: ParentProps) {
       if (document.visibilityState !== "hidden") return
       reset()
     }
-    window.addEventListener("pointerup", stop)
-    window.addEventListener("pointercancel", stop)
-    window.addEventListener("blur", stop)
-    window.addEventListener("blur", blur)
-    document.addEventListener("visibilitychange", hide)
-    onCleanup(() => {
-      window.removeEventListener("pointerup", stop)
-      window.removeEventListener("pointercancel", stop)
-      window.removeEventListener("blur", stop)
-      window.removeEventListener("blur", blur)
-      document.removeEventListener("visibilitychange", hide)
-    })
+    makeEventListener(window, "pointerup", stop)
+    makeEventListener(window, "pointercancel", stop)
+    makeEventListener(window, "blur", stop)
+    makeEventListener(window, "blur", blur)
+    makeEventListener(document, "visibilitychange", hide)
   })
 
   const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
@@ -1394,8 +1388,7 @@ export default function Layout(props: ParentProps) {
     }
 
     handleDeepLinks(drainPendingDeepLinks(window))
-    window.addEventListener(deepLinkEvent, handler as EventListener)
-    onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
+    makeEventListener(window, deepLinkEvent, handler as EventListener)
   })
 
   async function renameProject(project: LocalProject, next: string) {

+ 4 - 5
packages/app/src/pages/session.tsx

@@ -14,6 +14,7 @@ import {
   onMount,
   untrack,
 } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { createMediaQuery } from "@solid-primitives/media"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { useLocal } from "@/context/local"
@@ -329,10 +330,9 @@ export default function Page() {
   const { params, sessionKey, tabs, view } = useSessionLayout()
 
   createEffect(() => {
-    if (!untrack(() => prompt.ready())) return
-    prompt.ready()
+    if (!prompt.ready()) return
     untrack(() => {
-      if (params.id || !prompt.ready()) return
+      if (params.id) return
       const text = searchParams.prompt
       if (!text) return
       prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
@@ -1688,11 +1688,10 @@ export default function Page() {
   )
 
   onMount(() => {
-    document.addEventListener("keydown", handleKeyDown)
+    makeEventListener(document, "keydown", handleKeyDown)
   })
 
   onCleanup(() => {
-    document.removeEventListener("keydown", handleKeyDown)
     if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
     if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
     if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)

+ 3 - 6
packages/app/src/pages/session/composer/session-composer-region.tsx

@@ -13,6 +13,7 @@ import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
 import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
 import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
 import type { FollowupDraft } from "@/components/prompt-input/submit"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
 
 export function SessionComposerRegion(props: {
   state: SessionComposerState
@@ -115,13 +116,9 @@ export function SessionComposerRegion(props: {
   createEffect(() => {
     const el = store.body
     if (!el) return
-    const update = () => {
-      setStore("height", el.getBoundingClientRect().height)
-    }
+    const update = () => setStore("height", el.getBoundingClientRect().height)
+    createResizeObserver(store.body, update)
     update()
-    const observer = new ResizeObserver(update)
-    observer.observe(el)
-    onCleanup(() => observer.disconnect())
   })
 
   return (

+ 2 - 2
packages/app/src/pages/session/composer/session-composer-state.ts

@@ -1,5 +1,6 @@
 import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
 import { useParams } from "@solidjs/router"
 import { showToast } from "@opencode-ai/ui/toast"
@@ -86,8 +87,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
       pull()
     }
 
-    window.addEventListener(composerEvent, onEvent)
-    onCleanup(() => window.removeEventListener(composerEvent, onEvent))
+    makeEventListener(window, composerEvent, onEvent)
   })
 
   const todos = createMemo((): Todo[] => {

+ 5 - 6
packages/app/src/pages/session/composer/session-question-dock.tsx

@@ -8,6 +8,8 @@ import { showToast } from "@opencode-ai/ui/toast"
 import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
 import { useLanguage } from "@/context/language"
 import { useSDK } from "@/context/sdk"
+import { makeEventListener } from "@solid-primitives/event-listener"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
 
 const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
 
@@ -172,17 +174,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     }
 
     update()
-    window.addEventListener("resize", update)
+
+    makeEventListener(window, "resize", update)
 
     const dock = root?.closest('[data-component="session-prompt-dock"]')
     const scroller = document.querySelector(".scroll-view__viewport")
-    const observer = new ResizeObserver(update)
-    if (dock instanceof HTMLElement) observer.observe(dock)
-    if (scroller instanceof HTMLElement) observer.observe(scroller)
+    createResizeObserver([dock, scroller], update)
 
     onCleanup(() => {
-      window.removeEventListener("resize", update)
-      observer.disconnect()
       if (raf !== undefined) cancelAnimationFrame(raf)
     })
 

+ 2 - 3
packages/app/src/pages/session/composer/session-todo-dock.tsx

@@ -6,6 +6,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { TextReveal } from "@opencode-ai/ui/text-reveal"
 import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { composerEnabled, composerProbe } from "@/testing/session-composer"
@@ -91,9 +92,7 @@ export function SessionTodoDock(props: {
       setStore("height", el.getBoundingClientRect().height)
     }
     update()
-    const observer = new ResizeObserver(update)
-    observer.observe(el)
-    onCleanup(() => observer.disconnect())
+    createResizeObserver(el, update)
   })
 
   createEffect(() => {

+ 16 - 24
packages/app/src/pages/session/file-tabs.tsx

@@ -1,6 +1,7 @@
-import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
+import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import type { FileSearchHandle } from "@opencode-ai/ui/file"
 import { useFileComponent } from "@opencode-ai/ui/context/file"
 import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
@@ -59,7 +60,7 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
   let scrollFrame: number | undefined
   let restoreFrame: number | undefined
   let pending: ScrollPos | undefined
-  let code: HTMLElement[] = []
+  const [code, setCode] = createSignal<HTMLElement[]>([])
 
   const getCode = () => {
     const el = scroll
@@ -106,17 +107,9 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
 
   const sync = () => {
     const next = getCode()
-    if (next.length === code.length && next.every((el, i) => el === code[i])) return
-
-    for (const item of code) {
-      item.removeEventListener("scroll", onCodeScroll)
-    }
-
-    code = next
-
-    for (const item of code) {
-      item.addEventListener("scroll", onCodeScroll)
-    }
+    const current = code()
+    if (next.length === current.length && next.every((el, i) => el === current[i])) return
+    setCode(next)
   }
 
   const restore = () => {
@@ -128,14 +121,14 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
 
     sync()
 
-    if (code.length > 0) {
-      for (const item of code) {
+    if (code().length > 0) {
+      for (const item of code()) {
         if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
       }
     }
 
     if (el.scrollTop !== pos.y) el.scrollTop = pos.y
-    if (code.length > 0) return
+    if (code().length > 0) return
     if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
   }
 
@@ -149,24 +142,24 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
   }
 
   const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
-    if (code.length === 0) sync()
+    if (code().length === 0) sync()
 
     save({
-      x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
+      x: code()[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
       y: event.currentTarget.scrollTop,
     })
   }
 
+  createEffect(() => {
+    for (const item of code()) makeEventListener(item, "scroll", onCodeScroll)
+  })
+
   const setViewport = (el: HTMLDivElement) => {
     scroll = el
     restore()
   }
 
   onCleanup(() => {
-    for (const item of code) {
-      item.removeEventListener("scroll", onCodeScroll)
-    }
-
     if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
     if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
   })
@@ -358,8 +351,7 @@ export function FileTabContent(props: { tab: string }) {
       find?.focus()
     }
 
-    window.addEventListener("keydown", onKeyDown, { capture: true })
-    onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
+    makeEventListener(window, "keydown", onKeyDown, { capture: true })
   })
 
   createEffect(

+ 4 - 8
packages/app/src/pages/session/helpers.ts

@@ -1,5 +1,6 @@
 import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { same } from "@/utils/same"
 
 const emptyTabs: string[] = []
@@ -171,14 +172,9 @@ export const createSizing = () => {
   }
 
   onMount(() => {
-    window.addEventListener("pointerup", stop)
-    window.addEventListener("pointercancel", stop)
-    window.addEventListener("blur", stop)
-    onCleanup(() => {
-      window.removeEventListener("pointerup", stop)
-      window.removeEventListener("pointercancel", stop)
-      window.removeEventListener("blur", stop)
-    })
+    makeEventListener(window, "pointerup", stop)
+    makeEventListener(window, "pointercancel", stop)
+    makeEventListener(window, "blur", stop)
   })
 
   onCleanup(() => {

+ 7 - 13
packages/app/src/pages/session/review-tab.tsx

@@ -1,4 +1,5 @@
-import { createEffect, onCleanup, type JSX } from "solid-js"
+import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import type { FileDiff } from "@opencode-ai/sdk/v2"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import type {
@@ -123,13 +124,6 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
 
   onCleanup(() => {
     if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
-    if (scroll) {
-      scroll.removeEventListener("wheel", handleInteraction, { capture: true })
-      scroll.removeEventListener("mousewheel", handleInteraction, { capture: true })
-      scroll.removeEventListener("pointerdown", handleInteraction, { capture: true })
-      scroll.removeEventListener("touchstart", handleInteraction, { capture: true })
-      scroll.removeEventListener("keydown", handleInteraction, { capture: true })
-    }
   })
 
   return (
@@ -138,11 +132,11 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
       empty={props.empty}
       scrollRef={(el) => {
         scroll = el
-        el.addEventListener("wheel", handleInteraction, { passive: true, capture: true })
-        el.addEventListener("mousewheel", handleInteraction, { passive: true, capture: true })
-        el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true })
-        el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true })
-        el.addEventListener("keydown", handleInteraction, { passive: true, capture: true })
+        makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true })
+        makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true })
+        makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true })
+        makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true })
+        makeEventListener(el, "keydown", handleInteraction, { capture: true })
         props.onScrollRef?.(el)
         queueRestore()
       }}

+ 3 - 6
packages/app/src/pages/session/terminal-panel.tsx

@@ -1,5 +1,6 @@
 import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -50,12 +51,8 @@ export function TerminalPanel() {
     const port = window.visualViewport
 
     sync()
-    window.addEventListener("resize", sync)
-    port?.addEventListener("resize", sync)
-    onCleanup(() => {
-      window.removeEventListener("resize", sync)
-      port?.removeEventListener("resize", sync)
-    })
+    makeEventListener(window, "resize", sync)
+    if (port) makeEventListener(port, "resize", sync)
   })
 
   createEffect(() => {

+ 2 - 0
packages/console/app/src/i18n/ar.ts

@@ -363,6 +363,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
   "zen.api.error.modelDisabled": "النموذج معطل",
+  "zen.api.error.trialEnded":
+    "انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}",
 
   "black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم",
   "black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.",

+ 2 - 0
packages/console/app/src/i18n/br.ts

@@ -371,6 +371,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
   "zen.api.error.modelDisabled": "O modelo está desabilitado",
+  "zen.api.error.trialEnded":
+    "A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}",
 
   "black.meta.title": "OpenCode Black | Acesse os melhores modelos de codificação do mundo",
   "black.meta.description": "Tenha acesso ao Claude, GPT, Gemini e mais com os planos de assinatura OpenCode Black.",

+ 2 - 0
packages/console/app/src/i18n/da.ts

@@ -368,6 +368,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
   "zen.api.error.modelDisabled": "Modellen er deaktiveret",
+  "zen.api.error.trialEnded":
+    "Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}",
 
   "black.meta.title": "OpenCode Black | Få adgang til verdens bedste kodningsmodeller",
   "black.meta.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.",

+ 2 - 0
packages/console/app/src/i18n/de.ts

@@ -371,6 +371,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
   "zen.api.error.modelDisabled": "Modell ist deaktiviert",
+  "zen.api.error.trialEnded":
+    "Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}",
 
   "black.meta.title": "OpenCode Black | Zugriff auf die weltweit besten Coding-Modelle",
   "black.meta.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.",

+ 2 - 0
packages/console/app/src/i18n/en.ts

@@ -364,6 +364,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
   "zen.api.error.modelDisabled": "Model is disabled",
+  "zen.api.error.trialEnded":
+    "Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}",
 
   "black.meta.title": "OpenCode Black | Access all the world's best coding models",
   "black.meta.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.",

+ 2 - 0
packages/console/app/src/i18n/es.ts

@@ -371,6 +371,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
   "zen.api.error.modelDisabled": "El modelo está deshabilitado",
+  "zen.api.error.trialEnded":
+    "La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}",
 
   "black.meta.title": "OpenCode Black | Accede a los mejores modelos de codificación del mundo",
   "black.meta.description": "Obtén acceso a Claude, GPT, Gemini y más con los planes de suscripción de OpenCode Black.",

+ 2 - 0
packages/console/app/src/i18n/fr.ts

@@ -372,6 +372,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}",
   "zen.api.error.modelDisabled": "Le modèle est désactivé",
+  "zen.api.error.trialEnded":
+    "La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}",
 
   "black.meta.title": "OpenCode Black | Accédez aux meilleurs modèles de code au monde",
   "black.meta.description": "Accédez à Claude, GPT, Gemini et plus avec les forfaits d'abonnement OpenCode Black.",

+ 2 - 0
packages/console/app/src/i18n/it.ts

@@ -367,6 +367,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}",
   "zen.api.error.modelDisabled": "Il modello è disabilitato",
+  "zen.api.error.trialEnded":
+    "La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}",
 
   "black.meta.title": "OpenCode Black | Accedi ai migliori modelli di coding al mondo",
   "black.meta.description":

+ 2 - 0
packages/console/app/src/i18n/ja.ts

@@ -369,6 +369,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}",
   "zen.api.error.modelDisabled": "モデルが無効です",
+  "zen.api.error.trialEnded":
+    "{{model}} の無料プロモーションは終了しました。OpenCode Go を購読するとモデルを引き続き使用できます - {{link}}",
 
   "black.meta.title": "OpenCode Black | 世界最高峰のコーディングモデルすべてにアクセス",
   "black.meta.description": "OpenCode Black サブスクリプションプランで、Claude、GPT、Gemini などにアクセス。",

+ 2 - 0
packages/console/app/src/i18n/ko.ts

@@ -363,6 +363,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}",
   "zen.api.error.modelDisabled": "모델이 비활성화되었습니다",
+  "zen.api.error.trialEnded":
+    "{{model}}의 무료 프로모션이 종료되었습니다. OpenCode Go를 구독하면 모델을 계속 사용할 수 있습니다 - {{link}}",
 
   "black.meta.title": "OpenCode Black | 세계 최고의 코딩 모델에 액세스하세요",
   "black.meta.description": "OpenCode Black 구독 플랜으로 Claude, GPT, Gemini 등에 액세스하세요.",

+ 2 - 0
packages/console/app/src/i18n/no.ts

@@ -368,6 +368,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}",
   "zen.api.error.modelDisabled": "Modellen er deaktivert",
+  "zen.api.error.trialEnded":
+    "Den gratis kampanjen for {{model}} er avsluttet. Du kan fortsette å bruke modellen ved å abonnere på OpenCode Go - {{link}}",
 
   "black.meta.title": "OpenCode Black | Få tilgang til verdens beste kodemodeller",
   "black.meta.description": "Få tilgang til Claude, GPT, Gemini og mer med OpenCode Black-abonnementer.",

+ 2 - 0
packages/console/app/src/i18n/pl.ts

@@ -369,6 +369,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "Osiągnąłeś swój miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{membersUrl}}",
   "zen.api.error.modelDisabled": "Model jest wyłączony",
+  "zen.api.error.trialEnded":
+    "Bezpłatna promocja {{model}} dobiegła końca. Możesz dalej korzystać z modelu, subskrybując OpenCode Go - {{link}}",
 
   "black.meta.title": "OpenCode Black | Dostęp do najlepszych na świecie modeli kodujących",
   "black.meta.description": "Uzyskaj dostęp do Claude, GPT, Gemini i innych dzięki planom subskrypcji OpenCode Black.",

+ 2 - 0
packages/console/app/src/i18n/ru.ts

@@ -373,6 +373,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}",
   "zen.api.error.modelDisabled": "Модель отключена",
+  "zen.api.error.trialEnded":
+    "Бесплатная акция для {{model}} завершена. Вы можете продолжить использование модели, подписавшись на OpenCode Go - {{link}}",
 
   "black.meta.title": "OpenCode Black | Доступ к лучшим моделям для кодинга в мире",
   "black.meta.description": "Получите доступ к Claude, GPT, Gemini и другим моделям с подпиской OpenCode Black.",

+ 2 - 0
packages/console/app/src/i18n/th.ts

@@ -365,6 +365,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}",
   "zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน",
+  "zen.api.error.trialEnded":
+    "โปรโมชันฟรีสำหรับ {{model}} สิ้นสุดแล้ว คุณสามารถใช้โมเดลต่อได้โดยสมัครสมาชิก OpenCode Go - {{link}}",
 
   "black.meta.title": "OpenCode Black | เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
   "black.meta.description": "เข้าถึง Claude, GPT, Gemini และอื่นๆ ด้วยแผนสมาชิก OpenCode Black",

+ 2 - 0
packages/console/app/src/i18n/tr.ts

@@ -372,6 +372,8 @@ export const dict = {
   "zen.api.error.userMonthlyLimitReached":
     "Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}",
   "zen.api.error.modelDisabled": "Model devre dışı",
+  "zen.api.error.trialEnded":
+    "{{model}} için ücretsiz promosyon sona erdi. OpenCode Go'ya abone olarak modeli kullanmaya devam edebilirsiniz - {{link}}",
 
   "black.meta.title": "OpenCode Black | Dünyanın en iyi kodlama modellerine erişin",
   "black.meta.description": "OpenCode Black abonelik planlarıyla Claude, GPT, Gemini ve daha fazlasına erişin.",

+ 1 - 0
packages/console/app/src/i18n/zh.ts

@@ -349,6 +349,7 @@ export const dict = {
     "您的工作区已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{billingUrl}}",
   "zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}",
   "zen.api.error.modelDisabled": "模型已禁用",
+  "zen.api.error.trialEnded": "{{model}} 的限免活动已结束。您可以订阅 OpenCode Go 继续使用该模型 - {{link}}",
 
   "black.meta.title": "OpenCode Black | 访问全球顶尖编程模型",
   "black.meta.description": "通过 OpenCode Black 订阅计划使用 Claude, GPT, Gemini 等模型。",

+ 1 - 0
packages/console/app/src/i18n/zht.ts

@@ -349,6 +349,7 @@ export const dict = {
     "你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}",
   "zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}",
   "zen.api.error.modelDisabled": "模型已停用",
+  "zen.api.error.trialEnded": "{{model}} 的限免活动已結束。您可以訂閱 OpenCode Go 繼續使用該模型 - {{link}}",
 
   "black.meta.title": "OpenCode Black | 存取全球最佳編碼模型",
   "black.meta.description": "透過 OpenCode Black 訂閱方案存取 Claude、GPT、Gemini 等模型。",

+ 8 - 0
packages/console/app/src/routes/zen/util/handler.ts

@@ -404,6 +404,14 @@ export async function handler(
         }),
       )
 
+    if (modelData.trialEnded)
+      throw new ModelError(
+        `${t("zen.api.error.trialEnded", {
+          model: modelData.name,
+          link: "https://opencode.ai/go",
+        })}`,
+      )
+
     logger.metric({ model: modelId })
 
     return { id: modelId, ...modelData }

+ 1 - 0
packages/console/core/src/model.ts

@@ -27,6 +27,7 @@ export namespace ZenData {
     byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
     stickyProvider: z.enum(["strict", "prefer"]).optional(),
     trialProviders: z.array(z.string()).optional(),
+    trialEnded: z.boolean().optional(),
     fallbackProvider: z.string().optional(),
     rateLimit: z.number().optional(),
     providers: z.array(

+ 1 - 0
packages/opencode/package.json

@@ -147,6 +147,7 @@
     "tree-sitter-powershell": "0.25.10",
     "turndown": "7.2.0",
     "ulid": "catalog:",
+    "venice-ai-sdk-provider": "2.0.1",
     "vscode-jsonrpc": "8.2.1",
     "web-tree-sitter": "0.25.10",
     "which": "6.0.1",

+ 2 - 0
packages/opencode/script/build-node.ts

@@ -1,5 +1,6 @@
 #!/usr/bin/env bun
 
+import { Script } from "@opencode-ai/script"
 import fs from "fs"
 import path from "path"
 import { fileURLToPath } from "url"
@@ -48,6 +49,7 @@ await Bun.build({
   external: ["jsonc-parser"],
   define: {
     OPENCODE_MIGRATIONS: JSON.stringify(migrations),
+    OPENCODE_CHANNEL: `'${Script.channel}'`,
   },
 })
 

+ 6 - 2
packages/opencode/src/account/index.ts

@@ -120,6 +120,10 @@ class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefres
 
 const clientId = "opencode-cli"
 const eagerRefreshThreshold = Duration.minutes(5)
+const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold)
+
+const isTokenFresh = (tokenExpiry: number | null, now: number) =>
+  tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs
 
 const mapAccountServiceError =
   (message = "Account service operation failed") =>
@@ -219,7 +223,7 @@ export namespace Account {
 
           const account = maybeAccount.value
           const now = yield* Clock.currentTimeMillis
-          if (account.token_expiry && account.token_expiry > now + Duration.toMillis(eagerRefreshThreshold)) {
+          if (isTokenFresh(account.token_expiry, now)) {
             return account.access_token
           }
 
@@ -229,7 +233,7 @@ export namespace Account {
 
       const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
         const now = yield* Clock.currentTimeMillis
-        if (row.token_expiry && row.token_expiry > now + Duration.toMillis(eagerRefreshThreshold)) {
+        if (isTokenFresh(row.token_expiry, now)) {
           return row.access_token
         }
 

+ 1 - 0
packages/opencode/src/agent/prompt/compaction.txt

@@ -12,3 +12,4 @@ Focus on information that would be helpful for continuing the conversation, incl
 Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
 
 Do not respond to any questions in the conversation, only output the summary.
+Respond in the same language the user used in the conversation.

+ 53 - 4
packages/opencode/src/cli/ui.ts

@@ -1,6 +1,7 @@
 import z from "zod"
 import { EOL } from "os"
 import { NamedError } from "@opencode-ai/util/error"
+import { logo as glyphs } from "./logo"
 
 export namespace UI {
   const wordmark = [
@@ -47,12 +48,60 @@ export namespace UI {
   }
 
   export function logo(pad?: string) {
-    const result = []
-    for (const row of wordmark) {
+    if (!process.stdout.isTTY && !process.stderr.isTTY) {
+      const result = []
+      for (const row of wordmark) {
+        if (pad) result.push(pad)
+        result.push(row)
+        result.push(EOL)
+      }
+      return result.join("").trimEnd()
+    }
+
+    const result: string[] = []
+    const reset = "\x1b[0m"
+    const left = {
+      fg: "\x1b[90m",
+      shadow: "\x1b[38;5;235m",
+      bg: "\x1b[48;5;235m",
+    }
+    const right = {
+      fg: reset,
+      shadow: "\x1b[38;5;238m",
+      bg: "\x1b[48;5;238m",
+    }
+    const gap = " "
+    const draw = (line: string, fg: string, shadow: string, bg: string) => {
+      const parts: string[] = []
+      for (const char of line) {
+        if (char === "_") {
+          parts.push(bg, " ", reset)
+          continue
+        }
+        if (char === "^") {
+          parts.push(fg, bg, "▀", reset)
+          continue
+        }
+        if (char === "~") {
+          parts.push(shadow, "▀", reset)
+          continue
+        }
+        if (char === " ") {
+          parts.push(" ")
+          continue
+        }
+        parts.push(fg, char, reset)
+      }
+      return parts.join("")
+    }
+    glyphs.left.forEach((row, index) => {
       if (pad) result.push(pad)
-      result.push(row)
+      result.push(draw(row, left.fg, left.shadow, left.bg))
+      result.push(gap)
+      const other = glyphs.right[index] ?? ""
+      result.push(draw(other, right.fg, right.shadow, right.bg))
       result.push(EOL)
-    }
+    })
     return result.join("").trimEnd()
   }
 

+ 24 - 4
packages/opencode/src/index.ts

@@ -48,7 +48,19 @@ process.on("uncaughtException", (e) => {
   })
 })
 
-const cli = yargs(hideBin(process.argv))
+const args = hideBin(process.argv)
+
+function show(out: string) {
+  const text = out.trimStart()
+  if (!text.startsWith("opencode ")) {
+    process.stderr.write(UI.logo() + EOL + EOL)
+    process.stderr.write(text)
+    return
+  }
+  process.stderr.write(out)
+}
+
+const cli = yargs(args)
   .parserConfiguration({ "populate--": true })
   .scriptName("opencode")
   .wrap(100)
@@ -130,7 +142,7 @@ const cli = yargs(hideBin(process.argv))
       process.stderr.write("Database migration complete." + EOL)
     }
   })
-  .usage("\n" + UI.logo())
+  .usage("")
   .completion("completion", "generate shell completion script")
   .command(AcpCommand)
   .command(McpCommand)
@@ -162,7 +174,7 @@ const cli = yargs(hideBin(process.argv))
       msg?.startsWith("Invalid values:")
     ) {
       if (err) throw err
-      cli.showHelp("log")
+      cli.showHelp(show)
     }
     if (err) throw err
     process.exit(1)
@@ -170,7 +182,15 @@ const cli = yargs(hideBin(process.argv))
   .strict()
 
 try {
-  await cli.parse()
+  if (args.includes("-h") || args.includes("--help")) {
+    await cli.parse(args, (err: Error | undefined, _argv: unknown, out: string) => {
+      if (err) throw err
+      if (!out) return
+      show(out)
+    })
+  } else {
+    await cli.parse()
+  }
 } catch (e) {
   let data: Record<string, any> = {}
   if (e instanceof NamedError) {

+ 2 - 0
packages/opencode/src/provider/provider.ts

@@ -44,6 +44,7 @@ import { createGateway } from "@ai-sdk/gateway"
 import { createTogetherAI } from "@ai-sdk/togetherai"
 import { createPerplexity } from "@ai-sdk/perplexity"
 import { createVercel } from "@ai-sdk/vercel"
+import { createVenice } from "venice-ai-sdk-provider"
 import {
   createGitLab,
   VERSION as GITLAB_PROVIDER_VERSION,
@@ -139,6 +140,7 @@ export namespace Provider {
     "@ai-sdk/vercel": createVercel,
     "gitlab-ai-provider": createGitLab,
     "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
+    "venice-ai-sdk-provider": createVenice,
   }
 
   type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>

+ 1 - 0
packages/opencode/src/session/compaction.ts

@@ -190,6 +190,7 @@ export namespace SessionCompaction {
 Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
 The summary that you construct will be used so that another agent can read it and continue the work.
 Do not call any tools. Respond only with the summary text.
+Respond in the same language as the user's messages in the conversation.
 
 When constructing the summary, try to stick to this template:
 ---

+ 11 - 1
packages/opencode/src/session/prompt.ts

@@ -756,7 +756,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
         }
         const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID))
         const userMsg: MessageV2.User = {
-          id: MessageID.ascending(),
+          id: input.messageID ?? MessageID.ascending(),
           sessionID: input.sessionID,
           time: { created: Date.now() },
           role: "user",
@@ -1362,9 +1362,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the
             }
 
             if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
+
+            const lastAssistantMsg = msgs.findLast(
+              (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
+            )
+            // Some providers return "stop" even when the assistant message contains tool calls.
+            // Keep the loop running so tool results can be sent back to the model.
+            const hasToolCalls = lastAssistantMsg?.parts.some((part) => part.type === "tool") ?? false
+
             if (
               lastAssistant?.finish &&
               !["tool-calls"].includes(lastAssistant.finish) &&
+              !hasToolCalls &&
               lastUser.id < lastAssistant.id
             ) {
               log.info("exiting loop", { sessionID })
@@ -1818,6 +1827,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
 
   export const ShellInput = z.object({
     sessionID: SessionID.zod,
+    messageID: MessageID.zod.optional(),
     agent: z.string(),
     model: z
       .object({

+ 1 - 1
packages/opencode/src/session/retry.ts

@@ -54,7 +54,7 @@ export namespace SessionRetry {
     if (MessageV2.APIError.isInstance(error)) {
       if (!error.data.isRetryable) return undefined
       if (error.data.responseBody?.includes("FreeUsageLimitError"))
-        return `Free usage exceeded, add credits https://opencode.ai/zen`
+        return `Free usage exceeded, subscribe to Go https://opencode.ai/go`
       return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
     }
 

+ 7 - 4
packages/opencode/test/account/service.test.ts

@@ -18,6 +18,9 @@ const truncate = Layer.effectDiscard(
 
 const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
 
+const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1))
+const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10))
+
 const live = (client: HttpClient.HttpClient) =>
   Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
 
@@ -63,7 +66,7 @@ it.live("orgsByAccount groups orgs per account", () =>
         url: "https://one.example.com",
         accessToken: AccessToken.make("at_1"),
         refreshToken: RefreshToken.make("rt_1"),
-        expiry: Date.now() + 10 * 60_000,
+        expiry: Date.now() + outsideEagerRefreshWindow,
         orgID: Option.none(),
       }),
     )
@@ -75,7 +78,7 @@ it.live("orgsByAccount groups orgs per account", () =>
         url: "https://two.example.com",
         accessToken: AccessToken.make("at_2"),
         refreshToken: RefreshToken.make("rt_2"),
-        expiry: Date.now() + 10 * 60_000,
+        expiry: Date.now() + outsideEagerRefreshWindow,
         orgID: Option.none(),
       }),
     )
@@ -159,7 +162,7 @@ it.live("token refreshes before expiry when inside the eager refresh window", ()
         url: "https://one.example.com",
         accessToken: AccessToken.make("at_old"),
         refreshToken: RefreshToken.make("rt_old"),
-        expiry: Date.now() + 60_000,
+        expiry: Date.now() + insideEagerRefreshWindow,
         orgID: Option.none(),
       }),
     )
@@ -267,7 +270,7 @@ it.live("config sends the selected org header", () =>
         url: "https://one.example.com",
         accessToken: AccessToken.make("at_1"),
         refreshToken: RefreshToken.make("rt_1"),
-        expiry: Date.now() + 10 * 60_000,
+        expiry: Date.now() + outsideEagerRefreshWindow,
         orgID: Option.none(),
       }),
     )

+ 31 - 2
packages/opencode/test/session/prompt-effect.test.ts

@@ -3,7 +3,6 @@ import { expect, spyOn } from "bun:test"
 import { Cause, Effect, Exit, Fiber, Layer } from "effect"
 import path from "path"
 import z from "zod"
-import type { Agent } from "../../src/agent/agent"
 import { Agent as AgentSvc } from "../../src/agent/agent"
 import { Bus } from "../../src/bus"
 import { Command } from "../../src/command"
@@ -35,7 +34,7 @@ import { Log } from "../../src/util/log"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
-import { TestLLMServer } from "../lib/llm-server"
+import { reply, TestLLMServer } from "../lib/llm-server"
 
 Log.init({ print: false })
 
@@ -453,6 +452,36 @@ it.live("loop continues when finish is tool-calls", () =>
   ),
 )
 
+it.live("loop continues when finish is stop but assistant has tool parts", () =>
+  provideTmpdirServer(
+    Effect.fnUntraced(function* ({ llm }) {
+      const prompt = yield* SessionPrompt.Service
+      const sessions = yield* Session.Service
+      const session = yield* sessions.create({
+        title: "Pinned",
+        permission: [{ permission: "*", pattern: "*", action: "allow" }],
+      })
+      yield* prompt.prompt({
+        sessionID: session.id,
+        agent: "build",
+        noReply: true,
+        parts: [{ type: "text", text: "hello" }],
+      })
+      yield* llm.push(reply().tool("first", { value: "first" }).stop())
+      yield* llm.text("second")
+
+      const result = yield* prompt.loop({ sessionID: session.id })
+      expect(yield* llm.calls).toBe(2)
+      expect(result.info.role).toBe("assistant")
+      if (result.info.role === "assistant") {
+        expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true)
+        expect(result.info.finish).toBe("stop")
+      }
+    }),
+    { git: true, config: providerCfg },
+  ),
+)
+
 it.live("failed subtask preserves metadata on error tool state", () =>
   provideTmpdirServer(
     Effect.fnUntraced(function* ({ llm }) {

+ 2 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -2231,6 +2231,7 @@ export class Session2 extends HeyApiClient {
       sessionID: string
       directory?: string
       workspace?: string
+      messageID?: string
       agent?: string
       model?: {
         providerID: string
@@ -2248,6 +2249,7 @@ export class Session2 extends HeyApiClient {
             { in: "path", key: "sessionID" },
             { in: "query", key: "directory" },
             { in: "query", key: "workspace" },
+            { in: "body", key: "messageID" },
             { in: "body", key: "agent" },
             { in: "body", key: "model" },
             { in: "body", key: "command" },

+ 1 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -3815,6 +3815,7 @@ export type SessionCommandResponse = SessionCommandResponses[keyof SessionComman
 
 export type SessionShellData = {
   body?: {
+    messageID?: string
     agent: string
     model?: {
       providerID: string

+ 4 - 0
packages/sdk/openapi.json

@@ -3942,6 +3942,10 @@
               "schema": {
                 "type": "object",
                 "properties": {
+                  "messageID": {
+                    "type": "string",
+                    "pattern": "^msg.*"
+                  },
                   "agent": {
                     "type": "string"
                   },

+ 1 - 0
packages/ui/package.json

@@ -48,6 +48,7 @@
     "@pierre/diffs": "catalog:",
     "@shikijs/transformers": "3.9.2",
     "@solid-primitives/bounds": "0.1.3",
+    "@solid-primitives/event-listener": "2.4.5",
     "@solid-primitives/media": "2.3.3",
     "@solid-primitives/resize-observer": "2.1.3",
     "@solidjs/meta": "catalog:",

+ 5 - 11
packages/ui/src/components/file.tsx

@@ -16,6 +16,7 @@ import {
 } from "@pierre/diffs"
 import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 import { createMediaQuery } from "@solid-primitives/media"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
 import { createDefaultOptions, styleVariables } from "../pierre"
 import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
@@ -286,17 +287,10 @@ function useFileViewer(config: ViewerConfig) {
   createEffect(() => {
     if (!config.enableLineSelection()) return
 
-    container.addEventListener("mousedown", handleMouseDown)
-    container.addEventListener("mousemove", handleMouseMove)
-    window.addEventListener("mouseup", handleMouseUp)
-    document.addEventListener("selectionchange", handleSelectionChange)
-
-    onCleanup(() => {
-      container.removeEventListener("mousedown", handleMouseDown)
-      container.removeEventListener("mousemove", handleMouseMove)
-      window.removeEventListener("mouseup", handleMouseUp)
-      document.removeEventListener("selectionchange", handleSelectionChange)
-    })
+    makeEventListener(container, "mousedown", handleMouseDown)
+    makeEventListener(container, "mousemove", handleMouseMove)
+    makeEventListener(window, "mouseup", handleMouseUp)
+    makeEventListener(document, "selectionchange", handleSelectionChange)
   })
 
   onCleanup(() => {

+ 0 - 5
packages/ui/src/components/line-comment-annotations.tsx

@@ -294,11 +294,6 @@ export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
     cancelDraft()
   }
 
-  createEffect(() => {
-    props.commenting()
-    setDraft("")
-  })
-
   return {
     draft,
     setDraft,

+ 6 - 13
packages/ui/src/components/line-comment.tsx

@@ -1,6 +1,6 @@
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { createEffect, createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js"
+import { createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js"
 import { Button } from "./button"
 import { FileIcon } from "./file-icon"
 import { Icon } from "./icon"
@@ -210,7 +210,6 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
   const refs = {
     textarea: undefined as HTMLTextAreaElement | undefined,
   }
-  const [text, setText] = createSignal(split.value)
   const [open, setOpen] = createSignal(false)
 
   function selectMention(item: { path: string } | undefined) {
@@ -220,10 +219,9 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
     const query = currentMention()
     if (!textarea || !query) return
 
-    const value = `${text().slice(0, query.start)}@${item.path} ${text().slice(query.end)}`
+    const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}`
     const cursor = query.start + item.path.length + 2
 
-    setText(value)
     split.onInput(value)
     closeMention()
 
@@ -257,10 +255,6 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
       fn()
     }
 
-  createEffect(() => {
-    setText(split.value)
-  })
-
   const closeMention = () => {
     setOpen(false)
     mention.clear()
@@ -302,7 +296,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
   }
 
   const submit = () => {
-    const value = text().trim()
+    const value = split.value.trim()
     if (!value) return
     split.onSubmit(value)
   }
@@ -322,10 +316,9 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
           data-slot="line-comment-textarea"
           rows={split.rows ?? 3}
           placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
-          value={text()}
+          value={split.value}
           on:input={(e) => {
             const value = (e.currentTarget as HTMLTextAreaElement).value
-            setText(value)
             split.onInput(value)
             syncMention()
           }}
@@ -422,7 +415,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
                   type="button"
                   data-slot="line-comment-action"
                   data-variant="primary"
-                  disabled={text().trim().length === 0}
+                  disabled={split.value.trim().length === 0}
                   on:mousedown={hold as any}
                   on:click={click(submit) as any}
                 >
@@ -434,7 +427,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
             <Button size="small" variant="ghost" onClick={split.onCancel}>
               {split.cancelLabel ?? i18n.t("ui.common.cancel")}
             </Button>
-            <Button size="small" variant="primary" disabled={text().trim().length === 0} onClick={submit}>
+            <Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
               {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
             </Button>
           </Show>

+ 3 - 3
packages/ui/src/components/list.tsx

@@ -1,6 +1,7 @@
 import { type FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
-import { createEffect, For, onCleanup, type JSX, on, Show } from "solid-js"
+import { createEffect, For, type JSX, on, Show } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { useI18n } from "../context/i18n"
 import { Icon, type IconProps } from "./icon"
 import { IconButton } from "./icon-button"
@@ -228,9 +229,8 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
         setState("stuck", rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0)
       }
 
-      scroll.addEventListener("scroll", handler, { passive: true })
+      makeEventListener(scroll, "scroll", handler, { passive: true })
       handler()
-      onCleanup(() => scroll.removeEventListener("scroll", handler))
     })
 
     return (

+ 23 - 8
packages/ui/src/components/message-part.tsx

@@ -230,6 +230,19 @@ function createPacedValue(getValue: () => string, live?: () => boolean) {
   return value
 }
 
+function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boolean }) {
+  const value = createPacedValue(
+    () => props.text,
+    () => props.streaming,
+  )
+
+  return (
+    <Show when={value()}>
+      <Markdown text={value()} cacheKey={props.cacheKey} streaming={props.streaming} />
+    </Show>
+  )
+}
+
 function relativizeProjectPath(path: string, directory?: string) {
   if (!path) return ""
   if (!directory) return path
@@ -1373,8 +1386,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
   const streaming = createMemo(
     () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
   )
-  const displayText = () => (part().text ?? "").trim()
-  const throttledText = createPacedValue(displayText, streaming)
+  const text = () => (part().text ?? "").trim()
   const isLastTextPart = createMemo(() => {
     const last = (data.store.part?.[props.message.id] ?? [])
       .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
@@ -1390,7 +1402,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
   const [copied, setCopied] = createSignal(false)
 
   const handleCopy = async () => {
-    const content = displayText()
+    const content = text()
     if (!content) return
     await navigator.clipboard.writeText(content)
     setCopied(true)
@@ -1398,10 +1410,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
   }
 
   return (
-    <Show when={throttledText()}>
+    <Show when={text()}>
       <div data-component="text-part">
         <div data-slot="text-part-body">
-          <Markdown text={throttledText()} cacheKey={part().id} streaming={streaming()} />
+          <Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
+            <PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
+          </Show>
         </div>
         <Show when={showCopy()}>
           <div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
@@ -1437,12 +1451,13 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
     () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
   )
   const text = () => part().text.trim()
-  const throttledText = createPacedValue(text, streaming)
 
   return (
-    <Show when={throttledText()}>
+    <Show when={text()}>
       <div data-component="reasoning-part">
-        <Markdown text={throttledText()} cacheKey={part().id} streaming={streaming()} />
+        <Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
+          <PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
+        </Show>
       </div>
     </Show>
   )

+ 5 - 19
packages/ui/src/components/popover.tsx

@@ -1,15 +1,7 @@
 import { Popover as Kobalte } from "@kobalte/core/popover"
-import {
-  ComponentProps,
-  JSXElement,
-  ParentProps,
-  Show,
-  createEffect,
-  onCleanup,
-  splitProps,
-  ValidComponent,
-} from "solid-js"
+import { ComponentProps, JSXElement, ParentProps, Show, createEffect, splitProps, ValidComponent } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { useI18n } from "../context/i18n"
 import { IconButton } from "./icon-button"
 
@@ -104,15 +96,9 @@ export function Popover<T extends ValidComponent = "div">(props: PopoverProps<T>
       close("outside")
     }
 
-    window.addEventListener("keydown", onKeyDown, true)
-    window.addEventListener("pointerdown", onPointerDown, true)
-    window.addEventListener("focusin", onFocusIn, true)
-
-    onCleanup(() => {
-      window.removeEventListener("keydown", onKeyDown, true)
-      window.removeEventListener("pointerdown", onPointerDown, true)
-      window.removeEventListener("focusin", onFocusIn, true)
-    })
+    makeEventListener(window, "keydown", onKeyDown, { capture: true })
+    makeEventListener(window, "pointerdown", onPointerDown, { capture: true })
+    makeEventListener(window, "focusin", onFocusIn, { capture: true })
   })
 
   const content = () => (

+ 3 - 14
packages/ui/src/components/scroll-view.tsx

@@ -1,4 +1,5 @@
-import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
+import { onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { createStore } from "solid-js/store"
 import { useI18n } from "../context/i18n"
 
@@ -97,19 +98,7 @@ export function ScrollView(props: ScrollViewProps) {
       local.viewportRef(viewportRef)
     }
 
-    const observer = new ResizeObserver(() => {
-      updateThumb()
-    })
-
-    observer.observe(viewportRef)
-    // Also observe the first child if possible to catch content changes
-    if (viewportRef.firstElementChild) {
-      observer.observe(viewportRef.firstElementChild)
-    }
-
-    onCleanup(() => {
-      observer.disconnect()
-    })
+    createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb)
 
     updateThumb()
   })

+ 1 - 4
packages/ui/src/components/session-turn.tsx

@@ -343,14 +343,12 @@ export function SessionTurn(
   })
   const assistantDerived = createMemo(() => {
     let visible = 0
-    let tail: "text" | "other" | undefined
     let reason: string | undefined
     const show = showReasoningSummaries()
     for (const message of assistantMessages()) {
       for (const part of list(data.store.part?.[message.id], emptyParts)) {
         if (partState(part, show) === "visible") {
           visible++
-          tail = part.type === "text" ? "text" : "other"
         }
         if (part.type === "reasoning" && part.text) {
           const h = heading(part.text)
@@ -358,10 +356,9 @@ export function SessionTurn(
         }
       }
     }
-    return { visible, tail, reason }
+    return { visible, reason }
   })
   const assistantVisible = createMemo(() => assistantDerived().visible)
-  const assistantTailVisible = createMemo(() => assistantDerived().tail)
   const reasoningHeading = createMemo(() => assistantDerived().reason)
   const showThinking = createMemo(() => {
     if (!working() || !!error()) return false

+ 3 - 8
packages/ui/src/components/text-strikethrough.stories.tsx

@@ -1,5 +1,6 @@
 // @ts-nocheck
-import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { createSignal, onMount } from "solid-js"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { createStore } from "solid-js/store"
 import { useSpring } from "./motion-spring"
 import { TextStrikethrough } from "./text-strikethrough"
@@ -144,13 +145,7 @@ function VariantF(props: { active: boolean; text: string }) {
   }
 
   onMount(measure)
-  createEffect(() => {
-    const el = containerRef
-    if (!el) return
-    const observer = new ResizeObserver(measure)
-    observer.observe(el)
-    onCleanup(() => observer.disconnect())
-  })
+  createResizeObserver(() => containerRef, measure)
 
   const clipRight = () => {
     const cw = containerWidth()

+ 3 - 9
packages/ui/src/components/text-strikethrough.tsx

@@ -1,5 +1,6 @@
 import type { JSX } from "solid-js"
-import { createEffect, onCleanup, onMount } from "solid-js"
+import { onMount } from "solid-js"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { createStore } from "solid-js/store"
 import { useSpring } from "./motion-spring"
 
@@ -33,14 +34,7 @@ export function TextStrikethrough(props: {
   }
 
   onMount(measure)
-
-  createEffect(() => {
-    const el = containerRef
-    if (!el) return
-    const observer = new ResizeObserver(measure)
-    observer.observe(el)
-    onCleanup(() => observer.disconnect())
-  })
+  createResizeObserver(() => containerRef, measure)
 
   // Revealed pixels from left = progress * textWidth
   const revealedPx = () => {

+ 2 - 2
packages/ui/src/context/dialog.tsx

@@ -12,6 +12,7 @@ import {
   type JSX,
 } from "solid-js"
 import { Dialog as Kobalte } from "@kobalte/core/dialog"
+import { makeEventListener } from "@solid-primitives/event-listener"
 
 type DialogElement = () => JSX.Element
 
@@ -68,8 +69,7 @@ function init() {
       event.stopPropagation()
     }
 
-    window.addEventListener("keydown", onKeyDown, true)
-    onCleanup(() => window.removeEventListener("keydown", onKeyDown, true))
+    makeEventListener(window, "keydown", onKeyDown, { capture: true })
   })
 
   const show = (element: DialogElement, owner: Owner, onClose?: () => void) => {

+ 3 - 15
packages/ui/src/hooks/create-auto-scroll.tsx

@@ -1,5 +1,6 @@
-import { createEffect, on, onCleanup } from "solid-js"
+import { createEffect, createSignal, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 
 export interface AutoScrollOptions {
@@ -14,7 +15,6 @@ export function createAutoScroll(options: AutoScrollOptions) {
   let settling = false
   let settleTimer: ReturnType<typeof setTimeout> | undefined
   let autoTimer: ReturnType<typeof setTimeout> | undefined
-  let cleanup: (() => void) | undefined
   let auto: { top: number; time: number } | undefined
 
   const threshold = () => options.bottomThreshold ?? 10
@@ -216,26 +216,14 @@ export function createAutoScroll(options: AutoScrollOptions) {
   onCleanup(() => {
     if (settleTimer) clearTimeout(settleTimer)
     if (autoTimer) clearTimeout(autoTimer)
-    if (cleanup) cleanup()
   })
 
   return {
     scrollRef: (el: HTMLElement | undefined) => {
-      if (cleanup) {
-        cleanup()
-        cleanup = undefined
-      }
-
-      scroll = el
-
       if (!el) return
 
       updateOverflowAnchor(el)
-      el.addEventListener("wheel", handleWheel, { passive: true })
-
-      cleanup = () => {
-        el.removeEventListener("wheel", handleWheel)
-      }
+      makeEventListener(el, "wheel", handleWheel, { passive: true })
     },
     contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
     handleScroll,

+ 14 - 15
packages/ui/src/pierre/file-find.ts

@@ -1,4 +1,6 @@
-import { createEffect, onCleanup, onMount } from "solid-js"
+import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { makeEventListener } from "@solid-primitives/event-listener"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { createStore } from "solid-js/store"
 
 export type FindHost = {
@@ -104,9 +106,9 @@ type CreateFileFindOptions = {
 export function createFileFind(opts: CreateFileFindOptions) {
   let input: HTMLInputElement | undefined
   let overlayFrame: number | undefined
-  let overlayScroll: HTMLElement[] = []
   let mode: "highlights" | "overlay" = "overlay"
   let hits: Range[] = []
+  const [overlayScroll, setOverlayScroll] = createSignal<HTMLElement[]>([])
 
   const [state, setState] = createStore({
     open: false,
@@ -122,8 +124,7 @@ export function createFileFind(opts: CreateFileFindOptions) {
   const pos = () => state.pos
 
   const clearOverlayScroll = () => {
-    for (const el of overlayScroll) el.removeEventListener("scroll", scheduleOverlay)
-    overlayScroll = []
+    setOverlayScroll([])
   }
 
   const clearOverlay = () => {
@@ -196,11 +197,11 @@ export function createFileFind(opts: CreateFileFindOptions) {
           (node): node is HTMLElement => node instanceof HTMLElement,
         )
       : []
-    if (next.length === overlayScroll.length && next.every((el, i) => el === overlayScroll[i])) return
+    const current = overlayScroll()
+    if (next.length === current.length && next.every((el, i) => el === current[i])) return
 
     clearOverlayScroll()
-    overlayScroll = next
-    for (const el of overlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
+    setOverlayScroll(next)
   }
 
   const clearFind = () => {
@@ -403,6 +404,10 @@ export function createFileFind(opts: CreateFileFindOptions) {
     close,
   }
 
+  createEffect(() => {
+    for (const el of overlayScroll()) makeEventListener(el, "scroll", scheduleOverlay, { passive: true })
+  })
+
   onMount(() => {
     mode = supportsHighlights() ? "highlights" : "overlay"
     installShortcuts()
@@ -424,18 +429,12 @@ export function createFileFind(opts: CreateFileFindOptions) {
 
     const update = () => positionBar()
     requestAnimationFrame(update)
-    window.addEventListener("resize", update, { passive: true })
+    makeEventListener(window, "resize", update, { passive: true })
 
     const wrapper = opts.wrapper()
     if (!wrapper) return
     const root = scrollParent(wrapper) ?? wrapper
-    const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
-    observer?.observe(root)
-
-    onCleanup(() => {
-      window.removeEventListener("resize", update)
-      observer?.disconnect()
-    })
+    createResizeObserver(root, update)
   })
 
   onCleanup(() => {

+ 5 - 8
packages/ui/src/theme/context.tsx

@@ -1,5 +1,6 @@
-import { createEffect, onCleanup, onMount } from "solid-js"
+import { createEffect, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
 import { createSimpleContext } from "../context/helper"
 import oc2ThemeJson from "./themes/oc-2.json"
 import { resolveThemeVariant, themeToCss } from "./resolve"
@@ -237,19 +238,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
       }
     }
 
-    if (typeof window === "object") {
-      window.addEventListener("storage", onStorage)
-      onCleanup(() => window.removeEventListener("storage", onStorage))
-    }
-
     onMount(() => {
+      makeEventListener(window, "storage", onStorage)
+
       const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
       const onMedia = () => {
         if (store.colorScheme !== "system") return
         setStore("mode", getSystemMode())
       }
-      mediaQuery.addEventListener("change", onMedia)
-      onCleanup(() => mediaQuery.removeEventListener("change", onMedia))
+      makeEventListener(mediaQuery, "change", onMedia)
 
       const rawTheme = read(STORAGE_KEYS.THEME_ID)
       const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2"

+ 1 - 0
packages/web/package.json

@@ -18,6 +18,7 @@
     "@astrojs/starlight": "0.34.3",
     "@fontsource/ibm-plex-mono": "5.2.5",
     "@shikijs/transformers": "3.20.0",
+    "@solid-primitives/resize-observer": "2.1.5",
     "@types/luxon": "catalog:",
     "ai": "catalog:",
     "astro": "5.7.13",

+ 3 - 11
packages/web/src/components/Share.tsx

@@ -366,21 +366,13 @@ export default function Share(props: {
                         <Suspense>
                           <For each={filteredParts()}>
                             {(part, partIndex) => {
-                              const last = createMemo(
-                                () =>
-                                  data().messages.length === msgIndex() + 1 &&
-                                  filteredParts().length === partIndex() + 1,
-                              )
+                              const last = () =>
+                                data().messages.length === msgIndex() + 1 && filteredParts().length === partIndex() + 1
 
                               onMount(() => {
                                 const hash = window.location.hash.slice(1)
                                 // Wait till all parts are loaded
-                                if (
-                                  hash !== "" &&
-                                  !hasScrolledToAnchor &&
-                                  filteredParts().length === partIndex() + 1 &&
-                                  data().messages.length === msgIndex() + 1
-                                ) {
+                                if (hash !== "" && !hasScrolledToAnchor && last()) {
                                   hasScrolledToAnchor = true
                                   scrollToAnchor(hash)
                                 }

+ 10 - 12
packages/web/src/components/share/common.tsx

@@ -1,5 +1,6 @@
-import { createContext, createSignal, onCleanup, splitProps, useContext } from "solid-js"
+import { createContext, createSignal, splitProps, useContext } from "solid-js"
 import type { JSX } from "solid-js/jsx-runtime"
+import { makeResizeObserver } from "@solid-primitives/resize-observer"
 import { IconCheckCircle, IconHashtag } from "../icons"
 
 export type ShareMessages = { locale: string } & Record<string, string>
@@ -83,17 +84,14 @@ export function createOverflow() {
       return overflow()
     },
     ref(el: HTMLElement) {
-      const ro = new ResizeObserver(() => {
-        if (el.scrollHeight > el.clientHeight + 1) {
-          setOverflow(true)
-        }
-        return
-      })
-      ro.observe(el)
-
-      onCleanup(() => {
-        ro.disconnect()
-      })
+      const sync = () => {
+        setOverflow(el.scrollHeight > el.clientHeight + 1)
+      }
+
+      const obs = makeResizeObserver(sync)
+      obs.observe(el)
+
+      sync()
     },
   }
 }

+ 28 - 19
packages/web/src/components/share/content-diff.tsx

@@ -1,5 +1,5 @@
 import { parsePatch } from "diff"
-import { createMemo } from "solid-js"
+import { createMemo, For } from "solid-js"
 import { ContentCode } from "./content-code"
 import styles from "./content-diff.module.css"
 
@@ -160,28 +160,37 @@ export function ContentDiff(props: Props) {
   return (
     <div class={styles.root}>
       <div data-component="desktop">
-        {rows().map((r) => (
-          <div data-component="diff-row" data-type={r.type}>
-            <div data-slot="before" data-diff-type={r.type === "removed" || r.type === "modified" ? "removed" : ""}>
-              <ContentCode code={r.left} flush lang={props.lang} />
-            </div>
-            <div data-slot="after" data-diff-type={r.type === "added" || r.type === "modified" ? "added" : ""}>
-              <ContentCode code={r.right} lang={props.lang} flush />
+        <For each={rows()}>
+          {(row) => (
+            <div data-component="diff-row" data-type={row.type}>
+              <div
+                data-slot="before"
+                data-diff-type={row.type === "removed" || row.type === "modified" ? "removed" : ""}
+              >
+                <ContentCode code={row.left} flush lang={props.lang} />
+              </div>
+              <div data-slot="after" data-diff-type={row.type === "added" || row.type === "modified" ? "added" : ""}>
+                <ContentCode code={row.right} lang={props.lang} flush />
+              </div>
             </div>
-          </div>
-        ))}
+          )}
+        </For>
       </div>
 
       <div data-component="mobile">
-        {mobileRows().map((block) => (
-          <div data-component="diff-block" data-type={block.type}>
-            {block.lines.map((line) => (
-              <div data-diff-type={block.type === "removed" ? "removed" : block.type === "added" ? "added" : ""}>
-                <ContentCode code={line} lang={props.lang} flush />
-              </div>
-            ))}
-          </div>
-        ))}
+        <For each={mobileRows()}>
+          {(block) => (
+            <div data-component="diff-block" data-type={block.type}>
+              <For each={block.lines}>
+                {(line) => (
+                  <div data-diff-type={block.type === "removed" ? "removed" : block.type === "added" ? "added" : ""}>
+                    <ContentCode code={line} lang={props.lang} flush />
+                  </div>
+                )}
+              </For>
+            </div>
+          )}
+        </For>
       </div>
     </div>
   )