perf(app): faster workspace creation
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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(() => [
|
||||
|
||||
58
packages/app/src/utils/worktree.ts
Normal file
58
packages/app/src/utils/worktree.ts
Normal 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)
|
||||
})
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user