Compare commits

...
10 changed files with 120 additions and 30 deletions
@@ -16,6 +16,7 @@ import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
import { beginWorkspaceSetup } from "@/workspaces/setup"
export function createNewSessionComposerAdapter(props: {
draftID: string
@@ -46,7 +47,7 @@ export function createNewSessionComposerAdapter(props: {
async start(selection, submission) {
const projectDirectory = location().directory
const worktree = props.worktree()
const sessionDirectory = await resolveSessionDirectory({
const workspace = await resolveSessionDirectory({
projectDirectory,
worktree,
branch: props.branch(),
@@ -54,7 +55,8 @@ export function createNewSessionComposerAdapter(props: {
serverSDK,
language,
})
if (!sessionDirectory) return
if (!workspace) return
const sessionDirectory = workspace.directory
const created = data.session.create({
agent: selection.agent,
@@ -84,6 +86,7 @@ export function createNewSessionComposerAdapter(props: {
serverSDK.scope,
SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id),
)
if (workspace.initializing) beginWorkspaceSetup(created.id, workspace.ready)
const cleanupReady = startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined, branch: undefined })
local.session.promote(sessionDirectory, created.id, {
@@ -119,7 +122,7 @@ export function createNewSessionComposerAdapter(props: {
prompt: (input) =>
data.session.prompt({
...input,
gate: Promise.all([input.gate, afterCreation(async () => undefined)]),
gate: Promise.all([input.gate, afterCreation(async () => undefined), workspace.ready]),
}),
},
},
@@ -168,28 +171,54 @@ async function resolveSessionDirectory(input: {
serverSDK: ReturnType<typeof useServerSDK>
language: ReturnType<typeof useLanguage>
}) {
if (input.worktree === "main") return input.projectDirectory
if (input.worktree !== "create") return input.worktree
if (input.worktree !== "create") {
return {
directory: input.worktree === "main" ? input.projectDirectory : input.worktree,
ready: Promise.resolve(),
initializing: false,
}
}
return input.serverSDK.api.worktree
const projectID = input.data.location.info({ directory: input.projectDirectory })?.project.id ?? ""
const pending = Promise.withResolvers<
{ directory: string; ready: Promise<void>; initializing: boolean } | undefined
>()
const unsubscribe = input.serverSDK.event.on("worktree.updated", (event) => {
if (event.data.projectID !== projectID || !event.data.directory) return
unsubscribe()
pending.resolve({ directory: event.data.directory, ready, initializing: true })
})
const creation = input.serverSDK.api.worktree
.create({
projectID: input.data.location.info({ directory: input.projectDirectory })?.project.id ?? "",
projectID,
strategy: "git",
branch: input.branch,
directory: getDirectory(
input.data.location.info({ directory: input.projectDirectory })?.project.directory ?? input.projectDirectory,
),
})
.then(async (created) => {
await input.serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
})
.catch((error) => {
showToast({
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(input.language, error),
})
.then(
(created) => ({ ok: true as const, created }),
(error) => ({ ok: false as const, error }),
)
const ready = creation.then((result) => {
unsubscribe()
if (result.ok) {
pending.resolve({ directory: result.created.directory, ready: Promise.resolve(), initializing: false })
return
}
showToast({
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(input.language, result.error),
})
pending.resolve(undefined)
throw result.error
})
void ready.catch(() => undefined)
const workspace = await pending.promise
if (!workspace) return
await input.serverSDK.api.location.get({ location: { directory: workspace.directory } })
return workspace
}
function errorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
+1
View File
@@ -312,6 +312,7 @@ export const dict = {
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
"session.workspace.initializing": "Setting up workspace",
"prompt.toast.sessionCreateFailed.title": "Failed to create session",
"prompt.toast.shellSendFailed.title": "Failed to send shell command",
"prompt.toast.commandSendFailed.title": "Failed to send command",
@@ -30,6 +30,7 @@ import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { useCommand } from "@/shell/commands/command"
import { useSettings } from "@/settings/model"
import { isWorkspaceSetupPending } from "@/workspaces/setup"
import { SessionTitleHeader } from "../session-identity-header"
type BackgroundTask = {
@@ -350,8 +351,7 @@ function MessageTimelineView(
return { ...value, worktree: value.canonical, worktrees: [] }
})
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const avatarProject = createMemo(() => {
if (!showProjectIcon()) return
const session = props.session.data.info()
@@ -465,6 +465,8 @@ function MessageTimelineView(
editToolDefaultOpen: props.data.editToolPartsExpanded,
disclosure: virtualized.disclosure,
centered: () => props.centered,
thinkingLabel: () =>
isWorkspaceSetupPending(sessionID() ?? "") ? language.t("session.workspace.initializing") : undefined,
padding: turnPadding,
anchor: props.anchor,
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test"
import { beginWorkspaceSetup, isWorkspaceSetupPending } from "./setup"
describe("workspace setup", () => {
test("remains pending until the setup script finishes", async () => {
const ready = Promise.withResolvers<void>()
beginWorkspaceSetup("session-ready", ready.promise)
expect(isWorkspaceSetupPending("session-ready")).toBe(true)
ready.resolve()
await ready.promise
expect(isWorkspaceSetupPending("session-ready")).toBe(false)
})
test("clears pending setup when the script fails", async () => {
const ready = Promise.withResolvers<void>()
beginWorkspaceSetup("session-failed", ready.promise)
ready.reject(new Error("setup failed"))
await ready.promise.catch(() => undefined)
expect(isWorkspaceSetupPending("session-failed")).toBe(false)
})
})
+15
View File
@@ -0,0 +1,15 @@
import { createStore } from "solid-js/store"
const [sessions, setSessions] = createStore<Record<string, boolean | undefined>>({})
export function beginWorkspaceSetup(sessionID: string, ready: Promise<void>) {
setSessions(sessionID, true)
void ready.then(
() => setSessions(sessionID, undefined),
() => setSessions(sessionID, undefined),
)
}
export function isWorkspaceSetupPending(sessionID: string) {
return sessions[sessionID] === true
}
@@ -964,7 +964,7 @@ export type WorktreeUpdated = {
metadata?: { [x: string]: any }
type: "worktree.updated"
location?: LocationRef
data: { projectID: string }
data: { projectID: string; directory?: string }
}
export type WorktreeResolved = {
+7 -2
View File
@@ -153,8 +153,12 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const processService = yield* AppProcess.Service
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
const changed = Effect.fnUntraced(function* (
projectID: ProjectSchema.ID,
update: boolean,
directory?: AbsolutePath,
) {
if (update) yield* bus.publish(Event.Updated, { projectID, directory })
})
const ops = {
@@ -265,6 +269,7 @@ const layer = Layer.effect(
directory: result.directory,
strategy: input.strategy,
}),
result.directory,
)
const project = yield* db
.select({ worktree: ProjectTable.worktree, commands: ProjectTable.commands })
+19 -7
View File
@@ -183,7 +183,10 @@ describe("Worktree", () => {
{ directory: created.directory, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({
projectID: input.projectID,
directory: created.directory,
})
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
@@ -196,6 +199,7 @@ describe("Worktree", () => {
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const bus = yield* Bus.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-setup"))
yield* Effect.addFinalizer(() =>
@@ -212,12 +216,20 @@ describe("Worktree", () => {
.where(eq(ProjectTable.id, input.projectID))
.run()
.pipe(Effect.orDie)
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
directory: parent,
name: "worktree",
})
const updated = yield* bus
.subscribe(Worktree.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const creating = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
directory: parent,
name: "worktree",
})
.pipe(Effect.forkScoped)
expect(Array.from(yield* Fiber.join(updated))[0]?.data.directory).toBe(abs(path.join(parent, "worktree")))
const created = yield* Fiber.join(creating)
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "setup.json")).json())).toEqual([
input.sourceDirectory,
+1 -1
View File
@@ -47,7 +47,7 @@ export type List = typeof List.Type
const Updated = ephemeral({
type: "worktree.updated",
schema: { projectID: Project.ID },
schema: { projectID: Project.ID, directory: optional(AbsolutePath) },
})
const Resolved = durable({
@@ -49,6 +49,7 @@ export function createSessionTimelineRowRenderer(input: {
set: (key: string, open: boolean) => void
}
centered?: Accessor<boolean>
thinkingLabel?: Accessor<string | undefined>
padding?: Accessor<string>
anchor?: (messageID: string) => string | undefined
}) {
@@ -505,7 +506,7 @@ export function createSessionTimelineRowRenderer(input: {
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
<TextShimmer text={input.thinkingLabel?.() ?? i18n.t("ui.sessionTurn.status.thinking")} />
</span>
<Show when={!input.showReasoningSummaries()}>
<span data-slot="basic-tool-tool-subtitle">