summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-22 22:09:18 -0600
committerAdam <[email protected]>2026-01-23 05:18:42 -0600
commitc4d223eb99c4f677ff9f540cbef1f71e8a502ac8 (patch)
tree577e50e95bc5e101e46eb9e88ec5d99d14e646dd /packages
parent3fbda540457ac1db860a2c011d3a9d62b650381c (diff)
downloadopencode-c4d223eb99c4f677ff9f540cbef1f71e8a502ac8.tar.gz
opencode-c4d223eb99c4f677ff9f540cbef1f71e8a502ac8.zip
perf(app): faster workspace creation
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/components/prompt-input.tsx113
-rw-r--r--packages/app/src/pages/layout.tsx50
-rw-r--r--packages/app/src/utils/worktree.ts58
-rw-r--r--packages/opencode/src/project/project.ts16
-rw-r--r--packages/opencode/src/worktree/index.ts119
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts17
6 files changed, 329 insertions, 44 deletions
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<string, PendingPrompt>()
+
interface PromptInputProps {
class?: string
ref?: (el: HTMLDivElement) => void
@@ -846,12 +854,22 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (props) => {
})
return
}
+ WorktreeState.pending(createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
@@ -1409,20 +1428,16 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (props) => {
})
}
restoreInput()
+ }
+
+ pending.set(session.id, { abort: controller, cleanup })
+
+ const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((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<Awaited<ReturnType<typeof WorktreeState.wait>>>((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
-
- const keep = existing.filter((d) => next.includes(d))
- const missing = next.filter((d) => !existing.includes(d))
- return [...keep, ...missing]
+ if (!existing) return extra ? [...dirs, extra] : dirs
+
+ 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<string, State>()
+const waiters = new Map<string, Array<(state: State) => 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<State>((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<Info>(["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<Info>(["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.Info>(["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.Info>(["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