From c797b60069df9b6510442e6e2d582c572f88d5c1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 8 Mar 2026 06:11:57 -0500 Subject: [PATCH 01/10] fix(app): messages not loading reliably --- packages/app/src/pages/session.tsx | 253 +++------------- .../src/pages/session/history-window.test.ts | 35 +++ .../app/src/pages/session/history-window.ts | 273 ++++++++++++++++++ 3 files changed, 355 insertions(+), 206 deletions(-) create mode 100644 packages/app/src/pages/session/history-window.test.ts create mode 100644 packages/app/src/pages/session/history-window.ts 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, + } +} From f386137fbaf2e2f56fb32f8656e802f592a41341 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 8 Mar 2026 06:34:02 -0500 Subject: [PATCH 02/10] chore: refactoring ui hooks --- bun.lock | 7 ++ .../pages/session/session-timeline-header.tsx | 4 +- packages/ui/package.json | 3 + .../src/components/context-tool-results.tsx | 4 +- packages/ui/src/components/grow-box.tsx | 34 +++--- packages/ui/src/components/message-part.tsx | 19 ++-- packages/ui/src/components/motion-spring.tsx | 4 +- .../ui/src/components/rolling-results.tsx | 7 +- .../src/components/shell-rolling-results.tsx | 4 +- packages/ui/src/components/text-reveal.tsx | 5 +- .../ui/src/components/tool-status-title.tsx | 5 +- packages/ui/src/components/tool-utils.ts | 101 ++++++++++-------- packages/ui/src/hooks/index.ts | 2 - packages/ui/src/hooks/use-element-height.ts | 25 ----- packages/ui/src/hooks/use-page-visible.ts | 11 -- packages/ui/src/hooks/use-reduced-motion.ts | 17 +-- 16 files changed, 123 insertions(+), 129 deletions(-) delete mode 100644 packages/ui/src/hooks/use-element-height.ts delete mode 100644 packages/ui/src/hooks/use-page-visible.ts diff --git a/bun.lock b/bun.lock index f19cacbe3d..360ef8facc 100644 --- a/bun.lock +++ b/bun.lock @@ -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", @@ -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/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/ui/package.json b/packages/ui/package.json index 664fd9752e..6384df19b3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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..abed1d84bb 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" @@ -149,10 +149,10 @@ export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: bo } export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) { + const reduce = useReducedMotion() const wiped = new Set() 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..1885c19f5b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,3 +1,4 @@ +import { usePageVisibility } from "@solid-primitives/page-visibility" import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js" import stripAnsi from "strip-ansi" import { createStore } from "solid-js/store" @@ -254,8 +255,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) => { @@ -277,6 +276,7 @@ function createGroupOpenState() { function shouldCollapseGroup( statuses: (string | undefined)[], opts: { afterTool?: boolean; groupTail?: boolean; working?: boolean }, + pageVisible: () => boolean, ) { if (opts.afterTool) return true if (opts.groupTail === false) return true @@ -363,6 +363,7 @@ export function AssistantParts(props: { }) { const data = useData() const emptyParts: PartType[] = [] + const pageVisible = usePageVisibility() const groupState = createGroupOpenState() const grouped = createMemo(() => { const keys: string[] = [] @@ -485,11 +486,15 @@ export function AssistantParts(props: { groupTail?: boolean, group?: { part: ToolPart; message: AssistantMessage }[], ) => - shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], { - afterTool, - groupTail, - working: props.working, - }) + shouldCollapseGroup( + group?.map((item) => item.part.state.status) ?? [], + { + afterTool, + groupTail, + working: props.working, + }, + pageVisible, + ) const value = ctx() if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts)) const entry = part() 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..4deef089e1 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" @@ -178,6 +178,7 @@ function ShellExpanded(props: { cmd: string; out: string; open: boolean }) { export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) { const i18n = useI18n() + const reduce = useReducedMotion() const wiped = new Set() const [mounted, setMounted] = createSignal(false) const [userToggled, setUserToggled] = createSignal(false) @@ -208,7 +209,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) diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx index 7ddf4a50b8..edf5dbf837 100644 --- a/packages/ui/src/components/text-reveal.tsx +++ b/packages/ui/src/components/text-reveal.tsx @@ -1,4 +1,5 @@ import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js" +import { useReducedMotion } from "../hooks/use-reduced-motion" import { animate, type AnimationPlaybackControls, @@ -7,7 +8,6 @@ import { GROW_SPRING, WIPE_MASK, } from "./motion" -import { prefersReducedMotion } from "../hooks/use-reduced-motion" const px = (value: number | string | undefined, fallback: number) => { 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() +}) From c53d1d3ad8280881fbb4364e0061a682608c420f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 8 Mar 2026 07:09:41 -0500 Subject: [PATCH 03/10] fix(app): less auto-expand/collapse --- .../src/components/context-tool-results.tsx | 8 ++- packages/ui/src/components/message-part.tsx | 54 ++++++------------- .../src/components/shell-rolling-results.tsx | 41 ++++---------- 3 files changed, 29 insertions(+), 74 deletions(-) diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx index abed1d84bb..a0d9311de4 100644 --- a/packages/ui/src/components/context-tool-results.tsx +++ b/packages/ui/src/components/context-tool-results.tsx @@ -58,11 +58,9 @@ export function ContextToolGroupHeader(props: { { - if (!props.pending) props.onOpenChange(v) - }} + open={props.open} + showArrow + onOpenChange={props.onOpenChange} trigger={
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 @@ -363,7 +349,6 @@ export function AssistantParts(props: { }) { const data = useData() const emptyParts: PartType[] = [] - const pageVisible = usePageVisibility() const groupState = createGroupOpenState() const grouped = createMemo(() => { const keys: string[] = [] @@ -481,24 +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, - }, - pageVisible, - ) 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 @@ -544,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 @@ -598,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/shell-rolling-results.tsx b/packages/ui/src/components/shell-rolling-results.tsx index 4deef089e1..0210e46e0e 100644 --- a/packages/ui/src/components/shell-rolling-results.tsx +++ b/packages/ui/src/components/shell-rolling-results.tsx @@ -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,24 +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 @@ -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) => } - - - - - + + + - +
Date: Sun, 8 Mar 2026 12:25:35 +0000 Subject: [PATCH 04/10] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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=" } } From 6e9e027886e78bdf08ecf94bc365537576b76b26 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 8 Mar 2026 18:20:04 +0530 Subject: [PATCH 05/10] fix: trim retained desktop terminal buffers (#16583) --- .../app/e2e/terminal/terminal-tabs.spec.ts | 35 +++++++++++++---- packages/app/src/context/terminal.tsx | 39 ++++++++++++++++++- .../app/src/pages/session/terminal-panel.tsx | 1 + 3 files changed, 66 insertions(+), 9 deletions(-) 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/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/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)} /> From 5cc4bb408914e2da35de66def1925867bceac230 Mon Sep 17 00:00:00 2001 From: David Hill Date: Sun, 8 Mar 2026 13:31:18 +0000 Subject: [PATCH 06/10] app: suppress hover when opening project menu or right-clicking to prevent flickering --- packages/app/src/pages/layout/sidebar-project.tsx | 7 +++++++ 1 file changed, 7 insertions(+) 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 From d15c2ce349f7c73aad25138de27df61ebe9bc634 Mon Sep 17 00:00:00 2001 From: David Hill Date: Sun, 8 Mar 2026 13:34:56 +0000 Subject: [PATCH 07/10] tui: fix sidebar background color when collapsed When the sidebar was collapsed (not on mobile), the background color was showing as the stronger variant instead of matching the base background. This fixes the hover state detection so users see a consistent lighter background when the sidebar is in collapsed mode. --- packages/app/src/pages/layout.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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, }} From e51ed460a6d5392c67485a829e2b1991ec50dbe8 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:57:48 +1000 Subject: [PATCH 08/10] fix(tui): canonicalize cwd after chdir (#16641) --- packages/opencode/src/cli/cmd/tui/thread.ts | 12 +- packages/opencode/test/cli/tui/thread.test.ts | 157 ++++++++++++++++++ 2 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/test/cli/tui/thread.test.ts 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(".") + }) +}) From 49a3a9fe365ab7c971220ac58572e34f2cc68897 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 8 Mar 2026 23:14:41 +0100 Subject: [PATCH 09/10] guard tui exit (#16640) --- packages/opencode/src/cli/cmd/tui/app.tsx | 2 -- .../opencode/src/cli/cmd/tui/context/exit.tsx | 31 +++++++++++-------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 97c910a47d..3304d6be6a 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, From 1db292f4df3bd69d4262ca6471aa5879b83b1fd1 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 8 Mar 2026 22:34:59 +0000 Subject: [PATCH 10/10] release: v1.2.22 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 360ef8facc..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:*", @@ -524,7 +524,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.2.21", + "version": "1.2.22", "dependencies": { "zod": "catalog:", }, @@ -535,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", 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/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/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 6384df19b3..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": { 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",