Merge branch 'dev' into feat/canceled-prompts-in-history

This commit is contained in:
Ariane Emory
2026-03-08 19:28:41 -04:00
committed by GitHub
46 changed files with 796 additions and 471 deletions

View File

@@ -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=="],

View File

@@ -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="
}
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.21",
"version": "1.2.22",
"description": "",
"type": "module",
"exports": {

View File

@@ -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<Map<string, TerminalCacheEntry>>()
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<typeof useSDK>, 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<LocalPTY> & { 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),

View File

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

View File

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

View File

@@ -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<void>
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 (

View File

@@ -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,
)
})
})

View File

@@ -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<void>
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,
}
}

View File

@@ -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: "",

View File

@@ -250,6 +250,7 @@ export function TerminalPanel() {
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal
pty={pty()}
onConnect={() => terminal.trim(id)}
onCleanup={terminal.update}
onConnectError={() => terminal.clone(id)}
/>

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.21",
"version": "1.2.22",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.21",
"version": "1.2.22",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.21",
"version": "1.2.22",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -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"]

View File

@@ -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",

View File

@@ -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",

View File

@@ -111,7 +111,6 @@ export function tui(input: {
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
onExit?: () => Promise<void>
}) {
// promise to prevent immediate exit
return new Promise<void>(async (resolve) => {
@@ -126,7 +125,6 @@ export function tui(input: {
const onExit = async () => {
unguard?.()
await input.onExit?.()
resolve()
}

View File

@@ -15,6 +15,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
init: (input: { onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
let message: string | undefined
let task: Promise<void> | 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,

View File

@@ -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(

View File

@@ -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: <T>(input: Promise<T>) => input,
}))
mock.module("@/cli/network", () => ({
withNetworkOptions: <T>(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> | 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<NonNullable<typeof TuiThreadCommand.handler>>[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(".")
})
})

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.2.21",
"version": "1.2.22",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -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",

View File

@@ -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: {
<ToolCall
variant="row"
icon="magnifying-glass-menu"
open={!props.pending && props.open}
showArrow={!props.pending}
onOpenChange={(v) => {
if (!props.pending) props.onOpenChange(v)
}}
open={props.open}
showArrow
onOpenChange={props.onOpenChange}
trigger={
<div data-component="context-tool-group-trigger" data-pending={props.pending || undefined}>
<span
@@ -149,10 +147,10 @@ export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: bo
}
export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
const reduce = useReducedMotion()
const wiped = new Set<string>()
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)

View File

@@ -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",
}}
>
<div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
{props.children}

View File

@@ -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<Record<string, boolean>>({})
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)}
/>
</PartGrow>
<ContextToolExpandedList parts={ctxParts()} expanded={!ctxPending() && contextOpen()} />
<ContextToolRollingResults parts={ctxParts()} pending={ctxHoldOpen()} />
<ContextToolExpandedList parts={ctxParts()} expanded={contextOpen() && !ctxPending()} />
<ContextToolRollingResults parts={ctxParts()} pending={contextOpen() && ctxPending()} />
</>
)}
</Show>
<Show when={shell()}>{(value) => <ShellRollingResults part={value()} animate={props.animate} />}</Show>
<Show when={shell()}>
{(value) => (
<ShellRollingResults
part={value()}
animate={props.animate}
defaultOpen={props.shellToolDefaultOpen}
/>
)}
</Show>
<Show when={!shell() ? part() : undefined}>
{(entry) => (
<Show when={!entry().context}>

View File

@@ -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<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
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())

View File

@@ -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<T> = {
items: T[]
@@ -27,8 +27,7 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
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<T>(props: RollingResultsProps<T>) {
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()))

View File

@@ -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<string>()
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<string, any>)
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 }
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
</span>
<Show when={subtitle()}>{(text) => <ShellRollingSubtitle text={text()} animate={props.animate} />}</Show>
<Show when={!pending()}>
<span data-slot="shell-rolling-actions">
<span data-slot="shell-rolling-arrow" data-open={effectiveOpen() ? "true" : "false"}>
<Icon name="chevron-down" size="small" />
</span>
<span data-slot="shell-rolling-actions">
<span data-slot="shell-rolling-arrow" data-open={open() ? "true" : "false"}>
<Icon name="chevron-down" size="small" />
</span>
</Show>
</span>
</div>
</div>
<div

View File

@@ -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" &&

View File

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

View File

@@ -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<string>
}) {
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 &&

View File

@@ -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"

View File

@@ -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> | (() => HTMLElement | undefined),
initial = 0,
): Accessor<number> {
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
}

View File

@@ -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
})()

View File

@@ -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()
})

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.2.21",
"version": "1.2.22",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -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",

View File

@@ -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",