diff options
| author | Adam <[email protected]> | 2026-01-22 22:09:18 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-23 05:18:42 -0600 |
| commit | c4d223eb99c4f677ff9f540cbef1f71e8a502ac8 (patch) | |
| tree | 577e50e95bc5e101e46eb9e88ec5d99d14e646dd /packages/app/src | |
| parent | 3fbda540457ac1db860a2c011d3a9d62b650381c (diff) | |
| download | opencode-c4d223eb99c4f677ff9f540cbef1f71e8a502ac8.tar.gz opencode-c4d223eb99c4f677ff9f540cbef1f71e8a502ac8.zip | |
perf(app): faster workspace creation
Diffstat (limited to 'packages/app/src')
| -rw-r--r-- | packages/app/src/components/prompt-input.tsx | 113 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 50 | ||||
| -rw-r--r-- | packages/app/src/utils/worktree.ts | 58 |
3 files changed, 194 insertions, 27 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) + }) + }, +} |
