From acac05f22eb3c23eebea3c13ed0a0f5495425384 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Wed, 4 Feb 2026 00:45:49 +0100 Subject: [PATCH 01/35] refactor(e2e): faster tests (#12021) --- packages/app/e2e/fixtures.ts | 57 +++- .../app/e2e/projects/project-edit.spec.ts | 87 +++--- .../app/e2e/projects/projects-close.spec.ts | 74 ++--- .../app/e2e/projects/projects-switch.spec.ts | 33 +- packages/app/e2e/projects/workspaces.spec.ts | 286 +++++++----------- packages/app/playwright.config.ts | 4 +- 6 files changed, 259 insertions(+), 282 deletions(-) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index bace8cd591..ea41ed8516 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,5 +1,5 @@ -import { test as base, expect } from "@playwright/test" -import { seedProjects } from "./actions" +import { test as base, expect, type Page } from "@playwright/test" +import { cleanupTestProject, createTestProject, seedProjects } from "./actions" import { promptSelector } from "./selectors" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" @@ -8,6 +8,14 @@ export const settingsKey = "settings.v3" type TestFixtures = { sdk: ReturnType gotoSession: (sessionID?: string) => Promise + withProject: ( + callback: (project: { + directory: string + slug: string + gotoSession: (sessionID?: string) => Promise + }) => Promise, + options?: { extra?: string[] }, + ) => Promise } type WorkerFixtures = { @@ -33,17 +41,7 @@ export const test = base.extend({ await use(createSdk(directory)) }, gotoSession: async ({ page, directory }, use) => { - await seedProjects(page, { directory }) - await page.addInitScript(() => { - localStorage.setItem( - "opencode.global.dat:model", - JSON.stringify({ - recent: [{ providerID: "opencode", modelID: "big-pickle" }], - user: [], - variant: {}, - }), - ) - }) + await seedStorage(page, { directory }) const gotoSession = async (sessionID?: string) => { await page.goto(sessionPath(directory, sessionID)) @@ -51,6 +49,39 @@ export const test = base.extend({ } await use(gotoSession) }, + withProject: async ({ page }, use) => { + await use(async (callback, options) => { + const directory = await createTestProject() + const slug = dirSlug(directory) + await seedStorage(page, { directory, extra: options?.extra }) + + const gotoSession = async (sessionID?: string) => { + await page.goto(sessionPath(directory, sessionID)) + await expect(page.locator(promptSelector)).toBeVisible() + } + + try { + await gotoSession() + return await callback({ directory, slug, gotoSession }) + } finally { + await cleanupTestProject(directory) + } + }) + }, }) +async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) { + await seedProjects(page, input) + await page.addInitScript(() => { + localStorage.setItem( + "opencode.global.dat:model", + JSON.stringify({ + recent: [{ providerID: "opencode", modelID: "big-pickle" }], + user: [], + variant: {}, + }), + ) + }) +} + export { expect } diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts index 772c259517..4a286fea75 100644 --- a/packages/app/e2e/projects/project-edit.spec.ts +++ b/packages/app/e2e/projects/project-edit.spec.ts @@ -1,52 +1,53 @@ import { test, expect } from "../fixtures" import { openSidebar } from "../actions" -test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => { - await gotoSession() +test("dialog edit project updates name and startup script", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await openSidebar(page) + await withProject(async () => { + await openSidebar(page) + + const open = async () => { + const header = page.locator(".group\\/project").first() + await header.hover() + const trigger = header.getByRole("button", { name: "More options" }).first() + await expect(trigger).toBeVisible() + await trigger.click({ force: true }) + + const menu = page.locator('[data-component="dropdown-menu-content"]').first() + await expect(menu).toBeVisible() + + const editItem = menu.getByRole("menuitem", { name: "Edit" }).first() + await expect(editItem).toBeVisible() + await editItem.click({ force: true }) + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") + return dialog + } + + const name = `e2e project ${Date.now()}` + const startup = `echo e2e_${Date.now()}` + + const dialog = await open() + + const nameInput = dialog.getByLabel("Name") + await nameInput.fill(name) + + const startupInput = dialog.getByLabel("Workspace startup script") + await startupInput.fill(startup) + + await dialog.getByRole("button", { name: "Save" }).click() + await expect(dialog).toHaveCount(0) - const open = async () => { const header = page.locator(".group\\/project").first() - await header.hover() - const trigger = header.getByRole("button", { name: "More options" }).first() - await expect(trigger).toBeVisible() - await trigger.click({ force: true }) + await expect(header).toContainText(name) - const menu = page.locator('[data-component="dropdown-menu-content"]').first() - await expect(menu).toBeVisible() - - const editItem = menu.getByRole("menuitem", { name: "Edit" }).first() - await expect(editItem).toBeVisible() - await editItem.click({ force: true }) - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") - return dialog - } - - const name = `e2e project ${Date.now()}` - const startup = `echo e2e_${Date.now()}` - - const dialog = await open() - - const nameInput = dialog.getByLabel("Name") - await nameInput.fill(name) - - const startupInput = dialog.getByLabel("Workspace startup script") - await startupInput.fill(startup) - - await dialog.getByRole("button", { name: "Save" }).click() - await expect(dialog).toHaveCount(0) - - const header = page.locator(".group\\/project").first() - await expect(header).toContainText(name) - - const reopened = await open() - await expect(reopened.getByLabel("Name")).toHaveValue(name) - await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup) - await reopened.getByRole("button", { name: "Cancel" }).click() - await expect(reopened).toHaveCount(0) + const reopened = await open() + await expect(reopened.getByLabel("Name")).toHaveValue(name) + await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup) + await reopened.getByRole("button", { name: "Cancel" }).click() + await expect(reopened).toHaveCount(0) + }) }) diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts index bd323b90c6..95768d21e9 100644 --- a/packages/app/e2e/projects/projects-close.spec.ts +++ b/packages/app/e2e/projects/projects-close.spec.ts @@ -1,69 +1,73 @@ import { test, expect } from "../fixtures" -import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions" +import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions" import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors" import { dirSlug } from "../utils" -test("can close a project via hover card close button", async ({ page, directory, gotoSession }) => { +test("can close a project via hover card close button", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherSlug = dirSlug(other) - await seedProjects(page, { directory, extra: [other] }) try { - await gotoSession() + await withProject( + async () => { + await openSidebar(page) - await openSidebar(page) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.hover() - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.hover() + const close = page.locator(projectCloseHoverSelector(otherSlug)).first() + await expect(close).toBeVisible() + await close.click() - const close = page.locator(projectCloseHoverSelector(otherSlug)).first() - await expect(close).toBeVisible() - await close.click() - - await expect(otherButton).toHaveCount(0) + await expect(otherButton).toHaveCount(0) + }, + { extra: [other] }, + ) } finally { await cleanupTestProject(other) } }) -test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => { +test("can close a project via project header more options menu", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherName = other.split("/").pop() ?? other const otherSlug = dirSlug(other) - await seedProjects(page, { directory, extra: [other] }) try { - await gotoSession() + await withProject( + async () => { + await openSidebar(page) - await openSidebar(page) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + const header = page + .locator(".group\\/project") + .filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) }) + .first() + await expect(header).toContainText(otherName) - const header = page - .locator(".group\\/project") - .filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) }) - .first() - await expect(header).toContainText(otherName) + const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first() + await expect(trigger).toHaveCount(1) + await trigger.focus() + await page.keyboard.press("Enter") - const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first() - await expect(trigger).toHaveCount(1) - await trigger.focus() - await page.keyboard.press("Enter") + const menu = page.locator('[data-component="dropdown-menu-content"]').first() + await expect(menu).toBeVisible({ timeout: 10_000 }) - const menu = page.locator('[data-component="dropdown-menu-content"]').first() - await expect(menu).toBeVisible({ timeout: 10_000 }) - - await clickMenuItem(menu, /^Close$/i, { force: true }) - await expect(otherButton).toHaveCount(0) + await clickMenuItem(menu, /^Close$/i, { force: true }) + await expect(otherButton).toHaveCount(0) + }, + { extra: [other] }, + ) } finally { await cleanupTestProject(other) } diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 829ed8e57d..a817412cde 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,33 +1,34 @@ import { test, expect } from "../fixtures" -import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions" +import { defocus, createTestProject, cleanupTestProject } from "../actions" import { projectSwitchSelector } from "../selectors" import { dirSlug } from "../utils" -test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => { +test("can switch between projects from sidebar", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherSlug = dirSlug(other) - await seedProjects(page, { directory, extra: [other] }) - try { - await gotoSession() + await withProject( + async ({ directory }) => { + await defocus(page) - await defocus(page) + const currentSlug = dirSlug(directory) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() - const currentSlug = dirSlug(directory) - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() + await expect(currentButton).toBeVisible() + await currentButton.click() - const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() - await expect(currentButton).toBeVisible() - await currentButton.click() - - await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) + await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) + }, + { extra: [other] }, + ) } finally { await cleanupTestProject(other) } diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 80cd63aa2a..41a28e3e38 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -10,33 +10,20 @@ import { cleanupTestProject, clickMenuItem, confirmDialog, - createTestProject, openSidebar, openWorkspaceMenu, - seedProjects, setWorkspacesEnabled, } from "../actions" -import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors" -import { dirSlug } from "../utils" +import { inlineInputSelector, workspaceItemSelector } from "../selectors" function slugFromUrl(url: string) { return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" } -async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise) { - const project = await createTestProject() - const rootSlug = dirSlug(project) - await seedProjects(page, { directory, extra: [project] }) - - await gotoSession() +async function setupWorkspaceTest(page: Page, project: { slug: string }) { + const rootSlug = project.slug await openSidebar(page) - const target = page.locator(projectSwitchSelector(rootSlug)).first() - await expect(target).toBeVisible() - await target.click() - await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) - - await openSidebar(page) await setWorkspacesEnabled(page, rootSlug, true) await page.getByRole("button", { name: "New workspace" }).first().click() @@ -70,25 +57,13 @@ async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () ) .toBe(true) - return { project, rootSlug, slug, directory: dir } + return { rootSlug, slug, directory: dir } } -test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => { +test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const project = await createTestProject() - const slug = dirSlug(project) - await seedProjects(page, { directory, extra: [project] }) - - try { - await gotoSession() - await openSidebar(page) - - const target = page.locator(projectSwitchSelector(slug)).first() - await expect(target).toBeVisible() - await target.click() - await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) - + await withProject(async ({ slug }) => { await openSidebar(page) await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() @@ -101,27 +76,13 @@ test("can enable and disable workspaces from project menu", async ({ page, direc await setWorkspacesEnabled(page, slug, false) await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) - } finally { - await cleanupTestProject(project) - } + }) }) -test("can create a workspace", async ({ page, directory, gotoSession }) => { +test("can create a workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const project = await createTestProject() - const slug = dirSlug(project) - await seedProjects(page, { directory, extra: [project] }) - - try { - await gotoSession() - await openSidebar(page) - - const target = page.locator(projectSwitchSelector(slug)).first() - await expect(target).toBeVisible() - await target.click() - await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) - + await withProject(async ({ slug }) => { await openSidebar(page) await setWorkspacesEnabled(page, slug, true) @@ -162,17 +123,15 @@ test("can create a workspace", async ({ page, directory, gotoSession }) => { await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() await cleanupTestProject(workspaceDir) - } finally { - await cleanupTestProject(project) - } + }) }) -test("can rename a workspace", async ({ page, directory, gotoSession }) => { +test("can rename a workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession) + await withProject(async (project) => { + const { slug } = await setupWorkspaceTest(page, project) - try { const rename = `e2e workspace ${Date.now()}` const menu = await openWorkspaceMenu(page, slug) await clickMenuItem(menu, /^Rename$/i, { force: true }) @@ -186,17 +145,15 @@ test("can rename a workspace", async ({ page, directory, gotoSession }) => { await input.fill(rename) await input.press("Enter") await expect(item).toContainText(rename) - } finally { - await cleanupTestProject(project) - } + }) }) -test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => { +test("can reset a workspace", async ({ page, sdk, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession) + await withProject(async (project) => { + const { slug, directory: createdDir } = await setupWorkspaceTest(page, project) - try { const readme = path.join(createdDir, "README.md") const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`) const original = await fs.readFile(readme, "utf8") @@ -250,17 +207,15 @@ test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => { .catch(() => false) }) .toBe(false) - } finally { - await cleanupTestProject(project) - } + }) }) -test("can delete a workspace", async ({ page, directory, gotoSession }) => { +test("can delete a workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession) + await withProject(async (project) => { + const { rootSlug, slug } = await setupWorkspaceTest(page, project) - try { const menu = await openWorkspaceMenu(page, slug) await clickMenuItem(menu, /^Delete$/i, { force: true }) await confirmDialog(page, /^Delete workspace$/i) @@ -268,124 +223,111 @@ test("can delete a workspace", async ({ page, directory, gotoSession }) => { await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() - } finally { - await cleanupTestProject(project) - } + }) }) -test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => { +test("can reorder workspaces by drag and drop", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) + await withProject(async ({ slug: rootSlug }) => { + const workspaces = [] as { directory: string; slug: string }[] - const project = await createTestProject() - const rootSlug = dirSlug(project) - await seedProjects(page, { directory, extra: [project] }) + const listSlugs = async () => { + const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + const slugs = await nodes.evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + return slugs + } - const workspaces = [] as { directory: string; slug: string }[] - - const listSlugs = async () => { - const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') - const slugs = await nodes.evaluateAll((els) => { - return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) - }) - return slugs - } - - const waitReady = async (slug: string) => { - await expect - .poll( - async () => { - const item = page.locator(workspaceItemSelector(slug)).first() - try { - await item.hover({ timeout: 500 }) - return true - } catch { - return false - } - }, - { timeout: 60_000 }, - ) - .toBe(true) - } - - const drag = async (from: string, to: string) => { - const src = page.locator(workspaceItemSelector(from)).first() - const dst = page.locator(workspaceItemSelector(to)).first() - - await src.scrollIntoViewIfNeeded() - await dst.scrollIntoViewIfNeeded() - - const a = await src.boundingBox() - const b = await dst.boundingBox() - if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") - - await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) - await page.mouse.down() - await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) - await page.mouse.up() - } - - try { - await gotoSession() - await openSidebar(page) - - const target = page.locator(projectSwitchSelector(rootSlug)).first() - await expect(target).toBeVisible() - await target.click() - await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) - - await openSidebar(page) - await setWorkspacesEnabled(page, rootSlug, true) - - for (const _ of [0, 1]) { - const prev = slugFromUrl(page.url()) - await page.getByRole("button", { name: "New workspace" }).first().click() + const waitReady = async (slug: string) => { await expect .poll( - () => { - const slug = slugFromUrl(page.url()) - return slug.length > 0 && slug !== rootSlug && slug !== prev + async () => { + const item = page.locator(workspaceItemSelector(slug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } }, - { timeout: 45_000 }, + { timeout: 60_000 }, ) .toBe(true) + } - const slug = slugFromUrl(page.url()) - const dir = base64Decode(slug) - workspaces.push({ slug, directory: dir }) + const drag = async (from: string, to: string) => { + const src = page.locator(workspaceItemSelector(from)).first() + const dst = page.locator(workspaceItemSelector(to)).first() + await src.scrollIntoViewIfNeeded() + await dst.scrollIntoViewIfNeeded() + + const a = await src.boundingBox() + const b = await dst.boundingBox() + if (!a || !b) throw new Error("Failed to resolve workspace drag bounds") + + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) + await page.mouse.down() + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) + await page.mouse.up() + } + + try { await openSidebar(page) + + await setWorkspacesEnabled(page, rootSlug, true) + + for (const _ of [0, 1]) { + const prev = slugFromUrl(page.url()) + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + return slug.length > 0 && slug !== rootSlug && slug !== prev + }, + { timeout: 45_000 }, + ) + .toBe(true) + + const slug = slugFromUrl(page.url()) + const dir = base64Decode(slug) + workspaces.push({ slug, directory: dir }) + + await openSidebar(page) + } + + if (workspaces.length !== 2) throw new Error("Expected two created workspaces") + + const a = workspaces[0].slug + const b = workspaces[1].slug + + await waitReady(a) + await waitReady(b) + + const list = async () => { + const slugs = await listSlugs() + return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2) + } + + await expect + .poll(async () => { + const slugs = await list() + return slugs.length === 2 + }) + .toBe(true) + + const before = await list() + const from = before[1] + const to = before[0] + if (!from || !to) throw new Error("Failed to resolve initial workspace order") + + await drag(from, to) + + await expect.poll(async () => await list()).toEqual([from, to]) + } finally { + await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory))) } - - if (workspaces.length !== 2) throw new Error("Expected two created workspaces") - - const a = workspaces[0].slug - const b = workspaces[1].slug - - await waitReady(a) - await waitReady(b) - - const list = async () => { - const slugs = await listSlugs() - return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2) - } - - await expect - .poll(async () => { - const slugs = await list() - return slugs.length === 2 - }) - .toBe(true) - - const before = await list() - const from = before[1] - const to = before[0] - if (!from || !to) throw new Error("Failed to resolve initial workspace order") - - await drag(from, to) - - await expect.poll(async () => await list()).toEqual([from, to]) - } finally { - await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory))) - await cleanupTestProject(project) - } + }) }) diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 57bf86b5a8..10819e69ff 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -6,7 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const reuse = !process.env.CI -const win = process.platform === "win32" export default defineConfig({ testDir: "./e2e", @@ -15,8 +14,7 @@ export default defineConfig({ expect: { timeout: 10_000, }, - fullyParallel: !win, - workers: win ? 1 : undefined, + fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], From 93e060272aad768b8a6ee30f0e42940c1ff9c80b Mon Sep 17 00:00:00 2001 From: Max Leiter Date: Tue, 3 Feb 2026 15:51:26 -0800 Subject: [PATCH 02/35] fix: prevent memory leaks from AbortController closures (#12024) --- packages/opencode/src/tool/codesearch.ts | 10 +- packages/opencode/src/tool/webfetch.ts | 8 +- packages/opencode/src/tool/websearch.ts | 10 +- packages/opencode/src/util/abort.ts | 35 +++++ .../opencode/test/memory/abort-leak.test.ts | 136 ++++++++++++++++++ 5 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 packages/opencode/src/util/abort.ts create mode 100644 packages/opencode/test/memory/abort-leak.test.ts diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index 369cdb4504..28dd4eb491 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -1,6 +1,7 @@ import z from "zod" import { Tool } from "./tool" import DESCRIPTION from "./codesearch.txt" +import { abortAfterAny } from "../util/abort" const API_CONFIG = { BASE_URL: "https://mcp.exa.ai", @@ -73,8 +74,7 @@ export const CodeSearchTool = Tool.define("codesearch", { }, } - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 30000) + const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort) try { const headers: Record = { @@ -86,10 +86,10 @@ export const CodeSearchTool = Tool.define("codesearch", { method: "POST", headers, body: JSON.stringify(codeRequest), - signal: AbortSignal.any([controller.signal, ctx.abort]), + signal, }) - clearTimeout(timeoutId) + clearTimeout() if (!response.ok) { const errorText = await response.text() @@ -120,7 +120,7 @@ export const CodeSearchTool = Tool.define("codesearch", { metadata: {}, } } catch (error) { - clearTimeout(timeoutId) + clearTimeout() if (error instanceof Error && error.name === "AbortError") { throw new Error("Code search request timed out") diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index a4a54598c7..c9479b9df8 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -2,6 +2,7 @@ import z from "zod" import { Tool } from "./tool" import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" +import { abortAfterAny } from "../util/abort" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds @@ -36,8 +37,7 @@ export const WebFetchTool = Tool.define("webfetch", { const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), timeout) + const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort) // Build Accept header based on requested format with q parameters for fallbacks let acceptHeader = "*/*" @@ -55,8 +55,6 @@ export const WebFetchTool = Tool.define("webfetch", { acceptHeader = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" } - - const signal = AbortSignal.any([controller.signal, ctx.abort]) const headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", @@ -72,7 +70,7 @@ export const WebFetchTool = Tool.define("webfetch", { ? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } }) : initial - clearTimeout(timeoutId) + clearTimeout() if (!response.ok) { throw new Error(`Request failed with status code: ${response.status}`) diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 9cc6af72df..beedd9c7cb 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,6 +1,7 @@ import z from "zod" import { Tool } from "./tool" import DESCRIPTION from "./websearch.txt" +import { abortAfterAny } from "../util/abort" const API_CONFIG = { BASE_URL: "https://mcp.exa.ai", @@ -91,8 +92,7 @@ export const WebSearchTool = Tool.define("websearch", async () => { }, } - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 25000) + const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort) try { const headers: Record = { @@ -104,10 +104,10 @@ export const WebSearchTool = Tool.define("websearch", async () => { method: "POST", headers, body: JSON.stringify(searchRequest), - signal: AbortSignal.any([controller.signal, ctx.abort]), + signal, }) - clearTimeout(timeoutId) + clearTimeout() if (!response.ok) { const errorText = await response.text() @@ -137,7 +137,7 @@ export const WebSearchTool = Tool.define("websearch", async () => { metadata: {}, } } catch (error) { - clearTimeout(timeoutId) + clearTimeout() if (error instanceof Error && error.name === "AbortError") { throw new Error("Search request timed out") diff --git a/packages/opencode/src/util/abort.ts b/packages/opencode/src/util/abort.ts new file mode 100644 index 0000000000..3e7cfd8b28 --- /dev/null +++ b/packages/opencode/src/util/abort.ts @@ -0,0 +1,35 @@ +/** + * Creates an AbortController that automatically aborts after a timeout. + * + * Uses bind() instead of arrow functions to avoid capturing the surrounding + * scope in closures. Arrow functions like `() => controller.abort()` capture + * request bodies and other large objects, preventing GC for the timer lifetime. + * + * @param ms Timeout in milliseconds + * @returns Object with controller, signal, and clearTimeout function + */ +export function abortAfter(ms: number) { + const controller = new AbortController() + const id = setTimeout(controller.abort.bind(controller), ms) + return { + controller, + signal: controller.signal, + clearTimeout: () => globalThis.clearTimeout(id), + } +} + +/** + * Combines multiple AbortSignals with a timeout. + * + * @param ms Timeout in milliseconds + * @param signals Additional signals to combine + * @returns Combined signal that aborts on timeout or when any input signal aborts + */ +export function abortAfterAny(ms: number, ...signals: AbortSignal[]) { + const timeout = abortAfter(ms) + const signal = AbortSignal.any([timeout.signal, ...signals]) + return { + signal, + clearTimeout: timeout.clearTimeout, + } +} diff --git a/packages/opencode/test/memory/abort-leak.test.ts b/packages/opencode/test/memory/abort-leak.test.ts new file mode 100644 index 0000000000..b202c9127a --- /dev/null +++ b/packages/opencode/test/memory/abort-leak.test.ts @@ -0,0 +1,136 @@ +import { describe, test, expect } from "bun:test" +import path from "path" +import { Instance } from "../../src/project/instance" +import { WebFetchTool } from "../../src/tool/webfetch" + +const projectRoot = path.join(__dirname, "../..") + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +const MB = 1024 * 1024 +const ITERATIONS = 50 + +const getHeapMB = () => { + Bun.gc(true) + return process.memoryUsage().heapUsed / MB +} + +describe("memory: abort controller leak", () => { + test("webfetch does not leak memory over many invocations", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const tool = await WebFetchTool.init() + + // Warm up + await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {}) + + Bun.gc(true) + const baseline = getHeapMB() + + // Run many fetches + for (let i = 0; i < ITERATIONS; i++) { + await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {}) + } + + Bun.gc(true) + const after = getHeapMB() + const growth = after - baseline + + console.log(`Baseline: ${baseline.toFixed(2)} MB`) + console.log(`After ${ITERATIONS} fetches: ${after.toFixed(2)} MB`) + console.log(`Growth: ${growth.toFixed(2)} MB`) + + // Memory growth should be minimal - less than 1MB per 10 requests + // With the old closure pattern, this would grow ~0.5MB per request + expect(growth).toBeLessThan(ITERATIONS / 10) + }, + }) + }, 60000) + + test("compare closure vs bind pattern directly", async () => { + const ITERATIONS = 500 + + // Test OLD pattern: arrow function closure + // Store closures in a map keyed by content to force retention + const closureMap = new Map void>() + const timers: Timer[] = [] + const controllers: AbortController[] = [] + + Bun.gc(true) + Bun.sleepSync(100) + const baseline = getHeapMB() + + for (let i = 0; i < ITERATIONS; i++) { + // Simulate large response body like webfetch would have + const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration + const controller = new AbortController() + controllers.push(controller) + + // OLD pattern - closure captures `content` + const handler = () => { + // Actually use content so it can't be optimized away + if (content.length > 1000000000) controller.abort() + } + closureMap.set(content, handler) + const timeoutId = setTimeout(handler, 30000) + timers.push(timeoutId) + } + + Bun.gc(true) + Bun.sleepSync(100) + const after = getHeapMB() + const oldGrowth = after - baseline + + console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`) + + // Cleanup after measuring + timers.forEach(clearTimeout) + controllers.forEach((c) => c.abort()) + closureMap.clear() + + // Test NEW pattern: bind + Bun.gc(true) + Bun.sleepSync(100) + const baseline2 = getHeapMB() + const handlers2: (() => void)[] = [] + const timers2: Timer[] = [] + const controllers2: AbortController[] = [] + + for (let i = 0; i < ITERATIONS; i++) { + const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured + const controller = new AbortController() + controllers2.push(controller) + + // NEW pattern - bind doesn't capture surrounding scope + const handler = controller.abort.bind(controller) + handlers2.push(handler) + const timeoutId = setTimeout(handler, 30000) + timers2.push(timeoutId) + } + + Bun.gc(true) + Bun.sleepSync(100) + const after2 = getHeapMB() + const newGrowth = after2 - baseline2 + + // Cleanup after measuring + timers2.forEach(clearTimeout) + controllers2.forEach((c) => c.abort()) + handlers2.length = 0 + + console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`) + console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`) + + expect(newGrowth).toBeLessThanOrEqual(oldGrowth) + }) +}) From 93a07e5a2a61f46b5f2beb9482c007267a2350b4 Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 4 Feb 2026 01:35:15 +0000 Subject: [PATCH 03/35] release: v1.1.50 --- bun.lock | 30 +++++++++++++------------- 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/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 +- 18 files changed, 37 insertions(+), 37 deletions(-) diff --git a/bun.lock b/bun.lock index 9cbd90e446..5e50ce3115 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -213,7 +213,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -242,7 +242,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -258,7 +258,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.49", + "version": "1.1.50", "bin": { "opencode": "./bin/opencode", }, @@ -363,7 +363,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -383,7 +383,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.49", + "version": "1.1.50", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -394,7 +394,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -407,7 +407,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -449,7 +449,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "zod": "catalog:", }, @@ -460,7 +460,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.49", + "version": "1.1.50", "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 9808c36173..88f6bdf243 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.49", + "version": "1.1.50", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 88bf7a48ba..e63554b8f5 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.49", + "version": "1.1.50", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index e2ad1ac95f..26c0948ef4 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.1.49", + "version": "1.1.50", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 6acecac63b..192c64c788 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.49", + "version": "1.1.50", "$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 122f362ad6..afc292e87c 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 946822542d..fd7349738f 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.49", + "version": "1.1.50", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index fa6b070b16..d38643ecee 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.49", + "version": "1.1.50", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 9e8689280c..f68880ecfc 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.1.49" +version = "1.1.50" 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.1.49/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.50/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.50/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.50/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.1.49/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.50/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.1.49/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.50/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 062e0bfcb0..3ace9a2f87 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.49", + "version": "1.1.50", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 7dd0cbd272..953cd2f45f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.49", + "version": "1.1.50", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0482ffa15f..71ae5f211c 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.1.49", + "version": "1.1.50", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index f8a45019c3..f2fb3a0146 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.1.49", + "version": "1.1.50", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 1e7586dd69..4818a42133 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.49", + "version": "1.1.50", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 9826e10429..bc407000cc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.49", + "version": "1.1.50", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 92adc64687..bad9d5283d 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.49", + "version": "1.1.50", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 9e63b4a7d6..66009cc5f4 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.1.49", + "version": "1.1.50", "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 9d90b2c660..4d94984e4a 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.1.49", + "version": "1.1.50", "publisher": "sst-dev", "repository": { "type": "git", From 6daa962aaa38058a29d30fcfa824d61c128b8a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20G=C5=82adysz?= Date: Wed, 4 Feb 2026 02:09:31 +0100 Subject: [PATCH 04/35] fix: prioritize OPENCODE_CONFIG_DIR for AGENTS.md (#11536) --- packages/opencode/src/session/instruction.ts | 9 +- .../opencode/test/session/instruction.test.ts | 102 +++++++++++++++++- 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 65ca1e9bb2..6fb2a7aeb5 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -17,13 +17,14 @@ const FILES = [ ] function globalFiles() { - const files = [path.join(Global.Path.config, "AGENTS.md")] - if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { - files.push(path.join(os.homedir(), ".claude", "CLAUDE.md")) - } + const files = [] if (Flag.OPENCODE_CONFIG_DIR) { files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) } + files.push(path.join(Global.Path.config, "AGENTS.md")) + if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) { + files.push(path.join(os.homedir(), ".claude", "CLAUDE.md")) + } return files } diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 4d57e92a25..e0bf94a950 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, test } from "bun:test" import path from "path" import { InstructionPrompt } from "../../src/session/instruction" import { Instance } from "../../src/project/instance" +import { Global } from "../../src/global" import { tmpdir } from "../fixture/fixture" describe("InstructionPrompt.resolve", () => { @@ -68,3 +69,102 @@ describe("InstructionPrompt.resolve", () => { }) }) }) + +describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => { + let originalConfigDir: string | undefined + + beforeEach(() => { + originalConfigDir = process.env["OPENCODE_CONFIG_DIR"] + }) + + afterEach(() => { + if (originalConfigDir === undefined) { + delete process.env["OPENCODE_CONFIG_DIR"] + } else { + process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir + } + }) + + test("prefers OPENCODE_CONFIG_DIR AGENTS.md over global when both exist", async () => { + await using profileTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Profile Instructions") + }, + }) + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") + }, + }) + await using projectTmp = await tmpdir() + + process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path + const originalGlobalConfig = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await InstructionPrompt.systemPaths() + expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true) + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false) + }, + }) + } finally { + ;(Global.Path as { config: string }).config = originalGlobalConfig + } + }) + + test("falls back to global AGENTS.md when OPENCODE_CONFIG_DIR has no AGENTS.md", async () => { + await using profileTmp = await tmpdir() + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") + }, + }) + await using projectTmp = await tmpdir() + + process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path + const originalGlobalConfig = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await InstructionPrompt.systemPaths() + expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false) + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) + }, + }) + } finally { + ;(Global.Path as { config: string }).config = originalGlobalConfig + } + }) + + test("uses global AGENTS.md when OPENCODE_CONFIG_DIR is not set", async () => { + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions") + }, + }) + await using projectTmp = await tmpdir() + + delete process.env["OPENCODE_CONFIG_DIR"] + const originalGlobalConfig = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + + try { + await Instance.provide({ + directory: projectTmp.path, + fn: async () => { + const paths = await InstructionPrompt.systemPaths() + expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true) + }, + }) + } finally { + ;(Global.Path as { config: string }).config = originalGlobalConfig + } + }) +}) From b7bd561eaafa05d5176bdc177236d1e1bfecb990 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 3 Feb 2026 16:59:34 +0800 Subject: [PATCH 05/35] ci: use numeric release id instead of gql one --- packages/script/src/index.ts | 4 ++-- script/version.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 496bdede2d..a3f5e7a8e2 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -70,8 +70,8 @@ export const Script = { get preview() { return IS_PREVIEW }, - get release() { - return env.OPENCODE_RELEASE + get release(): boolean { + return !!env.OPENCODE_RELEASE }, get team() { return team diff --git a/script/version.ts b/script/version.ts index 1b7eac5d91..e011f44539 100755 --- a/script/version.ts +++ b/script/version.ts @@ -14,8 +14,8 @@ if (!Script.preview) { const file = `${dir}/opencode-release-notes.txt` await Bun.write(file, body) await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${file}` - const release = await $`gh release view v${Script.version} --json id,tagName`.json() - output.push(`release=${release.id}`) + const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json() + output.push(`release=${release.databaseId}`) output.push(`tag=${release.tagName}`) } From 7c440ae82c1797acd40b395b6555029dfa84df4c Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 4 Feb 2026 10:40:08 +0800 Subject: [PATCH 06/35] chore: add brendonovich as rust codeowner --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9a888adbb2..3aeef82d62 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,5 @@ # web + desktop packages packages/app/ @adamdotdevin packages/tauri/ @adamdotdevin +packages/desktop/src-tauri/ @brendonovich packages/desktop/ @adamdotdevin From f2826137463d099d3cf032fc8329a8fce437ef80 Mon Sep 17 00:00:00 2001 From: Devin Griffin <31415269+DNGriffin@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:10:56 -0600 Subject: [PATCH 07/35] fix(app): tighten up session padding-top for mobile (#11247) Co-authored-by: Brendan Allan --- packages/app/src/pages/session.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 29fed369e3..e8c61ee989 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1771,7 +1771,7 @@ export default function Page() {
Date: Wed, 4 Feb 2026 00:06:17 -0500 Subject: [PATCH 08/35] fix: prevent double-prefixing of Bedrock cross-region inference models (#12056) --- packages/opencode/src/provider/provider.ts | 4 +- .../test/provider/amazon-bedrock.test.ts | 213 +++++++++++++++++- 2 files changed, 215 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index fc90571fef..d5d54c5e50 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -239,7 +239,9 @@ export namespace Provider { options: providerOptions, async getModel(sdk: any, modelID: string, options?: Record) { // Skip region prefixing if model already has a cross-region inference profile prefix - if (modelID.startsWith("global.") || modelID.startsWith("jp.")) { + // Models from models.dev may already include prefixes like us., eu., global., etc. + const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."] + if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) { return sdk.languageModel(modelID) } diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 6b5cf681cd..a90c9632d9 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -1,4 +1,4 @@ -import { test, expect, mock } from "bun:test" +import { test, expect, mock, describe } from "bun:test" import path from "path" import { unlink } from "fs/promises" @@ -266,3 +266,214 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () }, }) }) + +// Tests for cross-region inference profile prefix handling +// Models from models.dev may come with prefixes already (e.g., us., eu., global.) +// These should NOT be double-prefixed when passed to the SDK + +test("Bedrock: model with us. prefix should not be double-prefixed", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "amazon-bedrock": { + options: { + region: "us-east-1", + }, + models: { + "us.anthropic.claude-opus-4-5-20251101-v1:0": { + name: "Claude Opus 4.5 (US)", + }, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("AWS_PROFILE", "default") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["amazon-bedrock"]).toBeDefined() + // The model should exist with the us. prefix + expect(providers["amazon-bedrock"].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() + }, + }) +}) + +test("Bedrock: model with global. prefix should not be prefixed", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "amazon-bedrock": { + options: { + region: "us-east-1", + }, + models: { + "global.anthropic.claude-opus-4-5-20251101-v1:0": { + name: "Claude Opus 4.5 (Global)", + }, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("AWS_PROFILE", "default") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["amazon-bedrock"]).toBeDefined() + expect(providers["amazon-bedrock"].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() + }, + }) +}) + +test("Bedrock: model with eu. prefix should not be double-prefixed", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "amazon-bedrock": { + options: { + region: "eu-west-1", + }, + models: { + "eu.anthropic.claude-opus-4-5-20251101-v1:0": { + name: "Claude Opus 4.5 (EU)", + }, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("AWS_PROFILE", "default") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["amazon-bedrock"]).toBeDefined() + expect(providers["amazon-bedrock"].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() + }, + }) +}) + +test("Bedrock: model without prefix in US region should get us. prefix added", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "amazon-bedrock": { + options: { + region: "us-east-1", + }, + models: { + "anthropic.claude-opus-4-5-20251101-v1:0": { + name: "Claude Opus 4.5", + }, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("AWS_PROFILE", "default") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["amazon-bedrock"]).toBeDefined() + // Non-prefixed model should still be registered + expect(providers["amazon-bedrock"].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() + }, + }) +}) + +// Direct unit tests for cross-region inference profile prefix handling +// These test the prefix detection logic used in getModel + +describe("Bedrock cross-region prefix detection", () => { + const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."] + + test("should detect global. prefix", () => { + const modelID = "global.anthropic.claude-opus-4-5-20251101-v1:0" + const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix)) + expect(hasPrefix).toBe(true) + }) + + test("should detect us. prefix", () => { + const modelID = "us.anthropic.claude-opus-4-5-20251101-v1:0" + const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix)) + expect(hasPrefix).toBe(true) + }) + + test("should detect eu. prefix", () => { + const modelID = "eu.anthropic.claude-opus-4-5-20251101-v1:0" + const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix)) + expect(hasPrefix).toBe(true) + }) + + test("should detect jp. prefix", () => { + const modelID = "jp.anthropic.claude-sonnet-4-20250514-v1:0" + const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix)) + expect(hasPrefix).toBe(true) + }) + + test("should detect apac. prefix", () => { + const modelID = "apac.anthropic.claude-sonnet-4-20250514-v1:0" + const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix)) + expect(hasPrefix).toBe(true) + }) + + test("should detect au. prefix", () => { + const modelID = "au.anthropic.claude-sonnet-4-5-20250929-v1:0" + const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix)) + expect(hasPrefix).toBe(true) + }) + + test("should NOT detect prefix for non-prefixed model", () => { + const modelID = "anthropic.claude-opus-4-5-20251101-v1:0" + const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix)) + expect(hasPrefix).toBe(false) + }) + + test("should NOT detect prefix for amazon nova models", () => { + const modelID = "amazon.nova-pro-v1:0" + const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix)) + expect(hasPrefix).toBe(false) + }) + + test("should NOT detect prefix for cohere models", () => { + const modelID = "cohere.command-r-plus-v1:0" + const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix)) + expect(hasPrefix).toBe(false) + }) +}) From 8c1f1f13dcb74cd22d158e5f7eb3fce5f4240cbf Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 4 Feb 2026 00:00:05 -0600 Subject: [PATCH 09/35] docs: document the built in agents (#12066) --- packages/opencode/src/provider/provider.ts | 2 +- packages/web/src/content/docs/agents.mdx | 32 +++++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d5d54c5e50..af4048c6c6 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -989,7 +989,7 @@ export namespace Provider { // Merge configured headers into request headers opts.headers = { - ...(typeof opts.headers === "object" ? opts.headers : {}), + ...(opts.headers ?? {}), ...options["headers"], } diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 755c4ba25a..53de8af5f0 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -46,7 +46,7 @@ OpenCode comes with two built-in primary agents and two built-in subagents. --- -### Build +### Use build _Mode_: `primary` @@ -54,7 +54,7 @@ Build is the **default** primary agent with all tools enabled. This is the stand --- -### Plan +### Use plan _Mode_: `primary` @@ -68,7 +68,7 @@ This agent is useful when you want the LLM to analyze code, suggest changes, or --- -### General +### Use general _Mode_: `subagent` @@ -76,7 +76,7 @@ A general-purpose agent for researching complex questions and executing multi-st --- -### Explore +### Use explore _Mode_: `subagent` @@ -84,6 +84,30 @@ A fast, read-only agent for exploring codebases. Cannot modify files. Use this w --- +### Use compaction + +_Mode_: `primary` + +Hidden system agent that compacts long context into a smaller summary. It runs automatically when needed and is not selectable in the UI. + +--- + +### Use title + +_Mode_: `primary` + +Hidden system agent that generates short session titles. It runs automatically and is not selectable in the UI. + +--- + +### Use summary + +_Mode_: `primary` + +Hidden system agent that creates session summaries. It runs automatically and is not selectable in the UI. + +--- + ## Usage 1. For primary agents, use the **Tab** key to cycle through them during a session. You can also use your configured `switch_agent` keybind. From 5aaf8f82475c84640ab5f03caf7fffda4b0ffc9f Mon Sep 17 00:00:00 2001 From: Gary Chu Date: Wed, 4 Feb 2026 14:12:24 +0800 Subject: [PATCH 10/35] docs: add agent-compatible paths to skills documentation (#12067) --- packages/web/src/content/docs/skills.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 553931eec4..2ce88ea568 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -17,15 +17,17 @@ OpenCode searches these locations: - Global config: `~/.config/opencode/skills//SKILL.md` - Project Claude-compatible: `.claude/skills//SKILL.md` - Global Claude-compatible: `~/.claude/skills//SKILL.md` +- Project agent-compatible: `.agents/skills//SKILL.md` +- Global agent-compatible: `~/.agents/skills//SKILL.md` --- ## Understand discovery For project-local paths, OpenCode walks up from your current working directory until it reaches the git worktree. -It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. +It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` or `.agents/skills/*/SKILL.md` along the way. -Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. +Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md`, `~/.claude/skills/*/SKILL.md`, and `~/.agents/skills/*/SKILL.md`. --- From 5588453cbede8c84c3ac7429d7d208fe140c0d20 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 4 Feb 2026 00:30:32 -0600 Subject: [PATCH 11/35] fix: revert change that caused headers to be double merged if provider was authed in multiple places (#12072) --- packages/opencode/src/provider/provider.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index af4048c6c6..2a01bbc443 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -987,12 +987,6 @@ export namespace Provider { const fetchFn = customFetch ?? fetch const opts = init ?? {} - // Merge configured headers into request headers - opts.headers = { - ...(opts.headers ?? {}), - ...options["headers"], - } - if (options["timeout"] !== undefined && options["timeout"] !== null) { const signals: AbortSignal[] = [] if (opts.signal) signals.push(opts.signal) From 64bafce6650bd0ebebf962aed01b2dcfc82459f8 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 4 Feb 2026 07:35:57 +0100 Subject: [PATCH 12/35] restore direct osc52 (#12071) --- packages/opencode/src/cli/cmd/tui/app.tsx | 1 - .../src/cli/cmd/tui/util/clipboard.ts | 26 ++++++++++--------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index c5f88a7c67..7442037604 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -187,7 +187,6 @@ function App() { const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() - Clipboard.setRenderer(renderer) renderer.disableStdoutInterception() const dialog = useDialog() const local = useLocal() diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index ad1f86e307..4be6787346 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -1,12 +1,23 @@ import { $ } from "bun" -import type { CliRenderer } from "@opentui/core" import { platform, release } from "os" import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" -const rendererRef = { current: undefined as CliRenderer | undefined } +/** + * Writes text to clipboard via OSC 52 escape sequence. + * This allows clipboard operations to work over SSH by having + * the terminal emulator handle the clipboard locally. + */ +function writeOsc52(text: string): void { + if (!process.stdout.isTTY) return + const base64 = Buffer.from(text).toString("base64") + const osc52 = `\x1b]52;c;${base64}\x07` + const passthrough = process.env["TMUX"] || process.env["STY"] + const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 + process.stdout.write(sequence) +} export namespace Clipboard { export interface Content { @@ -14,10 +25,6 @@ export namespace Clipboard { mime: string } - export function setRenderer(renderer: CliRenderer | undefined): void { - rendererRef.current = renderer - } - export async function read(): Promise { const os = platform() @@ -146,12 +153,7 @@ export namespace Clipboard { }) export async function copy(text: string): Promise { - const renderer = rendererRef.current - if (renderer) { - // Try OSC52 but don't early return - always fall back to native method - // OSC52 may report success but not actually work in all terminals - renderer.copyToClipboardOSC52(text) - } + writeOsc52(text) await getCopyMethod()(text) } } From 154cbf6996cdc93b574e93bcbe063f6d11170c0c Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 4 Feb 2026 07:06:48 +0000 Subject: [PATCH 13/35] release: v1.1.51 --- bun.lock | 30 +++++++++++++------------- 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/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 +- 18 files changed, 37 insertions(+), 37 deletions(-) diff --git a/bun.lock b/bun.lock index 5e50ce3115..a1573116cc 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -213,7 +213,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -242,7 +242,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -258,7 +258,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.50", + "version": "1.1.51", "bin": { "opencode": "./bin/opencode", }, @@ -363,7 +363,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -383,7 +383,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.50", + "version": "1.1.51", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -394,7 +394,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -407,7 +407,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -449,7 +449,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "zod": "catalog:", }, @@ -460,7 +460,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.50", + "version": "1.1.51", "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 88f6bdf243..3a492ed919 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.50", + "version": "1.1.51", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index e63554b8f5..d162f1ab0c 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.50", + "version": "1.1.51", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 26c0948ef4..b6f2a8e900 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.1.50", + "version": "1.1.51", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 192c64c788..efe6dc4256 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.50", + "version": "1.1.51", "$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 afc292e87c..9da4390c58 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.50", + "version": "1.1.51", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index fd7349738f..92676ec596 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.50", + "version": "1.1.51", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index d38643ecee..13008747eb 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.50", + "version": "1.1.51", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index f68880ecfc..eeadfa04f0 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.1.50" +version = "1.1.51" 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.1.50/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.50/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.50/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/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.1.50/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/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.1.50/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 3ace9a2f87..110525c80f 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.50", + "version": "1.1.51", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 953cd2f45f..fdf2c0519b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.50", + "version": "1.1.51", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 71ae5f211c..d8b8733cb3 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.1.50", + "version": "1.1.51", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index f2fb3a0146..19f4401a64 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.1.50", + "version": "1.1.51", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 4818a42133..0c97a26c55 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.50", + "version": "1.1.51", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index bc407000cc..83902a10f9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.50", + "version": "1.1.51", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index bad9d5283d..f048583f75 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.50", + "version": "1.1.51", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 66009cc5f4..1b405bc6b0 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.1.50", + "version": "1.1.51", "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 4d94984e4a..013caf7dbc 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.1.50", + "version": "1.1.51", "publisher": "sst-dev", "repository": { "type": "git", From 891875402cce45112053115fea8f68c0c61ffd81 Mon Sep 17 00:00:00 2001 From: "Lucas (TaeYoung) Jo" Date: Wed, 4 Feb 2026 20:01:10 +0900 Subject: [PATCH 14/35] fix(terminal): support remote server connections and fix GLIBC compatibility (#11906) --- bun.lock | 4 ++-- packages/app/src/components/terminal.tsx | 1 + packages/opencode/package.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index a1573116cc..9b0d0eccc6 100644 --- a/bun.lock +++ b/bun.lock @@ -309,7 +309,7 @@ "ai": "catalog:", "ai-gateway-provider": "2.3.1", "bonjour-service": "1.3.0", - "bun-pty": "0.4.4", + "bun-pty": "0.4.8", "chokidar": "4.0.3", "clipboardy": "4.0.0", "decimal.js": "10.5.0", @@ -2108,7 +2108,7 @@ "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], - "bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="], + "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index d388448024..11bcd4cc85 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -146,6 +146,7 @@ export const Terminal = (props: TerminalProps) => { const once = { value: false } const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" if (window.__OPENCODE__?.serverPassword) { url.username = "opencode" url.password = window.__OPENCODE__?.serverPassword diff --git a/packages/opencode/package.json b/packages/opencode/package.json index fdf2c0519b..e03ad80093 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -94,7 +94,7 @@ "ai": "catalog:", "ai-gateway-provider": "2.3.1", "bonjour-service": "1.3.0", - "bun-pty": "0.4.4", + "bun-pty": "0.4.8", "chokidar": "4.0.3", "clipboardy": "4.0.0", "decimal.js": "10.5.0", From 310de8b1ea09ca2360c835ba33a88520f6f53212 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 4 Feb 2026 11:12:01 +0000 Subject: [PATCH 15/35] 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 32f6950042..fc964a8788 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Uc9UFWrG9bVROt+DmXduXoY409wBBLtBe0G7R41NF8Q=", - "aarch64-linux": "sha256-KTUsuPfWaw2qb26GmEa5tcSeF3+Kx2X5ZP5DE8jJuvQ=", - "aarch64-darwin": "sha256-C650/LVIoeymKnRw9lVO3f5ve9xYZPrO0vOM5pqY2nE=", - "x86_64-darwin": "sha256-xLLI2mNn222ktx6s8rwej3rMzQGl1S1jV/NXmLFg2DU=" + "x86_64-linux": "sha256-9XlAYCNdBhw8NmfJoYNjvQYhSn02rFhWvbJtlOnnCjc=", + "aarch64-linux": "sha256-Mdz3gAy8auN7mhMHRaWyH/exHGO9eYDyUMQKqscg6Xc=", + "aarch64-darwin": "sha256-NDB6+NVZ4+9+Yds/cjEGQAn9Tl/LRuEjEH6wV5dTdVg=", + "x86_64-darwin": "sha256-LGJ5TJYgyK8Vn0BliEeJdoblcubj5ZIjvJoUtdVXfvU=" } } From 2e8d8de58bc954cfa3fde64e66c19798e82e2a6b Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Wed, 4 Feb 2026 13:34:18 +0200 Subject: [PATCH 16/35] fix(desktop): removed compression from rpm bundle to save 15m in CI (#12097) --- packages/desktop/src-tauri/tauri.conf.json | 7 +++++++ packages/desktop/src-tauri/tauri.prod.conf.json | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 5f76d510bc..53c28d9c1c 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -42,6 +42,13 @@ "active": true, "targets": ["deb", "rpm", "dmg", "nsis", "app"], "externalBin": ["sidecars/opencode-cli"], + "linux": { + "rpm": { + "compression": { + "type": "none" + } + } + }, "macOS": { "entitlements": "./entitlements.plist" }, diff --git a/packages/desktop/src-tauri/tauri.prod.conf.json b/packages/desktop/src-tauri/tauri.prod.conf.json index 7ce4c78420..0416c59cbb 100644 --- a/packages/desktop/src-tauri/tauri.prod.conf.json +++ b/packages/desktop/src-tauri/tauri.prod.conf.json @@ -21,6 +21,11 @@ "files": { "/usr/share/metainfo/ai.opencode.opencode.metainfo.xml": "release/appstream.metainfo.xml" } + }, + "rpm": { + "compression": { + "type": "none" + } } } }, From af06175b1f293ea13d4165cee56db65fbbd56c65 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 4 Feb 2026 11:35:05 +0000 Subject: [PATCH 17/35] chore: generate --- packages/desktop/src-tauri/tauri.conf.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 53c28d9c1c..b631e2876e 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -44,9 +44,9 @@ "externalBin": ["sidecars/opencode-cli"], "linux": { "rpm": { - "compression": { - "type": "none" - } + "compression": { + "type": "none" + } } }, "macOS": { From a219615fe5aeb6a40898300b17f076072aed5bcc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 06:22:55 -0600 Subject: [PATCH 18/35] fix(app): opened tabs follow created session --- packages/app/src/components/prompt-input.tsx | 5 ++- packages/app/src/context/layout.tsx | 19 ++++++++++ packages/app/src/pages/session.tsx | 38 ++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 6b568e9160..b897e394aa 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1220,7 +1220,10 @@ export const PromptInput: Component = (props) => { }) return undefined }) - if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + if (session) { + layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) + navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + } } if (!session) return diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 28fe628a80..71f3f6cfff 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -35,6 +35,12 @@ type SessionView = { reviewOpen?: string[] } +type TabHandoff = { + dir: string + id: string + at: number +} + export type LocalProject = Partial & { worktree: string; expanded: boolean } export type ReviewDiffStyle = "unified" | "split" @@ -115,6 +121,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, sessionTabs: {} as Record, sessionView: {} as Record, + handoff: { + tabs: undefined as TabHandoff | undefined, + }, }), ) @@ -411,6 +420,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( return { ready, + handoff: { + tabs: createMemo(() => store.handoff?.tabs), + setTabs(dir: string, id: string) { + setStore("handoff", "tabs", { dir, id, at: Date.now() }) + }, + clearTabs() { + if (!store.handoff?.tabs) return + setStore("handoff", "tabs", undefined) + }, + }, projects: { list, open(directory: string) { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e8c61ee989..e31ab18b98 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -280,9 +280,47 @@ export default function Page() { .finally(() => setUi("responding", false)) } const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const workspaceKey = createMemo(() => params.dir ?? "") + const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) const tabs = createMemo(() => layout.tabs(sessionKey)) const view = createMemo(() => layout.view(sessionKey)) + createEffect( + on( + () => params.id, + (id, prev) => { + if (!id) return + if (prev) return + + const pending = layout.handoff.tabs() + if (!pending) return + if (Date.now() - pending.at > 60_000) { + layout.handoff.clearTabs() + return + } + + if (pending.id !== id) return + layout.handoff.clearTabs() + if (pending.dir !== (params.dir ?? "")) return + + const from = workspaceTabs().tabs() + if (from.all.length === 0 && !from.active) return + + const current = tabs().tabs() + if (current.all.length > 0 || current.active) return + + const all = normalizeTabs(from.all) + const active = from.active ? normalizeTab(from.active) : undefined + tabs().setAll(all) + tabs().setActive(active && all.includes(active) ? active : all[0]) + + workspaceTabs().setAll([]) + workspaceTabs().setActive(undefined) + }, + { defer: true }, + ), + ) + if (import.meta.env.DEV) { createEffect( on( From a2face30f43fe22148f6abea35b0c654e45d56b2 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 06:37:59 -0600 Subject: [PATCH 19/35] wip(app): session options --- packages/app/src/pages/session.tsx | 288 +++++++++++++++++++++++++++-- 1 file changed, 273 insertions(+), 15 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e31ab18b98..644fa66b3b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -16,13 +16,16 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" import { useLocal } from "@/context/local" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" import { SessionContextUsage } from "@/components/session-context-usage" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { useCodeComponent } from "@opencode-ai/ui/context/code" @@ -436,6 +439,218 @@ export default function Page() { if (!id) return false return sync.session.history.loading(id) }) + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return language.t("common.requestFailed") + } + + async function archiveSession(sessionID: string) { + const session = sync.session.get(sessionID) + if (!session) return + + const sessions = sync.data.session ?? [] + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + await sdk.client.session + .update({ sessionID, time: { archived: Date.now() } }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) draft.session.splice(index, 1) + }), + ) + + if (params.id !== sessionID) return + if (session.parentID) { + navigate(`/${params.dir}/session/${session.parentID}`) + return + } + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + return + } + navigate(`/${params.dir}/session`) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + + async function deleteSession(sessionID: string) { + const session = sync.session.get(sessionID) + if (!session) return false + + const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) + const index = sessions.findIndex((s) => s.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + const result = await sdk.client.session + .delete({ sessionID }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: errorMessage(err), + }) + return false + }) + + if (!result) return false + + sync.set( + produce((draft) => { + const removed = new Set([sessionID]) + + const byParent = new Map() + for (const item of draft.session) { + const parentID = item.parentID + if (!parentID) continue + const existing = byParent.get(parentID) + if (existing) { + existing.push(item.id) + continue + } + byParent.set(parentID, [item.id]) + } + + const stack = [sessionID] + while (stack.length) { + const parentID = stack.pop() + if (!parentID) continue + + const children = byParent.get(parentID) + if (!children) continue + + for (const child of children) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + + draft.session = draft.session.filter((s) => !removed.has(s.id)) + }), + ) + + if (params.id !== sessionID) return true + if (session.parentID) { + navigate(`/${params.dir}/session/${session.parentID}`) + return true + } + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + return true + } + navigate(`/${params.dir}/session`) + return true + } + + function DialogRenameSession(props: { sessionID: string }) { + const [data, setData] = createStore({ + title: sync.session.get(props.sessionID)?.title ?? "", + saving: false, + }) + + const submit = (event: Event) => { + event.preventDefault() + if (data.saving) return + + const title = data.title.trim() + if (!title) { + dialog.close() + return + } + + const current = sync.session.get(props.sessionID)?.title ?? "" + if (title === current) { + dialog.close() + return + } + + setData("saving", true) + void sdk.client.session + .update({ sessionID: props.sessionID, title }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === props.sessionID) + if (index !== -1) draft.session[index].title = title + }), + ) + dialog.close() + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + .finally(() => { + setData("saving", false) + }) + } + + return ( + +
+ setData("title", value)} + /> +
+ + +
+ +
+ ) + } + + function DialogDeleteSession(props: { sessionID: string }) { + const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) + const handleDelete = async () => { + await deleteSession(props.sessionID) + dialog.close() + } + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: title() })} + +
+
+ + +
+
+
+ ) + } + const emptyUserMessages: UserMessage[] = [] const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], @@ -1992,20 +2207,63 @@ export default function Page() { centered(), }} > -
- - { - navigate(`/${params.dir}/session/${info()?.parentID}`) - }} - aria-label={language.t("common.goBack")} - /> - - -

