diff --git a/bun.lock b/bun.lock index 2b37d21ccd..2d3f815f5d 100644 --- a/bun.lock +++ b/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/refs@1.1.3", "", { "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/resize-observer@2.1.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/resize-observer@2.1.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/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="], @@ -4755,6 +4759,8 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.1", "", { "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": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], "vfile": ["vfile@6.0.3", "", { "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": ["htmlparser2@9.1.0", "", { "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/resize-observer@2.1.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=="], + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "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": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -5301,6 +5309,8 @@ "@opencode-ai/desktop-electron/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.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=="], + "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "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/util-utf8@4.2.2", "", { "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/resize-observer@2.1.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=="], + "@solidjs/start/path-to-regexp": ["path-to-regexp@8.4.1", "", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="], "@solidjs/start/shiki": ["shiki@1.29.2", "", { "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=="], diff --git a/nix/hashes.json b/nix/hashes.json index b9e7bb9db2..23e4a310c9 100644 --- a/nix/hashes.json +++ b/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=" } } diff --git a/packages/app/package.json b/packages/app/package.json index 670bec60e1..d179e4a524 100644 --- a/packages/app/package.json +++ b/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", diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index f4b7a1bc0e..11f9f59e4e 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/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() }) }) diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index fa9930f683..f12a4210c0 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/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 { diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx index 63a40bac24..d4f68d6306 100644 --- a/packages/app/src/components/server/server-row.tsx +++ b/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 = () => ( diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 7e2a48110c..7d2dfaa636 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/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 }) }) } diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 65805f40c8..d2238828c6 100644 --- a/packages/app/src/context/command.tsx +++ b/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 diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index d240f9eeff..1205a8fa82 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/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() diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index aafa4fb66c..bab3d39f38 100644 --- a/packages/app/src/context/layout.tsx +++ b/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() }) }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b5a96110f6..79b9abd332 100644 --- a/packages/app/src/pages/layout.tsx +++ b/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) { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 18bae6e2d0..98d06fda74 100644 --- a/packages/app/src/pages/session.tsx +++ b/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) diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index a5263cd743..372adef96a 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/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 ( diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 0884f4cc60..eab2108687 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/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[] => { diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 38974b2465..35690030c9 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/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() @@ -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) }) diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index c16ac83993..7928bcc9ca 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/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(() => { diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 9430b70253..cb76175236 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/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([]) const getCode = () => { const el = scroll @@ -106,17 +107,9 @@ function createScrollSync(input: { tab: () => string; view: ReturnType { 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 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 { - 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( diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 7e2c1ccf7b..f3215f6850 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/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(() => { diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 76b65a2210..b681286450 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/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() }} diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index c663d7d671..1161d565a7 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/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(() => { diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 393396c36a..8896df1c8b 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/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.", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 764434264f..0259b5f884 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/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.", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 3f9d9deace..5a376cb5df 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/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.", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 535bafe514..d6e8533921 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/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.", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index c68711dd25..49339a3f52 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/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.", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index d56099d7a6..632a29a1d2 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/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.", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index ad1d377cc0..f657c6164f 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/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.", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 0b6b6b6cfa..c3c73b2835 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/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": diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 3ef298ee31..f645637f07 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/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 などにアクセス。", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 526791652c..f2a6147130 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/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 등에 액세스하세요.", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 3e405506b3..95c55c1e99 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/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.", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 513d8f9cc9..c119fad5b3 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/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.", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index ed90e61584..4b9401af9e 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/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.", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 3eb7e63123..8ba75548a6 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/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", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index a663e65119..ce3ec7e1d6 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/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.", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index cbf4932ef2..0803ffd132 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/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 等模型。", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index 0670c11936..66e242eb76 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/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 等模型。", diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 53bd0e6012..db5977bc16 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/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 } diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 191fdf1b7e..3b24394316 100644 --- a/packages/console/core/src/model.ts +++ b/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( diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 5893dcaa51..a59d742183 100644 --- a/packages/opencode/package.json +++ b/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", diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts index 17bc86307a..fc515a67a6 100644 --- a/packages/opencode/script/build-node.ts +++ b/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}'`, }, }) diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 947fadad07..bcc90b7b1d 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -120,6 +120,10 @@ class TokenRefreshRequest extends Schema.Class("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 } diff --git a/packages/opencode/src/agent/prompt/compaction.txt b/packages/opencode/src/agent/prompt/compaction.txt index 3308627e15..11deccb3af 100644 --- a/packages/opencode/src/agent/prompt/compaction.txt +++ b/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. diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index c592d4206c..b24a2a2f44 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/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 (pad) result.push(pad) - result.push(row) - result.push(EOL) + 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(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() } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 2da35ace1d..bb14e0588a 100644 --- a/packages/opencode/src/index.ts +++ b/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 = {} if (e instanceof NamedError) { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 45fbeb7e02..441f84b907 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/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) => Promise diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index e48b1c7b08..3158393f11 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/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: --- diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fb4705603e..5121f24527 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/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({ diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 8ba48375bc..ec1116da0b 100644 --- a/packages/opencode/src/session/retry.ts +++ b/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 } diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index e4c43a1f64..85ab259f1d 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/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(), }), ) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 8e4543c247..d077f26d6b 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/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 }) { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 527584e7e2..113b3ed0f8 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/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" }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 290c6fd5ec..2f8e99cfed 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/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 diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index ba7188af13..8e41f9deb0 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3942,6 +3942,10 @@ "schema": { "type": "object", "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, "agent": { "type": "string" }, diff --git a/packages/ui/package.json b/packages/ui/package.json index f84454695f..8c925753e8 100644 --- a/packages/ui/package.json +++ b/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:", diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index 15915dd52d..fb488729e2 100644 --- a/packages/ui/src/components/file.tsx +++ b/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(() => { diff --git a/packages/ui/src/components/line-comment-annotations.tsx b/packages/ui/src/components/line-comment-annotations.tsx index 80018d3dda..f0286a36a9 100644 --- a/packages/ui/src/components/line-comment-annotations.tsx +++ b/packages/ui/src/components/line-comment-annotations.tsx @@ -294,11 +294,6 @@ export function createLineCommentState(props: LineCommentStateProps) { cancelDraft() } - createEffect(() => { - props.commenting() - setDraft("") - }) - return { draft, setDraft, diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx index f0e29a485d..26e763bb3e 100644 --- a/packages/ui/src/components/line-comment.tsx +++ b/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) => { - diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 8ce45bc5c6..b5879624e0 100644 --- a/packages/ui/src/components/list.tsx +++ b/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(props: ListProps & { 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 ( diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 1555a09a07..03477e5a7f 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/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 ( + + + + ) +} + 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 ( - +
- + }> + +
@@ -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 ( - +
- + }> + +
) diff --git a/packages/ui/src/components/popover.tsx b/packages/ui/src/components/popover.tsx index 9d3da41090..8263640a50 100644 --- a/packages/ui/src/components/popover.tsx +++ b/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(props: PopoverProps 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 = () => ( diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 2b58300b9f..3ff00f117d 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/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() }) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index ed4c0e9149..fe029485a1 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/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 diff --git a/packages/ui/src/components/text-strikethrough.stories.tsx b/packages/ui/src/components/text-strikethrough.stories.tsx index 5e86413f93..5ef06dbcf4 100644 --- a/packages/ui/src/components/text-strikethrough.stories.tsx +++ b/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() diff --git a/packages/ui/src/components/text-strikethrough.tsx b/packages/ui/src/components/text-strikethrough.tsx index aee5e0cbd0..958befff68 100644 --- a/packages/ui/src/components/text-strikethrough.tsx +++ b/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 = () => { diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index afba5f648c..c1c56212b5 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/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) => { diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index 3dc520c621..9733b094ec 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/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 | undefined let autoTimer: ReturnType | 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, diff --git a/packages/ui/src/pierre/file-find.ts b/packages/ui/src/pierre/file-find.ts index 692ab31670..d1cf6dd305 100644 --- a/packages/ui/src/pierre/file-find.ts +++ b/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([]) 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(() => { diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index 7d25ac3972..5664eeebd5 100644 --- a/packages/ui/src/theme/context.tsx +++ b/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" diff --git a/packages/web/package.json b/packages/web/package.json index ef0d8aa6c3..60082740bb 100644 --- a/packages/web/package.json +++ b/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", diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index de12baede0..3ee86c270d 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -366,21 +366,13 @@ export default function Share(props: { {(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) } diff --git a/packages/web/src/components/share/common.tsx b/packages/web/src/components/share/common.tsx index 7ca4daa6ac..ad50e425fa 100644 --- a/packages/web/src/components/share/common.tsx +++ b/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 @@ -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) + const sync = () => { + setOverflow(el.scrollHeight > el.clientHeight + 1) + } - onCleanup(() => { - ro.disconnect() - }) + const obs = makeResizeObserver(sync) + obs.observe(el) + + sync() }, } } diff --git a/packages/web/src/components/share/content-diff.tsx b/packages/web/src/components/share/content-diff.tsx index 9ccd554d04..c8dd35b38f 100644 --- a/packages/web/src/components/share/content-diff.tsx +++ b/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 (
- {rows().map((r) => ( -
-
- + + {(row) => ( +
+
+ +
+
+ +
-
- -
-
- ))} + )} +
- {mobileRows().map((block) => ( -
- {block.lines.map((line) => ( -
- -
- ))} -
- ))} + + {(block) => ( +
+ + {(line) => ( +
+ +
+ )} +
+
+ )} +
)