From bd4527b4f28333a3c49faba43dc03d99e1e02ae2 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:32:11 +0100 Subject: [PATCH 01/48] fix(desktop): remote server switching (#17214) Co-authored-by: Brendan Allan --- packages/app/src/app.tsx | 39 ++++-- .../src/components/dialog-select-server.tsx | 2 +- .../app/src/components/status-popover.tsx | 2 +- packages/app/src/components/terminal.tsx | 24 ++-- packages/app/src/context/terminal.tsx | 122 +++++++++++------- .../app/src/pages/session/terminal-panel.tsx | 33 ++--- 6 files changed, 133 insertions(+), 89 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e370862212..857892123b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -265,6 +265,15 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ) } +function ServerKey(props: ParentProps) { + const server = useServer() + return ( + + {props.children} + + ) +} + export function AppInterface(props: { children?: JSX.Element defaultServer: ServerConnection.Key @@ -275,20 +284,22 @@ export function AppInterface(props: { return ( - - - {routerProps.children}} - > - - - - - - - - + + + + {routerProps.children}} + > + + + + + + + + + ) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index eb039c14d6..f8d14cbb94 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -291,8 +291,8 @@ export function DialogSelectServer() { navigate("/") return } - server.setActive(ServerConnection.key(conn)) navigate("/") + queueMicrotask(() => server.setActive(ServerConnection.key(conn))) } const handleAddChange = (value: string) => { diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 61facb84eb..063205f0c3 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -277,8 +277,8 @@ export function StatusPopover() { aria-disabled={isBlocked()} onClick={() => { if (isBlocked()) return - server.setActive(key) navigate("/") + queueMicrotask(() => server.setActive(key)) }} > diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 9297d66266..aed46f1262 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -165,6 +165,12 @@ export const Terminal = (props: TerminalProps) => { const theme = useTheme() const language = useLanguage() const server = useServer() + const directory = sdk.directory + const client = sdk.client + const url = sdk.url + const auth = server.current?.http + const username = auth?.username ?? "opencode" + const password = auth?.password ?? "" let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const id = local.pty.id @@ -215,7 +221,7 @@ export const Terminal = (props: TerminalProps) => { } const pushSize = (cols: number, rows: number) => { - return sdk.client.pty + return client.pty .update({ ptyID: id, size: { cols, rows }, @@ -474,7 +480,7 @@ export const Terminal = (props: TerminalProps) => { } const gone = () => - sdk.client.pty + client.pty .get({ ptyID: id }) .then(() => false) .catch((err) => { @@ -506,14 +512,14 @@ export const Terminal = (props: TerminalProps) => { if (disposed) return drop?.() - const url = new URL(sdk.url + `/pty/${id}/connect`) - url.searchParams.set("directory", sdk.directory) - url.searchParams.set("cursor", String(seek)) - url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - url.username = server.current?.http.username ?? "opencode" - url.password = server.current?.http.password ?? "" + const next = new URL(url + `/pty/${id}/connect`) + next.searchParams.set("directory", directory) + next.searchParams.set("cursor", String(seek)) + next.protocol = next.protocol === "https:" ? "wss:" : "ws:" + next.username = username + next.password = password - const socket = new WebSocket(url) + const socket = new WebSocket(next) socket.binaryType = "arraybuffer" ws = socket diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index e65c167884..17355aab9a 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -185,6 +185,60 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str }) onCleanup(unsub) + const update = (client: ReturnType["client"], pty: Partial & { id: string }) => { + const index = store.all.findIndex((x) => x.id === pty.id) + const previous = index >= 0 ? store.all[index] : undefined + if (index >= 0) { + setStore("all", index, (item) => ({ ...item, ...pty })) + } + client.pty + .update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + .catch((error: unknown) => { + if (previous) { + const currentIndex = store.all.findIndex((item) => item.id === pty.id) + if (currentIndex >= 0) setStore("all", currentIndex, previous) + } + console.error("Failed to update terminal", error) + }) + } + + const clone = async (client: ReturnType["client"], id: string) => { + const index = store.all.findIndex((x) => x.id === id) + const pty = store.all[index] + if (!pty) return + const next = await client.pty + .create({ + title: pty.title, + }) + .catch((error: unknown) => { + console.error("Failed to clone terminal", error) + return undefined + }) + if (!next?.data) return + + const active = store.active === pty.id + + batch(() => { + setStore("all", index, { + id: next.data.id, + title: next.data.title ?? pty.title, + titleNumber: pty.titleNumber, + buffer: undefined, + cursor: undefined, + scrollY: undefined, + rows: undefined, + cols: undefined, + }) + if (active) { + setStore("active", next.data.id) + } + }) + } + return { ready, all: createMemo(() => store.all), @@ -216,24 +270,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str }) }, update(pty: Partial & { id: string }) { - const index = store.all.findIndex((x) => x.id === pty.id) - const previous = index >= 0 ? store.all[index] : undefined - if (index >= 0) { - setStore("all", index, (item) => ({ ...item, ...pty })) - } - sdk.client.pty - .update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - .catch((error: unknown) => { - if (previous) { - const currentIndex = store.all.findIndex((item) => item.id === pty.id) - if (currentIndex >= 0) setStore("all", currentIndex, previous) - } - console.error("Failed to update terminal", error) - }) + update(sdk.client, pty) }, trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -248,37 +285,23 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str }) }, async clone(id: string) { - const index = store.all.findIndex((x) => x.id === id) - const pty = store.all[index] - if (!pty) return - const clone = await sdk.client.pty - .create({ - title: pty.title, - }) - .catch((error: unknown) => { - console.error("Failed to clone terminal", error) - return undefined - }) - if (!clone?.data) return - - const active = store.active === pty.id - - batch(() => { - setStore("all", index, { - id: clone.data.id, - title: clone.data.title ?? pty.title, - titleNumber: pty.titleNumber, - // New PTY process, so start clean. - buffer: undefined, - cursor: undefined, - scrollY: undefined, - rows: undefined, - cols: undefined, - }) - if (active) { - setStore("active", clone.data.id) - } - }) + await clone(sdk.client, id) + }, + bind() { + const client = sdk.client + return { + trim(id: string) { + const index = store.all.findIndex((x) => x.id === id) + if (index === -1) return + setStore("all", index, (pty) => trimTerminal(pty)) + }, + update(pty: Partial & { id: string }) { + update(client, pty) + }, + async clone(id: string) { + await clone(client, id) + }, + } }, open(id: string) { setStore("active", id) @@ -403,6 +426,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont trim: (id: string) => workspace().trim(id), trimAll: () => workspace().trimAll(), clone: (id: string) => workspace().clone(id), + bind: () => workspace(), open: (id: string) => workspace().open(id), close: (id: string) => workspace().close(id), move: (id: string, to: number) => workspace().move(id, to), diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index d62d91c197..c663d7d671 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -280,21 +280,24 @@ export function TerminalPanel() {
- {(id) => ( - pty.id === id)}> - {(pty) => ( -
- terminal.trim(id)} - onCleanup={terminal.update} - onConnectError={() => terminal.clone(id)} - /> -
- )} -
- )} + {(id) => { + const ops = terminal.bind() + return ( + pty.id === id)}> + {(pty) => ( +
+ ops.trim(id)} + onCleanup={ops.update} + onConnectError={() => ops.clone(id)} + /> +
+ )} +
+ ) + }}
From cbf4b68fee8ff45c10a484b27f94e2dbe5f5dea2 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 19 Mar 2026 21:47:47 +0800 Subject: [PATCH 02/48] electron: lazily read updater enabled --- packages/desktop-electron/src/renderer/index.tsx | 4 ++-- packages/desktop-electron/src/renderer/updater.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 6719c04bee..30e882e237 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -152,12 +152,12 @@ const createPlatform = (): Platform => { storage, checkUpdate: async () => { - if (!UPDATER_ENABLED) return { updateAvailable: false } + if (!UPDATER_ENABLED()) return { updateAvailable: false } return window.api.checkUpdate() }, update: async () => { - if (!UPDATER_ENABLED) return + if (!UPDATER_ENABLED()) return await window.api.installUpdate() }, diff --git a/packages/desktop-electron/src/renderer/updater.ts b/packages/desktop-electron/src/renderer/updater.ts index fe9e601db8..ba2b458ece 100644 --- a/packages/desktop-electron/src/renderer/updater.ts +++ b/packages/desktop-electron/src/renderer/updater.ts @@ -1,6 +1,6 @@ import { initI18n, t } from "./i18n" -export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false +export const UPDATER_ENABLED = () => window.__OPENCODE__?.updaterEnabled ?? false export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { await initI18n() From 84f60d97a0c37c9136dfd965a66a0c8685a19e71 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 19 Mar 2026 21:59:14 +0800 Subject: [PATCH 03/48] app: fix workspace flicker when switching directories (#18207) Co-authored-by: Shoubhit Dash --- packages/app/e2e/actions.ts | 25 +++++ .../app/e2e/projects/projects-switch.spec.ts | 8 +- .../projects/workspace-new-session.spec.ts | 68 +++++++------ packages/app/e2e/projects/workspaces.spec.ts | 29 +++--- .../session/session-model-persistence.spec.ts | 14 ++- packages/app/src/app.tsx | 18 ++-- packages/app/src/pages/directory-layout.tsx | 97 +++++++++---------- packages/app/src/pages/layout.tsx | 77 +++++++++------ packages/app/src/pages/layout/helpers.test.ts | 6 +- packages/app/src/pages/layout/helpers.ts | 28 +++--- .../src/pages/layout/sidebar-workspace.tsx | 12 ++- 11 files changed, 215 insertions(+), 167 deletions(-) diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index aa047fb287..88d71f94cf 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -1,3 +1,4 @@ +import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { expect, type Locator, type Page } from "@playwright/test" import fs from "node:fs/promises" import os from "node:os" @@ -361,6 +362,30 @@ export async function waitSlug(page: Page, skip: string[] = []) { return next } +export async function resolveSlug(slug: string) { + const directory = base64Decode(slug) + if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) + const resolved = await resolveDirectory(directory) + return { directory: resolved, slug: base64Encode(resolved), raw: slug } +} + +export async function waitDir(page: Page, directory: string) { + const target = await resolveDirectory(directory) + await expect + .poll( + async () => { + const slug = slugFromUrl(page.url()) + if (!slug) return "" + return resolveSlug(slug) + .then((item) => item.directory) + .catch(() => "") + }, + { timeout: 45_000 }, + ) + .toBe(target) + return { directory: target, slug: base64Encode(target) } +} + export function sessionIDFromUrl(url: string) { const match = /\/session\/([^/?#]+)/.exec(url) return match?.[1] diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 6ad64f5927..1416aec726 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,7 +1,7 @@ import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions" +import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitDir, waitSlug } from "../actions" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { dirSlug, resolveDirectory } from "../utils" @@ -100,11 +100,8 @@ test("switching back to a project opens the latest workspace session", async ({ await expect(btn).toBeVisible() await btn.click({ force: true }) - // A new workspace can be discovered via a transient slug before the route and sidebar - // settle to the canonical workspace path on Windows, so interact with either and assert - // against the resolved workspace slug. await waitSlug(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + await waitDir(page, space) // Create a session by sending a prompt const prompt = page.locator(promptSelector) @@ -132,6 +129,7 @@ test("switching back to a project opens the latest workspace session", async ({ await expect(rootButton).toBeVisible() await rootButton.click() + await waitDir(page, space) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created) await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`)) }, diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index 18fa46d329..0858f26273 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -1,18 +1,25 @@ -import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions" +import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { createSdk } from "../utils" -async function waitWorkspaceReady(page: Page, slug: string) { +function item(space: { slug: string; raw: string }) { + return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}` +} + +function button(space: { slug: string; raw: string }) { + return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}` +} + +async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) { await openSidebar(page) await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(slug)).first() + const row = page.locator(item(space)).first() try { - await item.hover({ timeout: 500 }) + await row.hover({ timeout: 500 }) return true } catch { return false @@ -27,29 +34,30 @@ async function createWorkspace(page: Page, root: string, seen: string[]) { await openSidebar(page) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [root, ...seen]) - const directory = base64Decode(slug) - if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) - return { slug, directory } -} - -async function openWorkspaceNewSession(page: Page, slug: string) { - await waitWorkspaceReady(page, slug) - - const item = page.locator(workspaceItemSelector(slug)).first() - await item.hover() - - const button = page.locator(workspaceNewSessionSelector(slug)).first() - await expect(button).toBeVisible() - await button.click({ force: true }) - - const next = await waitSlug(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + const next = await resolveSlug(await waitSlug(page, [root, ...seen])) + await waitDir(page, next.directory) return next } -async function createSessionFromWorkspace(page: Page, slug: string, text: string) { - const next = await openWorkspaceNewSession(page, slug) +async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) { + await waitWorkspaceReady(page, space) + + const row = page.locator(item(space)).first() + await row.hover() + + const next = page.locator(button(space)).first() + await expect(next).toBeVisible() + await next.click({ force: true }) + + return waitDir(page, space.directory) +} + +async function createSessionFromWorkspace( + page: Page, + space: { slug: string; raw: string; directory: string }, + text: string, +) { + const next = await openWorkspaceNewSession(page, space) const prompt = page.locator(promptSelector) await expect(prompt).toBeVisible() @@ -60,13 +68,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) await prompt.press("Enter") - await expect.poll(() => slugFromUrl(page.url())).toBe(next) + await waitDir(page, next.directory) await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") const sessionID = sessionIDFromUrl(page.url()) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) - await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`)) - return { sessionID, slug: next } + await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`)) + return { sessionID, slug: next.slug } } async function sessionDirectory(directory: string, sessionID: string) { @@ -87,11 +95,11 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a const first = await createWorkspace(page, root, []) trackDirectory(first.directory) - await waitWorkspaceReady(page, first.slug) + await waitWorkspaceReady(page, first) const second = await createWorkspace(page, root, [first.slug]) trackDirectory(second.directory) - await waitWorkspaceReady(page, second.slug) + await waitWorkspaceReady(page, second) const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) trackSession(firstSession.sessionID, first.directory) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index aeeccb9bba..8ee899f18e 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -1,4 +1,3 @@ -import { base64Decode } from "@opencode-ai/util/encode" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -13,8 +12,10 @@ import { confirmDialog, openSidebar, openWorkspaceMenu, + resolveSlug, setWorkspacesEnabled, slugFromUrl, + waitDir, waitSlug, } from "../actions" import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" @@ -27,15 +28,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { await setWorkspacesEnabled(page, rootSlug, true) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [rootSlug]) - const dir = base64Decode(slug) + const next = await resolveSlug(await waitSlug(page, [rootSlug])) + await waitDir(page, next.directory) await openSidebar(page) await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(slug)).first() + const item = page.locator(workspaceItemSelector(next.slug)).first() try { await item.hover({ timeout: 500 }) return true @@ -47,7 +48,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) { ) .toBe(true) - return { rootSlug, slug, directory: dir } + return { rootSlug, slug: next.slug, directory: next.directory } } test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { @@ -79,15 +80,15 @@ test("can create a workspace", async ({ page, withProject }) => { await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible() await page.getByRole("button", { name: "New workspace" }).first().click() - const workspaceSlug = await waitSlug(page, [slug]) - const workspaceDir = base64Decode(workspaceSlug) + const next = await resolveSlug(await waitSlug(page, [slug])) + await waitDir(page, next.directory) await openSidebar(page) await expect .poll( async () => { - const item = page.locator(workspaceItemSelector(workspaceSlug)).first() + const item = page.locator(workspaceItemSelector(next.slug)).first() try { await item.hover({ timeout: 500 }) return true @@ -99,9 +100,9 @@ test("can create a workspace", async ({ page, withProject }) => { ) .toBe(true) - await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() + await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible() - await cleanupTestProject(workspaceDir) + await cleanupTestProject(next.directory) }) }) @@ -119,7 +120,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") - const activeDir = base64Decode(slugFromUrl(page.url())) + const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory) expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") await openSidebar(page) @@ -331,9 +332,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) => for (const _ of [0, 1]) { const prev = slugFromUrl(page.url()) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [rootSlug, prev]) - const dir = base64Decode(slug) - workspaces.push({ slug, directory: dir }) + const next = await resolveSlug(await waitSlug(page, [rootSlug, prev])) + await waitDir(page, next.directory) + workspaces.push(next) await openSidebar(page) } diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts index 933d5e6f96..2c2e4e886d 100644 --- a/packages/app/e2e/session/session-model-persistence.spec.ts +++ b/packages/app/e2e/session/session-model-persistence.spec.ts @@ -1,7 +1,6 @@ -import { base64Decode } from "@opencode-ai/util/encode" import type { Locator, Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" +import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" import { promptAgentSelector, promptModelSelector, @@ -224,10 +223,9 @@ async function createWorkspace(page: Page, root: string, seen: string[]) { await openSidebar(page) await page.getByRole("button", { name: "New workspace" }).first().click() - const slug = await waitSlug(page, [root, ...seen]) - const directory = base64Decode(slug) - if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) - return { slug, directory } + const next = await resolveSlug(await waitSlug(page, [root, ...seen])) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) + return next } async function waitWorkspace(page: Page, slug: string) { @@ -257,8 +255,8 @@ async function newWorkspaceSession(page: Page, slug: string) { await expect(button).toBeVisible() await button.click({ force: true }) - const next = await waitSlug(page) - await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) + const next = await resolveSlug(await waitSlug(page)) + await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`)) await expect(page.locator(promptSelector)).toBeVisible() return currentDir(page) } diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 857892123b..9a282bbb70 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -46,21 +46,13 @@ import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" -const Home = lazy(() => import("@/pages/home")) +const HomeRoute = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) const Loading = () =>
-const HomeRoute = () => ( - }> - - -) - const SessionRoute = () => ( - }> - - + ) @@ -124,8 +116,10 @@ function SessionProviders(props: ParentProps) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { return ( - {props.appChildren} - {props.children} + }> + {props.appChildren} + {props.children} + ) } diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index f993ffcd89..cd5e079a69 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,16 +1,15 @@ -import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js" -import { createStore } from "solid-js/store" +import { DataProvider } from "@opencode-ai/ui/context" +import { showToast } from "@opencode-ai/ui/toast" +import { base64Encode } from "@opencode-ai/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { createMemo, createResource, type ParentProps, Show } from "solid-js" +import { useGlobalSDK } from "@/context/global-sdk" +import { useLanguage } from "@/context/language" +import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" -import { LocalProvider } from "@/context/local" -import { useGlobalSDK } from "@/context/global-sdk" - -import { DataProvider } from "@opencode-ai/ui/context" -import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" -import { showToast } from "@opencode-ai/ui/toast" -import { useLanguage } from "@/context/language" + function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { const navigate = useNavigate() const sync = useSync() @@ -30,57 +29,53 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { export default function Layout(props: ParentProps) { const params = useParams() - const navigate = useNavigate() const location = useLocation() const language = useLanguage() const globalSDK = useGlobalSDK() - const directory = createMemo(() => decode64(params.dir) ?? "") - const [state, setState] = createStore({ invalid: "", resolved: "" }) + const navigate = useNavigate() + let invalid = "" - createEffect(() => { - if (!params.dir) return - const raw = directory() - if (!raw) { - if (state.invalid === params.dir) return - setState("invalid", params.dir) - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - return - } + const [resolved] = createResource( + () => { + if (params.dir) return [location.pathname, params.dir] as const + }, + async ([pathname, b64Dir]) => { + const directory = decode64(b64Dir) - const current = params.dir - globalSDK - .createClient({ - directory: raw, - throwOnError: true, - }) - .path.get() - .then((x) => { - if (params.dir !== current) return - const next = x.data?.directory ?? raw - batch(() => { - setState("invalid", "") - setState("resolved", next) + if (!directory) { + if (invalid === params.dir) return + invalid = b64Dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), }) - if (next === raw) return - const path = location.pathname.slice(current.length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - .catch(() => { - if (params.dir !== current) return - batch(() => { - setState("invalid", "") - setState("resolved", raw) + navigate("/", { replace: true }) + return + } + + return await globalSDK + .createClient({ + directory, + throwOnError: true, }) - }) - }) + .path.get() + .then((x) => { + const next = x.data?.directory ?? directory + invalid = "" + if (next === directory) return next + const path = pathname.slice(b64Dir.length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + .catch(() => { + invalid = "" + return directory + }) + }, + ) return ( - + {(resolved) => ( resolved}> diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4c3a00dfb5..cca14fd50c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -543,13 +543,14 @@ export default function Layout(props: ParentProps) { const currentProject = createMemo(() => { const directory = currentDir() if (!directory) return + const key = workspaceKey(directory) const projects = layout.projects.list() - const sandbox = projects.find((p) => p.sandboxes?.includes(directory)) + const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key)) if (sandbox) return sandbox - const direct = projects.find((p) => p.worktree === directory) + const direct = projects.find((p) => workspaceKey(p.worktree) === key) if (direct) return direct const [child] = globalSync.child(directory, { bootstrap: false }) @@ -630,7 +631,10 @@ export default function Layout(props: ParentProps) { const projects = layout.projects.list() for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { if (!expanded) continue - const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + const key = workspaceKey(directory) + const project = projects.find( + (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + ) if (!project) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue setStore("workspaceExpanded", directory, false) @@ -1155,13 +1159,16 @@ export default function Layout(props: ParentProps) { } function projectRoot(directory: string) { + const key = workspaceKey(directory) const project = layout.projects .list() - .find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) + .find( + (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + ) if (project) return project.worktree const known = Object.entries(store.workspaceOrder).find( - ([root, dirs]) => root === directory || dirs.includes(directory), + ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key), ) if (known) return known[0] @@ -1177,13 +1184,6 @@ export default function Layout(props: ParentProps) { return currentProject()?.worktree ?? projectRoot(directory) } - function touchProjectRoute() { - const root = currentProject()?.worktree - if (!root) return - if (server.projects.last() !== root) server.projects.touch(root) - return root - } - function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) { setStore("lastProjectSession", root, { directory, id, at: Date.now() }) return root @@ -1347,8 +1347,9 @@ export default function Layout(props: ParentProps) { function closeProject(directory: string) { const list = layout.projects.list() - const index = list.findIndex((x) => x.worktree === directory) - const active = currentProject()?.worktree === directory + const key = workspaceKey(directory) + const index = list.findIndex((x) => workspaceKey(x.worktree) === key) + const active = workspaceKey(currentProject()?.worktree ?? "") === key if (index === -1) return const next = list[index + 1] @@ -1683,38 +1684,55 @@ export default function Layout(props: ParentProps) { const activeRoute = { session: "", sessionProject: "", + directory: "", } createEffect( on( - () => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const, - ([ready, dir, id]) => { - if (!ready || !dir) { + () => { + const dir = params.dir + const directory = dir ? decode64(dir) : undefined + const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : "" + return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const + }, + ([ready, dir, id, root, directory, resolved]) => { + if (!ready || !dir || !directory) { activeRoute.session = "" activeRoute.sessionProject = "" + activeRoute.directory = "" return } - const directory = decode64(dir) - if (!directory) return - - const root = touchProjectRoute() ?? activeProjectRoot(directory) - if (!id) { activeRoute.session = "" activeRoute.sessionProject = "" + activeRoute.directory = "" return } + const next = resolved || directory const session = `${dir}/${id}` - if (session !== activeRoute.session) { + + if (!root) { activeRoute.session = session - activeRoute.sessionProject = syncSessionRoute(directory, id, root) + activeRoute.directory = next + activeRoute.sessionProject = "" + return + } + + if (server.projects.last() !== root) server.projects.touch(root) + + const changed = session !== activeRoute.session || next !== activeRoute.directory + if (changed) { + activeRoute.session = session + activeRoute.directory = next + activeRoute.sessionProject = syncSessionRoute(next, id, root) return } if (root === activeRoute.sessionProject) return - activeRoute.sessionProject = rememberSessionRoute(directory, id, root) + activeRoute.directory = next + activeRoute.sessionProject = rememberSessionRoute(next, id, root) }, ), ) @@ -1778,8 +1796,13 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = active?.worktree === project.worktree ? currentDir() : undefined - const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined + const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined + const extra = + directory && + workspaceKey(directory) !== workspaceKey(local) && + !dirs.some((item) => workspaceKey(item) === workspaceKey(directory)) + ? directory + : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree]) diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 9dbc6c72d2..1fe52d47a0 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -104,14 +104,14 @@ describe("layout deep links", () => { describe("layout workspace helpers", () => { test("normalizes trailing slash in workspace key", () => { expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") - expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") + expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo") }) test("preserves posix and drive roots in workspace key", () => { expect(workspaceKey("/")).toBe("/") expect(workspaceKey("///")).toBe("/") - expect(workspaceKey("C:\\")).toBe("C:\\") - expect(workspaceKey("C:\\\\\\")).toBe("C:\\") + expect(workspaceKey("C:\\")).toBe("C:/") + expect(workspaceKey("C://")).toBe("C:/") expect(workspaceKey("C:///")).toBe("C:/") }) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index be4ce9f574..886ffd26a1 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,11 +1,17 @@ import { getFilename } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" +type SessionStore = { + session?: Session[] + path: { directory: string } +} + export const workspaceKey = (directory: string) => { - const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) - if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` - if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/" - return directory.replace(/[\\/]+$/, "") + const value = directory.replaceAll("\\", "/") + const drive = value.match(/^([A-Za-z]:)\/+$/) + if (drive) return `${drive[1]}/` + if (/^\/+$/i.test(value)) return "/" + return value.replace(/\/+$/, "") } function sortSessions(now: number) { @@ -25,13 +31,11 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => - store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now)) +const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) -export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) => - stores - .flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory))) - .sort(sortSessions(now))[0] +export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) + +export const latestRootSession = (stores: SessionStore[], now: number) => stores.flatMap(roots).sort(sortSessions(now))[0] export function hasProjectPermissions( request: Record, @@ -40,9 +44,9 @@ export function hasProjectPermissions( return Object.values(request).some((list) => list?.some(include)) } -export const childMapByParent = (sessions: Session[]) => { +export const childMapByParent = (sessions: Session[] | undefined) => { const map = new Map() - for (const session of sessions) { + for (const session of sessions ?? []) { if (!session.parentID) continue const existing = map.get(session.parentID) if (existing) { diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 86ede774e6..127626febe 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -332,12 +332,13 @@ export const SortableWorkspace = (props: { const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const boot = createMemo(() => open() || active()) const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) - const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) + const count = createMemo(() => sessions()?.length ?? 0) + const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) const busy = createMemo(() => props.ctx.isBusy(props.directory)) const wasBusy = createMemo((prev) => prev || busy(), false) - const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) + const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy()) const touch = createMediaQuery("(hover: none)") - const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id))) + const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id))) const loadMore = async () => { setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5) await globalSync.project.loadSessions(props.directory) @@ -472,8 +473,9 @@ export const LocalWorkspace = (props: { const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const children = createMemo(() => childMapByParent(workspace().store.session)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) - const loading = createMemo(() => !booted() && sessions().length === 0) - const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) + const count = createMemo(() => sessions()?.length ?? 0) + const loading = createMemo(() => !booted() && count() === 0) + const hasMore = createMemo(() => workspace().store.sessionTotal > count()) const loadMore = async () => { workspace().setStore("limit", (limit) => (limit ?? 0) + 5) await globalSync.project.loadSessions(props.project.worktree) From e6f521477959b1009153d8310fb90ac41d766b8b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 19 Mar 2026 19:29:29 +0530 Subject: [PATCH 04/48] feat: add git-backed session review modes (#17961) --- .../app/src/context/global-sync/bootstrap.ts | 2 +- .../src/context/global-sync/event-reducer.ts | 4 +- packages/app/src/i18n/en.ts | 2 + packages/app/src/pages/session.tsx | 194 ++++++++++-- .../src/pages/session/session-side-panel.tsx | 54 ++-- .../pages/session/use-session-commands.tsx | 6 +- packages/opencode/src/cli/cmd/github.ts | 10 +- packages/opencode/src/cli/cmd/pr.ts | 8 +- packages/opencode/src/effect/instances.ts | 2 +- packages/opencode/src/effect/runtime.ts | 2 + packages/opencode/src/file/index.ts | 14 +- packages/opencode/src/file/watcher.ts | 4 +- packages/opencode/src/git/effect.ts | 298 ++++++++++++++++++ packages/opencode/src/git/index.ts | 64 ++++ packages/opencode/src/project/project.ts | 10 +- packages/opencode/src/project/vcs.ts | 167 ++++++++-- packages/opencode/src/server/server.ts | 38 ++- packages/opencode/src/storage/storage.ts | 4 +- packages/opencode/src/util/git.ts | 35 -- packages/opencode/src/worktree/index.ts | 44 +-- packages/opencode/test/git/git.test.ts | 107 +++++++ .../opencode/test/project/project.test.ts | 17 +- packages/opencode/test/project/vcs.test.ts | 111 ++++++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 33 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 23 +- packages/ui/src/i18n/en.ts | 2 + 26 files changed, 1072 insertions(+), 183 deletions(-) create mode 100644 packages/opencode/src/git/effect.ts create mode 100644 packages/opencode/src/git/index.ts delete mode 100644 packages/opencode/src/util/git.ts create mode 100644 packages/opencode/test/git/git.test.ts diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 13494b7ade..c71e00f6fa 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -158,7 +158,7 @@ export async function bootstrapDirectory(input: { input.sdk.vcs.get().then((x) => { const next = x.data ?? input.store.vcs input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) + if (next) input.vcsCache.setStore("value", next) }), input.sdk.permission.list().then((x) => { const grouped = groupBySession( diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index b8eda0573f..0e243e95a3 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -268,9 +268,9 @@ export function applyDirectoryEvent(input: { break } case "vcs.branch.updated": { - const props = event.properties as { branch: string } + const props = event.properties as { branch?: string } if (input.store.vcs?.branch === props.branch) break - const next = { branch: props.branch } + const next = { ...input.store.vcs, branch: props.branch } input.setStore("vcs", next) if (input.vcsCache) input.vcsCache.setStore("value", next) break diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 72caed40ad..c7e0c95ea3 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -533,6 +533,8 @@ export const dict = { "session.review.noVcs.createGit.action": "Create Git repository", "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noChanges": "No changes", + "session.review.noUncommittedChanges": "No uncommitted changes yet", + "session.review.noBranchChanges": "No branch changes yet", "session.files.selectToOpen": "Select a file to open", "session.files.all": "All files", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6d29170081..6fe2e56b8e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import type { Project, UserMessage } from "@opencode-ai/sdk/v2" +import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import { batch, @@ -57,6 +57,9 @@ import { formatServerError } from "@/utils/server-errors" const emptyUserMessages: UserMessage[] = [] const emptyFollowups: (FollowupDraft & { id: string })[] = [] +type ChangeMode = "git" | "branch" | "session" | "turn" +type VcsMode = "git" | "branch" + type SessionHistoryWindowInput = { sessionID: () => string | undefined messagesReady: () => boolean @@ -415,15 +418,16 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) - const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) - const hasReview = createMemo(() => reviewCount() > 0) + const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const hasSessionReview = createMemo(() => sessionCount() > 0) + const canReview = createMemo(() => !!params.id) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ tabs, pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, - hasReview, + hasReview: canReview, }) const contextOpen = tabState.contextOpen const openedTabs = tabState.openedTabs @@ -499,11 +503,22 @@ export default function Page() { const [store, setStore] = createStore({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", - changes: "session" as "session" | "turn", + changes: "git" as ChangeMode, newSessionWorktree: "main", deferRender: false, }) + const [vcs, setVcs] = createStore({ + diff: { + git: [] as FileDiff[], + branch: [] as FileDiff[], + }, + ready: { + git: false, + branch: false, + }, + }) + const [followup, setFollowup] = createStore({ items: {} as Record, sending: {} as Record, @@ -531,6 +546,40 @@ export default function Page() { let refreshTimer: number | undefined let diffFrame: number | undefined let diffTimer: number | undefined + const vcsTask = new Map>() + + const resetVcs = () => { + vcsTask.clear() + setVcs({ + diff: { git: [], branch: [] }, + ready: { git: false, branch: false }, + }) + } + + const loadVcs = (mode: VcsMode, force = false) => { + if (sync.project?.vcs !== "git") return Promise.resolve() + if (vcs.ready[mode] && !force) return Promise.resolve() + const current = vcsTask.get(mode) + if (current) return current + + const task = sdk.client.vcs + .diff({ mode }) + .then((result) => { + setVcs("diff", mode, result.data ?? []) + setVcs("ready", mode, true) + }) + .catch((error) => { + console.debug("[session-review] failed to load vcs diff", { mode, error }) + setVcs("diff", mode, []) + setVcs("ready", mode, true) + }) + .finally(() => { + vcsTask.delete(mode) + }) + + vcsTask.set(mode, task) + return task + } createComputed((prev) => { const open = desktopReviewOpen() @@ -546,7 +595,38 @@ export default function Page() { }, desktopReviewOpen()) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) - const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) + const changesOptions = createMemo(() => { + const list: ChangeMode[] = [] + const git = sync.project?.vcs === "git" + if (git) list.push("git") + if (git && sync.data.vcs?.branch && sync.data.vcs?.default_branch && sync.data.vcs.branch !== sync.data.vcs.default_branch) { + list.push("branch") + } + list.push("session", "turn") + return list + }) + const vcsMode = createMemo(() => { + if (store.changes === "git" || store.changes === "branch") return store.changes + }) + const reviewDiffs = createMemo(() => { + if (store.changes === "git") return vcs.diff.git + if (store.changes === "branch") return vcs.diff.branch + if (store.changes === "session") return diffs() + return turnDiffs() + }) + const reviewCount = createMemo(() => { + if (store.changes === "git") return vcs.diff.git.length + if (store.changes === "branch") return vcs.diff.branch.length + if (store.changes === "session") return sessionCount() + return turnDiffs().length + }) + const hasReview = createMemo(() => reviewCount() > 0) + const reviewReady = createMemo(() => { + if (store.changes === "git") return vcs.ready.git + if (store.changes === "branch") return vcs.ready.branch + if (store.changes === "session") return !hasSessionReview() || diffsReady() + return true + }) const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" @@ -615,10 +695,10 @@ export default function Page() { const diffsReady = createMemo(() => { const id = params.id if (!id) return true - if (!hasReview()) return true + if (!hasSessionReview()) return true return sync.data.session_diff[id] !== undefined }) - const reviewEmptyKey = createMemo(() => { + const sessionEmptyKey = createMemo(() => { const project = sync.project if (project && !project.vcs) return "session.review.noVcs" if (sync.data.config.snapshot === false) return "session.review.noSnapshot" @@ -741,13 +821,23 @@ export default function Page() { sessionKey, () => { setStore("messageId", undefined) - setStore("changes", "session") + setStore("changes", "git") setUi("pendingMessage", undefined) }, { defer: true }, ), ) + createEffect( + on( + () => sdk.directory, + () => { + resetVcs() + }, + { defer: true }, + ), + ) + createEffect( on( () => params.dir, @@ -870,6 +960,38 @@ export default function Page() { } const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") + const wantsReview = createMemo(() => + isDesktop() ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") : store.mobileTab === "changes", + ) + + createEffect(() => { + const list = changesOptions() + if (list.includes(store.changes)) return + const next = list[0] + if (!next) return + setStore("changes", next) + }) + + createEffect(() => { + const mode = vcsMode() + if (!mode) return + if (!wantsReview()) return + void loadVcs(mode) + }) + + createEffect( + on( + () => sync.data.session_status[params.id ?? ""]?.type, + (next, prev) => { + const mode = vcsMode() + if (!mode) return + if (!wantsReview()) return + if (next !== "idle" || prev === undefined || prev === "idle") return + void loadVcs(mode, true) + }, + { defer: true }, + ), + ) const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) @@ -916,21 +1038,23 @@ export default function Page() { loadFile: file.load, }) - const changesOptions = ["session", "turn"] as const - const changesOptionsList = [...changesOptions] - const changesTitle = () => { - if (!hasReview()) { + if (!canReview()) { return null } + const label = (option: ChangeMode) => { + if (option === "git") return language.t("ui.sessionReview.title.git") + if (option === "branch") return language.t("ui.sessionReview.title.branch") + if (option === "session") return language.t("ui.sessionReview.title") + return language.t("ui.sessionReview.title.lastTurn") + } + return ( + option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn") + } onSelect={(option) => option && setStore("changes", option)} variant="ghost" size="small" @@ -1070,34 +939,20 @@ export default function Page() { ) } - const empty = (text: string) => ( + const emptyTurn = () => (
-
{text}
+
{language.t("session.review.noChanges")}
) - const reviewEmptyText = createMemo(() => { - if (store.changes === "git") return language.t("session.review.noUncommittedChanges") - if (store.changes === "branch") return language.t("session.review.noBranchChanges") - if (store.changes === "turn") return language.t("session.review.noChanges") - return language.t(sessionEmptyKey()) - }) - const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => { - if (store.changes === "git" || store.changes === "branch") { - if (!reviewReady()) return
{language.t("session.review.loadingChanges")}
- return empty(reviewEmptyText()) - } + if (store.changes === "turn") return emptyTurn() - if (store.changes === "turn") { - return empty(reviewEmptyText()) - } - - if (hasSessionReview() && !diffsReady()) { + if (hasReview() && !diffsReady()) { return
{language.t("session.review.loadingChanges")}
} - if (sessionEmptyKey() === "session.review.noVcs") { + if (reviewEmptyKey() === "session.review.noVcs") { return (
@@ -1117,7 +972,7 @@ export default function Page() { return (
-
{reviewEmptyText()}
+
{language.t(reviewEmptyKey())}
) } @@ -1165,18 +1020,6 @@ export default function Page() {
) - const mobileReview = () => - reviewContent({ - diffStyle: "unified", - classes: { - root: "pb-8", - header: "px-4", - container: "px-4", - }, - loadingClass: "px-4 py-4 text-text-weak", - emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", - }) - createEffect( on( activeFileTab, @@ -1233,7 +1076,7 @@ export default function Page() { const pending = tree.pendingDiff if (!pending) return if (!tree.reviewScroll) return - if (!reviewReady()) return + if (!diffsReady()) return const attempt = (count: number) => { if (tree.pendingDiff !== pending) return @@ -1810,7 +1653,7 @@ export default function Page() {
- + - }> -
{mobileReview()}
-
+
@@ -1958,12 +1808,6 @@ export default function Page() {
boolean - diffs: () => FileDiff[] - diffsReady: () => boolean - empty: () => string - hasReview: () => boolean - reviewCount: () => number reviewPanel: () => JSX.Element activeDiff?: string focusReviewDiff: (path: string) => void @@ -40,11 +34,12 @@ export function SessionSidePanel(props: { size: Sizing }) { const layout = useLayout() + const sync = useSync() const file = useFile() const language = useLanguage() const command = useCommand() const dialog = useDialog() - const { sessionKey, tabs, view } = useSessionLayout() + const { params, sessionKey, tabs, view } = useSessionLayout() const isDesktop = createMediaQuery("(min-width: 768px)") @@ -59,7 +54,24 @@ export function SessionSidePanel(props: { }) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) - const diffFiles = createMemo(() => props.diffs().map((d) => d.file)) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) + const hasReview = createMemo(() => reviewCount() > 0) + const diffsReady = createMemo(() => { + const id = params.id + if (!id) return true + if (!hasReview()) return true + return sync.data.session_diff[id] !== undefined + }) + + const reviewEmptyKey = createMemo(() => { + if (sync.project && !sync.project.vcs) return "session.review.noVcs" + if (sync.data.config.snapshot === false) return "session.review.noSnapshot" + return "session.review.noChanges" + }) + + const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { if (!a) return b @@ -70,7 +82,7 @@ export function SessionSidePanel(props: { const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const out = new Map() - for (const diff of props.diffs()) { + for (const diff of diffs()) { const file = normalize(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" @@ -124,7 +136,7 @@ export function SessionSidePanel(props: { pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, - hasReview: props.canReview, + hasReview, }) const contextOpen = tabState.contextOpen const openedTabs = tabState.openedTabs @@ -229,12 +241,12 @@ export function SessionSidePanel(props: { onCleanup(stop) }} > - +
{language.t("session.tab.review")}
- -
{props.reviewCount()}
+ +
{reviewCount()}
@@ -291,7 +303,7 @@ export function SessionSidePanel(props: {
- + {props.reviewPanel()} @@ -365,10 +377,8 @@ export function SessionSidePanel(props: { > - {props.reviewCount()}{" "} - {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", - )} + {reviewCount()}{" "} + {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")} {language.t("session.files.all")} @@ -376,9 +386,9 @@ export function SessionSidePanel(props: { - + {language.t("common.loading")} @@ -397,7 +407,11 @@ export function SessionSidePanel(props: { /> - {empty(props.empty())} + + {empty( + language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()), + )} + diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index f45374359a..1a2e777f52 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -56,7 +56,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => { if (!id) return return sync.session.get(id) } - const hasReview = () => !!params.id + const hasReview = () => { + const id = params.id + if (!id) return false + return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0 + } const normalizeTab = (tab: string) => { if (!tab.startsWith("file://")) return tab return file.tab(tab) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 1d8542133c..edd9d75610 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -30,7 +30,7 @@ import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "@/session/prompt" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util/process" -import { Git } from "@/git" +import { git } from "@/util/git" type GitHubAuthor = { login: string @@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({ } // Get repo info - const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim() + const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim() const parsed = parseGitHubRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) @@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await Git.run(args, { cwd: Instance.worktree }) + const result = await git(args, { cwd: Instance.worktree }) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { - const result = await Git.run(args, { cwd: Instance.worktree }) + const result = await git(args, { cwd: Instance.worktree }) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } - const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree }) + const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree }) const commitChanges = async (summary: string, actor?: string) => { const args = ["commit", "-m", summary] if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index a3a15afbcb..8826fe343e 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -2,7 +2,7 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { Instance } from "@/project/instance" import { Process } from "@/util/process" -import { Git } from "@/git" +import { git } from "@/util/git" export const PrCommand = cmd({ command: "pr ", @@ -67,9 +67,9 @@ export const PrCommand = cmd({ const remoteName = forkOwner // Check if remote already exists - const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim() + const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim() if (!remotes.split("\n").includes(remoteName)) { - await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { + await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { cwd: Instance.worktree, }) UI.println(`Added fork remote: ${remoteName}`) @@ -77,7 +77,7 @@ export const PrCommand = cmd({ // Set upstream to the fork so pushes go there const headRefName = prInfo.headRefName - await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { + await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { cwd: Instance.worktree, }) } diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 5ff1fc121c..c05458d5df 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -40,7 +40,7 @@ function lookup(_key: string) { Layer.fresh(PermissionNext.layer), Layer.fresh(ProviderAuth.defaultLayer), Layer.fresh(FileWatcher.layer).pipe(Layer.orDie), - Layer.fresh(Vcs.defaultLayer), + Layer.fresh(Vcs.layer), Layer.fresh(FileTime.layer).pipe(Layer.orDie), Layer.fresh(Format.layer), Layer.fresh(File.layer), diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 02209e7a03..f52203b222 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,7 +1,6 @@ import { Effect, Layer, ManagedRuntime } from "effect" import { AccountEffect } from "@/account/effect" import { AuthEffect } from "@/auth/effect" -import { GitEffect } from "@/git/effect" import { Instances } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances" import { TruncateEffect } from "@/tool/truncate-effect" @@ -10,7 +9,6 @@ import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( Layer.mergeAll( AccountEffect.defaultLayer, // - GitEffect.defaultLayer, TruncateEffect.defaultLayer, Instances.layer, ).pipe(Layer.provideMerge(AuthEffect.layer)), diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 79f8d9f7d9..6e9b917271 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,7 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" -import { Git } from "@/git" +import { git } from "@/util/git" import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" import { formatPatch, structuredPatch } from "diff" import fs from "fs" @@ -440,7 +440,7 @@ export namespace File { return yield* Effect.promise(async () => { const diffOutput = ( - await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { + await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { cwd: instance.directory, }) ).text() @@ -460,7 +460,7 @@ export namespace File { } const untrackedOutput = ( - await Git.run( + await git( [ "-c", "core.fsmonitor=false", @@ -493,7 +493,7 @@ export namespace File { } const deletedOutput = ( - await Git.run( + await git( [ "-c", "core.fsmonitor=false", @@ -584,17 +584,17 @@ export namespace File { if (instance.project.vcs === "git") { let diff = ( - await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory }) + await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory }) ).text() if (!diff.trim()) { diff = ( - await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { + await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory, }) ).text() } if (diff.trim()) { - const original = (await Git.run(["show", `HEAD:${file}`], { cwd: instance.directory })).text() + const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text() const patch = structuredPatch(file, file, original, content, "old", "new", { context: Infinity, ignoreWhitespace: true, diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index a689e63851..16ab3c6d33 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -10,7 +10,7 @@ import { BusEvent } from "@/bus/bus-event" import { InstanceContext } from "@/effect/instance-context" import { Flag } from "@/flag/flag" import { Instance } from "@/project/instance" -import { Git } from "@/git" +import { git } from "@/util/git" import { lazy } from "@/util/lazy" import { Config } from "../config/config" import { FileIgnore } from "./ignore" @@ -117,7 +117,7 @@ export namespace FileWatcher { if (instance.project.vcs === "git") { const result = yield* Effect.promise(() => - Git.run(["rev-parse", "--git-dir"], { + git(["rev-parse", "--git-dir"], { cwd: instance.project.worktree, }), ) diff --git a/packages/opencode/src/git/effect.ts b/packages/opencode/src/git/effect.ts deleted file mode 100644 index feff72b448..0000000000 --- a/packages/opencode/src/git/effect.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" -import { Effect, Layer, ServiceMap, Stream } from "effect" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" - -export namespace GitEffect { - const cfg = [ - "--no-optional-locks", - "-c", - "core.autocrlf=false", - "-c", - "core.fsmonitor=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ] as const - - function out(result: { text(): string }) { - return result.text().trim() - } - - function split(text: string) { - return text.split("\0").filter(Boolean) - } - - export type Kind = "added" | "deleted" | "modified" - - export type Base = { - readonly name: string - readonly ref: string - } - - export type Item = { - readonly file: string - readonly code: string - readonly status: Kind - } - - export type Stat = { - readonly file: string - readonly additions: number - readonly deletions: number - } - - export interface Result { - readonly exitCode: number - readonly text: () => string - readonly stdout: Buffer - readonly stderr: Buffer - } - - export interface Options { - readonly cwd: string - readonly env?: Record - } - - export interface Interface { - readonly run: (args: string[], opts: Options) => Effect.Effect - readonly text: (args: string[], opts: Options) => Effect.Effect - readonly lines: (args: string[], opts: Options) => Effect.Effect - readonly branch: (cwd: string) => Effect.Effect - readonly prefix: (cwd: string) => Effect.Effect - readonly defaultBranch: (cwd: string) => Effect.Effect - readonly hasHead: (cwd: string) => Effect.Effect - readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect - readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect - readonly status: (cwd: string) => Effect.Effect - readonly diff: (cwd: string, ref: string) => Effect.Effect - readonly stats: (cwd: string, ref: string) => Effect.Effect - } - - function kind(code: string | undefined): Kind { - if (code === "??") return "added" - if (code?.includes("U")) return "modified" - if (code?.includes("A") && !code.includes("D")) return "added" - if (code?.includes("D") && !code.includes("A")) return "deleted" - return "modified" - } - - function parseStatus(text: string) { - return split(text).flatMap((item) => { - const file = item.slice(3) - if (!file) return [] - const code = item.slice(0, 2) - return [{ file, code, status: kind(code) } satisfies Item] - }) - } - - function parseNames(text: string) { - const list = split(text) - const out: Item[] = [] - for (let i = 0; i < list.length; i += 2) { - const code = list[i] - const file = list[i + 1] - if (!code || !file) continue - out.push({ file, code, status: kind(code) }) - } - return out - } - - function parseStats(text: string) { - const out: Stat[] = [] - for (const item of split(text)) { - const a = item.indexOf("\t") - const b = item.indexOf("\t", a + 1) - if (a === -1 || b === -1) continue - const file = item.slice(b + 1) - if (!file) continue - const adds = item.slice(0, a) - const dels = item.slice(a + 1, b) - const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10) - const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10) - out.push({ - file, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - }) - } - return out - } - - export class Service extends ServiceMap.Service()("@opencode/Git") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - const run = Effect.fn("Git.run")( - function* (args: string[], opts: Options) { - const proc = ChildProcess.make("git", [...cfg, ...args], { - cwd: opts.cwd, - env: opts.env, - extendEnv: true, - }) - const handle = yield* spawner.spawn(proc) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - return { - exitCode: yield* handle.exitCode, - text: () => stdout, - stdout: Buffer.from(stdout), - stderr: Buffer.from(stderr), - } satisfies Result - }, - Effect.scoped, - Effect.catch((err) => - Effect.succeed({ - exitCode: ChildProcessSpawner.ExitCode(1), - text: () => "", - stdout: Buffer.alloc(0), - stderr: Buffer.from(String(err)), - }), - ), - ) - - const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) { - return (yield* run(args, opts)).text() - }) - - const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) { - return (yield* text(args, opts)) - .split(/\r?\n/) - .map((item) => item.trim()) - .filter(Boolean) - }) - - const refs = Effect.fnUntraced(function* (cwd: string) { - return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd }) - }) - - const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) { - const result = yield* run(["config", "init.defaultBranch"], { cwd }) - if (result.exitCode !== 0) return - const name = out(result) - if (!name || !list.includes(name)) return - const ref = yield* run(["rev-parse", "--verify", name], { cwd }) - if (ref.exitCode !== 0) return - return { name, ref: name } satisfies Base - }) - - const remoteHead = Effect.fnUntraced(function* (cwd: string, remote: string) { - const result = yield* run(["ls-remote", "--symref", remote, "HEAD"], { cwd }) - if (result.exitCode !== 0) return - for (const line of result.text().split("\n")) { - const match = /^ref: refs\/heads\/(.+)\tHEAD$/.exec(line.trim()) - if (!match?.[1]) continue - return { name: match[1], ref: `${remote}/${match[1]}` } satisfies Base - } - }) - - const primary = Effect.fnUntraced(function* (cwd: string) { - const list = yield* lines(["remote"], { cwd }) - if (list.includes("origin")) return "origin" - if (list.length === 1) return list[0] - if (list.includes("upstream")) return "upstream" - return list[0] - }) - - const branch = Effect.fn("Git.branch")(function* (cwd: string) { - const result = yield* run(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }) - if (result.exitCode !== 0) return - const text = out(result) - return text || undefined - }) - - const prefix = Effect.fn("Git.prefix")(function* (cwd: string) { - const result = yield* run(["rev-parse", "--show-prefix"], { cwd }) - if (result.exitCode !== 0) return "" - return out(result) - }) - - const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) { - const remote = yield* primary(cwd) - if (remote) { - const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd }) - if (head.exitCode === 0) { - const ref = out(head).replace(/^refs\/remotes\//, "") - const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : "" - if (name) return { name, ref } satisfies Base - } - - const next = yield* remoteHead(cwd, remote) - if (next) return next - } - - const list = yield* refs(cwd) - const next = yield* configured(cwd, list) - if (next) return next - for (const name of ["main", "master"]) { - if (list.includes(name)) return { name, ref: name } satisfies Base - } - }) - - const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) { - const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd }) - return result.exitCode === 0 - }) - - const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") { - const result = yield* run(["merge-base", base, head], { cwd }) - if (result.exitCode !== 0) return - const text = out(result) - return text || undefined - }) - - const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") { - const target = prefix ? `${prefix}${file}` : file - const result = yield* run(["show", `${ref}:${target}`], { cwd }) - if (result.exitCode !== 0) return "" - return result.text() - }) - - const status = Effect.fn("Git.status")(function* (cwd: string) { - return parseStatus( - yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], { cwd }), - ) - }) - - const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) { - return parseNames( - yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }), - ) - }) - - const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) { - return parseStats( - yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }), - ) - }) - - return Service.of({ - run, - text, - lines, - branch, - prefix, - defaultBranch, - hasHead, - mergeBase, - show, - status, - diff, - stats, - }) - }), - ) - - const platformLayer = NodeChildProcessSpawner.layer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), - ) - - export const defaultLayer = layer.pipe(Layer.provide(platformLayer)) -} diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts deleted file mode 100644 index 1b04353b10..0000000000 --- a/packages/opencode/src/git/index.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Effect } from "effect" -import { runtime } from "@/effect/runtime" -import { GitEffect } from "./effect" - -function runPromise(f: (service: GitEffect.Interface) => Effect.Effect): Promise { - return runtime.runPromise(GitEffect.Service.use(f)) -} - -export namespace Git { - export type Kind = GitEffect.Kind - export type Base = GitEffect.Base - export type Item = GitEffect.Item - export type Stat = GitEffect.Stat - export type Result = GitEffect.Result - export type Options = GitEffect.Options - - export function run(args: string[], opts: Options) { - return runPromise((git) => git.run(args, opts)) - } - - export function text(args: string[], opts: Options) { - return runPromise((git) => git.text(args, opts)) - } - - export function lines(args: string[], opts: Options) { - return runPromise((git) => git.lines(args, opts)) - } - - export function branch(cwd: string) { - return runPromise((git) => git.branch(cwd)) - } - - export function prefix(cwd: string) { - return runPromise((git) => git.prefix(cwd)) - } - - export function defaultBranch(cwd: string) { - return runPromise((git) => git.defaultBranch(cwd)) - } - - export function hasHead(cwd: string) { - return runPromise((git) => git.hasHead(cwd)) - } - - export function mergeBase(cwd: string, base: string, head?: string) { - return runPromise((git) => git.mergeBase(cwd, base, head)) - } - - export function show(cwd: string, ref: string, file: string, prefix?: string) { - return runPromise((git) => git.show(cwd, ref, file, prefix)) - } - - export function status(cwd: string) { - return runPromise((git) => git.status(cwd)) - } - - export function diff(cwd: string, ref: string) { - return runPromise((git) => git.diff(cwd, ref)) - } - - export function stats(cwd: string, ref: string) { - return runPromise((git) => git.stats(cwd, ref)) - } -} diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f2df9982e1..1cef41c85c 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -11,7 +11,7 @@ import { BusEvent } from "@/bus/bus-event" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" import { existsSync } from "fs" -import { Git } from "@/git" +import { git } from "../util/git" import { Glob } from "../util/glob" import { which } from "../util/which" import { ProjectID } from "./schema" @@ -119,7 +119,7 @@ export namespace Project { } } - const worktree = await Git.run(["rev-parse", "--git-common-dir"], { + const worktree = await git(["rev-parse", "--git-common-dir"], { cwd: sandbox, }) .then(async (result) => { @@ -147,7 +147,7 @@ export namespace Project { // generate id from root commit if (!id) { - const roots = await Git.run(["rev-list", "--max-parents=0", "HEAD"], { + const roots = await git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox, }) .then(async (result) => @@ -184,7 +184,7 @@ export namespace Project { } } - const top = await Git.run(["rev-parse", "--show-toplevel"], { + const top = await git(["rev-parse", "--show-toplevel"], { cwd: sandbox, }) .then(async (result) => gitpath(sandbox, await result.text())) @@ -349,7 +349,7 @@ export namespace Project { if (input.project.vcs === "git") return input.project if (!which("git")) throw new Error("Git is not installed") - const result = await Git.run(["init", "--quiet"], { + const result = await git(["init", "--quiet"], { cwd: input.directory, }) if (result.exitCode !== 0) { diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 7358ec4144..9e85571c49 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,122 +1,16 @@ -import { AppFileSystem } from "@/filesystem" import { Effect, Layer, ServiceMap } from "effect" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceContext } from "@/effect/instance-context" import { FileWatcher } from "@/file/watcher" -import { GitEffect } from "@/git/effect" -import { Snapshot } from "@/snapshot" import { Log } from "@/util/log" -import path from "path" +import { git } from "@/util/git" import { Instance } from "./instance" import z from "zod" -function count(text: string) { - if (!text) return 0 - if (!text.endsWith("\n")) return text.split("\n").length - return text.slice(0, -1).split("\n").length -} - -const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) { - const full = path.join(cwd, file) - if (!(yield* fs.exists(full).pipe(Effect.orDie))) return "" - const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) - if (Buffer.from(buf).includes(0)) return "" - return Buffer.from(buf).toString("utf8") -}) - -function stats(list: GitEffect.Stat[]) { - const out = new Map() - for (const item of list) { - out.set(item.file, { - additions: item.additions, - deletions: item.deletions, - }) - } - return out -} - -function merge(...lists: GitEffect.Item[][]) { - const out = new Map() - for (const list of lists) { - for (const item of list) { - if (!out.has(item.file)) out.set(item.file, item) - } - } - return [...out.values()] -} - -const files = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: GitEffect.Interface, - cwd: string, - ref: string | undefined, - list: GitEffect.Item[], - nums: Map, -) { - const base = ref ? yield* git.prefix(cwd) : "" - const next = yield* Effect.forEach( - list, - (item) => - Effect.gen(function* () { - const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base) - const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file) - const stat = nums.get(item.file) - return { - file: item.file, - before, - after, - additions: stat?.additions ?? (item.status === "added" ? count(after) : 0), - deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0), - status: item.status, - } satisfies Snapshot.FileDiff - }), - { concurrency: 8 }, - ) - return next.toSorted((a, b) => a.file.localeCompare(b.file)) -}) - -const track = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: GitEffect.Interface, - cwd: string, - ref: string | undefined, -) { - if (!ref) { - return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map()) - } - const [list, nums] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 }) - return yield* files(fs, git, cwd, ref, list, stats(nums)) -}) - -const compare = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: GitEffect.Interface, - cwd: string, - ref: string, -) { - const [list, nums, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { - concurrency: 3, - }) - return yield* files( - fs, - git, - cwd, - ref, - merge( - list, - extra.filter((item) => item.code === "??"), - ), - stats(nums), - ) -}) - export namespace Vcs { const log = Log.create({ service: "vcs" }) - export const Mode = z.enum(["git", "branch"]) - export type Mode = z.infer - export const Event = { BranchUpdated: BusEvent.define( "vcs.branch.updated", @@ -128,8 +22,7 @@ export namespace Vcs { export const Info = z .object({ - branch: z.string().optional(), - default_branch: z.string().optional(), + branch: z.string(), }) .meta({ ref: "VcsInfo", @@ -138,8 +31,6 @@ export namespace Vcs { export interface Interface { readonly branch: () => Effect.Effect - readonly defaultBranch: () => Effect.Effect - readonly diff: (mode: Mode) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/Vcs") {} @@ -148,18 +39,20 @@ export namespace Vcs { Service, Effect.gen(function* () { const instance = yield* InstanceContext - const fs = yield* AppFileSystem.Service - const git = yield* GitEffect.Service - let current: string | undefined - let root: GitEffect.Base | undefined + let currentBranch: string | undefined if (instance.project.vcs === "git") { - const get = () => Effect.runPromise(git.branch(instance.directory)) + const getCurrentBranch = async () => { + const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: instance.project.worktree, + }) + if (result.exitCode !== 0) return undefined + const text = result.text().trim() + return text || undefined + } - ;[current, root] = yield* Effect.all([git.branch(instance.directory), git.defaultBranch(instance.directory)], { - concurrency: 2, - }) - log.info("initialized", { branch: current, default_branch: root?.name }) + currentBranch = yield* Effect.promise(() => getCurrentBranch()) + log.info("initialized", { branch: currentBranch }) yield* Effect.acquireRelease( Effect.sync(() => @@ -167,11 +60,12 @@ export namespace Vcs { FileWatcher.Event.Updated, Instance.bind(async (evt) => { if (!evt.properties.file.endsWith("HEAD")) return - const next = await get() - if (next === current) return - log.info("branch changed", { from: current, to: next }) - current = next - Bus.publish(Event.BranchUpdated, { branch: next }) + const next = await getCurrentBranch() + if (next !== currentBranch) { + log.info("branch changed", { from: currentBranch, to: next }) + currentBranch = next + Bus.publish(Event.BranchUpdated, { branch: next }) + } }), ), ), @@ -181,30 +75,9 @@ export namespace Vcs { return Service.of({ branch: Effect.fn("Vcs.branch")(function* () { - return current - }), - defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () { - return root?.name - }), - diff: Effect.fn("Vcs.diff")(function* (mode: Mode) { - if (instance.project.vcs !== "git") return [] - if (mode === "git") { - const ok = yield* git.hasHead(instance.directory) - return yield* track(fs, git, instance.directory, ok ? "HEAD" : undefined) - } - - if (!root) return [] - if (current && current === root.name) return [] - const ref = yield* git.mergeBase(instance.directory, root.ref) - if (!ref) return [] - return yield* compare(fs, git, instance.directory, ref) + return currentBranch }), }) }), ) - - export const defaultLayer = layer.pipe( - Layer.provide(GitEffect.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - ) } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index b0872b5574..c485654fdf 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -41,7 +41,6 @@ import { websocket } from "hono/bun" import { HTTPException } from "hono/http-exception" import { errors } from "./error" import { Filesystem } from "@/util/filesystem" -import { Snapshot } from "@/snapshot" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" @@ -333,39 +332,10 @@ export namespace Server { }, }), async (c) => { - const [branch, default_branch] = await Promise.all([ - runPromiseInstance(Vcs.Service.use((s) => s.branch())), - runPromiseInstance(Vcs.Service.use((s) => s.defaultBranch())), - ]) - return c.json({ branch, default_branch }) - }, - ) - .get( - "/vcs/diff", - describeRoute({ - summary: "Get VCS diff", - description: "Retrieve the current git diff for the working tree or against the default branch.", - operationId: "vcs.diff", - responses: { - 200: { - description: "VCS diff", - content: { - "application/json": { - schema: resolver(Snapshot.FileDiff.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - mode: Vcs.Mode, - }), - ), - async (c) => { - const mode = c.req.valid("query").mode - return c.json(await runPromiseInstance(Vcs.Service.use((s) => s.diff(mode)))) + const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch())) + return c.json({ + branch, + }) }, ) .get( diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 99067594d3..a78607cdfd 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -8,7 +8,7 @@ import { Lock } from "../util/lock" import { NamedError } from "@opencode-ai/util/error" import z from "zod" import { Glob } from "../util/glob" -import { Git } from "@/git" +import { git } from "@/util/git" export namespace Storage { const log = Log.create({ service: "storage" }) @@ -49,7 +49,7 @@ export namespace Storage { } if (!worktree) continue if (!(await Filesystem.isDir(worktree))) continue - const result = await Git.run(["rev-list", "--max-parents=0", "--all"], { + const result = await git(["rev-list", "--max-parents=0", "--all"], { cwd: worktree, }) const [id] = result diff --git a/packages/opencode/src/util/git.ts b/packages/opencode/src/util/git.ts new file mode 100644 index 0000000000..731131357f --- /dev/null +++ b/packages/opencode/src/util/git.ts @@ -0,0 +1,35 @@ +import { Process } from "./process" + +export interface GitResult { + exitCode: number + text(): string + stdout: Buffer + stderr: Buffer +} + +/** + * Run a git command. + * + * Uses Process helpers with stdin ignored to avoid protocol pipe inheritance + * issues in embedded/client environments. + */ +export async function git(args: string[], opts: { cwd: string; env?: Record }): Promise { + return Process.run(["git", ...args], { + cwd: opts.cwd, + env: opts.env, + stdin: "ignore", + nothrow: true, + }) + .then((result) => ({ + exitCode: result.code, + text: () => result.stdout.toString(), + stdout: result.stdout, + stderr: result.stderr, + })) + .catch((error) => ({ + exitCode: 1, + text: () => "", + stdout: Buffer.alloc(0), + stderr: Buffer.from(error instanceof Error ? error.message : String(error)), + })) +} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index d1f52fff96..6ed0e48202 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -12,7 +12,7 @@ import type { ProjectID } from "../project/schema" import { fn } from "../util/fn" import { Log } from "../util/log" import { Process } from "../util/process" -import { Git } from "@/git" +import { git } from "../util/git" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" @@ -250,14 +250,14 @@ export namespace Worktree { } async function sweep(root: string) { - const first = await Git.run(["clean", "-ffdx"], { cwd: root }) + const first = await git(["clean", "-ffdx"], { cwd: root }) if (first.exitCode === 0) return first const entries = failed(first) if (!entries.length) return first await prune(root, entries) - return Git.run(["clean", "-ffdx"], { cwd: root }) + return git(["clean", "-ffdx"], { cwd: root }) } async function canonical(input: string) { @@ -276,7 +276,7 @@ export namespace Worktree { if (await exists(directory)) continue const ref = `refs/heads/${branch}` - const branchCheck = await Git.run(["show-ref", "--verify", "--quiet", ref], { + const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree, }) if (branchCheck.exitCode === 0) continue @@ -348,7 +348,7 @@ export namespace Worktree { } export async function createFromInfo(info: Info, startCommand?: string) { - const created = await Git.run(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { + const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { cwd: Instance.worktree, }) if (created.exitCode !== 0) { @@ -362,7 +362,7 @@ export namespace Worktree { return () => { const start = async () => { - const populated = await Git.run(["reset", "--hard"], { cwd: info.directory }) + const populated = await git(["reset", "--hard"], { cwd: info.directory }) if (populated.exitCode !== 0) { const message = errorText(populated) || "Failed to populate worktree" log.error("worktree checkout failed", { directory: info.directory, message }) @@ -479,10 +479,10 @@ export namespace Worktree { const stop = async (target: string) => { if (!(await exists(target))) return - await Git.run(["fsmonitor--daemon", "stop"], { cwd: target }) + await git(["fsmonitor--daemon", "stop"], { cwd: target }) } - const list = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) } @@ -499,11 +499,11 @@ export namespace Worktree { } await stop(entry.path) - const removed = await Git.run(["worktree", "remove", "--force", entry.path], { + const removed = await git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree, }) if (removed.exitCode !== 0) { - const next = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (next.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(removed) || errorText(next) || "Failed to remove git worktree", @@ -520,7 +520,7 @@ export namespace Worktree { const branch = entry.branch?.replace(/^refs\/heads\//, "") if (branch) { - const deleted = await Git.run(["branch", "-D", branch], { cwd: Instance.worktree }) + const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree }) if (deleted.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" }) } @@ -540,7 +540,7 @@ export namespace Worktree { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } - const list = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) + const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.exitCode !== 0) { throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" }) } @@ -573,7 +573,7 @@ export namespace Worktree { throw new ResetFailedError({ message: "Worktree not found" }) } - const remoteList = await Git.run(["remote"], { cwd: Instance.worktree }) + const remoteList = await git(["remote"], { cwd: Instance.worktree }) if (remoteList.exitCode !== 0) { throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" }) } @@ -592,17 +592,17 @@ export namespace Worktree { : "" const remoteHead = remote - ? await Git.run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree }) + ? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree }) : { exitCode: 1, stdout: undefined, stderr: undefined } const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : "" const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : "" const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : "" - const mainCheck = await Git.run(["show-ref", "--verify", "--quiet", "refs/heads/main"], { + const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree, }) - const masterCheck = await Git.run(["show-ref", "--verify", "--quiet", "refs/heads/master"], { + const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree, }) const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : "" @@ -613,7 +613,7 @@ export namespace Worktree { } if (remoteBranch) { - const fetch = await Git.run(["fetch", remote, remoteBranch], { cwd: Instance.worktree }) + const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree }) if (fetch.exitCode !== 0) { throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` }) } @@ -625,7 +625,7 @@ export namespace Worktree { const worktreePath = entry.path - const resetToTarget = await Git.run(["reset", "--hard", target], { cwd: worktreePath }) + const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath }) if (resetToTarget.exitCode !== 0) { throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" }) } @@ -635,26 +635,26 @@ export namespace Worktree { throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" }) } - const update = await Git.run(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath }) + const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath }) if (update.exitCode !== 0) { throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" }) } - const subReset = await Git.run(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], { + const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], { cwd: worktreePath, }) if (subReset.exitCode !== 0) { throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" }) } - const subClean = await Git.run(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], { + const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], { cwd: worktreePath, }) if (subClean.exitCode !== 0) { throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" }) } - const status = await Git.run(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) + const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) if (status.exitCode !== 0) { throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" }) } diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts deleted file mode 100644 index 3427d8eb5a..0000000000 --- a/packages/opencode/test/git/git.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { $ } from "bun" -import { describe, expect, test } from "bun:test" -import fs from "fs/promises" -import path from "path" -import { ManagedRuntime } from "effect" -import { GitEffect } from "../../src/git/effect" -import { tmpdir } from "../fixture/fixture" - -const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" - -async function withGit(fn: (rt: ManagedRuntime.ManagedRuntime) => Promise) { - const rt = ManagedRuntime.make(GitEffect.defaultLayer) - try { - return await fn(rt) - } finally { - await rt.dispose() - } -} - -describe("Git", () => { - test("branch() returns current branch name", async () => { - await using tmp = await tmpdir({ git: true }) - - await withGit(async (rt) => { - const branch = await rt.runPromise(GitEffect.Service.use((git) => git.branch(tmp.path))) - expect(branch).toBeDefined() - expect(typeof branch).toBe("string") - }) - }) - - test("branch() returns undefined for non-git directories", async () => { - await using tmp = await tmpdir() - - await withGit(async (rt) => { - const branch = await rt.runPromise(GitEffect.Service.use((git) => git.branch(tmp.path))) - expect(branch).toBeUndefined() - }) - }) - - test("defaultBranch() uses init.defaultBranch when available", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M trunk`.cwd(tmp.path).quiet() - await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() - - await withGit(async (rt) => { - const branch = await rt.runPromise(GitEffect.Service.use((git) => git.defaultBranch(tmp.path))) - expect(branch?.name).toBe("trunk") - expect(branch?.ref).toBe("trunk") - }) - }) - - test("status() handles special filenames", async () => { - await using tmp = await tmpdir({ git: true }) - const file = weird - await fs.writeFile(path.join(tmp.path, file), "hello\n", "utf-8") - - await withGit(async (rt) => { - const status = await rt.runPromise(GitEffect.Service.use((git) => git.status(tmp.path))) - expect(status).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file, - status: "added", - }), - ]), - ) - }) - }) - - test("diff(), stats(), and mergeBase() parse tracked changes", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - const file = weird - await fs.writeFile(path.join(tmp.path, file), "before\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await $`git checkout -b feature/test`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, file), "after\n", "utf-8") - - await withGit(async (rt) => { - const [base, diff, stats] = await Promise.all([ - rt.runPromise(GitEffect.Service.use((git) => git.mergeBase(tmp.path, "main"))), - rt.runPromise(GitEffect.Service.use((git) => git.diff(tmp.path, "HEAD"))), - rt.runPromise(GitEffect.Service.use((git) => git.stats(tmp.path, "HEAD"))), - ]) - - expect(base).toBeTruthy() - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file, - status: "modified", - }), - ]), - ) - expect(stats).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file, - additions: 1, - deletions: 1, - }), - ]), - ) - }) - }) -}) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index e6588fb38e..a71fe0528f 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -10,48 +10,45 @@ import { ProjectID } from "../../src/project/schema" Log.init({ print: false }) -const gitModule = await import("../../src/git") -const originalGit = gitModule.Git.run +const gitModule = await import("../../src/util/git") +const originalGit = gitModule.git type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail" let mode: Mode = "none" -mock.module("../../src/git", () => ({ - Git: { - ...gitModule.Git, - run: (args: string[], opts: { cwd: string; env?: Record }) => { - const cmd = ["git", ...args].join(" ") - if ( - mode === "rev-list-fail" && - cmd.includes("git rev-list") && - cmd.includes("--max-parents=0") && - cmd.includes("HEAD") - ) { - return Promise.resolve({ - exitCode: 128, - text: () => "", - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { - return Promise.resolve({ - exitCode: 128, - text: () => "", - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { - return Promise.resolve({ - exitCode: 128, - text: () => "", - stdout: Buffer.from(""), - stderr: Buffer.from("fatal"), - }) - } - return originalGit(args, opts) - }, +mock.module("../../src/util/git", () => ({ + git: (args: string[], opts: { cwd: string; env?: Record }) => { + const cmd = ["git", ...args].join(" ") + if ( + mode === "rev-list-fail" && + cmd.includes("git rev-list") && + cmd.includes("--max-parents=0") && + cmd.includes("HEAD") + ) { + return Promise.resolve({ + exitCode: 128, + text: () => Promise.resolve(""), + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) + } + if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { + return Promise.resolve({ + exitCode: 128, + text: () => Promise.resolve(""), + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) + } + if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { + return Promise.resolve({ + exitCode: 128, + text: () => Promise.resolve(""), + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) + } + return originalGit(args, opts) }, })) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index c0967993fd..90f445ed78 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -23,7 +23,7 @@ function withVcs( ) { return withServices( directory, - Layer.merge(FileWatcher.layer, Vcs.defaultLayer), + Layer.merge(FileWatcher.layer, Vcs.layer), async (rt) => { await rt.runPromise(FileWatcher.Service.use(() => Effect.void)) await rt.runPromise(Vcs.Service.use(() => Effect.void)) @@ -34,15 +34,7 @@ function withVcs( ) } -function withVcsOnly( - directory: string, - body: (rt: ManagedRuntime.ManagedRuntime) => Promise, -) { - return withServices(directory, Vcs.defaultLayer, body) -} - type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } } -const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" /** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */ function nextBranchUpdate(directory: string, timeout = 10_000) { @@ -129,104 +121,3 @@ describeVcs("Vcs", () => { }) }) }) - -describe("Vcs diff", () => { - afterEach(() => Instance.disposeAll()) - - test("defaultBranch() falls back to main", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async (rt) => { - const branch = await rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())) - expect(branch).toBe("main") - }) - }) - - test("defaultBranch() uses init.defaultBranch when available", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M trunk`.cwd(tmp.path).quiet() - await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async (rt) => { - const branch = await rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())) - expect(branch).toBe("trunk") - }) - }) - - test("detects current branch from the active worktree", async () => { - await using tmp = await tmpdir({ git: true }) - await using wt = await tmpdir() - await $`git branch -M main`.cwd(tmp.path).quiet() - const dir = path.join(wt.path, "feature") - await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet() - - await withVcsOnly(dir, async (rt) => { - const [branch, base] = await Promise.all([ - rt.runPromise(Vcs.Service.use((s) => s.branch())), - rt.runPromise(Vcs.Service.use((s) => s.defaultBranch())), - ]) - expect(branch).toBe("feature/test") - expect(base).toBe("main") - }) - }) - - test("diff('git') returns uncommitted changes", async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8") - - await withVcsOnly(tmp.path, async (rt) => { - const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("git"))) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: "file.txt", - status: "modified", - }), - ]), - ) - }) - }) - - test("diff('git') handles special filenames", async () => { - await using tmp = await tmpdir({ git: true }) - const file = weird - await fs.writeFile(path.join(tmp.path, file), "hello\n", "utf-8") - - await withVcsOnly(tmp.path, async (rt) => { - const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("git"))) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file, - status: "added", - }), - ]), - ) - }) - }) - - test("diff('branch') returns changes against default branch", async () => { - await using tmp = await tmpdir({ git: true }) - await $`git branch -M main`.cwd(tmp.path).quiet() - await $`git checkout -b feature/test`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet() - - await withVcsOnly(tmp.path, async (rt) => { - const diff = await rt.runPromise(Vcs.Service.use((s) => s.diff("branch"))) - expect(diff).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - file: "branch.txt", - status: "added", - }), - ]), - ) - }) - }) -}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6ec934e6b8..aa759bb1e0 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -172,7 +172,6 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, - VcsDiffResponses, VcsGetResponses, WorktreeCreateErrors, WorktreeCreateInput, @@ -3662,38 +3661,6 @@ export class Vcs extends HeyApiClient { ...params, }) } - - /** - * Get VCS diff - * - * Retrieve the current git diff for the working tree or against the default branch. - */ - public diff( - parameters: { - directory?: string - workspace?: string - mode: "git" | "branch" - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "mode" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/vcs/diff", - ...options, - ...params, - }) - } } export class Command extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 942e1c3e13..41aa248171 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1889,8 +1889,7 @@ export type Path = { } export type VcsInfo = { - branch?: string - default_branch?: string + branch: string } export type Command = { @@ -4834,26 +4833,6 @@ export type VcsGetResponses = { export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] -export type VcsDiffData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - mode: "git" | "branch" - } - url: "/vcs/diff" -} - -export type VcsDiffResponses = { - /** - * VCS diff - */ - 200: Array -} - -export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] - export type CommandListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 41819ef120..350395423f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6576,59 +6576,6 @@ ] } }, - "/vcs/diff": { - "get": { - "operationId": "vcs.diff", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "mode", - "schema": { - "type": "string", - "enum": ["git", "branch"] - }, - "required": true - } - ], - "summary": "Get VCS diff", - "description": "Retrieve the current git diff for the working tree or against the default branch.", - "responses": { - "200": { - "description": "VCS diff", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileDiff" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff({\n ...\n})" - } - ] - } - }, "/command": { "get": { "operationId": "command.list", @@ -11981,11 +11928,9 @@ "properties": { "branch": { "type": "string" - }, - "default_branch": { - "type": "string" } - } + }, + "required": ["branch"] }, "Command": { "type": "object", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index d9f724fce1..18823aeaa1 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -1,7 +1,5 @@ export const dict: Record = { "ui.sessionReview.title": "Session changes", - "ui.sessionReview.title.git": "Git changes", - "ui.sessionReview.title.branch": "Branch changes", "ui.sessionReview.title.lastTurn": "Last turn changes", "ui.sessionReview.diffStyle.unified": "Unified", "ui.sessionReview.diffStyle.split": "Split", From baa204193c02f1a4699ea2790df19b48e40440db Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 19 Mar 2026 16:16:29 +0000 Subject: [PATCH 09/48] chore: generate --- packages/app/e2e/projects/projects-switch.spec.ts | 10 +++++++++- packages/app/src/pages/layout.tsx | 6 ++++-- packages/app/src/pages/layout/helpers.ts | 6 ++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 1416aec726..e9cbf868df 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,7 +1,15 @@ import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitDir, waitSlug } from "../actions" +import { + defocus, + createTestProject, + cleanupTestProject, + openSidebar, + sessionIDFromUrl, + waitDir, + waitSlug, +} from "../actions" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { dirSlug, resolveDirectory } from "../utils" diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index cca14fd50c..52ac7c5f37 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -633,7 +633,8 @@ export default function Layout(props: ParentProps) { if (!expanded) continue const key = workspaceKey(directory) const project = projects.find( - (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + (item) => + workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), ) if (!project) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue @@ -1163,7 +1164,8 @@ export default function Layout(props: ParentProps) { const project = layout.projects .list() .find( - (item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + (item) => + workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), ) if (project) return project.worktree diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 886ffd26a1..209cff8a7c 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -31,11 +31,13 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) +const roots = (store: SessionStore) => + (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) -export const latestRootSession = (stores: SessionStore[], now: number) => stores.flatMap(roots).sort(sortSessions(now))[0] +export const latestRootSession = (stores: SessionStore[], now: number) => + stores.flatMap(roots).sort(sortSessions(now))[0] export function hasProjectPermissions( request: Record, From 05407518973a494785b85259ad4ec29abf4f8158 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 19 Mar 2026 13:51:14 -0400 Subject: [PATCH 10/48] fix(core): use a queue to process events in event routes (#18259) --- packages/opencode/src/bus/index.ts | 4 +- packages/opencode/src/server/routes/event.ts | 85 +++++++++++++++++++ packages/opencode/src/server/routes/global.ts | 54 +++++++----- packages/opencode/src/server/server.ts | 63 +------------- 4 files changed, 123 insertions(+), 83 deletions(-) create mode 100644 packages/opencode/src/server/routes/event.ts diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index edb093f197..625f296622 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -51,8 +51,8 @@ export namespace Bus { }) const pending = [] for (const key of [def.type, "*"]) { - const match = state().subscriptions.get(key) - for (const sub of match ?? []) { + const match = [...(state().subscriptions.get(key) ?? [])] + for (const sub of match) { pending.push(sub(payload)) } } diff --git a/packages/opencode/src/server/routes/event.ts b/packages/opencode/src/server/routes/event.ts new file mode 100644 index 0000000000..f34ff05667 --- /dev/null +++ b/packages/opencode/src/server/routes/event.ts @@ -0,0 +1,85 @@ +import { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import { streamSSE } from "hono/streaming" +import { Log } from "@/util/log" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { lazy } from "../../util/lazy" +import { AsyncQueue } from "../../util/queue" +import { Instance } from "@/project/instance" + +const log = Log.create({ service: "server" }) + +export const EventRoutes = lazy(() => + new Hono().get( + "/event", + describeRoute({ + summary: "Subscribe to events", + description: "Get events", + operationId: "event.subscribe", + responses: { + 200: { + description: "Event stream", + content: { + "text/event-stream": { + schema: resolver(BusEvent.payloads()), + }, + }, + }, + }, + }), + async (c) => { + log.info("event connected") + c.header("X-Accel-Buffering", "no") + c.header("X-Content-Type-Options", "nosniff") + return streamSSE(c, async (stream) => { + const q = new AsyncQueue() + let done = false + + q.push( + JSON.stringify({ + type: "server.connected", + properties: {}, + }), + ) + + // Send heartbeat every 10s to prevent stalled proxy streams. + const heartbeat = setInterval(() => { + q.push( + JSON.stringify({ + type: "server.heartbeat", + properties: {}, + }), + ) + }, 10_000) + + const unsub = Bus.subscribeAll((event) => { + q.push(JSON.stringify(event)) + if (event.type === Bus.InstanceDisposed.type) { + stop() + } + }) + + const stop = () => { + if (done) return + done = true + clearInterval(heartbeat) + unsub() + q.push(null) + log.info("event disconnected") + } + + stream.onAbort(stop) + + try { + for await (const data of q) { + if (data === null) return + await stream.writeSSE({ data }) + } + } finally { + stop() + } + }) + }, + ), +) diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 4d019f6a7e..4a6a3ebc7e 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -4,6 +4,7 @@ import { streamSSE } from "hono/streaming" import z from "zod" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" +import { AsyncQueue } from "@/util/queue" import { Instance } from "../../project/instance" import { Installation } from "@/installation" import { Log } from "../../util/log" @@ -69,41 +70,54 @@ export const GlobalRoutes = lazy(() => c.header("X-Accel-Buffering", "no") c.header("X-Content-Type-Options", "nosniff") return streamSSE(c, async (stream) => { - stream.writeSSE({ - data: JSON.stringify({ + const q = new AsyncQueue() + let done = false + + q.push( + JSON.stringify({ payload: { type: "server.connected", properties: {}, }, }), - }) - async function handler(event: any) { - await stream.writeSSE({ - data: JSON.stringify(event), - }) - } - GlobalBus.on("event", handler) + ) // Send heartbeat every 10s to prevent stalled proxy streams. const heartbeat = setInterval(() => { - stream.writeSSE({ - data: JSON.stringify({ + q.push( + JSON.stringify({ payload: { type: "server.heartbeat", properties: {}, }, }), - }) + ) }, 10_000) - await new Promise((resolve) => { - stream.onAbort(() => { - clearInterval(heartbeat) - GlobalBus.off("event", handler) - resolve() - log.info("global event disconnected") - }) - }) + async function handler(event: any) { + q.push(JSON.stringify(event)) + } + GlobalBus.on("event", handler) + + const stop = () => { + if (done) return + done = true + clearInterval(heartbeat) + GlobalBus.off("event", handler) + q.push(null) + log.info("event disconnected") + } + + stream.onAbort(stop) + + try { + for await (const data of q) { + if (data === null) return + await stream.writeSSE({ data }) + } + } finally { + stop() + } }) }, ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index c485654fdf..a68becb1fb 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,10 +1,7 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" import { Log } from "../util/log" import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi" import { Hono } from "hono" import { cors } from "hono/cors" -import { streamSSE } from "hono/streaming" import { proxy } from "hono/proxy" import { basicAuth } from "hono/basic-auth" import z from "zod" @@ -34,6 +31,7 @@ import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" import { ExperimentalRoutes } from "./routes/experimental" import { ProviderRoutes } from "./routes/provider" +import { EventRoutes } from "./routes/event" import { InstanceBootstrap } from "../project/bootstrap" import { NotFoundError } from "../storage/db" import type { ContentfulStatusCode } from "hono/utils/http-status" @@ -251,6 +249,7 @@ export namespace Server { .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) .route("/", FileRoutes()) + .route("/", EventRoutes()) .route("/mcp", McpRoutes()) .route("/tui", TuiRoutes()) .post( @@ -498,64 +497,6 @@ export namespace Server { return c.json(await Format.status()) }, ) - .get( - "/event", - describeRoute({ - summary: "Subscribe to events", - description: "Get events", - operationId: "event.subscribe", - responses: { - 200: { - description: "Event stream", - content: { - "text/event-stream": { - schema: resolver(BusEvent.payloads()), - }, - }, - }, - }, - }), - async (c) => { - log.info("event connected") - c.header("X-Accel-Buffering", "no") - c.header("X-Content-Type-Options", "nosniff") - return streamSSE(c, async (stream) => { - stream.writeSSE({ - data: JSON.stringify({ - type: "server.connected", - properties: {}, - }), - }) - const unsub = Bus.subscribeAll(async (event) => { - await stream.writeSSE({ - data: JSON.stringify(event), - }) - if (event.type === Bus.InstanceDisposed.type) { - stream.close() - } - }) - - // Send heartbeat every 10s to prevent stalled proxy streams. - const heartbeat = setInterval(() => { - stream.writeSSE({ - data: JSON.stringify({ - type: "server.heartbeat", - properties: {}, - }), - }) - }, 10_000) - - await new Promise((resolve) => { - stream.onAbort(() => { - clearInterval(heartbeat) - unsub() - resolve() - log.info("event disconnected") - }) - }) - }) - }, - ) .all("/*", async (c) => { const path = c.req.path From a6f23cb08ee451e3f38a161dc314e9fb92e73a39 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 19 Mar 2026 17:52:50 +0000 Subject: [PATCH 11/48] chore: generate --- packages/sdk/js/src/v2/gen/sdk.gen.ts | 74 +++++++++++----------- packages/sdk/js/src/v2/gen/types.gen.ts | 38 ++++++------ packages/sdk/openapi.json | 82 ++++++++++++------------- 3 files changed, 97 insertions(+), 97 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index aa759bb1e0..b6821322e2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2845,6 +2845,38 @@ export class File extends HeyApiClient { } } +export class Event extends HeyApiClient { + /** + * Subscribe to events + * + * Get events + */ + public subscribe( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).sse.get({ + url: "/event", + ...options, + ...params, + }) + } +} + export class Auth2 extends HeyApiClient { /** * Remove MCP OAuth @@ -3866,38 +3898,6 @@ export class Formatter extends HeyApiClient { } } -export class Event extends HeyApiClient { - /** - * Subscribe to events - * - * Get events - */ - public subscribe( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).sse.get({ - url: "/event", - ...options, - ...params, - }) - } -} - export class OpencodeClient extends HeyApiClient { public static readonly __registry = new HeyApiRegistry() @@ -3981,6 +3981,11 @@ export class OpencodeClient extends HeyApiClient { return (this._file ??= new File({ client: this.client })) } + private _event?: Event + get event(): Event { + return (this._event ??= new Event({ client: this.client })) + } + private _mcp?: Mcp get mcp(): Mcp { return (this._mcp ??= new Mcp({ client: this.client })) @@ -4025,9 +4030,4 @@ export class OpencodeClient extends HeyApiClient { get formatter(): Formatter { return (this._formatter ??= new Formatter({ client: this.client })) } - - private _event?: Event - get event(): Event { - return (this._event ??= new Event({ client: this.client })) - } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 41aa248171..ec797f2ba8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4229,6 +4229,25 @@ export type FileStatusResponses = { export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] +export type EventSubscribeData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/event" +} + +export type EventSubscribeResponses = { + /** + * Event stream + */ + 200: Event +} + +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] + export type McpStatusData = { body?: never path?: never @@ -4979,22 +4998,3 @@ export type FormatterStatusResponses = { } export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] - -export type EventSubscribeData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/event" -} - -export type EventSubscribeResponses = { - /** - * Event stream - */ - 200: Event -} - -export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 350395423f..fa894045e1 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5243,6 +5243,47 @@ ] } }, + "/event": { + "get": { + "operationId": "event.subscribe", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Subscribe to events", + "description": "Get events", + "responses": { + "200": { + "description": "Event stream", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" + } + ] + } + }, "/mcp": { "get": { "operationId": "mcp.status", @@ -6894,47 +6935,6 @@ } ] } - }, - "/event": { - "get": { - "operationId": "event.subscribe", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Subscribe to events", - "description": "Get events", - "responses": { - "200": { - "description": "Event stream", - "content": { - "text/event-stream": { - "schema": { - "$ref": "#/components/schemas/Event" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" - } - ] - } } }, "components": { From d69962b0f7ca54494452dd902053088f8113809d Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 19 Mar 2026 14:30:08 -0400 Subject: [PATCH 12/48] fix(core): disable chunk timeout by default (#18264) --- packages/opencode/src/provider/provider.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0255cf9bf1..f7667fc2cb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -47,8 +47,6 @@ import { ProviderTransform } from "./transform" import { Installation } from "../installation" import { ModelID, ProviderID } from "./schema" -const DEFAULT_CHUNK_TIMEOUT = 300_000 - export namespace Provider { const log = Log.create({ service: "provider" }) @@ -1130,7 +1128,7 @@ export namespace Provider { if (existing) return existing const customFetch = options["fetch"] - const chunkTimeout = options["chunkTimeout"] || DEFAULT_CHUNK_TIMEOUT + const chunkTimeout = options["chunkTimeout"] delete options["chunkTimeout"] options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { From 48a7f0fd932c9a7f02dfccaff03433348d5ed7cc Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 19 Mar 2026 15:38:54 -0400 Subject: [PATCH 13/48] Fix `base64Decode` import in workspaces.spec.ts (#18274) --- packages/app/e2e/projects/workspaces.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 8ee899f18e..297cdb9fc9 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" +import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" From 2dbcd79fd211513b9acfafac0e2963f26424ba78 Mon Sep 17 00:00:00 2001 From: jorge g Date: Thu, 19 Mar 2026 16:04:03 -0400 Subject: [PATCH 14/48] fix: stabilize agent and skill ordering in prompt descriptions (#18261) Co-authored-by: Aiden Cline Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/agent/agent.ts | 5 +- packages/opencode/src/skill/skill.ts | 2 +- packages/opencode/src/tool/task.ts | 7 ++- packages/opencode/test/agent/agent.test.ts | 26 ++++++++ packages/opencode/test/session/system.test.ts | 59 +++++++++++++++++++ packages/opencode/test/tool/skill.test.ts | 50 ++++++++++++++++ packages/opencode/test/tool/task.test.ts | 45 ++++++++++++++ 7 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/test/session/system.test.ts create mode 100644 packages/opencode/test/tool/task.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b2dae0402c..e30d05e935 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -260,7 +260,10 @@ export namespace Agent { return pipe( await state(), values(), - sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]), + sortBy( + [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"], + [(x) => x.name, "asc"], + ), ) } diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index d7aeb911f3..5339691a01 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -204,7 +204,7 @@ export namespace Skill { const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { yield* Effect.promise(() => state.ensure()) - const list = Object.values(state.skills) + const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name)) if (!agent) return list return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 14ecea1075..ad2b41f002 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -33,12 +33,13 @@ export const TaskTool = Tool.define("task", async (ctx) => { const accessibleAgents = caller ? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny") : agents + const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name)) const description = DESCRIPTION.replace( "{agents}", - accessibleAgents - .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) - .join("\n"), + list.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`).join( + "\n", + ), ) return { description, diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index d6b6ebb33b..60c8e57c92 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -384,6 +384,32 @@ test("multiple custom agents can be defined", async () => { }) }) +test("Agent.list keeps the default agent first and sorts the rest by name", async () => { + await using tmp = await tmpdir({ + config: { + default_agent: "plan", + agent: { + zebra: { + description: "Zebra", + mode: "subagent", + }, + alpha: { + description: "Alpha", + mode: "subagent", + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const names = (await Agent.list()).map((a) => a.name) + expect(names[0]).toBe("plan") + expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b))) + }, + }) +}) + test("Agent.get returns undefined for non-existent agent", async () => { await using tmp = await tmpdir() await Instance.provide({ diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts new file mode 100644 index 0000000000..47f5f6fc25 --- /dev/null +++ b/packages/opencode/test/session/system.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Agent } from "../../src/agent/agent" +import { Instance } from "../../src/project/instance" +import { SystemPrompt } from "../../src/session/system" +import { tmpdir } from "../fixture/fixture" + +describe("session.system", () => { + test("skills output is sorted by name and stable across calls", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (const [name, description] of [ + ["zeta-skill", "Zeta skill."], + ["alpha-skill", "Alpha skill."], + ["middle-skill", "Middle skill."], + ]) { + const skillDir = path.join(dir, ".opencode", "skill", name) + await Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: ${name} +description: ${description} +--- + +# ${name} +`, + ) + } + }, + }) + + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tmp.path + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + const first = await SystemPrompt.skills(build!) + const second = await SystemPrompt.skills(build!) + + expect(first).toBe(second) + + const alpha = first!.indexOf("alpha-skill") + const middle = first!.indexOf("middle-skill") + const zeta = first!.indexOf("zeta-skill") + + expect(alpha).toBeGreaterThan(-1) + expect(middle).toBeGreaterThan(alpha) + expect(zeta).toBeGreaterThan(middle) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = home + } + }) +}) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 7cfaee1353..f622341d33 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -54,6 +54,56 @@ description: Skill for tool tests. } }) + test("description sorts skills by name and is stable across calls", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (const [name, description] of [ + ["zeta-skill", "Zeta skill."], + ["alpha-skill", "Alpha skill."], + ["middle-skill", "Middle skill."], + ]) { + const skillDir = path.join(dir, ".opencode", "skill", name) + await Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: ${name} +description: ${description} +--- + +# ${name} +`, + ) + } + }, + }) + + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tmp.path + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const first = await SkillTool.init() + const second = await SkillTool.init() + + expect(first.description).toBe(second.description) + + const alpha = first.description.indexOf("**alpha-skill**: Alpha skill.") + const middle = first.description.indexOf("**middle-skill**: Middle skill.") + const zeta = first.description.indexOf("**zeta-skill**: Zeta skill.") + + expect(alpha).toBeGreaterThan(-1) + expect(middle).toBeGreaterThan(alpha) + expect(zeta).toBeGreaterThan(middle) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = home + } + }) + test("execute returns skill content block with files", async () => { await using tmp = await tmpdir({ git: true, diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts new file mode 100644 index 0000000000..df319d8de1 --- /dev/null +++ b/packages/opencode/test/tool/task.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { Agent } from "../../src/agent/agent" +import { Instance } from "../../src/project/instance" +import { TaskTool } from "../../src/tool/task" +import { tmpdir } from "../fixture/fixture" + +describe("tool.task", () => { + test("description sorts subagents by name and is stable across calls", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", + }, + }, + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + const first = await TaskTool.init({ agent: build }) + const second = await TaskTool.init({ agent: build }) + + expect(first.description).toBe(second.description) + + const alpha = first.description.indexOf("- alpha: Alpha agent") + const explore = first.description.indexOf("- explore:") + const general = first.description.indexOf("- general:") + const zebra = first.description.indexOf("- zebra: Zebra agent") + + expect(alpha).toBeGreaterThan(-1) + expect(explore).toBeGreaterThan(alpha) + expect(general).toBeGreaterThan(explore) + expect(zebra).toBeGreaterThan(general) + }, + }) + }) +}) From 4f21757e0db7eca318b24fd05c5e0fe3aad477b0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 19 Mar 2026 20:05:33 +0000 Subject: [PATCH 15/48] chore: generate --- packages/opencode/src/tool/task.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index ad2b41f002..9cabf47eb1 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -37,9 +37,9 @@ export const TaskTool = Tool.define("task", async (ctx) => { const description = DESCRIPTION.replace( "{agents}", - list.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`).join( - "\n", - ), + list + .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) + .join("\n"), ) return { description, From 6e09a1d9041880e5b4a55ed756c8ea9a51b94e0d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 16:18:28 -0400 Subject: [PATCH 16/48] fix(account): handle pending console login polling (#18281) --- packages/opencode/src/account/effect.ts | 8 +- .../opencode/test/account/service.test.ts | 85 ++++++++++++++++--- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts index 444676046e..2f1304d505 100644 --- a/packages/opencode/src/account/effect.ts +++ b/packages/opencode/src/account/effect.ts @@ -148,6 +148,12 @@ export namespace AccountEffect { mapAccountServiceError("HTTP request failed"), ) + const executeEffect = (request: Effect.Effect) => + request.pipe( + Effect.flatMap((req) => http.execute(req)), + mapAccountServiceError("HTTP request failed"), + ) + const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { const now = yield* Clock.currentTimeMillis if (row.token_expiry && row.token_expiry > now) return row.access_token @@ -290,7 +296,7 @@ export namespace AccountEffect { }) const poll = Effect.fn("Account.poll")(function* (input: Login) { - const response = yield* executeEffectOk( + const response = yield* executeEffect( HttpClientRequest.post(`${input.server}/auth/device/token`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.schemaBodyJson(DeviceTokenRequest)( diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 94cd9eb94d..f87eed64ec 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -34,6 +34,24 @@ const encodeOrg = Schema.encodeSync(Org) const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name })) +const login = () => + new Login({ + code: DeviceCode.make("device-code"), + user: UserCode.make("user-code"), + url: "https://one.example.com/verify", + server: "https://one.example.com", + expiry: Duration.seconds(600), + interval: Duration.seconds(5), + }) + +const deviceTokenClient = (body: unknown, status = 400) => + HttpClient.make((req) => + Effect.succeed(req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404)), + ) + +const poll = (body: unknown, status = 400) => + AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status)))) + it.effect("orgsByAccount groups orgs per account", () => Effect.gen(function* () { yield* AccountRepo.use((r) => @@ -172,15 +190,6 @@ it.effect("config sends the selected org header", () => it.effect("poll stores the account and first org on success", () => Effect.gen(function* () { - const login = new Login({ - code: DeviceCode.make("device-code"), - user: UserCode.make("user-code"), - url: "https://one.example.com/verify", - server: "https://one.example.com", - expiry: Duration.seconds(600), - interval: Duration.seconds(5), - }) - const client = HttpClient.make((req) => Effect.succeed( req.url === "https://one.example.com/auth/device/token" @@ -198,7 +207,7 @@ it.effect("poll stores the account and first org on success", () => ), ) - const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client))) + const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client))) expect(res._tag).toBe("PollSuccess") if (res._tag === "PollSuccess") { @@ -215,3 +224,59 @@ it.effect("poll stores the account and first org on success", () => ) }), ) + +for (const [name, body, expectedTag] of [ + [ + "pending", + { + error: "authorization_pending", + error_description: "The authorization request is still pending", + }, + "PollPending", + ], + [ + "slow", + { + error: "slow_down", + error_description: "Polling too frequently, please slow down", + }, + "PollSlow", + ], + [ + "denied", + { + error: "access_denied", + error_description: "The authorization request was denied", + }, + "PollDenied", + ], + [ + "expired", + { + error: "expired_token", + error_description: "The device code has expired", + }, + "PollExpired", + ], +] as const) { + it.effect(`poll returns ${name} for ${body.error}`, () => + Effect.gen(function* () { + const result = yield* poll(body) + expect(result._tag).toBe(expectedTag) + }), + ) +} + +it.effect("poll returns poll error for other OAuth errors", () => + Effect.gen(function* () { + const result = yield* poll({ + error: "server_error", + error_description: "An unexpected error occurred", + }) + + expect(result._tag).toBe("PollError") + if (result._tag === "PollError") { + expect(String(result.cause)).toContain("server_error") + } + }), +) From a6ef9e92065937dfeb9920abfd6e88bf95b1e572 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 19 Mar 2026 20:21:10 +0000 Subject: [PATCH 17/48] chore: generate --- packages/opencode/test/account/service.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index f87eed64ec..098e00de50 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -46,7 +46,9 @@ const login = () => const deviceTokenClient = (body: unknown, status = 400) => HttpClient.make((req) => - Effect.succeed(req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404)), + Effect.succeed( + req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404), + ), ) const poll = (body: unknown, status = 400) => From bd44489ada70cf908b69466f623ca74e800b3fc7 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 19 Mar 2026 18:44:21 -0400 Subject: [PATCH 18/48] go: upi payment --- infra/console.ts | 1 + packages/console/app/src/component/icon.tsx | 13 ++ packages/console/app/src/component/modal.css | 1 + .../console/app/src/routes/stripe/webhook.ts | 23 ++-- .../[id]/billing/billing-section.tsx | 5 +- .../[id]/billing/payment-section.tsx | 14 +- .../workspace/[id]/go/lite-section.module.css | 41 +++++- .../routes/workspace/[id]/go/lite-section.tsx | 127 +++++++++++++----- packages/console/core/src/billing.ts | 125 ++++++++++++----- packages/console/core/src/lite.ts | 1 + .../console/core/src/schema/billing.sql.ts | 1 + packages/console/core/sst-env.d.ts | 1 + packages/console/function/sst-env.d.ts | 1 + packages/console/resource/sst-env.d.ts | 1 + packages/enterprise/sst-env.d.ts | 1 + packages/function/sst-env.d.ts | 1 + sst-env.d.ts | 1 + 17 files changed, 278 insertions(+), 80 deletions(-) diff --git a/infra/console.ts b/infra/console.ts index 7b6f21001e..22652f2daa 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -122,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", { properties: { product: zenLiteProduct.id, price: zenLitePrice.id, + priceInr: 92900, firstMonth50Coupon: zenLiteCouponFirstMonth50.id, }, }) diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx index df7e067c28..0aaa302b3e 100644 --- a/packages/console/app/src/component/icon.tsx +++ b/packages/console/app/src/component/icon.tsx @@ -76,6 +76,19 @@ export function IconAlipay(props: JSX.SvgSVGAttributes) { ) } +export function IconUpi(props: JSX.SvgSVGAttributes) { + return ( + + + + + + ) +} + export function IconWechat(props: JSX.SvgSVGAttributes) { return ( diff --git a/packages/console/app/src/component/modal.css b/packages/console/app/src/component/modal.css index 1f47f395de..e71fd1a192 100644 --- a/packages/console/app/src/component/modal.css +++ b/packages/console/app/src/component/modal.css @@ -62,5 +62,6 @@ font-size: var(--font-size-lg); font-weight: 600; color: var(--color-text); + text-align: center; } } diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index 95cd9da21b..47fee05cf0 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -244,6 +244,7 @@ export async function POST(input: APIEvent) { customerID, enrichment: { type: productID === LiteData.productID() ? "lite" : "subscription", + currency: body.data.object.currency === "inr" ? "inr" : undefined, couponID, }, }), @@ -331,16 +332,17 @@ export async function POST(input: APIEvent) { ) if (!workspaceID) throw new Error("Workspace ID not found") - const amount = await Database.use((tx) => + const payment = await Database.use((tx) => tx .select({ amount: PaymentTable.amount, + enrichment: PaymentTable.enrichment, }) .from(PaymentTable) .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID))) - .then((rows) => rows[0]?.amount), + .then((rows) => rows[0]), ) - if (!amount) throw new Error("Payment not found") + if (!payment) throw new Error("Payment not found") await Database.transaction(async (tx) => { await tx @@ -350,12 +352,15 @@ export async function POST(input: APIEvent) { }) .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID))) - await tx - .update(BillingTable) - .set({ - balance: sql`${BillingTable.balance} - ${amount}`, - }) - .where(eq(BillingTable.workspaceID, workspaceID)) + // deduct balance only for top up + if (!payment.enrichment?.type) { + await tx + .update(BillingTable) + .set({ + balance: sql`${BillingTable.balance} - ${payment.amount}`, + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + } }) } })() diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx index 50e30585bd..4d9b0cabd5 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx @@ -3,7 +3,7 @@ import { createMemo, Match, Show, Switch, createEffect } from "solid-js" import { createStore } from "solid-js/store" import { Billing } from "@opencode-ai/console-core/billing.js" import { withActor } from "~/context/auth.withActor" -import { IconAlipay, IconCreditCard, IconStripe, IconWechat } from "~/component/icon" +import { IconAlipay, IconCreditCard, IconStripe, IconUpi, IconWechat } from "~/component/icon" import styles from "./billing-section.module.css" import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common" import { useI18n } from "~/context/i18n" @@ -211,6 +211,9 @@ export function BillingSection() { + + +
diff --git a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx index 2311be3215..6da5c42ed0 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx @@ -6,6 +6,14 @@ import { formatDateUTC, formatDateForTable } from "../../common" import styles from "./payment-section.module.css" import { useI18n } from "~/context/i18n" +function money(amount: number, currency?: string) { + const formatter = + currency === "inr" + ? new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" }) + : new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }) + return formatter.format(amount / 100_000_000) +} + const getPaymentsInfo = query(async (workspaceID: string) => { "use server" return withActor(async () => { @@ -81,6 +89,10 @@ export function PaymentSection() { const date = new Date(payment.timeCreated) const amount = payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount + const currency = + payment.enrichment?.type === "subscription" || payment.enrichment?.type === "lite" + ? payment.enrichment.currency + : undefined return ( @@ -88,7 +100,7 @@ export function PaymentSection() { {payment.id} - ${((amount ?? 0) / 100000000).toFixed(2)} + {money(amount, currency)} {" "} diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css index a760753d04..05daf43b7a 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css @@ -188,8 +188,45 @@ line-height: 1.4; } - [data-slot="subscribe-button"] { - align-self: flex-start; + [data-slot="subscribe-actions"] { + display: flex; + align-items: center; + gap: var(--space-4); margin-top: var(--space-4); } + + [data-slot="subscribe-button"] { + align-self: stretch; + } + + [data-slot="other-methods"] { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + } + + [data-slot="other-methods-icons"] { + display: inline-flex; + align-items: center; + gap: 4px; + } + + [data-slot="modal-actions"] { + display: flex; + gap: var(--space-3); + margin-top: var(--space-4); + + button { + flex: 1; + } + } + + [data-slot="method-button"] { + display: flex; + align-items: center; + justify-content: flex-start; + gap: var(--space-2); + height: 48px; + } } diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index ccdda5b450..4a64eb1b24 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -1,6 +1,7 @@ import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router" import { createStore } from "solid-js/store" import { createMemo, For, Show } from "solid-js" +import { Modal } from "~/component/modal" import { Billing } from "@opencode-ai/console-core/billing.js" import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js" import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js" @@ -14,6 +15,8 @@ import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import { formError } from "~/lib/form-error" +import { IconAlipay, IconUpi } from "~/component/icon" + const queryLiteSubscription = query(async (workspaceID: string) => { "use server" return withActor(async () => { @@ -78,22 +81,25 @@ function formatResetTime(seconds: number, i18n: ReturnType) { return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}` } -const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => { - "use server" - return json( - await withActor( - () => - Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl }) - .then((data) => ({ error: undefined, data })) - .catch((e) => ({ - error: e.message as string, - data: undefined, - })), - workspaceID, - ), - { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] }, - ) -}, "liteCheckoutUrl") +const createLiteCheckoutUrl = action( + async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => { + "use server" + return json( + await withActor( + () => + Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method }) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ + error: e.message as string, + data: undefined, + })), + workspaceID, + ), + { revalidate: [queryBillingInfo.key, queryLiteSubscription.key] }, + ) + }, + "liteCheckoutUrl", +) const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => { "use server" @@ -147,23 +153,30 @@ export function LiteSection() { const checkoutSubmission = useSubmission(createLiteCheckoutUrl) const useBalanceSubmission = useSubmission(setLiteUseBalance) const [store, setStore] = createStore({ - redirecting: false, + loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi", + showModal: false, }) + const busy = createMemo(() => !!store.loading) + async function onClickSession() { + setStore("loading", "session") const result = await sessionAction(params.id!, window.location.href) if (result.data) { - setStore("redirecting", true) window.location.href = result.data + return } + setStore("loading", undefined) } - async function onClickSubscribe() { - const result = await checkoutAction(params.id!, window.location.href, window.location.href) + async function onClickSubscribe(method?: "alipay" | "upi") { + setStore("loading", method ?? "checkout") + const result = await checkoutAction(params.id!, window.location.href, window.location.href, method) if (result.data) { - setStore("redirecting", true) window.location.href = result.data + return } + setStore("loading", undefined) } return ( @@ -179,12 +192,8 @@ export function LiteSection() {

{i18n.t("workspace.lite.subscription.message")}

- @@ -282,16 +291,60 @@ export function LiteSection() {
  • MiniMax M2.7
  • {i18n.t("workspace.lite.promo.footer")}

    - +
    + + +
    + setStore("showModal", false)} title="Select payment method"> +
    + + +
    +
    diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index ee41652ef2..66b9806985 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -239,10 +239,11 @@ export namespace Billing { z.object({ successUrl: z.string(), cancelUrl: z.string(), + method: z.enum(["alipay", "upi"]).optional(), }), async (input) => { const user = Actor.assert("user") - const { successUrl, cancelUrl } = input + const { successUrl, cancelUrl, method } = input const email = await User.getAuthEmail(user.properties.userID) const billing = await Billing.get() @@ -250,38 +251,102 @@ export namespace Billing { if (billing.subscriptionID) throw new Error("Already subscribed to Black") if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite") - const session = await Billing.stripe().checkout.sessions.create({ - mode: "subscription", - billing_address_collection: "required", - line_items: [{ price: LiteData.priceID(), quantity: 1 }], - discounts: [{ coupon: LiteData.firstMonth50Coupon() }], - ...(billing.customerID - ? { - customer: billing.customerID, - customer_update: { - name: "auto", - address: "auto", - }, + const createSession = () => + Billing.stripe().checkout.sessions.create({ + mode: "subscription", + discounts: [{ coupon: LiteData.firstMonth50Coupon() }], + ...(billing.customerID + ? { + customer: billing.customerID, + customer_update: { + name: "auto", + address: "auto", + }, + } + : { + customer_email: email!, + }), + ...(() => { + if (method === "alipay") { + return { + line_items: [{ price: LiteData.priceID(), quantity: 1 }], + payment_method_types: ["alipay"], + adaptive_pricing: { + enabled: false, + }, + } } - : { - customer_email: email!, - }), - currency: "usd", - tax_id_collection: { - enabled: true, - }, - success_url: successUrl, - cancel_url: cancelUrl, - subscription_data: { - metadata: { - workspaceID: Actor.workspace(), - userID: user.properties.userID, - type: "lite", + if (method === "upi") { + return { + line_items: [ + { + price_data: { + currency: "inr", + product: LiteData.productID(), + recurring: { + interval: "month", + interval_count: 1, + }, + unit_amount: LiteData.priceInr(), + }, + quantity: 1, + }, + ], + payment_method_types: ["upi"] as any, + adaptive_pricing: { + enabled: false, + }, + } + } + return { + line_items: [{ price: LiteData.priceID(), quantity: 1 }], + billing_address_collection: "required", + } + })(), + tax_id_collection: { + enabled: true, }, - }, - }) + success_url: successUrl, + cancel_url: cancelUrl, + subscription_data: { + metadata: { + workspaceID: Actor.workspace(), + userID: user.properties.userID, + type: "lite", + }, + }, + }) - return session.url + try { + const session = await createSession() + return session.url + } catch (e: any) { + if ( + e.type !== "StripeInvalidRequestError" || + !e.message.includes("You cannot combine currencies on a single customer") + ) + throw e + + // get pending payment intent + const intents = await Billing.stripe().paymentIntents.search({ + query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`, + }) + if (intents.data.length === 0) throw e + + for (const intent of intents.data) { + // get checkout session + const sessions = await Billing.stripe().checkout.sessions.list({ + customer: billing.customerID!, + payment_intent: intent.id, + }) + + // delete pending payment intent + await Billing.stripe().checkout.sessions.expire(sessions.data[0].id) + } + + const session = await createSession() + return session.url + } }, ) diff --git a/packages/console/core/src/lite.ts b/packages/console/core/src/lite.ts index 8c5b63d0c7..2c4a09f711 100644 --- a/packages/console/core/src/lite.ts +++ b/packages/console/core/src/lite.ts @@ -10,6 +10,7 @@ export namespace LiteData { export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product) export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price) + export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr) export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon) export const planName = fn(z.void(), () => "lite") } diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index a5c70c2115..b06ca8966d 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -88,6 +88,7 @@ export const PaymentTable = mysqlTable( enrichment: json("enrichment").$type< | { type: "subscription" | "lite" + currency?: "inr" couponID?: string } | { diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index 5e2693ad86..6b842639ad 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -145,6 +145,7 @@ declare module "sst" { "ZEN_LITE_PRICE": { "firstMonth50Coupon": string "price": string + "priceInr": number "product": string "type": "sst.sst.Linkable" } diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 5e2693ad86..6b842639ad 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -145,6 +145,7 @@ declare module "sst" { "ZEN_LITE_PRICE": { "firstMonth50Coupon": string "price": string + "priceInr": number "product": string "type": "sst.sst.Linkable" } diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 5e2693ad86..6b842639ad 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -145,6 +145,7 @@ declare module "sst" { "ZEN_LITE_PRICE": { "firstMonth50Coupon": string "price": string + "priceInr": number "product": string "type": "sst.sst.Linkable" } diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index 5e2693ad86..6b842639ad 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -145,6 +145,7 @@ declare module "sst" { "ZEN_LITE_PRICE": { "firstMonth50Coupon": string "price": string + "priceInr": number "product": string "type": "sst.sst.Linkable" } diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 5e2693ad86..6b842639ad 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -145,6 +145,7 @@ declare module "sst" { "ZEN_LITE_PRICE": { "firstMonth50Coupon": string "price": string + "priceInr": number "product": string "type": "sst.sst.Linkable" } diff --git a/sst-env.d.ts b/sst-env.d.ts index e6bcc7ab11..c9e567997b 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -171,6 +171,7 @@ declare module "sst" { "ZEN_LITE_PRICE": { "firstMonth50Coupon": string "price": string + "priceInr": number "product": string "type": "sst.sst.Linkable" } From 63585db6a7f312f900a7aabc29b4ccbbbf34fb21 Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 19 Mar 2026 18:50:40 -0400 Subject: [PATCH 19/48] refactor: replace Bun.sleep with node:timers/promises sleep (#18301) --- .../src/cli/cmd/tui/component/dialog-workspace-list.tsx | 3 ++- packages/opencode/src/control-plane/workspace.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx index b11ad6a734..09bb492f63 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx @@ -9,6 +9,7 @@ import { useToast } from "../ui/toast" import { useKeybind } from "../context/keybind" import { DialogSessionList } from "./workspace/dialog-session-list" import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { setTimeout as sleep } from "node:timers/promises" async function openWorkspace(input: { dialog: ReturnType @@ -56,7 +57,7 @@ async function openWorkspace(input: { return } if (result.response.status >= 500 && result.response.status < 600) { - await Bun.sleep(1000) + await sleep(1000) continue } if (!result.data) { diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index c3c28ed605..e5294844b1 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,4 +1,5 @@ import z from "zod" +import { setTimeout as sleep } from "node:timers/promises" import { fn } from "@/util/fn" import { Database, eq } from "@/storage/db" import { Project } from "@/project/project" @@ -117,7 +118,7 @@ export namespace Workspace { const adaptor = await getAdaptor(space.type) const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined) if (!res || !res.ok || !res.body) { - await Bun.sleep(1000) + await sleep(1000) continue } await parseSSE(res.body, stop, (event) => { @@ -127,7 +128,7 @@ export namespace Workspace { }) }) // Wait 250ms and retry if SSE connection fails - await Bun.sleep(250) + await sleep(250) } } From 9a58c43ef4de8952f2ba50da7b5f99fc39ad1a5f Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 19 Mar 2026 18:54:32 -0400 Subject: [PATCH 20/48] go: upi translation --- packages/console/app/src/i18n/ar.ts | 2 ++ packages/console/app/src/i18n/br.ts | 2 ++ packages/console/app/src/i18n/da.ts | 2 ++ packages/console/app/src/i18n/de.ts | 2 ++ packages/console/app/src/i18n/en.ts | 2 ++ packages/console/app/src/i18n/es.ts | 2 ++ packages/console/app/src/i18n/fr.ts | 2 ++ packages/console/app/src/i18n/it.ts | 2 ++ packages/console/app/src/i18n/ja.ts | 2 ++ packages/console/app/src/i18n/ko.ts | 2 ++ packages/console/app/src/i18n/no.ts | 2 ++ packages/console/app/src/i18n/pl.ts | 2 ++ packages/console/app/src/i18n/ru.ts | 2 ++ packages/console/app/src/i18n/th.ts | 2 ++ packages/console/app/src/i18n/tr.ts | 2 ++ packages/console/app/src/i18n/zh.ts | 2 ++ packages/console/app/src/i18n/zht.ts | 2 ++ .../app/src/routes/workspace/[id]/go/lite-section.tsx | 8 ++++++-- 18 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 2914ebbdd0..59658f2247 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -644,6 +644,8 @@ export const dict = { "تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.", "workspace.lite.promo.subscribe": "الاشتراك في Go", "workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...", + "workspace.lite.promo.otherMethods": "طرق دفع أخرى", + "workspace.lite.promo.selectMethod": "اختر طريقة الدفع", "download.title": "OpenCode | تنزيل", "download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 982bfb85bd..36edd3192b 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -654,6 +654,8 @@ export const dict = { "O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Preços e limites de uso podem mudar conforme aprendemos com o uso inicial e feedback.", "workspace.lite.promo.subscribe": "Assinar Go", "workspace.lite.promo.subscribing": "Redirecionando...", + "workspace.lite.promo.otherMethods": "Outros métodos de pagamento", + "workspace.lite.promo.selectMethod": "Selecionar método de pagamento", "download.title": "OpenCode | Baixar", "download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index a2dda137d0..1246f12135 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -651,6 +651,8 @@ export const dict = { "Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af tidlig brug og feedback.", "workspace.lite.promo.subscribe": "Abonner på Go", "workspace.lite.promo.subscribing": "Omdirigerer...", + "workspace.lite.promo.otherMethods": "Andre betalingsmetoder", + "workspace.lite.promo.selectMethod": "Vælg betalingsmetode", "download.title": "OpenCode | Download", "download.meta.description": "Download OpenCode til macOS, Windows og Linux", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 48d9e9cc97..bf26856e35 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -654,6 +654,8 @@ export const dict = { "Der Plan wurde hauptsächlich für internationale Nutzer entwickelt, wobei die Modelle in den USA, der EU und Singapur gehostet werden, um einen stabilen weltweiten Zugriff zu gewährleisten. Preise und Nutzungslimits können sich ändern, während wir aus der frühen Nutzung und dem Feedback lernen.", "workspace.lite.promo.subscribe": "Go abonnieren", "workspace.lite.promo.subscribing": "Leite weiter...", + "workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden", + "workspace.lite.promo.selectMethod": "Zahlungsmethode auswählen", "download.title": "OpenCode | Download", "download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index bd90f42a2d..212f5167a4 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -646,6 +646,8 @@ export const dict = { "The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Pricing and usage limits may change as we learn from early usage and feedback.", "workspace.lite.promo.subscribe": "Subscribe to Go", "workspace.lite.promo.subscribing": "Redirecting...", + "workspace.lite.promo.otherMethods": "Other payment methods", + "workspace.lite.promo.selectMethod": "Select payment method", "download.title": "OpenCode | Download", "download.meta.description": "Download OpenCode for macOS, Windows, and Linux", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 43a3e9d7f4..9299efb3d2 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -654,6 +654,8 @@ export const dict = { "El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable. Los precios y los límites de uso pueden cambiar a medida que aprendemos del uso inicial y los comentarios.", "workspace.lite.promo.subscribe": "Suscribirse a Go", "workspace.lite.promo.subscribing": "Redirigiendo...", + "workspace.lite.promo.otherMethods": "Otros métodos de pago", + "workspace.lite.promo.selectMethod": "Seleccionar método de pago", "download.title": "OpenCode | Descargar", "download.meta.description": "Descarga OpenCode para macOS, Windows y Linux", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 38b284c46a..d31226afe8 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -661,6 +661,8 @@ export const dict = { "Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Les tarifs et les limites d'utilisation peuvent changer à mesure que nous apprenons des premières utilisations et des commentaires.", "workspace.lite.promo.subscribe": "S'abonner à Go", "workspace.lite.promo.subscribing": "Redirection...", + "workspace.lite.promo.otherMethods": "Autres méthodes de paiement", + "workspace.lite.promo.selectMethod": "Sélectionner la méthode de paiement", "download.title": "OpenCode | Téléchargement", "download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index c4ee970530..3a99abe850 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -652,6 +652,8 @@ export const dict = { "Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati in US, EU e Singapore per un accesso globale stabile. I prezzi e i limiti di utilizzo potrebbero cambiare man mano che impariamo dall'utilizzo iniziale e dal feedback.", "workspace.lite.promo.subscribe": "Abbonati a Go", "workspace.lite.promo.subscribing": "Reindirizzamento...", + "workspace.lite.promo.otherMethods": "Altri metodi di pagamento", + "workspace.lite.promo.selectMethod": "Seleziona metodo di pagamento", "download.title": "OpenCode | Download", "download.meta.description": "Scarica OpenCode per macOS, Windows e Linux", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 0d506a0f36..9bdc0a5272 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -653,6 +653,8 @@ export const dict = { "このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。", "workspace.lite.promo.subscribe": "Goを購読する", "workspace.lite.promo.subscribing": "リダイレクト中...", + "workspace.lite.promo.otherMethods": "その他の支払い方法", + "workspace.lite.promo.selectMethod": "支払い方法を選択", "download.title": "OpenCode | ダウンロード", "download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 5e11a1af7d..7dd4bfbf23 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -645,6 +645,8 @@ export const dict = { "이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.", "workspace.lite.promo.subscribe": "Go 구독하기", "workspace.lite.promo.subscribing": "리디렉션 중...", + "workspace.lite.promo.otherMethods": "기타 결제 수단", + "workspace.lite.promo.selectMethod": "결제 수단 선택", "download.title": "OpenCode | 다운로드", "download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 282872a98d..334f3dfb05 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -651,6 +651,8 @@ export const dict = { "Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Priser og bruksgrenser kan endres etter hvert som vi lærer fra tidlig bruk og tilbakemeldinger.", "workspace.lite.promo.subscribe": "Abonner på Go", "workspace.lite.promo.subscribing": "Omdirigerer...", + "workspace.lite.promo.otherMethods": "Andre betalingsmetoder", + "workspace.lite.promo.selectMethod": "Velg betalingsmetode", "download.title": "OpenCode | Last ned", "download.meta.description": "Last ned OpenCode for macOS, Windows og Linux", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index d33cc4c8ce..9daa538cb9 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -652,6 +652,8 @@ export const dict = { "Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Ceny i limity użycia mogą ulec zmianie w miarę analizy wczesnego użycia i zbierania opinii.", "workspace.lite.promo.subscribe": "Subskrybuj Go", "workspace.lite.promo.subscribing": "Przekierowywanie...", + "workspace.lite.promo.otherMethods": "Inne metody płatności", + "workspace.lite.promo.selectMethod": "Wybierz metodę płatności", "download.title": "OpenCode | Pobierz", "download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 918ea8d4b7..58a4a0d91d 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -658,6 +658,8 @@ export const dict = { "План предназначен в первую очередь для международных пользователей. Модели размещены в США, ЕС и Сингапуре для стабильного глобального доступа. Цены и лимиты использования могут меняться по мере того, как мы изучаем раннее использование и собираем отзывы.", "workspace.lite.promo.subscribe": "Подписаться на Go", "workspace.lite.promo.subscribing": "Перенаправление...", + "workspace.lite.promo.otherMethods": "Другие способы оплаты", + "workspace.lite.promo.selectMethod": "Выберите способ оплаты", "download.title": "OpenCode | Скачать", "download.meta.description": "Скачать OpenCode для macOS, Windows и Linux", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index e6572bc8f0..75c8b53b30 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -648,6 +648,8 @@ export const dict = { "แผนนี้ออกแบบมาสำหรับผู้ใช้งานต่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์อยู่ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงที่เสถียรทั่วโลก ราคาและขีดจำกัดการใช้งานอาจมีการเปลี่ยนแปลงตามที่เราได้เรียนรู้จากการใช้งานในช่วงแรกและข้อเสนอแนะ", "workspace.lite.promo.subscribe": "สมัครสมาชิก Go", "workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...", + "workspace.lite.promo.otherMethods": "วิธีการชำระเงินอื่นๆ", + "workspace.lite.promo.selectMethod": "เลือกวิธีการชำระเงิน", "download.title": "OpenCode | ดาวน์โหลด", "download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 37863bf2f8..2d7ea4ea87 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -655,6 +655,8 @@ export const dict = { "Plan öncelikle uluslararası kullanıcılar için tasarlanmıştır; modeller istikrarlı küresel erişim için ABD, AB ve Singapur'da barındırılmaktadır. Erken kullanımdan öğrendikçe ve geri bildirim topladıkça fiyatlandırma ve kullanım limitleri değişebilir.", "workspace.lite.promo.subscribe": "Go'ya Abone Ol", "workspace.lite.promo.subscribing": "Yönlendiriliyor...", + "workspace.lite.promo.otherMethods": "Diğer ödeme yöntemleri", + "workspace.lite.promo.selectMethod": "Ödeme yöntemini seçin", "download.title": "OpenCode | İndir", "download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 6fcf96f1e3..2cf88a0f98 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -626,6 +626,8 @@ export const dict = { "该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保全球范围内的稳定访问体验。定价和使用额度可能会根据早期用户的使用情况和反馈持续调整与优化。", "workspace.lite.promo.subscribe": "订阅 Go", "workspace.lite.promo.subscribing": "正在重定向...", + "workspace.lite.promo.otherMethods": "其他付款方式", + "workspace.lite.promo.selectMethod": "选择付款方式", "download.title": "OpenCode | 下载", "download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index ec34af07db..a6155b7d4a 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -626,6 +626,8 @@ export const dict = { "該計畫主要面向國際用戶設計,模型部署在美國、歐盟和新加坡,以確保全球範圍內的穩定存取體驗。定價和使用額度可能會根據早期用戶的使用情況和回饋持續調整與優化。", "workspace.lite.promo.subscribe": "訂閱 Go", "workspace.lite.promo.subscribing": "重新導向中...", + "workspace.lite.promo.otherMethods": "其他付款方式", + "workspace.lite.promo.selectMethod": "選擇付款方式", "download.title": "OpenCode | 下載", "download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode", diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index 4a64eb1b24..2f8ad8aba4 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -308,7 +308,7 @@ export function LiteSection() { data-color="ghost" onClick={() => setStore("showModal", true)} > - Other payment methods + {i18n.t("workspace.lite.promo.otherMethods")} @@ -317,7 +317,11 @@ export function LiteSection() {
    - setStore("showModal", false)} title="Select payment method"> + setStore("showModal", false)} + title={i18n.t("workspace.lite.promo.selectMethod")} + >