From c4d223eb99c4f677ff9f540cbef1f71e8a502ac8 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 22 Jan 2026 22:09:18 -0600 Subject: [PATCH] perf(app): faster workspace creation --- packages/app/src/components/prompt-input.tsx | 113 +++++++++++++++--- packages/app/src/pages/layout.tsx | 48 ++++++-- packages/app/src/utils/worktree.ts | 58 +++++++++ packages/opencode/src/project/project.ts | 16 +++ packages/opencode/src/worktree/index.ts | 119 ++++++++++++++++--- packages/sdk/js/src/v2/gen/types.gen.ts | 17 +++ 6 files changed, 328 insertions(+), 43 deletions(-) create mode 100644 packages/app/src/utils/worktree.ts diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 72bc927fa..a5d0569ed 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -48,6 +48,7 @@ import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" import { Identifier } from "@/utils/id" +import { Worktree as WorktreeState } from "@/utils/worktree" import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" @@ -61,6 +62,13 @@ import { base64Encode } from "@opencode-ai/util/encode" const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] +type PendingPrompt = { + abort: AbortController + cleanup: VoidFunction +} + +const pending = new Map() + interface PromptInputProps { class?: string ref?: (el: HTMLDivElement) => void @@ -846,12 +854,22 @@ export const PromptInput: Component = (props) => { setStore("popover", null) } - const abort = () => - sdk.client.session + const abort = () => { + const sessionID = params.id + if (!sessionID) return Promise.resolve() + const queued = pending.get(sessionID) + if (queued) { + queued.abort.abort() + queued.cleanup() + pending.delete(sessionID) + return Promise.resolve() + } + return sdk.client.session .abort({ - sessionID: params.id!, + sessionID, }) .catch(() => {}) + } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const text = prompt @@ -1111,6 +1129,7 @@ export const PromptInput: Component = (props) => { }) return } + WorktreeState.pending(createdWorktree.directory) sessionDirectory = createdWorktree.directory } @@ -1409,20 +1428,16 @@ export const PromptInput: Component = (props) => { clearInput() addOptimisticMessage() - client.session - .prompt({ - sessionID: session.id, - agent, - model, - messageID, - parts: requestParts, - variant, - }) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.promptSendFailed.title"), - description: errorMessage(err), - }) + const waitForWorktree = async () => { + const worktree = WorktreeState.get(sessionDirectory) + if (!worktree || worktree.status !== "pending") return true + + setSyncStore("session_status", session.id, { type: "busy" }) + + const controller = new AbortController() + + const cleanup = () => { + setSyncStore("session_status", session.id, { type: "idle" }) removeOptimisticMessage() for (const item of commentItems) { prompt.context.add({ @@ -1435,7 +1450,71 @@ export const PromptInput: Component = (props) => { }) } restoreInput() + } + + pending.set(session.id, { abort: controller, cleanup }) + + const abort = new Promise>>((resolve) => { + if (controller.signal.aborted) { + resolve({ status: "failed", message: "aborted" }) + return + } + controller.signal.addEventListener( + "abort", + () => { + resolve({ status: "failed", message: "aborted" }) + }, + { once: true }, + ) }) + + const timeoutMs = 5 * 60 * 1000 + const timeout = new Promise>>((resolve) => { + setTimeout(() => { + resolve({ status: "failed", message: "Workspace is still preparing" }) + }, timeoutMs) + }) + + const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]) + pending.delete(session.id) + if (controller.signal.aborted) return false + if (result.status === "failed") throw new Error(result.message) + return true + } + + const send = async () => { + const ok = await waitForWorktree() + if (!ok) return + await client.session.prompt({ + sessionID: session.id, + agent, + model, + messageID, + parts: requestParts, + variant, + }) + } + + void send().catch((err) => { + pending.delete(session.id) + setSyncStore("session_status", session.id, { type: "idle" }) + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: errorMessage(err), + }) + removeOptimisticMessage() + for (const item of commentItems) { + prompt.context.add({ + type: "file", + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + preview: item.preview, + }) + } + restoreInput() + }) } return ( diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index cb78d9a9e..6f51c5faa 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -56,6 +56,7 @@ import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { playSound, soundSrc } from "@/utils/sound" +import { Worktree as WorktreeState } from "@/utils/worktree" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" @@ -332,6 +333,18 @@ export default function Layout(props: ParentProps) { const cooldownMs = 5000 const unsub = globalSDK.event.listen((e) => { + if (e.details?.type === "worktree.ready") { + setBusy(e.name, false) + WorktreeState.ready(e.name) + return + } + + if (e.details?.type === "worktree.failed") { + setBusy(e.name, false) + WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed")) + return + } + if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return const title = e.details.type === "permission.asked" @@ -551,6 +564,7 @@ export default function Layout(props: ParentProps) { const project = currentProject() if (!project) return + const local = project.worktree const dirs = [project.worktree, ...(project.sandboxes ?? [])] const existing = store.workspaceOrder[project.worktree] if (!existing) { @@ -558,9 +572,9 @@ export default function Layout(props: ParentProps) { return } - const keep = existing.filter((d) => dirs.includes(d)) - const missing = dirs.filter((d) => !existing.includes(d)) - const merged = [...keep, ...missing] + const keep = existing.filter((d) => d !== local && dirs.includes(d)) + const missing = dirs.filter((d) => d !== local && !existing.includes(d)) + const merged = [local, ...missing, ...keep] if (merged.length !== existing.length) { setStore("workspaceOrder", project.worktree, merged) @@ -1434,17 +1448,22 @@ export default function Layout(props: ParentProps) { function workspaceIds(project: LocalProject | undefined) { if (!project) return [] - const dirs = [project.worktree, ...(project.sandboxes ?? [])] + const local = project.worktree + const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() const directory = active?.worktree === project.worktree && params.dir ? base64Decode(params.dir) : undefined - const next = directory && directory !== project.worktree && !dirs.includes(directory) ? [...dirs, directory] : dirs + const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined + const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const existing = store.workspaceOrder[project.worktree] - if (!existing) return next + if (!existing) return extra ? [...dirs, extra] : dirs - const keep = existing.filter((d) => next.includes(d)) - const missing = next.filter((d) => !existing.includes(d)) - return [...keep, ...missing] + const keep = existing.filter((d) => d !== local && dirs.includes(d)) + const missing = dirs.filter((d) => d !== local && !existing.includes(d)) + const merged = [local, ...(pending && extra ? [extra] : []), ...missing, ...keep] + if (!extra) return merged + if (pending) return merged + return [...merged, extra] } function handleWorkspaceDragStart(event: unknown) { @@ -2237,8 +2256,19 @@ export default function Layout(props: ParentProps) { if (!created?.directory) return + setBusy(created.directory, true) + WorktreeState.pending(created.directory) + setStore("workspaceExpanded", created.directory, true) + setStore("workspaceOrder", current.worktree, (prev) => { + const existing = prev ?? [] + const local = current.worktree + const next = existing.filter((d) => d !== local && d !== created.directory) + return [local, created.directory, ...next] + }) + globalSync.child(created.directory) navigate(`/${base64Encode(created.directory)}/session`) + layout.mobileSidebar.hide() } command.register(() => [ diff --git a/packages/app/src/utils/worktree.ts b/packages/app/src/utils/worktree.ts new file mode 100644 index 000000000..7c0055920 --- /dev/null +++ b/packages/app/src/utils/worktree.ts @@ -0,0 +1,58 @@ +const normalize = (directory: string) => directory.replace(/[\\/]+$/, "") + +type State = + | { + status: "pending" + } + | { + status: "ready" + } + | { + status: "failed" + message: string + } + +const state = new Map() +const waiters = new Map void>>() + +export const Worktree = { + get(directory: string) { + return state.get(normalize(directory)) + }, + pending(directory: string) { + const key = normalize(directory) + const current = state.get(key) + if (current && current.status !== "pending") return + state.set(key, { status: "pending" }) + }, + ready(directory: string) { + const key = normalize(directory) + state.set(key, { status: "ready" }) + const list = waiters.get(key) + if (!list) return + waiters.delete(key) + for (const fn of list) fn({ status: "ready" }) + }, + failed(directory: string, message: string) { + const key = normalize(directory) + state.set(key, { status: "failed", message }) + const list = waiters.get(key) + if (!list) return + waiters.delete(key) + for (const fn of list) fn({ status: "failed", message }) + }, + wait(directory: string) { + const key = normalize(directory) + const current = state.get(key) + if (current && current.status !== "pending") return Promise.resolve(current) + + return new Promise((resolve) => { + const list = waiters.get(key) + if (!list) { + waiters.set(key, [resolve]) + return + } + list.push(resolve) + }) + }, +} diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 0ab5e5824..f6902de4e 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -338,6 +338,22 @@ export namespace Project { return valid } + export async function addSandbox(projectID: string, directory: string) { + const result = await Storage.update(["project", projectID], (draft) => { + const sandboxes = draft.sandboxes ?? [] + if (!sandboxes.includes(directory)) sandboxes.push(directory) + draft.sandboxes = sandboxes + draft.time.updated = Date.now() + }) + GlobalBus.emit("event", { + payload: { + type: Event.Updated.type, + properties: result, + }, + }) + return result + } + export async function removeSandbox(projectID: string, directory: string) { const result = await Storage.update(["project", projectID], (draft) => { const sandboxes = draft.sandboxes ?? [] diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 70b1a0231..97fe2c4fc 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -5,12 +5,33 @@ import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { Global } from "../global" import { Instance } from "../project/instance" +import { InstanceBootstrap } from "../project/bootstrap" import { Project } from "../project/project" import { Storage } from "../storage/storage" import { fn } from "../util/fn" -import { Config } from "@/config/config" +import { Log } from "../util/log" +import { BusEvent } from "@/bus/bus-event" +import { GlobalBus } from "@/bus/global" export namespace Worktree { + const log = Log.create({ service: "worktree" }) + + export const Event = { + Ready: BusEvent.define( + "worktree.ready", + z.object({ + name: z.string(), + branch: z.string(), + }), + ), + Failed: BusEvent.define( + "worktree.failed", + z.object({ + message: z.string(), + }), + ), + } + export const Info = z .object({ name: z.string(), @@ -234,7 +255,7 @@ export namespace Worktree { const base = input?.name ? slug(input.name) : "" const info = await candidate(root, base || undefined) - const created = await $`git worktree add -b ${info.branch} ${info.directory}` + const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}` .quiet() .nothrow() .cwd(Instance.worktree) @@ -242,24 +263,88 @@ export namespace Worktree { throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" }) } - const project = await Storage.read(["project", Instance.project.id]).catch(() => Instance.project) - const startup = project.commands?.start?.trim() - if (startup) { - const ran = await runStartCommand(info.directory, startup) - if (ran.exitCode !== 0) { - throw new StartCommandFailedError({ - message: errorText(ran) || "Project start command failed", - }) - } - } + await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined) + const projectID = Instance.project.id const extra = input?.startCommand?.trim() - if (extra) { - const ran = await runStartCommand(info.directory, extra) - if (ran.exitCode !== 0) { - throw new StartCommandFailedError({ message: errorText(ran) || "Worktree start command failed" }) + setTimeout(() => { + const start = async () => { + const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory) + if (populated.exitCode !== 0) { + const message = errorText(populated) || "Failed to populate worktree" + log.error("worktree checkout failed", { directory: info.directory, message }) + GlobalBus.emit("event", { + directory: info.directory, + payload: { + type: Event.Failed.type, + properties: { + message, + }, + }, + }) + return + } + + const booted = await Instance.provide({ + directory: info.directory, + init: InstanceBootstrap, + fn: () => undefined, + }) + .then(() => true) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + log.error("worktree bootstrap failed", { directory: info.directory, message }) + GlobalBus.emit("event", { + directory: info.directory, + payload: { + type: Event.Failed.type, + properties: { + message, + }, + }, + }) + return false + }) + if (!booted) return + + GlobalBus.emit("event", { + directory: info.directory, + payload: { + type: Event.Ready.type, + properties: { + name: info.name, + branch: info.branch, + }, + }, + }) + + const project = await Storage.read(["project", projectID]).catch(() => undefined) + const startup = project?.commands?.start?.trim() ?? "" + + const run = async (cmd: string, kind: "project" | "worktree") => { + const ran = await runStartCommand(info.directory, cmd) + if (ran.exitCode === 0) return true + log.error("worktree start command failed", { + kind, + directory: info.directory, + message: errorText(ran), + }) + return false + } + + if (startup) { + const ok = await run(startup, "project") + if (!ok) return + } + if (extra) { + await run(extra, "worktree") + } } - } + + void start().catch((error) => { + log.error("worktree start task failed", { directory: info.directory, error }) + }) + }, 0) return info }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fabb16e8a..38a52b325 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -866,6 +866,21 @@ export type EventPtyDeleted = { } } +export type EventWorktreeReady = { + type: "worktree.ready" + properties: { + name: string + branch: string + } +} + +export type EventWorktreeFailed = { + type: "worktree.failed" + properties: { + message: string + } +} + export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable @@ -907,6 +922,8 @@ export type Event = | EventPtyUpdated | EventPtyExited | EventPtyDeleted + | EventWorktreeReady + | EventWorktreeFailed export type GlobalEvent = { directory: string