diff --git a/bun.lock b/bun.lock index f19cacbe3d..5202b70d98 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -76,7 +76,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -110,7 +110,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -137,7 +137,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -161,7 +161,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -185,7 +185,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -218,7 +218,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -248,7 +248,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -277,7 +277,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -293,7 +293,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.2.21", + "version": "1.2.22", "bin": { "opencode": "./bin/opencode", }, @@ -409,7 +409,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -429,7 +429,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.2.21", + "version": "1.2.22", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -440,7 +440,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -475,7 +475,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -483,8 +483,11 @@ "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", + "@solid-primitives/lifecycle": "0.1.2", "@solid-primitives/media": "2.3.3", + "@solid-primitives/page-visibility": "2.1.1", "@solid-primitives/resize-observer": "2.1.3", + "@solid-primitives/rootless": "1.5.2", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "dompurify": "3.3.1", @@ -521,7 +524,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "zod": "catalog:", }, @@ -532,7 +535,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1834,10 +1837,14 @@ "@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="], + "@solid-primitives/lifecycle": ["@solid-primitives/lifecycle@0.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-+K0T10kZXqorocFj0coIqt8NYm2UqoZfpF3nm2RwrDMZMV+C+SC0Oi3N6Dnq2i7W/n1cHAnfpoV4CBLsW21lJw=="], + "@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="], "@solid-primitives/media": ["@solid-primitives/media@2.3.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-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="], + "@solid-primitives/page-visibility": ["@solid-primitives/page-visibility@2.1.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.1", "@solid-primitives/rootless": "^1.5.1", "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-CV9BqMqhunf4OOyBkhJCH9f5ivg0ADavdcaBsrqoFvwIk1FoD/blPSHYM4CK8IjS/AEXNcsjlNVc34lMu+2Wdg=="], + "@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="], "@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="], diff --git a/nix/hashes.json b/nix/hashes.json index 73491735f4..2f14f9bf4e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=", - "aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=", - "aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=", - "x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ=" + "x86_64-linux": "sha256-c99eE1cKAQHvwJosaFo42U9Hk0Rtp/U5oTTlyiz2Zw4=", + "aarch64-linux": "sha256-LbdssPrf8Bijyp4mRo8QaO/swxwUWSo1g0jLPm2rvUA=", + "aarch64-darwin": "sha256-0L9y6Zk4l2vAxsM2bENahhtRZY1C3XhdxLgnnYlhkkY=", + "x86_64-darwin": "sha256-0J5sFG/kHHRDcTpdpdPBMJEOHwCRnAUYmbxEHPPLDvU=" } } diff --git a/packages/app/e2e/terminal/terminal-tabs.spec.ts b/packages/app/e2e/terminal/terminal-tabs.spec.ts index f76a86cf70..afa6254cd0 100644 --- a/packages/app/e2e/terminal/terminal-tabs.spec.ts +++ b/packages/app/e2e/terminal/terminal-tabs.spec.ts @@ -44,12 +44,14 @@ async function store(page: Page, key: string) { }, key) } -test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => { +test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => { await withProject(async ({ directory, gotoSession }) => { const key = workspacePersistKey(directory, "terminal") const one = `E2E_TERM_ONE_${Date.now()}` const two = `E2E_TERM_TWO_${Date.now()}` const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') + const first = tabs.filter({ hasText: /Terminal 1/ }).first() + const second = tabs.filter({ hasText: /Terminal 2/ }).first() await gotoSession() await open(page) @@ -61,22 +63,39 @@ test("terminal tab buffers persist across tab switches", async ({ page, withProj await run(page, `echo ${two}`) - await tabs - .filter({ hasText: /Terminal 1/ }) - .first() - .click() - + await first.click() + await expect(first).toHaveAttribute("aria-selected", "true") await expect .poll( async () => { const state = await store(page, key) const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" - return first.includes(one) && second.includes(two) + return { + first: first.includes(one), + second: second.includes(two), + } }, { timeout: 30_000 }, ) - .toBe(true) + .toEqual({ first: false, second: true }) + + await second.click() + await expect(second).toHaveAttribute("aria-selected", "true") + await expect + .poll( + async () => { + const state = await store(page, key) + const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? "" + const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? "" + return { + first: first.includes(one), + second: second.includes(two), + } + }, + { timeout: 30_000 }, + ) + .toEqual({ first: true, second: false }) }) }) diff --git a/packages/app/package.json b/packages/app/package.json index f87bd978f6..51f9883a56 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.2.21", + "version": "1.2.22", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 64f026219a..4467495b79 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -1,6 +1,6 @@ import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" import type { Platform } from "./platform" @@ -38,6 +38,16 @@ type TerminalCacheEntry = { const caches = new Set>() +const trimTerminal = (pty: LocalPTY) => { + if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty + return { + ...pty, + buffer: undefined, + cursor: undefined, + scrollY: undefined, + } +} + export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) { const key = getWorkspaceTerminalCacheKey(dir) for (const cache of caches) { @@ -188,6 +198,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str console.error("Failed to update terminal", error) }) }, + trim(id: string) { + const index = store.all.findIndex((x) => x.id === id) + if (index === -1) return + setStore("all", index, (pty) => trimTerminal(pty)) + }, + trimAll() { + setStore("all", (all) => { + const next = all.map(trimTerminal) + if (next.every((pty, index) => pty === all[index])) return all + return next + }) + }, async clone(id: string) { const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] @@ -322,12 +344,27 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont const workspace = createMemo(() => loadWorkspace(params.dir!, params.id)) + createEffect( + on( + () => ({ dir: params.dir, id: params.id }), + (next, prev) => { + if (!prev?.dir) return + if (next.dir === prev.dir && next.id === prev.id) return + if (next.dir === prev.dir && next.id) return + loadWorkspace(prev.dir, prev.id).trimAll() + }, + { defer: true }, + ), + ) + return { ready: () => workspace().ready(), all: () => workspace().all(), active: () => workspace().active(), new: () => workspace().new(), update: (pty: Partial & { id: string }) => workspace().update(pty), + trim: (id: string) => workspace().trim(id), + trimAll: () => workspace().trimAll(), clone: (id: string) => workspace().clone(id), open: (id: string) => workspace().open(id), close: (id: string) => workspace().close(id), diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 30925191f4..b7ac28ae1a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1891,6 +1891,7 @@ export default function Layout(props: ParentProps) { const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => { const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened())) + const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened()) const projectName = createMemo(() => { const project = panelProps.project if (!project) return "" @@ -1919,8 +1920,8 @@ export default function Layout(props: ParentProps) { "flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true, "border border-b-0 border-border-weak-base": !merged(), "border-l border-t border-border-weaker-base": merged(), - "bg-background-base": merged(), - "bg-background-stronger": !merged(), + "bg-background-base": merged() || hover(), + "bg-background-stronger": !merged() && !hover(), "flex-1 min-w-0": panelProps.mobile, "max-w-full overflow-hidden": panelProps.mobile, }} diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index fb66dcc975..187cd2f335 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -91,6 +91,7 @@ const ProjectTile = (props: { modal={!props.sidebarHovering()} onOpenChange={(value) => { props.setMenu(value) + props.setSuppressHover(value) if (value) props.setOpen(false) }} > @@ -107,6 +108,12 @@ const ProjectTile = (props: { !props.selected() && !props.active(), "bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(), }} + onPointerDown={(event) => { + if (!props.overlay()) return + if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return + props.setSuppressHover(true) + event.preventDefault() + }} onMouseEnter={(event: MouseEvent) => { if (!props.overlay()) return if (props.suppressHover()) return diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4967eaa553..90769a28ac 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -41,216 +41,12 @@ import { createScrollSpy } from "@/pages/session/scroll-spy" import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" +import { createSessionHistoryWindow, emptyUserMessages } from "@/pages/session/history-window" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" -const emptyUserMessages: UserMessage[] = [] - -type SessionHistoryWindowInput = { - sessionID: () => string | undefined - messagesReady: () => boolean - visibleUserMessages: () => UserMessage[] - historyMore: () => boolean - historyLoading: () => boolean - loadMore: (sessionID: string) => Promise - userScrolled: () => boolean - scroller: () => HTMLDivElement | undefined -} - -/** - * Maintains the rendered history window for a session timeline. - * - * It keeps initial paint bounded to recent turns, reveals cached turns in - * small batches while scrolling upward, and prefetches older history near top. - */ -function createSessionHistoryWindow(input: SessionHistoryWindowInput) { - const turnInit = 10 - const turnBatch = 8 - const turnScrollThreshold = 200 - const turnPrefetchBuffer = 16 - const prefetchCooldownMs = 400 - const prefetchNoGrowthLimit = 2 - - const [state, setState] = createStore({ - turnID: undefined as string | undefined, - turnStart: 0, - prefetchUntil: 0, - prefetchNoGrowth: 0, - }) - - const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0) - - const turnStart = createMemo(() => { - const id = input.sessionID() - const len = input.visibleUserMessages().length - if (!id || len <= 0) return 0 - if (state.turnID !== id) return initialTurnStart(len) - if (state.turnStart <= 0) return 0 - if (state.turnStart >= len) return initialTurnStart(len) - return state.turnStart - }) - - const setTurnStart = (start: number) => { - const id = input.sessionID() - const next = start > 0 ? start : 0 - if (!id) { - setState({ turnID: undefined, turnStart: next }) - return - } - setState({ turnID: id, turnStart: next }) - } - - const renderedUserMessages = createMemo( - () => { - const msgs = input.visibleUserMessages() - const start = turnStart() - if (start <= 0) return msgs - return msgs.slice(start) - }, - emptyUserMessages, - { - equals: same, - }, - ) - - const preserveScroll = (fn: () => void) => { - const el = input.scroller() - if (!el) { - fn() - return - } - const beforeTop = el.scrollTop - fn() - void el.scrollHeight - el.scrollTop = beforeTop - } - - const backfillTurns = () => { - const start = turnStart() - if (start <= 0) return - - const next = start - turnBatch - const nextStart = next > 0 ? next : 0 - - preserveScroll(() => setTurnStart(nextStart)) - } - - /** Button path: reveal all cached turns, fetch older history, reveal one batch. */ - const loadAndReveal = async () => { - const id = input.sessionID() - if (!id) return - - const start = turnStart() - const beforeVisible = input.visibleUserMessages().length - - if (start > 0) setTurnStart(0) - - if (!input.historyMore() || input.historyLoading()) return - - await input.loadMore(id) - if (input.sessionID() !== id) return - - const afterVisible = input.visibleUserMessages().length - const growth = afterVisible - beforeVisible - if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0) - if (growth <= 0) return - if (turnStart() !== 0) return - - const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch) - const nextStart = Math.max(0, afterVisible - target) - preserveScroll(() => setTurnStart(nextStart)) - } - - /** Scroll/prefetch path: fetch older history from server. */ - const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => { - const id = input.sessionID() - if (!id) return - if (!input.historyMore() || input.historyLoading()) return - - if (opts?.prefetch) { - const now = Date.now() - if (state.prefetchUntil > now) return - if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return - setState("prefetchUntil", now + prefetchCooldownMs) - } - - const start = turnStart() - const beforeVisible = input.visibleUserMessages().length - const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length - - await input.loadMore(id) - if (input.sessionID() !== id) return - - const afterVisible = input.visibleUserMessages().length - const growth = afterVisible - beforeVisible - - if (opts?.prefetch) { - setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1) - } else if (growth > 0 && state.prefetchNoGrowth) { - setState("prefetchNoGrowth", 0) - } - - if (growth <= 0) return - if (turnStart() !== start) return - - const reveal = !opts?.prefetch - const currentRendered = renderedUserMessages().length - const base = Math.max(beforeRendered, currentRendered) - const target = reveal ? Math.min(afterVisible, base + turnBatch) : base - const nextStart = Math.max(0, afterVisible - target) - preserveScroll(() => setTurnStart(nextStart)) - } - - const onScrollerScroll = () => { - if (!input.userScrolled()) return - const el = input.scroller() - if (!el) return - if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return - - const start = turnStart() - if (start > 0) { - if (start <= turnPrefetchBuffer) { - void fetchOlderMessages({ prefetch: true }) - } - backfillTurns() - return - } - - void fetchOlderMessages() - } - - createEffect( - on( - input.sessionID, - () => { - setState({ prefetchUntil: 0, prefetchNoGrowth: 0 }) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => [input.sessionID(), input.messagesReady()] as const, - ([id, ready]) => { - if (!id || !ready) return - setTurnStart(initialTurnStart(input.visibleUserMessages().length)) - }, - { defer: true }, - ), - ) - - return { - turnStart, - setTurnStart, - renderedUserMessages, - loadAndReveal, - onScrollerScroll, - } -} - export default function Page() { const globalSync = useGlobalSync() const layout = useLayout() @@ -1090,6 +886,7 @@ export default function Page() { let scrollStateFrame: number | undefined let scrollStateTarget: HTMLDivElement | undefined + let historyFillFrame: number | undefined const scrollSpy = createScrollSpy({ onActive: (id) => { if (id === store.messageId) return @@ -1159,7 +956,9 @@ export default function Page() { scroller = el autoScroll.scrollRef(el) scrollSpy.setContainer(el) - if (el) scheduleScrollState(el) + if (!el) return + scheduleScrollState(el) + scheduleHistoryFill() } createResizeObserver( @@ -1168,6 +967,7 @@ export default function Page() { const el = scroller if (el) scheduleScrollState(el) scrollSpy.markDirty() + scheduleHistoryFill() }, ) @@ -1182,6 +982,45 @@ export default function Page() { scroller: () => scroller, }) + const scheduleHistoryFill = () => { + if (historyFillFrame !== undefined) return + + historyFillFrame = requestAnimationFrame(() => { + historyFillFrame = undefined + + if (!params.id || !messagesReady()) return + if (autoScroll.userScrolled() || historyLoading()) return + + const el = scroller + if (!el) return + if (el.scrollHeight > el.clientHeight + 1) return + if (historyWindow.turnStart() <= 0 && !historyMore()) return + + void historyWindow.loadAndReveal() + }) + } + + createEffect( + on( + () => + [ + params.id, + messagesReady(), + historyWindow.turnStart(), + historyMore(), + historyLoading(), + autoScroll.userScrolled(), + visibleUserMessages().length, + ] as const, + ([id, ready, start, more, loading, scrolled]) => { + if (!id || !ready || loading || scrolled) return + if (start <= 0 && !more) return + scheduleHistoryFill() + }, + { defer: true }, + ), + ) + createResizeObserver( () => promptDock, ({ height }) => { @@ -1199,6 +1038,7 @@ export default function Page() { if (el) scheduleScrollState(el) scrollSpy.markDirty() + scheduleHistoryFill() }, ) @@ -1228,6 +1068,7 @@ export default function Page() { document.removeEventListener("keydown", handleKeyDown) scrollSpy.destroy() if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) + if (historyFillFrame !== undefined) cancelAnimationFrame(historyFillFrame) }) return ( diff --git a/packages/app/src/pages/session/history-window.test.ts b/packages/app/src/pages/session/history-window.test.ts new file mode 100644 index 0000000000..4a9b894e27 --- /dev/null +++ b/packages/app/src/pages/session/history-window.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { historyLoadMode, historyRevealTop } from "./history-window" + +describe("historyLoadMode", () => { + test("reveals cached turns before fetching", () => { + expect(historyLoadMode({ start: 10, more: true, loading: false })).toBe("reveal") + }) + + test("fetches older history when cache is already revealed", () => { + expect(historyLoadMode({ start: 0, more: true, loading: false })).toBe("fetch") + }) + + test("does nothing while history is unavailable or loading", () => { + expect(historyLoadMode({ start: 0, more: false, loading: false })).toBe("noop") + expect(historyLoadMode({ start: 0, more: true, loading: true })).toBe("noop") + }) +}) + +describe("historyRevealTop", () => { + test("pins the viewport to the top when older turns were revealed there", () => { + expect(historyRevealTop({ top: -400, height: 1000, gap: 0, max: 400 }, { clientHeight: 600, height: 2000 })).toBe( + -1400, + ) + }) + + test("keeps the latest turns pinned when the viewport was underfilled", () => { + expect(historyRevealTop({ top: 0, height: 200, gap: -400, max: -400 }, { clientHeight: 600, height: 2000 })).toBe(0) + }) + + test("keeps the current anchor when the user was not at the top", () => { + expect(historyRevealTop({ top: -200, height: 1000, gap: 200, max: 400 }, { clientHeight: 600, height: 2000 })).toBe( + -200, + ) + }) +}) diff --git a/packages/app/src/pages/session/history-window.ts b/packages/app/src/pages/session/history-window.ts new file mode 100644 index 0000000000..e3ef20f13d --- /dev/null +++ b/packages/app/src/pages/session/history-window.ts @@ -0,0 +1,273 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { createEffect, createMemo, on } from "solid-js" +import { createStore } from "solid-js/store" +import { same } from "@/utils/same" + +export const emptyUserMessages: UserMessage[] = [] + +export type SessionHistoryWindowInput = { + sessionID: () => string | undefined + messagesReady: () => boolean + visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise + userScrolled: () => boolean + scroller: () => HTMLDivElement | undefined +} + +type Snap = { + top: number + height: number + gap: number + max: number +} + +export const historyLoadMode = (input: { start: number; more: boolean; loading: boolean }) => { + if (input.start > 0) return "reveal" + if (!input.more || input.loading) return "noop" + return "fetch" +} + +export const historyRevealTop = ( + mark: { top: number; height: number; gap: number; max: number }, + next: { clientHeight: number; height: number }, + threshold = 16, +) => { + const delta = next.height - mark.height + if (delta <= 0) return mark.top + if (mark.max <= 0) return mark.top + if (mark.gap > threshold) return mark.top + + const max = next.height - next.clientHeight + if (max <= 0) return 0 + return Math.max(-max, Math.min(0, mark.top - delta)) +} + +const snap = (el: HTMLDivElement | undefined): Snap | undefined => { + if (!el) return + const max = el.scrollHeight - el.clientHeight + return { + top: el.scrollTop, + height: el.scrollHeight, + gap: max + el.scrollTop, + max, + } +} + +const clamp = (el: HTMLDivElement, top: number) => { + const max = el.scrollHeight - el.clientHeight + if (max <= 0) return 0 + return Math.max(-max, Math.min(0, top)) +} + +const revealThreshold = 16 + +const reveal = (input: SessionHistoryWindowInput, mark: Snap | undefined) => { + const el = input.scroller() + if (!el || !mark) return + el.scrollTop = clamp( + el, + historyRevealTop(mark, { clientHeight: el.clientHeight, height: el.scrollHeight }, revealThreshold), + ) +} + +const preserve = (input: SessionHistoryWindowInput, fn: () => void) => { + const el = input.scroller() + if (!el) { + fn() + return + } + const top = el.scrollTop + fn() + el.scrollTop = top +} + +/** + * Maintains the rendered history window for a session timeline. + * + * It keeps initial paint bounded to recent turns, reveals cached turns in + * small batches while scrolling upward, and prefetches older history near top. + */ +export function createSessionHistoryWindow(input: SessionHistoryWindowInput) { + const turnInit = 10 + const turnBatch = 8 + const turnScrollThreshold = 200 + const turnPrefetchBuffer = 16 + const prefetchCooldownMs = 400 + const prefetchNoGrowthLimit = 2 + + const [state, setState] = createStore({ + turnID: undefined as string | undefined, + turnStart: 0, + prefetchUntil: 0, + prefetchNoGrowth: 0, + }) + + const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0) + + const turnStart = createMemo(() => { + const id = input.sessionID() + const len = input.visibleUserMessages().length + if (!id || len <= 0) return 0 + if (state.turnID !== id) return initialTurnStart(len) + if (state.turnStart <= 0) return 0 + if (state.turnStart >= len) return initialTurnStart(len) + return state.turnStart + }) + + const setTurnStart = (start: number) => { + const id = input.sessionID() + const next = start > 0 ? start : 0 + if (!id) { + setState({ turnID: undefined, turnStart: next }) + return + } + setState({ turnID: id, turnStart: next }) + } + + const renderedUserMessages = createMemo( + () => { + const msgs = input.visibleUserMessages() + const start = turnStart() + if (start <= 0) return msgs + return msgs.slice(start) + }, + emptyUserMessages, + { + equals: same, + }, + ) + + const backfillTurns = () => { + const start = turnStart() + if (start <= 0) return + + const next = start - turnBatch + const nextStart = next > 0 ? next : 0 + + preserve(input, () => setTurnStart(nextStart)) + } + + /** Button path: reveal cached turns first, then fetch older history. */ + const loadAndReveal = async () => { + const id = input.sessionID() + if (!id) return + + const start = turnStart() + const mode = historyLoadMode({ + start, + more: input.historyMore(), + loading: input.historyLoading(), + }) + + if (mode === "reveal") { + const mark = snap(input.scroller()) + setTurnStart(0) + reveal(input, mark) + return + } + + if (mode === "noop") return + + const beforeVisible = input.visibleUserMessages().length + const mark = snap(input.scroller()) + + await input.loadMore(id) + if (input.sessionID() !== id) return + + const afterVisible = input.visibleUserMessages().length + const growth = afterVisible - beforeVisible + if (growth <= 0) return + if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0) + + reveal(input, mark) + } + + /** Scroll/prefetch path: fetch older history from server. */ + const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => { + const id = input.sessionID() + if (!id) return + if (!input.historyMore() || input.historyLoading()) return + + if (opts?.prefetch) { + const now = Date.now() + if (state.prefetchUntil > now) return + if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return + setState("prefetchUntil", now + prefetchCooldownMs) + } + + const start = turnStart() + const beforeVisible = input.visibleUserMessages().length + const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length + + await input.loadMore(id) + if (input.sessionID() !== id) return + + const afterVisible = input.visibleUserMessages().length + const growth = afterVisible - beforeVisible + + if (opts?.prefetch) { + setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1) + } else if (growth > 0 && state.prefetchNoGrowth) { + setState("prefetchNoGrowth", 0) + } + + if (growth <= 0) return + if (turnStart() !== start) return + + const revealMore = !opts?.prefetch + const currentRendered = renderedUserMessages().length + const base = Math.max(beforeRendered, currentRendered) + const target = revealMore ? Math.min(afterVisible, base + turnBatch) : base + const nextStart = Math.max(0, afterVisible - target) + preserve(input, () => setTurnStart(nextStart)) + } + + const onScrollerScroll = () => { + if (!input.userScrolled()) return + const el = input.scroller() + if (!el) return + if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return + + const start = turnStart() + if (start > 0) { + if (start <= turnPrefetchBuffer) { + void fetchOlderMessages({ prefetch: true }) + } + backfillTurns() + return + } + + void fetchOlderMessages() + } + + createEffect( + on( + input.sessionID, + () => { + setState({ prefetchUntil: 0, prefetchNoGrowth: 0 }) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => [input.sessionID(), input.messagesReady()] as const, + ([id, ready]) => { + if (!id || !ready) return + setTurnStart(initialTurnStart(input.visibleUserMessages().length)) + }, + { defer: true }, + ), + ) + + return { + turnStart, + setTurnStart, + renderedUserMessages, + loadAndReveal, + onScrollerScroll, + } +} diff --git a/packages/app/src/pages/session/session-timeline-header.tsx b/packages/app/src/pages/session/session-timeline-header.tsx index d10fe1a27e..32412f0a7f 100644 --- a/packages/app/src/pages/session/session-timeline-header.tsx +++ b/packages/app/src/pages/session/session-timeline-header.tsx @@ -2,10 +2,10 @@ import { createEffect, createMemo, on, onCleanup, Show } from "solid-js" import { createStore, produce } from "solid-js/store" import { useNavigate, useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" +import { useReducedMotion } from "@opencode-ai/ui/hooks" import { IconButton } from "@opencode-ai/ui/icon-button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" -import { prefersReducedMotion } from "@opencode-ai/ui/hooks" import { InlineInput } from "@opencode-ai/ui/inline-input" import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion" import { showToast } from "@opencode-ai/ui/toast" @@ -32,7 +32,7 @@ export function SessionTimelineHeader(props: { const sync = useSync() const dialog = useDialog() const language = useLanguage() - const reduce = prefersReducedMotion + const reduce = useReducedMotion() const [title, setTitle] = createStore({ draft: "", diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index d5eac2322b..8fd652e903 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -250,6 +250,7 @@ export function TerminalPanel() {
terminal.trim(id)} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} /> diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 0032a24319..62474711f0 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.2.21", + "version": "1.2.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 79de75cfbc..b8cf081047 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.2.21", + "version": "1.2.22", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 0e4589cc2c..ed4cfed17a 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.2.21", + "version": "1.2.22", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 4e28f18c0d..c41e66c051 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 7eca0e0417..2b9ce92da4 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.2.21", + "version": "1.2.22", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 13b3bfed6b..8663cc8d58 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.2.21", + "version": "1.2.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 0479f42eb4..9807922a2c 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.2.21", + "version": "1.2.22", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index d2af1bb60f..ddc61ca4c6 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.2.21" +version = "1.2.22" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index c00a99ae20..1bbb1dffde 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.2.21", + "version": "1.2.22", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 716546cbd4..7a25100b0b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.2.21", + "version": "1.2.22", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 998f22c1c7..c00e57c71f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -111,7 +111,6 @@ export function tui(input: { fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource - onExit?: () => Promise }) { // promise to prevent immediate exit return new Promise(async (resolve) => { @@ -126,7 +125,6 @@ export function tui(input: { const onExit = async () => { unguard?.() - await input.onExit?.() resolve() } diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx index a6f775913a..3ed4ae3d2c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/exit.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx @@ -15,6 +15,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({ init: (input: { onExit?: () => Promise }) => { const renderer = useRenderer() let message: string | undefined + let task: Promise | undefined const store = { set: (value?: string) => { const prev = message @@ -29,20 +30,24 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({ get: () => message, } const exit: Exit = Object.assign( - async (reason?: unknown) => { - // Reset window title before destroying renderer - renderer.setTerminalTitle("") - renderer.destroy() - win32FlushInputBuffer() - if (reason) { - const formatted = FormatError(reason) ?? FormatUnknownError(reason) - if (formatted) { - process.stderr.write(formatted + "\n") + (reason?: unknown) => { + if (task) return task + task = (async () => { + // Reset window title before destroying renderer + renderer.setTerminalTitle("") + renderer.destroy() + win32FlushInputBuffer() + if (reason) { + const formatted = FormatError(reason) ?? FormatUnknownError(reason) + if (formatted) { + process.stderr.write(formatted + "\n") + } } - } - const text = store.get() - if (text) process.stdout.write(text + "\n") - await input.onExit?.() + const text = store.get() + if (text) process.stdout.write(text + "\n") + await input.onExit?.() + })() + return task }, { message: store, diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index fea32a2b2b..14a9c88731 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -110,18 +110,20 @@ export const TuiThreadCommand = cmd({ return } - // Resolve relative paths against PWD to preserve behavior when using --cwd flag + // Resolve relative --project paths from PWD, then use the real cwd after + // chdir so the thread and worker share the same directory key. const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) - const cwd = args.project + const next = args.project ? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) - : root + : Filesystem.resolve(process.cwd()) const file = await target() try { - process.chdir(cwd) + process.chdir(next) } catch { - UI.error("Failed to change directory to " + cwd) + UI.error("Failed to change directory to " + next) return } + const cwd = Filesystem.resolve(process.cwd()) const worker = new Worker(file, { env: Object.fromEntries( diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts new file mode 100644 index 0000000000..d3de7c3183 --- /dev/null +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, mock, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { tmpdir } from "../../fixture/fixture" + +const stop = new Error("stop") +const seen = { + tui: [] as string[], + inst: [] as string[], +} + +mock.module("../../../src/cli/cmd/tui/app", () => ({ + tui: async (input: { directory: string }) => { + seen.tui.push(input.directory) + throw stop + }, +})) + +mock.module("@/util/rpc", () => ({ + Rpc: { + client: () => ({ + call: async () => ({ url: "http://127.0.0.1" }), + on: () => {}, + }), + }, +})) + +mock.module("@/cli/ui", () => ({ + UI: { + error: () => {}, + }, +})) + +mock.module("@/util/log", () => ({ + Log: { + init: async () => {}, + create: () => ({ + error: () => {}, + info: () => {}, + warn: () => {}, + debug: () => {}, + time: () => ({ stop: () => {} }), + }), + Default: { + error: () => {}, + info: () => {}, + warn: () => {}, + debug: () => {}, + }, + }, +})) + +mock.module("@/util/timeout", () => ({ + withTimeout: (input: Promise) => input, +})) + +mock.module("@/cli/network", () => ({ + withNetworkOptions: (input: T) => input, + resolveNetworkOptions: async () => ({ + mdns: false, + port: 0, + hostname: "127.0.0.1", + }), +})) + +mock.module("../../../src/cli/cmd/tui/win32", () => ({ + win32DisableProcessedInput: () => {}, + win32InstallCtrlCGuard: () => undefined, +})) + +mock.module("@/config/tui", () => ({ + TuiConfig: { + get: () => ({}), + }, +})) + +mock.module("@/project/instance", () => ({ + Instance: { + provide: async (input: { directory: string; fn: () => Promise | unknown }) => { + seen.inst.push(input.directory) + return input.fn() + }, + }, +})) + +describe("tui thread", () => { + async function call(project?: string) { + const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread") + const args: Parameters>[0] = { + _: [], + $0: "opencode", + project, + prompt: "hi", + model: undefined, + agent: undefined, + session: undefined, + continue: false, + fork: false, + port: 0, + hostname: "127.0.0.1", + mdns: false, + "mdns-domain": "opencode.local", + mdnsDomain: "opencode.local", + cors: [], + } + return TuiThreadCommand.handler(args) + } + + async function check(project?: string) { + await using tmp = await tmpdir({ git: true }) + const cwd = process.cwd() + const pwd = process.env.PWD + const worker = globalThis.Worker + const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") + const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") + const type = process.platform === "win32" ? "junction" : "dir" + seen.tui.length = 0 + seen.inst.length = 0 + await fs.symlink(tmp.path, link, type) + + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }) + globalThis.Worker = class extends EventTarget { + onerror = null + onmessage = null + onmessageerror = null + postMessage() {} + terminate() {} + } as unknown as typeof Worker + + try { + process.chdir(tmp.path) + process.env.PWD = link + await expect(call(project)).rejects.toBe(stop) + expect(seen.inst[0]).toBe(tmp.path) + expect(seen.tui[0]).toBe(tmp.path) + } finally { + process.chdir(cwd) + if (pwd === undefined) delete process.env.PWD + else process.env.PWD = pwd + if (tty) Object.defineProperty(process.stdin, "isTTY", tty) + else delete (process.stdin as { isTTY?: boolean }).isTTY + globalThis.Worker = worker + await fs.rm(link, { recursive: true, force: true }).catch(() => undefined) + } + } + + test("uses the real cwd when PWD points at a symlink", async () => { + await check() + }) + + test("uses the real cwd after resolving a relative project from PWD", async () => { + await check(".") + }) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index ab0abff1e8..cb6640b5d3 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.2.21", + "version": "1.2.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index c6ab0313a1..19d50e85cd 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.2.21", + "version": "1.2.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index aa0aeb1f62..b5c02a45ff 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.2.21", + "version": "1.2.22", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 664fd9752e..4022deb4ae 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.2.21", + "version": "1.2.22", "type": "module", "license": "MIT", "exports": { @@ -48,8 +48,11 @@ "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", + "@solid-primitives/lifecycle": "0.1.2", "@solid-primitives/media": "2.3.3", + "@solid-primitives/page-visibility": "2.1.1", "@solid-primitives/resize-observer": "2.1.3", + "@solid-primitives/rootless": "1.5.2", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "dompurify": "3.3.1", diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx index 25d120e05e..a0d9311de4 100644 --- a/packages/ui/src/components/context-tool-results.tsx +++ b/packages/ui/src/components/context-tool-results.tsx @@ -1,8 +1,8 @@ import { createMemo, createSignal, For, onMount } from "solid-js" import type { ToolPart } from "@opencode-ai/sdk/v2" import { getFilename } from "@opencode-ai/util/path" +import { useReducedMotion } from "../hooks/use-reduced-motion" import { useI18n } from "../context/i18n" -import { prefersReducedMotion } from "../hooks/use-reduced-motion" import { ToolCall } from "./basic-tool" import { ToolStatusTitle } from "./tool-status-title" import { AnimatedCountList } from "./tool-count-summary" @@ -58,11 +58,9 @@ export function ContextToolGroupHeader(props: { { - if (!props.pending) props.onOpenChange(v) - }} + open={props.open} + showArrow + onOpenChange={props.onOpenChange} trigger={
() const [mounted, setMounted] = createSignal(false) onMount(() => setMounted(true)) - const reduce = prefersReducedMotion const show = () => mounted() && props.pending const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING) const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING) diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx index ec4921ab3a..c8ea6f3b3a 100644 --- a/packages/ui/src/components/grow-box.tsx +++ b/packages/ui/src/components/grow-box.tsx @@ -1,6 +1,6 @@ import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js" +import { useReducedMotion } from "../hooks/use-reduced-motion" import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion" -import { prefersReducedMotion } from "../hooks/use-reduced-motion" export interface GrowBoxProps { children: JSX.Element @@ -49,7 +49,7 @@ export interface GrowBoxProps { * Used for timeline turns, assistant part groups, and user messages. */ export function GrowBox(props: GrowBoxProps) { - const reduce = prefersReducedMotion + const reduce = useReducedMotion() const spring = () => props.spring ?? GROW_SPRING const toggleSpring = () => props.toggleSpring ?? spring() let mode: "mount" | "toggle" = "mount" @@ -293,6 +293,18 @@ export function GrowBox(props: GrowBoxProps) { offChange() }) + if (watch()) { + observer = new ResizeObserver(() => { + if (!open()) return + if (resizeFrame !== undefined) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = undefined + setHeight("mount") + }) + }) + observer.observe(body) + } + if (!animated()) { setInstant(open()) return @@ -318,17 +330,6 @@ export function GrowBox(props: GrowBoxProps) { if (grow()) setHeight("mount") }) } - if (watch()) { - observer = new ResizeObserver(() => { - if (!open()) return - if (resizeFrame !== undefined) return - resizeFrame = requestAnimationFrame(() => { - resizeFrame = undefined - setHeight("mount") - }) - }) - observer.observe(body) - } }) createEffect( @@ -402,7 +403,12 @@ export function GrowBox(props: GrowBoxProps) { ref={root} data-slot={props.slot} class={props.class} - style={{ transform: "translateZ(0)", position: "relative" }} + style={{ + transform: "translateZ(0)", + position: "relative", + height: open() ? undefined : "0px", + overflow: open() ? undefined : "clip", + }} >
0 ? `${gap()}px` : undefined }}> {props.children} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index be99f36fd2..d821211592 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -38,7 +38,7 @@ import { TextShimmer } from "./text-shimmer" import { list } from "./text-utils" import { GrowBox } from "./grow-box" import { COLLAPSIBLE_SPRING } from "./motion" -import { busy, hold, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils" +import { busy, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils" import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results" import { ShellRollingResults } from "./shell-rolling-results" @@ -254,8 +254,6 @@ function urls(text: string | undefined) { const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) const HIDDEN_TOOLS = new Set(["todowrite", "todoread"]) -import { pageVisible } from "../hooks/use-page-visible" - function createGroupOpenState() { const [state, setState] = createStore>({}) const read = (key?: string, collapse?: boolean) => { @@ -274,18 +272,6 @@ function createGroupOpenState() { return { read, controlled, write } } -function shouldCollapseGroup( - statuses: (string | undefined)[], - opts: { afterTool?: boolean; groupTail?: boolean; working?: boolean }, -) { - if (opts.afterTool) return true - if (opts.groupTail === false) return true - if (!pageVisible()) return false - if (opts.working) return false - if (!statuses.length) return false - return !statuses.some((s) => busy(s)) -} - function renderable(part: PartType, showReasoningSummaries = true) { if (part.type === "tool") { if (HIDDEN_TOOLS.has(part.tool)) return false @@ -480,20 +466,9 @@ export function AssistantParts(props: { return COLLAPSIBLE_SPRING }) const contextOpen = createMemo(() => { - const collapse = ( - afterTool?: boolean, - groupTail?: boolean, - group?: { part: ToolPart; message: AssistantMessage }[], - ) => - shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], { - afterTool, - groupTail, - working: props.working, - }) const value = ctx() - if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts)) - const entry = part() - return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts)) + if (value) return groupState.read(value.groupKey, true) + return groupState.read(part()?.groupKey, true) }) const visible = createMemo(() => { if (!context()) return true @@ -539,9 +514,7 @@ export function AssistantParts(props: { ctxPartsPrev = result return result }) - const ctxPendingRaw = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail)) - const ctxPending = ctxPendingRaw - const ctxHoldOpen = hold(ctxPendingRaw) + const ctxPending = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail)) const shell = createMemo(() => { const value = part() if (!value) return @@ -593,12 +566,20 @@ export function AssistantParts(props: { onOpenChange={(value: boolean) => groupState.write(entry().groupKey, value)} /> - - + + )} - {(value) => } + + {(value) => ( + + )} + {(entry) => ( diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx index 5deefcfa61..c7ff1fbcd2 100644 --- a/packages/ui/src/components/motion-spring.tsx +++ b/packages/ui/src/components/motion-spring.tsx @@ -1,7 +1,7 @@ import { attachSpring, motionValue } from "motion" import type { SpringOptions } from "motion" import { createEffect, createSignal, onCleanup } from "solid-js" -import { prefersReducedMotion } from "../hooks/use-reduced-motion" +import { useReducedMotion } from "../hooks/use-reduced-motion" type Opt = Pick const eq = (a: Opt | undefined, b: Opt | undefined) => @@ -14,7 +14,7 @@ const eq = (a: Opt | undefined, b: Opt | undefined) => export function useSpring(target: () => number, options?: Opt | (() => Opt)) { const read = () => (typeof options === "function" ? options() : options) - const reduce = prefersReducedMotion + const reduce = useReducedMotion() const [value, setValue] = createSignal(target()) const source = motionValue(value()) const spring = motionValue(value()) diff --git a/packages/ui/src/components/rolling-results.tsx b/packages/ui/src/components/rolling-results.tsx index d2f30105e5..77ffdb1b34 100644 --- a/packages/ui/src/components/rolling-results.tsx +++ b/packages/ui/src/components/rolling-results.tsx @@ -1,6 +1,6 @@ import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js" +import { useReducedMotion } from "../hooks/use-reduced-motion" import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion" -import { prefersReducedMotion } from "../hooks/use-reduced-motion" export type RollingResultsProps = { items: T[] @@ -27,8 +27,7 @@ export function RollingResults(props: RollingResultsProps) { let shift: AnimationPlaybackControls | undefined let resize: AnimationPlaybackControls | undefined let edgeFade: AnimationPlaybackControls | undefined - - const reducedMotion = prefersReducedMotion + const reduce = useReducedMotion() const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3))) const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22))) @@ -54,7 +53,7 @@ export function RollingResults(props: RollingResultsProps) { return count() - rendered().length }) const open = createMemo(() => props.open !== false) - const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion()) + const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reduce()) const noFade = () => props.noFadeOnCollapse === true const overflowing = createMemo(() => count() > rows()) const shown = createMemo(() => Math.min(rows(), count())) diff --git a/packages/ui/src/components/shell-rolling-results.tsx b/packages/ui/src/components/shell-rolling-results.tsx index 6a3b7b02cc..0210e46e0e 100644 --- a/packages/ui/src/components/shell-rolling-results.tsx +++ b/packages/ui/src/components/shell-rolling-results.tsx @@ -1,7 +1,7 @@ import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import stripAnsi from "strip-ansi" import type { ToolPart } from "@opencode-ai/sdk/v2" -import { prefersReducedMotion } from "../hooks/use-reduced-motion" +import { useReducedMotion } from "../hooks/use-reduced-motion" import { useI18n } from "../context/i18n" import { RollingResults } from "./rolling-results" import { Icon } from "./icon" @@ -10,15 +10,7 @@ import { TextShimmer } from "./text-shimmer" import { Tooltip } from "./tooltip" import { GROW_SPRING } from "./motion" import { useSpring } from "./motion-spring" -import { - busy, - createThrottledValue, - hold, - updateScrollMask, - useCollapsible, - useRowWipe, - useToolFade, -} from "./tool-utils" +import { busy, createThrottledValue, updateScrollMask, useCollapsible, useRowWipe, useToolFade } from "./tool-utils" function ShellRollingSubtitle(props: { text: string; animate?: boolean }) { let ref: HTMLSpanElement | undefined @@ -176,23 +168,17 @@ function ShellExpanded(props: { cmd: string; out: string; open: boolean }) { ) } -export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) { +export function ShellRollingResults(props: { part: ToolPart; animate?: boolean; defaultOpen?: boolean }) { const i18n = useI18n() + const reduce = useReducedMotion() const wiped = new Set() const [mounted, setMounted] = createSignal(false) - const [userToggled, setUserToggled] = createSignal(false) - const [userOpen, setUserOpen] = createSignal(false) + const [open, setOpen] = createSignal(props.defaultOpen ?? true) onMount(() => setMounted(true)) const state = createMemo(() => props.part.state as Record) const pending = createMemo(() => busy(props.part.state.status)) - const autoOpen = hold(pending, 2000) - const effectiveOpen = createMemo(() => { - if (pending()) return true - if (userToggled()) return userOpen() - return autoOpen() - }) - const expanded = createMemo(() => !pending() && !autoOpen() && userToggled() && userOpen()) - const previewOpen = createMemo(() => effectiveOpen() && !expanded()) + const expanded = createMemo(() => open() && !pending()) + const previewOpen = createMemo(() => open() && pending()) const command = createMemo(() => { const value = state().input?.command ?? state().metadata?.command if (typeof value === "string") return value @@ -208,7 +194,6 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean } if (typeof value === "string") return value return "" }) - const reduce = prefersReducedMotion const skip = () => reduce() || props.animate === false const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING) const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING) @@ -217,12 +202,10 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean } const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING) let headerClipRef: HTMLDivElement | undefined const handleHeaderClick = () => { - if (pending()) return const el = headerClipRef const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null const beforeY = el?.getBoundingClientRect().top ?? 0 - setUserToggled(true) - setUserOpen((prev) => !prev) + setOpen((prev) => !prev) if (viewport && el) { requestAnimationFrame(() => { const afterY = el.getBoundingClientRect().top @@ -249,7 +232,7 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean } ref={headerClipRef} data-slot="shell-rolling-header-clip" data-scroll-preserve - data-clickable={!pending() ? "true" : "false"} + data-clickable="true" onClick={handleHeaderClick} style={{ height: `${skip() ? (mounted() ? 37 : 0) : headerHeight()}px`, overflow: "clip" }} > @@ -258,13 +241,11 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean } {(text) => } - - - - - + + + - +
{ if (typeof value === "number") return `${value}px` @@ -143,12 +143,13 @@ export function TextWipe(props: { text?: string; class?: string; delay?: number; let ref: HTMLSpanElement | undefined let frame: number | undefined let anim: AnimationPlaybackControls | undefined + const reduce = useReducedMotion() const run = () => { if (props.animate === false) return const el = ref if (!el || !props.text || typeof window === "undefined") return - if (prefersReducedMotion()) return + if (reduce()) return const mask = typeof CSS !== "undefined" && diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 0669f8cf26..444955af98 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,8 +1,8 @@ import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { useReducedMotion } from "../hooks/use-reduced-motion" import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion" import { TextShimmer } from "./text-shimmer" import { commonPrefix } from "./text-utils" -import { prefersReducedMotion } from "../hooks/use-reduced-motion" function contentWidth(el: HTMLSpanElement | undefined) { if (!el) return 0 @@ -18,6 +18,7 @@ export function ToolStatusTitle(props: { class?: string split?: boolean }) { + const reduce = useReducedMotion() const split = createMemo(() => commonPrefix(props.activeText, props.doneText)) const suffix = createMemo( () => @@ -38,8 +39,6 @@ export function ToolStatusTitle(props: { const node = () => (suffix() ? tailRef : swapRef) - const reduce = prefersReducedMotion - const setNodeWidth = (width: string) => { if (swapRef) swapRef.style.width = width if (tailRef) tailRef.style.width = width diff --git a/packages/ui/src/components/tool-utils.ts b/packages/ui/src/components/tool-utils.ts index 171649e3dc..4d57c626e8 100644 --- a/packages/ui/src/components/tool-utils.ts +++ b/packages/ui/src/components/tool-utils.ts @@ -1,4 +1,6 @@ +import type { ToolPart } from "@opencode-ai/sdk/v2" import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { useReducedMotion } from "../hooks/use-reduced-motion" import { animate, type AnimationPlaybackControls, @@ -8,8 +10,6 @@ import { GROW_SPRING, WIPE_MASK, } from "./motion" -import { prefersReducedMotion } from "../hooks/use-reduced-motion" -import type { ToolPart } from "@opencode-ai/sdk/v2" export const TEXT_RENDER_THROTTLE_MS = 100 @@ -106,57 +106,67 @@ export function useCollapsible(options: { measure?: () => number onOpen?: () => void }) { + const reduce = useReducedMotion() let heightAnim: AnimationPlaybackControls | undefined let fadeAnim: AnimationPlaybackControls | undefined let gen = 0 createEffect( - on( - options.open, - (isOpen) => { - const content = options.content() - const body = options.body() - if (!content || !body) return - heightAnim?.stop() - fadeAnim?.stop() - const id = ++gen + on(options.open, (isOpen) => { + const content = options.content() + const body = options.body() + if (!content || !body) return + heightAnim?.stop() + fadeAnim?.stop() + if (reduce()) { + body.style.opacity = "" + body.style.filter = "" if (isOpen) { content.style.display = "" - content.style.height = "0px" - body.style.opacity = "0" - body.style.filter = "blur(2px)" - fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING) - queueMicrotask(() => { - if (gen !== id) return - const c = options.content() - if (!c) return - const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height) - heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING) - heightAnim.finished.then( - () => { - if (gen !== id) return - c.style.height = "auto" - options.onOpen?.() - }, - () => {}, - ) - }) + content.style.height = "auto" + options.onOpen?.() return } + content.style.height = "0px" + content.style.display = "none" + return + } + const id = ++gen + if (isOpen) { + content.style.display = "" + content.style.height = "0px" + body.style.opacity = "0" + body.style.filter = "blur(2px)" + fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING) + queueMicrotask(() => { + if (gen !== id) return + const c = options.content() + if (!c) return + const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height) + heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING) + heightAnim.finished.then( + () => { + if (gen !== id) return + c.style.height = "auto" + options.onOpen?.() + }, + () => {}, + ) + }) + return + } - const h = content.getBoundingClientRect().height - heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING) - fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING) - heightAnim.finished.then( - () => { - if (gen !== id) return - content.style.display = "none" - }, - () => {}, - ) - }, - { defer: true }, - ), + const h = content.getBoundingClientRect().height + heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING) + fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING) + heightAnim.finished.then( + () => { + if (gen !== id) return + content.style.display = "none" + }, + () => {}, + ) + }), ) onCleanup(() => { @@ -181,7 +191,7 @@ export function useRowWipe(opts: { ref: () => HTMLElement | undefined seen: Set }) { - const reduce = prefersReducedMotion + const reduce = useReducedMotion() createEffect(() => { const id = opts.id() @@ -265,13 +275,14 @@ export function useToolFade( const delay = options?.delay ?? 0 const wipe = options?.wipe ?? false const active = options?.animate !== false + const reduce = useReducedMotion() onMount(() => { if (!active) return const el = ref() if (!el || typeof window === "undefined") return - if (prefersReducedMotion()) return + if (reduce()) return const mask = wipe && diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 4a218024d6..0fcf6f086c 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,5 +1,3 @@ export * from "./use-filtered-list" export * from "./create-auto-scroll" -export * from "./use-element-height" export * from "./use-reduced-motion" -export * from "./use-page-visible" diff --git a/packages/ui/src/hooks/use-element-height.ts b/packages/ui/src/hooks/use-element-height.ts deleted file mode 100644 index a9f06ec8b8..0000000000 --- a/packages/ui/src/hooks/use-element-height.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" - -/** - * Tracks an element's height via ResizeObserver. - * Returns a reactive signal that updates whenever the element resizes. - */ -export function useElementHeight( - ref: Accessor | (() => HTMLElement | undefined), - initial = 0, -): Accessor { - const [height, setHeight] = createSignal(initial) - - createEffect(() => { - const el = ref() - if (!el) return - setHeight(el.getBoundingClientRect().height) - const observer = new ResizeObserver(() => { - setHeight(el.getBoundingClientRect().height) - }) - observer.observe(el) - onCleanup(() => observer.disconnect()) - }) - - return height -} diff --git a/packages/ui/src/hooks/use-page-visible.ts b/packages/ui/src/hooks/use-page-visible.ts deleted file mode 100644 index 88788ef4a9..0000000000 --- a/packages/ui/src/hooks/use-page-visible.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createSignal } from "solid-js" - -export const pageVisible = /* @__PURE__ */ (() => { - const [visible, setVisible] = createSignal(true) - if (typeof document !== "undefined") { - const sync = () => setVisible(document.visibilityState !== "hidden") - sync() - document.addEventListener("visibilitychange", sync) - } - return visible -})() diff --git a/packages/ui/src/hooks/use-reduced-motion.ts b/packages/ui/src/hooks/use-reduced-motion.ts index 7fa815bbd3..0038760ec8 100644 --- a/packages/ui/src/hooks/use-reduced-motion.ts +++ b/packages/ui/src/hooks/use-reduced-motion.ts @@ -1,9 +1,10 @@ -import { createSignal } from "solid-js" +import { isHydrated } from "@solid-primitives/lifecycle" +import { createMediaQuery } from "@solid-primitives/media" +import { createHydratableSingletonRoot } from "@solid-primitives/rootless" -export const prefersReducedMotion = /* @__PURE__ */ (() => { - if (typeof window === "undefined") return () => false - const mql = window.matchMedia("(prefers-reduced-motion: reduce)") - const [reduced, setReduced] = createSignal(mql.matches) - mql.addEventListener("change", () => setReduced(mql.matches)) - return reduced -})() +const query = "(prefers-reduced-motion: reduce)" + +export const useReducedMotion = createHydratableSingletonRoot(() => { + const value = createMediaQuery(query) + return () => !isHydrated() || value() +}) diff --git a/packages/util/package.json b/packages/util/package.json index 1caf496d77..04b0bb93f4 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.2.21", + "version": "1.2.22", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 79c72a8840..783b3d1a6f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.2.21", + "version": "1.2.22", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 9dd009cbd6..dcbbbc3d07 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.2.21", + "version": "1.2.22", "publisher": "sst-dev", "repository": { "type": "git",