{info()?.title}

+
+
+ + { + navigate(`/${params.dir}/session/${info()?.parentID}`) + }} + aria-label={language.t("common.goBack")} + /> + + +

{info()?.title}

+
+
+ + {(id) => ( +
+ + + + + + + dialog.show(() => )} + > + + {language.t("common.rename")} + + + void archiveSession(id())}> + + {language.t("common.archive")} + + + + dialog.show(() => )} + > + + {language.t("common.delete")} + + + + + +
+ )}
From c277ee8cbf7ff3ca5a86947d974c2b72f88398d4 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:12:12 -0600 Subject: [PATCH 20/35] fix(app): move session options to the session page --- packages/app/src/pages/layout.tsx | 170 +++----------------------- packages/app/src/pages/session.tsx | 190 +++++++++++++++++------------ 2 files changed, 134 insertions(+), 226 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 46c9c9154f..c565d197f0 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1000,69 +1000,6 @@ export default function Layout(props: ParentProps) { } } - async function deleteSession(session: Session) { - const [store, setStore] = globalSync.child(session.directory) - const sessions = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived) - const index = sessions.findIndex((s) => s.id === session.id) - const nextSession = sessions[index + 1] ?? sessions[index - 1] - - const result = await globalSDK.client.session - .delete({ directory: session.directory, sessionID: session.id }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("session.delete.failed.title"), - description: errorMessage(err), - }) - return false - }) - - if (!result) return - - setStore( - produce((draft) => { - const removed = new Set([session.id]) - - const byParent = new Map() - for (const item of draft.session) { - const parentID = item.parentID - if (!parentID) continue - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [session.id] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) - - if (session.id === params.id) { - if (nextSession) { - navigate(`/${params.dir}/session/${nextSession.id}`) - } else { - navigate(`/${params.dir}/session`) - } - } - } - command.register(() => { const commands: CommandOption[] = [ { @@ -1316,15 +1253,6 @@ export default function Layout(props: ParentProps) { globalSync.project.meta(project.worktree, { name }) } - async function renameSession(session: Session, next: string) { - if (next === session.title) return - await globalSDK.client.session.update({ - directory: session.directory, - sessionID: session.id, - title: next, - }) - } - const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => { const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) if (current === next) return @@ -1475,33 +1403,6 @@ export default function Layout(props: ParentProps) { }) } - function DialogDeleteSession(props: { session: Session }) { - const handleDelete = async () => { - await deleteSession(props.session) - dialog.close() - } - - return ( - -
-
- - {language.t("session.delete.confirm", { name: props.session.title })} - -
-
- - -
-
-
- ) - } - function DialogDeleteWorkspace(props: { root: string; directory: string }) { const name = createMemo(() => getFilename(props.directory)) const [data, setData] = createStore({ @@ -1855,10 +1756,6 @@ export default function Layout(props: ParentProps) { const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) - const [menu, setMenu] = createStore({ - open: false, - pendingRename: false, - }) const hoverPrefetch = { current: undefined as ReturnType | undefined } const cancelHoverPrefetch = () => { @@ -1885,7 +1782,7 @@ export default function Layout(props: ParentProps) { const item = (
- props.session.title} - onSave={(next) => renameSession(props.session, next)} - class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - stopPropagation - /> + + {props.session.title} + {(summary) => (
@@ -1989,49 +1881,25 @@ export default function Layout(props: ParentProps) {
- setMenu("open", open)}> - - - - - { - if (!menu.pendingRename) return - event.preventDefault() - setMenu("pendingRename", false) - openEditor(`session:${props.session.id}`, props.session.title) - }} - > - { - setMenu("pendingRename", true) - setMenu("open", false) - }} - > - {language.t("common.rename")} - - archiveSession(props.session)}> - {language.t("common.archive")} - - - dialog.show(() => )}> - {language.t("common.delete")} - - - - + + { + event.preventDefault() + event.stopPropagation() + void archiveSession(props.session) + }} + /> +
) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 644fa66b3b..2143cd34b6 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -25,7 +25,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" -import { TextField } from "@opencode-ai/ui/text-field" +import { InlineInput } from "@opencode-ai/ui/inline-input" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { useCodeComponent } from "@opencode-ai/ui/context/code" @@ -440,6 +440,15 @@ export default function Page() { return sync.session.history.loading(id) }) + const [title, setTitle] = createStore({ + draft: "", + editing: false, + saving: false, + menuOpen: false, + pendingRename: false, + }) + let titleRef: HTMLInputElement | undefined + const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data @@ -449,6 +458,60 @@ export default function Page() { return language.t("common.requestFailed") } + createEffect( + on( + () => params.id, + () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!params.id) return + setTitle({ editing: true, draft: info()?.title ?? "" }) + requestAnimationFrame(() => { + titleRef?.focus() + titleRef?.select() + }) + } + + const closeTitleEditor = () => { + if (title.saving) return + setTitle({ editing: false, saving: false }) + } + + const saveTitleEditor = async () => { + const sessionID = params.id + if (!sessionID) return + if (title.saving) return + + const next = title.draft.trim() + if (!next || next === (info()?.title ?? "")) { + setTitle({ editing: false, saving: false }) + return + } + + setTitle("saving", true) + await sdk.client.session + .update({ sessionID, title: next }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) draft.session[index].title = next + }), + ) + setTitle({ editing: false, saving: false }) + }) + .catch((err) => { + setTitle("saving", false) + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + async function archiveSession(sessionID: string) { const session = sync.session.get(sessionID) if (!session) return @@ -555,74 +618,6 @@ export default function Page() { return true } - function DialogRenameSession(props: { sessionID: string }) { - const [data, setData] = createStore({ - title: sync.session.get(props.sessionID)?.title ?? "", - saving: false, - }) - - const submit = (event: Event) => { - event.preventDefault() - if (data.saving) return - - const title = data.title.trim() - if (!title) { - dialog.close() - return - } - - const current = sync.session.get(props.sessionID)?.title ?? "" - if (title === current) { - dialog.close() - return - } - - setData("saving", true) - void sdk.client.session - .update({ sessionID: props.sessionID, title }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === props.sessionID) - if (index !== -1) draft.session[index].title = title - }), - ) - dialog.close() - }) - .catch((err) => { - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }) - .finally(() => { - setData("saving", false) - }) - } - - return ( - -
- setData("title", value)} - /> -
- - -
- -
- ) - } - function DialogDeleteSession(props: { sessionID: string }) { const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) const handleDelete = async () => { @@ -2208,7 +2203,7 @@ export default function Page() { }} >
-
+
- -

{info()?.title}

+ + + {info()?.title} + + } + > + { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-16-medium text-text-strong grow-1 min-w-0" + onInput={(event) => setTitle("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + closeTitleEditor() + } + }} + onBlur={() => closeTitleEditor()} + /> +
{(id) => (
- + setTitle("menuOpen", open)} + > - + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > dialog.show(() => )} + onSelect={() => { + setTitle({ pendingRename: true, menuOpen: false }) + }} > {language.t("common.rename")} From c8622df762b953bfea4ba0dbc7097b123f29a288 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:59:42 -0600 Subject: [PATCH 21/35] fix(app): file tree not staying in sync across projects/sessions --- packages/app/src/context/layout.tsx | 46 ++++++++ packages/app/src/pages/layout.tsx | 5 +- packages/app/src/pages/session.tsx | 159 ++++++++++++++++++---------- 3 files changed, 154 insertions(+), 56 deletions(-) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 71f3f6cfff..e2fd0a7f45 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -33,6 +33,8 @@ type SessionTabs = { type SessionView = { scroll: Record reviewOpen?: string[] + pendingMessage?: string + pendingMessageAt?: number } type TabHandoff = { @@ -128,6 +130,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ) const MAX_SESSION_KEYS = 50 + const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 const meta = { active: undefined as string | undefined, pruned: false } const used = new Map() @@ -555,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("mobileSidebar", "opened", (x) => !x) }, }, + pendingMessage: { + set(sessionKey: string, messageID: string) { + const at = Date.now() + touch(sessionKey) + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { + scroll: {}, + pendingMessage: messageID, + pendingMessageAt: at, + }) + prune(meta.active ?? sessionKey) + return + } + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + draft.pendingMessage = messageID + draft.pendingMessageAt = at + }), + ) + }, + consume(sessionKey: string) { + const current = store.sessionView[sessionKey] + const message = current?.pendingMessage + const at = current?.pendingMessageAt + if (!message || !at) return + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + delete draft.pendingMessage + delete draft.pendingMessageAt + }), + ) + + if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return + return message + }, + }, view(sessionKey: string | Accessor) { const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c565d197f0..1c5edbf2b4 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1864,7 +1864,10 @@ export default function Layout(props: ParentProps) { getLabel={messageLabel} onMessageSelect={(message) => { if (!isActive()) { - sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`) + layout.pendingMessage.set( + `${base64Encode(props.session.directory)}/${props.session.id}`, + message.id, + ) navigate(`${props.slug}/session/${props.session.id}`) return } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2143cd34b6..7ff4bebb4d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -76,10 +76,31 @@ import { same } from "@/utils/same" type DiffStyle = "unified" | "split" +type HandoffSession = { + prompt: string + files: Record +} + +const HANDOFF_MAX = 40 + const handoff = { - prompt: "", - terminals: [] as string[], - files: {} as Record, + session: new Map(), + terminal: new Map(), +} + +const touch = (map: Map, key: K, value: V) => { + map.delete(key) + map.set(key, value) + while (map.size > HANDOFF_MAX) { + const first = map.keys().next().value + if (first === undefined) return + map.delete(first) + } +} + +const setSessionHandoff = (key: string, patch: Partial) => { + const prev = handoff.session.get(key) ?? { prompt: "", files: {} } + touch(handoff.session, key, { ...prev, ...patch }) } interface SessionReviewTabProps { @@ -793,8 +814,10 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs createEffect(() => { - if (!params.id) return - sync.session.sync(params.id) + sdk.directory + const id = params.id + if (!id) return + sync.session.sync(id) }) createEffect(() => { @@ -862,10 +885,22 @@ export default function Page() { createEffect( on( - () => params.id, + sessionKey, () => { setStore("messageId", undefined) setStore("expanded", {}) + setUi("autoCreated", false) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => params.dir, + (dir) => { + if (!dir) return + setStore("newSessionWorktree", "main") }, { defer: true }, ), @@ -1373,12 +1408,15 @@ export default function Page() { activeDiff: undefined as string | undefined, }) - const reviewScroll = () => tree.reviewScroll - const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value) - const pendingDiff = () => tree.pendingDiff - const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value) - const activeDiff = () => tree.activeDiff - const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value) + createEffect( + on( + sessionKey, + () => { + setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined }) + }, + { defer: true }, + ), + ) const showAllFiles = () => { if (fileTreeTab() !== "changes") return @@ -1399,8 +1437,8 @@ export default function Page() { view={view} diffStyle={layout.review.diffStyle()} onDiffStyleChange={layout.review.setDiffStyle} - onScrollRef={setReviewScroll} - focusedFile={activeDiff()} + onScrollRef={(el) => setTree("reviewScroll", el)} + focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} comments={comments.all()} focusedComment={comments.focus()} @@ -1450,7 +1488,7 @@ export default function Page() { } const reviewDiffTop = (path: string) => { - const root = reviewScroll() + const root = tree.reviewScroll if (!root) return const id = reviewDiffId(path) @@ -1466,7 +1504,7 @@ export default function Page() { } const scrollToReviewDiff = (path: string) => { - const root = reviewScroll() + const root = tree.reviewScroll if (!root) return false const top = reviewDiffTop(path) @@ -1480,24 +1518,23 @@ export default function Page() { const focusReviewDiff = (path: string) => { const current = view().review.open() ?? [] if (!current.includes(path)) view().review.setOpen([...current, path]) - setActiveDiff(path) - setPendingDiff(path) + setTree({ activeDiff: path, pendingDiff: path }) } createEffect(() => { - const pending = pendingDiff() + const pending = tree.pendingDiff if (!pending) return - if (!reviewScroll()) return + if (!tree.reviewScroll) return if (!diffsReady()) return const attempt = (count: number) => { - if (pendingDiff() !== pending) return + if (tree.pendingDiff !== pending) return if (count > 60) { - setPendingDiff(undefined) + setTree("pendingDiff", undefined) return } - const root = reviewScroll() + const root = tree.reviewScroll if (!root) { requestAnimationFrame(() => attempt(count + 1)) return @@ -1515,7 +1552,7 @@ export default function Page() { } if (Math.abs(root.scrollTop - top) <= 1) { - setPendingDiff(undefined) + setTree("pendingDiff", undefined) return } @@ -1558,13 +1595,17 @@ export default function Page() { void sync.session.diff(id) }) + let treeDir: string | undefined createEffect(() => { + const dir = sdk.directory if (!isDesktop()) return if (!layout.fileTree.opened()) return if (sync.status === "loading") return fileTreeTab() - void file.tree.list("") + const refresh = treeDir !== dir + treeDir = dir + void (refresh ? file.tree.refresh("") : file.tree.list("")) }) const autoScroll = createAutoScroll({ @@ -1599,6 +1640,18 @@ export default function Page() { let scrollSpyFrame: number | undefined let scrollSpyTarget: HTMLDivElement | undefined + createEffect( + on( + sessionKey, + () => { + if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) + scrollSpyFrame = undefined + scrollSpyTarget = undefined + }, + { defer: true }, + ), + ) + const anchor = (id: string) => `message-${id}` const setScrollRef = (el: HTMLDivElement | undefined) => { @@ -1713,20 +1766,14 @@ export default function Page() { window.history.replaceState(null, "", `#${anchor(id)}`) } - createEffect(() => { - const sessionID = params.id - if (!sessionID) return - const raw = sessionStorage.getItem("opencode.pendingMessage") - if (!raw) return - const parts = raw.split("|") - const pendingSessionID = parts[0] - const messageID = parts[1] - if (!pendingSessionID || !messageID) return - if (pendingSessionID !== sessionID) return - - sessionStorage.removeItem("opencode.pendingMessage") - setUi("pendingMessage", messageID) - }) + createEffect( + on(sessionKey, (key) => { + if (!params.id) return + const messageID = layout.pendingMessage.consume(key) + if (!messageID) return + setUi("pendingMessage", messageID) + }), + ) const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { const root = scroller @@ -1940,7 +1987,7 @@ export default function Page() { createEffect(() => { if (!prompt.ready()) return - handoff.prompt = previewPrompt() + setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) createEffect(() => { @@ -1960,20 +2007,22 @@ export default function Page() { return language.t("terminal.title") } - handoff.terminals = terminal.all().map(label) + touch(handoff.terminal, params.dir!, terminal.all().map(label)) }) createEffect(() => { if (!file.ready()) return - handoff.files = Object.fromEntries( - tabs() - .all() - .flatMap((tab) => { - const path = file.pathFromTab(tab) - if (!path) return [] - return [[path, file.selectedLines(path) ?? null] as const] - }), - ) + setSessionHandoff(sessionKey(), { + files: Object.fromEntries( + tabs() + .all() + .flatMap((tab) => { + const path = file.pathFromTab(tab) + if (!path) return [] + return [[path, file.selectedLines(path) ?? null] as const] + }), + ), + }) }) onCleanup(() => { @@ -2049,7 +2098,7 @@ export default function Page() { diffs={diffs} view={view} diffStyle="unified" - focusedFile={activeDiff()} + focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} comments={comments.all()} focusedComment={comments.focus()} @@ -2483,7 +2532,7 @@ export default function Page() { when={prompt.ready()} fallback={
- {handoff.prompt || language.t("prompt.loading")} + {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
} > @@ -2734,7 +2783,7 @@ export default function Page() { const p = path() if (!p) return null if (file.ready()) return file.selectedLines(p) ?? null - return handoff.files[p] ?? null + return handoff.session.get(sessionKey())?.files[p] ?? null }) let wrap: HTMLDivElement | undefined @@ -3228,7 +3277,7 @@ export default function Page() { allowed={diffFiles()} kinds={kinds()} draggable={false} - active={activeDiff()} + active={tree.activeDiff} onFileClick={(node) => focusReviewDiff(node.path)} /> @@ -3288,7 +3337,7 @@ export default function Page() { fallback={
- + {(title) => (
{title} From a3b281b2f3414b82518909d5e31e4fbbd3f7bf3b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 4 Feb 2026 10:31:21 -0500 Subject: [PATCH 22/35] ci: remove source-based AUR package from publish script Simplifies the release process by publishing only the binary package to AUR, eliminating the need to maintain separate source and binary build configurations. --- packages/opencode/script/publish.ts | 68 +---------------------------- 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 3113a85003..fbc1c83ba6 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -95,73 +95,7 @@ if (!Script.preview) { "", ].join("\n") - // Source-based PKGBUILD for opencode - const sourcePkgbuild = [ - "# Maintainer: dax", - "# Maintainer: adam", - "", - "pkgname='opencode'", - `pkgver=${pkgver}`, - `_subver=${_subver}`, - "options=('!debug' '!strip')", - "pkgrel=1", - "pkgdesc='The AI coding agent built for the terminal.'", - "url='https://github.com/anomalyco/opencode'", - "arch=('aarch64' 'x86_64')", - "license=('MIT')", - "provides=('opencode')", - "conflicts=('opencode-bin')", - "depends=('ripgrep')", - "makedepends=('git' 'bun' 'go')", - "", - `source=("opencode-\${pkgver}.tar.gz::https://github.com/anomalyco/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`, - `sha256sums=('SKIP')`, - "", - "build() {", - ` cd "opencode-\${pkgver}"`, - ` bun install`, - " cd ./packages/opencode", - ` OPENCODE_CHANNEL=latest OPENCODE_VERSION=${pkgver} bun run ./script/build.ts --single`, - "}", - "", - "package() {", - ` cd "opencode-\${pkgver}/packages/opencode"`, - ' mkdir -p "${pkgdir}/usr/bin"', - ' target_arch="x64"', - ' case "$CARCH" in', - ' x86_64) target_arch="x64" ;;', - ' aarch64) target_arch="arm64" ;;', - ' *) printf "unsupported architecture: %s\\n" "$CARCH" >&2 ; return 1 ;;', - " esac", - ' libc=""', - " if command -v ldd >/dev/null 2>&1; then", - " if ldd --version 2>&1 | grep -qi musl; then", - ' libc="-musl"', - " fi", - " fi", - ' if [ -z "$libc" ] && ls /lib/ld-musl-* >/dev/null 2>&1; then', - ' libc="-musl"', - " fi", - ' base=""', - ' if [ "$target_arch" = "x64" ]; then', - " if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then", - ' base="-baseline"', - " fi", - " fi", - ' bin="dist/opencode-linux-${target_arch}${base}${libc}/bin/opencode"', - ' if [ ! -f "$bin" ]; then', - ' printf "unable to find binary for %s%s%s\\n" "$target_arch" "$base" "$libc" >&2', - " return 1", - " fi", - ' install -Dm755 "$bin" "${pkgdir}/usr/bin/opencode"', - "}", - "", - ].join("\n") - - for (const [pkg, pkgbuild] of [ - ["opencode-bin", binaryPkgbuild], - ["opencode", sourcePkgbuild], - ]) { + for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) { for (let i = 0; i < 30; i++) { try { await $`rm -rf ./dist/aur-${pkg}` From 61d3f788b847593a865d1aa8a9a112911f55d117 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:00:55 -0600 Subject: [PATCH 23/35] fix(app): don't show scroll-to-bottom unecessarily --- packages/app/src/pages/session.tsx | 67 ++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7ff4bebb4d..f74eadc87b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -279,6 +279,10 @@ export default function Page() { pendingMessage: undefined as string | undefined, scrollGesture: 0, autoCreated: false, + scroll: { + overflow: false, + bottom: true, + }, }) createEffect( @@ -795,6 +799,7 @@ export default function Page() { let inputRef!: HTMLDivElement let promptDock: HTMLDivElement | undefined let scroller: HTMLDivElement | undefined + let content: HTMLDivElement | undefined const scrollGestureWindowMs = 250 @@ -1618,10 +1623,40 @@ export default function Page() { window.history.replaceState(null, "", window.location.href.replace(/#.*$/, "")) } + let scrollStateFrame: number | undefined + let scrollStateTarget: HTMLDivElement | undefined + + const updateScrollState = (el: HTMLDivElement) => { + const max = el.scrollHeight - el.clientHeight + const overflow = max > 1 + const bottom = !overflow || el.scrollTop >= max - 2 + + if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return + setUi("scroll", { overflow, bottom }) + } + + const scheduleScrollState = (el: HTMLDivElement) => { + scrollStateTarget = el + if (scrollStateFrame !== undefined) return + + scrollStateFrame = requestAnimationFrame(() => { + scrollStateFrame = undefined + + const target = scrollStateTarget + scrollStateTarget = undefined + if (!target) return + + updateScrollState(target) + }) + } + const resumeScroll = () => { setStore("messageId", undefined) autoScroll.forceScrollToBottom() clearMessageHash() + + const el = scroller + if (el) scheduleScrollState(el) } // When the user returns to the bottom, treat the active message as "latest". @@ -1657,8 +1692,17 @@ export default function Page() { const setScrollRef = (el: HTMLDivElement | undefined) => { scroller = el autoScroll.scrollRef(el) + if (el) scheduleScrollState(el) } + createResizeObserver( + () => content, + () => { + const el = scroller + if (el) scheduleScrollState(el) + }, + ) + const turnInit = 20 const turnBatch = 20 let turnHandle: number | undefined @@ -1759,6 +1803,8 @@ export default function Page() { el.scrollTo({ top: el.scrollHeight, behavior: "auto" }) }) } + + if (el) scheduleScrollState(el) }, ) @@ -1839,6 +1885,9 @@ export default function Page() { const hash = window.location.hash.slice(1) if (!hash) { autoScroll.forceScrollToBottom() + + const el = scroller + if (el) scheduleScrollState(el) return } @@ -1864,6 +1913,9 @@ export default function Page() { } autoScroll.forceScrollToBottom() + + const el = scroller + if (el) scheduleScrollState(el) } const closestMessage = (node: Element | null): HTMLElement | null => { @@ -2029,6 +2081,7 @@ export default function Page() { cancelTurnBackfill() document.removeEventListener("keydown", handleKeyDown) if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) + if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) }) return ( @@ -2133,8 +2186,9 @@ export default function Page() {