perf(app): faster workspace creation

This commit is contained in:
Adam
2026-01-22 22:09:18 -06:00
parent 3fbda54045
commit c4d223eb99
6 changed files with 328 additions and 43 deletions

View File

@@ -48,6 +48,7 @@ import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language" 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_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
type PendingPrompt = {
abort: AbortController
cleanup: VoidFunction
}
const pending = new Map<string, PendingPrompt>()
interface PromptInputProps { interface PromptInputProps {
class?: string class?: string
ref?: (el: HTMLDivElement) => void ref?: (el: HTMLDivElement) => void
@@ -846,12 +854,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("popover", null) setStore("popover", null)
} }
const abort = () => const abort = () => {
sdk.client.session 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({ .abort({
sessionID: params.id!, sessionID,
}) })
.catch(() => {}) .catch(() => {})
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const text = prompt const text = prompt
@@ -1111,6 +1129,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
return return
} }
WorktreeState.pending(createdWorktree.directory)
sessionDirectory = createdWorktree.directory sessionDirectory = createdWorktree.directory
} }
@@ -1409,20 +1428,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput() clearInput()
addOptimisticMessage() addOptimisticMessage()
client.session const waitForWorktree = async () => {
.prompt({ const worktree = WorktreeState.get(sessionDirectory)
sessionID: session.id, if (!worktree || worktree.status !== "pending") return true
agent,
model, setSyncStore("session_status", session.id, { type: "busy" })
messageID,
parts: requestParts, const controller = new AbortController()
variant,
}) const cleanup = () => {
.catch((err) => { setSyncStore("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
removeOptimisticMessage() removeOptimisticMessage()
for (const item of commentItems) { for (const item of commentItems) {
prompt.context.add({ prompt.context.add({
@@ -1435,7 +1450,71 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
} }
restoreInput() 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 ( return (

View File

@@ -56,6 +56,7 @@ import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound" import { playSound, soundSrc } from "@/utils/sound"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -332,6 +333,18 @@ export default function Layout(props: ParentProps) {
const cooldownMs = 5000 const cooldownMs = 5000
const unsub = globalSDK.event.listen((e) => { 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 if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
const title = const title =
e.details.type === "permission.asked" e.details.type === "permission.asked"
@@ -551,6 +564,7 @@ export default function Layout(props: ParentProps) {
const project = currentProject() const project = currentProject()
if (!project) return if (!project) return
const local = project.worktree
const dirs = [project.worktree, ...(project.sandboxes ?? [])] const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const existing = store.workspaceOrder[project.worktree] const existing = store.workspaceOrder[project.worktree]
if (!existing) { if (!existing) {
@@ -558,9 +572,9 @@ export default function Layout(props: ParentProps) {
return return
} }
const keep = existing.filter((d) => dirs.includes(d)) const keep = existing.filter((d) => d !== local && dirs.includes(d))
const missing = dirs.filter((d) => !existing.includes(d)) const missing = dirs.filter((d) => d !== local && !existing.includes(d))
const merged = [...keep, ...missing] const merged = [local, ...missing, ...keep]
if (merged.length !== existing.length) { if (merged.length !== existing.length) {
setStore("workspaceOrder", project.worktree, merged) setStore("workspaceOrder", project.worktree, merged)
@@ -1434,17 +1448,22 @@ export default function Layout(props: ParentProps) {
function workspaceIds(project: LocalProject | undefined) { function workspaceIds(project: LocalProject | undefined) {
if (!project) return [] if (!project) return []
const dirs = [project.worktree, ...(project.sandboxes ?? [])] const local = project.worktree
const dirs = [local, ...(project.sandboxes ?? [])]
const active = currentProject() const active = currentProject()
const directory = active?.worktree === project.worktree && params.dir ? base64Decode(params.dir) : undefined 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] 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 keep = existing.filter((d) => d !== local && dirs.includes(d))
const missing = next.filter((d) => !existing.includes(d)) const missing = dirs.filter((d) => d !== local && !existing.includes(d))
return [...keep, ...missing] const merged = [local, ...(pending && extra ? [extra] : []), ...missing, ...keep]
if (!extra) return merged
if (pending) return merged
return [...merged, extra]
} }
function handleWorkspaceDragStart(event: unknown) { function handleWorkspaceDragStart(event: unknown) {
@@ -2237,8 +2256,19 @@ export default function Layout(props: ParentProps) {
if (!created?.directory) return 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) globalSync.child(created.directory)
navigate(`/${base64Encode(created.directory)}/session`) navigate(`/${base64Encode(created.directory)}/session`)
layout.mobileSidebar.hide()
} }
command.register(() => [ command.register(() => [

View File

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

View File

@@ -338,6 +338,22 @@ export namespace Project {
return valid 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) { export async function removeSandbox(projectID: string, directory: string) {
const result = await Storage.update<Info>(["project", projectID], (draft) => { const result = await Storage.update<Info>(["project", projectID], (draft) => {
const sandboxes = draft.sandboxes ?? [] const sandboxes = draft.sandboxes ?? []

View File

@@ -5,12 +5,33 @@ import z from "zod"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global" import { Global } from "../global"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { InstanceBootstrap } from "../project/bootstrap"
import { Project } from "../project/project" import { Project } from "../project/project"
import { Storage } from "../storage/storage" import { Storage } from "../storage/storage"
import { fn } from "../util/fn" 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 { 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 export const Info = z
.object({ .object({
name: z.string(), name: z.string(),
@@ -234,7 +255,7 @@ export namespace Worktree {
const base = input?.name ? slug(input.name) : "" const base = input?.name ? slug(input.name) : ""
const info = await candidate(root, base || undefined) 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() .quiet()
.nothrow() .nothrow()
.cwd(Instance.worktree) .cwd(Instance.worktree)
@@ -242,24 +263,88 @@ export namespace Worktree {
throw new CreateFailedError({ message: errorText(created) || "Failed to create git 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) await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)
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",
})
}
}
const projectID = Instance.project.id
const extra = input?.startCommand?.trim() const extra = input?.startCommand?.trim()
if (extra) { setTimeout(() => {
const ran = await runStartCommand(info.directory, extra) const start = async () => {
if (ran.exitCode !== 0) { const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory)
throw new StartCommandFailedError({ message: errorText(ran) || "Worktree start command failed" }) 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 return info
}) })

View File

@@ -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 = export type Event =
| EventInstallationUpdated | EventInstallationUpdated
| EventInstallationUpdateAvailable | EventInstallationUpdateAvailable
@@ -907,6 +922,8 @@ export type Event =
| EventPtyUpdated | EventPtyUpdated
| EventPtyExited | EventPtyExited
| EventPtyDeleted | EventPtyDeleted
| EventWorktreeReady
| EventWorktreeFailed
export type GlobalEvent = { export type GlobalEvent = {
directory: string directory: string