diff --git a/packages/app/e2e/regression/new-session-workspace-pending.spec.ts b/packages/app/e2e/regression/new-session-workspace-pending.spec.ts new file mode 100644 index 00000000000..e6849c41fde --- /dev/null +++ b/packages/app/e2e/regression/new-session-workspace-pending.spec.ts @@ -0,0 +1,303 @@ +import { expect, test, type Page } from "@playwright/test" +import { base64Encode } from "@opencode-ai/util/encode" +import { currentSession, mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const directory = "C:/OpenCode/WorkspacePending" +const workspace = "C:/OpenCode/pending-workspace" +const projectID = "proj_workspace_pending" +const draftID = "draft_workspace_pending" +const otherID = "ses_workspace_pending_other" +const text = "Create the workspace, then explain the pending session." +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const sessionPath = `/server/${base64Encode(server)}/session/` +const draftPath = `/new-session?draftId=${draftID}` +const headers = { "access-control-allow-origin": "*" } + +test.use({ serviceWorkers: "block", viewport: { width: 1280, height: 900 } }) + +for (const viewport of [ + { name: "desktop", width: 1280, height: 900 }, + { name: "mobile", width: 390, height: 844 }, +]) { + test(`shows a pending workspace session immediately on ${viewport.name}`, async ({ page }, testInfo) => { + await page.setViewportSize(viewport) + const mock = await openDraft(page) + const pending = await submitPending(page, mock) + + await expect(pending.message).toBeInViewport() + await expect(pending.shimmer).toBeInViewport() + await testInfo.attach("creating-worktree", { + body: await page.screenshot({ path: testInfo.outputPath(`pending-${viewport.name}.png`) }), + contentType: "image/png", + }) + + if (viewport.name === "mobile") { + await page.locator("html").evaluate((element) => { + element.dir = "rtl" + }) + await expect(page.locator("html")).toHaveAttribute("dir", "rtl") + await expect(pending.message).toBeInViewport() + await expect(pending.shimmer).toBeInViewport() + await expect(page.locator('[data-component="session-preparing"]')).toHaveCSS("direction", "rtl") + expect( + await page + .locator('[data-component="session-preparing"]') + .evaluate((element) => element.scrollWidth <= element.clientWidth), + ).toBe(true) + } + + if (viewport.name === "desktop") { + await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click() + await expect(page).toHaveURL(`${sessionPath}${otherID}`) + await expect(page.locator('[data-component="composer-editor"]')).toBeEditable() + await expect(pending.shimmer).toBeHidden() + + await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click() + await expect(page).toHaveURL(pending.url) + await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`) + await expect(pending.shimmer).toHaveAttribute("data-active", "true") + expect(mock.calls).toEqual(["worktree"]) + + await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click() + await expect(page).toHaveURL(`${sessionPath}${otherID}`) + await page.locator('[data-component="composer-editor"]').fill("Keep focus in this other session") + await expect(page.locator('[data-component="composer-editor"]')).toBeFocused() + } + + expect(mock.calls).toEqual(["worktree"]) + mock.worktree.resolve({ status: 200, json: { directory: workspace } }) + await expect + .poll(() => mock.prompts) + .toEqual([{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) }]) + expect(mock.creates).toEqual([ + expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } }), + ]) + expect(mock.calls).toEqual(["worktree", "session", "prompt"]) + + if (viewport.name === "desktop") { + await expect(page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)).toContainText( + "Created workspace session", + ) + await expect(page).toHaveURL(`${sessionPath}${otherID}`) + await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep focus in this other session") + await expect(page.locator('[data-component="composer-editor"]')).toBeFocused() + await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click() + } + + await expect(page).toHaveURL(pending.url) + await expect(pending.shimmer).toHaveCount(0) + await expect(pending.message).toHaveCount(1) + await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text) + await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`) + }) +} + +test("restores the original draft when worktree creation fails", async ({ page }) => { + const mock = await openDraft(page) + const pending = await submitPending(page, mock) + + mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed in the fixture" } }) + + await expect(page).toHaveURL(draftPath) + await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible() + await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text) + await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled() + await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible() + await expect(pending.shimmer).toHaveCount(0) + await expect(pending.message).toHaveCount(0) + await expect(page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)).toHaveCount(0) + expect(mock.calls).toEqual(["worktree"]) + expect(mock.creates).toEqual([]) + expect(mock.prompts).toEqual([]) +}) + +test("retains the draft and reuses the created workspace after session creation fails", async ({ page }) => { + const mock = await openDraft(page, { failSessionCreate: true }) + const pending = await submitPending(page, mock) + + mock.worktree.resolve({ status: 200, json: { directory: workspace } }) + + await expect(page).toHaveURL(draftPath) + await expect(page.getByText("Failed to create session", { exact: true })).toBeVisible() + await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text) + await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled() + await expect(page.getByRole("button", { name: "pending-workspace", exact: true })).toBeVisible() + await expect(pending.shimmer).toHaveCount(0) + await expect(pending.message).toHaveCount(0) + expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })]) + expect(mock.calls).toEqual(["worktree", "session"]) + expect(mock.prompts).toEqual([]) + + await page.locator('[data-action="composer-submit"]').click() + + await expect.poll(() => mock.prompts.length).toBe(1) + expect(mock.creates).toHaveLength(2) + expect(mock.creates[1]).toMatchObject({ location: { directory: workspace } }) + expect(mock.prompts[0]).toMatchObject({ sessionID: mock.creates[1].id, body: { text } }) + expect(mock.calls).toEqual(["worktree", "session", "session", "prompt"]) + await expect(page).toHaveURL(`${sessionPath}${mock.creates[1].id}`) + await expect(page.locator('[data-component="user-message"] [data-slot="user-message-text"]')).toHaveText(text) +}) + +test("restores the draft after closing and revisiting a pending session that fails", async ({ page }) => { + const mock = await openDraft(page) + const pending = await submitPending(page, mock) + const tab = page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`) + + await page.locator("[data-titlebar-tab-slot]").filter({ has: tab }).locator('[data-slot="tab-close"] button').click() + + await expect(page).toHaveURL(`${sessionPath}${otherID}`) + await expect(page.locator('[data-component="composer-editor"]')).toBeEditable() + await expect(tab).toHaveCount(0) + await expect(pending.shimmer).toHaveCount(0) + + await page.goBack() + + await expect(page).toHaveURL(pending.url) + await expect(tab).toHaveCount(1) + await expect(tab).toBeVisible() + await expect(pending.message).toHaveCount(1) + await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text) + await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`) + await expect(pending.shimmer).toBeVisible() + await expect(pending.shimmer).toContainText("Creating worktree") + await expect(pending.shimmer).toHaveAttribute("data-active", "true") + expect(mock.calls).toEqual(["worktree"]) + + mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed after revisiting the session" } }) + + await expect(page).toHaveURL(draftPath) + await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible() + await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text) + await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled() + await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible() + await expect(page.locator(`[data-titlebar-tab-link][href="${draftPath}"]`)).toHaveCount(1) + await expect(tab).toHaveCount(0) + await expect(pending.shimmer).toHaveCount(0) + await expect(pending.message).toHaveCount(0) + expect(mock.calls).toEqual(["worktree"]) + expect(mock.creates).toEqual([]) + expect(mock.prompts).toEqual([]) +}) + +async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) { + const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>() + const calls: string[] = [] + const creates: Record[] = [] + const prompts: { sessionID: string; body: Record }[] = [] + const project = { + id: projectID, + worktree: directory, + vcs: "git", + name: "workspace-pending", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [] as string[], + } + const sessions = [currentSession({ id: otherID, projectID, title: "Other session" }, directory)] + await mockOpenCodeServer(page, { + directory, + project, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { "pending-model": { id: "pending-model", name: "Pending Model", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "pending-model" }, + }, + sessions, + pageMessages: () => ({ items: [] }), + onPrompt: (input) => prompts.push(input), + }) + page.on("request", (request) => { + if (request.method() !== "POST") return + const path = new URL(request.url()).pathname + if (path === `/api/worktree/${projectID}`) calls.push("worktree") + if (path === "/api/session") calls.push("session") + if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt") + }) + await page.route(`**/api/worktree/${projectID}`, async (route) => { + if (route.request().method() !== "POST") return route.fallback() + // Keep the real HTTP response pending until the test has checked the preview. + const response = await worktree.promise + if (response.status === 200) project.sandboxes.push(workspace) + await route.fulfill({ ...response, headers }) + }) + await page.route("**/api/session", async (route) => { + if (route.request().method() !== "POST") return route.fallback() + const body: Record = route.request().postDataJSON() + creates.push(body) + if (options?.failSessionCreate && creates.length === 1) { + return route.fulfill({ status: 500, json: { message: "Session creation failed in the fixture" }, headers }) + } + if (typeof body.id !== "string") throw new Error("Session creation must use the client-reserved ID") + const session = currentSession({ ...body, id: body.id, projectID, title: "Created workspace session" }, workspace) + sessions.push(session) + return route.fulfill({ json: { data: session }, headers }) + }) + await page.route("**/api/location?**", (route) => { + if (route.request().method() !== "GET") return route.fallback() + return route.fulfill({ + json: { + directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory, + project: { id: projectID, directory, canonical: directory }, + }, + headers, + }) + }) + await page.addInitScript( + ({ directory, draftID, otherID, server }) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "draft", draftID, server, directory }, + { type: "session", sessionId: otherID, server }, + ]), + ) + }, + { directory, draftID, otherID, server }, + ) + await page.goto(draftPath) + await expectAppVisible(page.locator('[data-component="composer-editor"]')) + await page.getByRole("button", { name: "Local", exact: true }).click() + await page.getByRole("menuitem", { name: "New workspace", exact: true }).click() + await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible() + await expect(page.locator('[data-component="composer-editor"]')).toBeEditable() + return { worktree, calls, creates, prompts } +} + +async function submitPending(page: Page, mock: Awaited>) { + await page.locator('[data-component="composer-editor"]').fill(text) + await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled() + await page.locator('[data-action="composer-submit"]').click() + await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname)) + const url = page.url() + const sessionID = new URL(url).pathname.slice(sessionPath.length) + const preparing = page.locator('[data-component="session-preparing"]') + const message = page.locator('[data-component="user-message"]') + const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]') + await expect(preparing).toBeVisible() + await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1) + await expect(message).toHaveCount(1) + await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text) + await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/) + const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "") + await expect(shimmer).toBeVisible() + await expect(shimmer).toContainText("Creating worktree") + await expect(shimmer).toHaveAttribute("data-active", "true") + await expect.poll(() => mock.calls).toEqual(["worktree"]) + expect(mock.creates).toEqual([]) + expect(mock.prompts).toEqual([]) + return { url, sessionID, messageID, message, shimmer } +} diff --git a/packages/app/src/composer/adapter.ts b/packages/app/src/composer/adapter.ts index 842a20247b7..ef068ab233f 100644 --- a/packages/app/src/composer/adapter.ts +++ b/packages/app/src/composer/adapter.ts @@ -103,7 +103,8 @@ export type NewSessionComposerAdapter = ComposerAdapterBase & { start: ( selection: ComposerSelection, submission: ReturnType, - ) => Promise<{ session: ComposerSession; cleanupReady: Promise } | undefined> + message: SessionMessageUser, + ) => Promise<{ session: ComposerSession; cleanupReady: Promise; complete?: () => Promise } | undefined> } export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index b683684eb0c..2f178cf735b 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -229,6 +229,50 @@ describe("Composer submission", () => { }) }) + test("previews the first prompt while starting and hands it off before completing preparation", async () => { + const draft = createMemoryComposerState({ prompt: "prepare my worktree" }).capture() + const preview = Promise.withResolvers() + const ready = Promise.withResolvers() + const calls: string[] = [] + const handoff: SessionMessageUser[] = [] + const target = session({ + calls, + handoff: { set: (message) => handoff.push(message), clear() {} }, + prompt: async () => undefined, + }) + const adapter: NewSessionComposerAdapter = { + kind: "new-session", + state: draft, + ready: () => true, + controls, + working: () => false, + submitted() {}, + async start(_selection, _submission, message) { + preview.resolve(message) + await ready.promise + return { + session: target, + cleanupReady: Promise.resolve(), + async complete() { + expect(handoff).toHaveLength(1) + expect(handoff[0]?.id).toBe(message.id) + expect(handoff[0]?.text).toBe("prepare my worktree") + calls.push("complete") + }, + } + }, + } + + const submitted = submitInput(adapter).submit(new Event("submit")) + expect(await preview.promise).toMatchObject({ type: "user", text: "prepare my worktree" }) + expect(calls).toEqual([]) + expect(draft.current()).toMatchObject([{ content: "prepare my worktree" }]) + ready.resolve() + await submitted + expect(calls).toContain("complete") + expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) + }) + test("does not restore a prompt already acknowledged by the durable inbox", async () => { const state = createMemoryComposerState({ prompt: "admitted prompt" }).capture() const checked = Promise.withResolvers() diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index da255ce3c36..83bdf4e90a0 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -70,7 +70,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) { const started = input.adapter.kind === "active-session" ? { session: input.adapter.session(), cleanupReady: Promise.resolve() } - : await input.adapter.start(value.selection, submission) + : await input.adapter.start(value.selection, submission, handoffMessage(value)) if (!started) return const session = started.session @@ -80,7 +80,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) { const command = value.mode === "normal" ? findCommand(session, value.text) : undefined if (value.mode === "normal" && !command) { - if (value.images.length > 0) session.handoff?.set(handoffMessage(value)) + session.handoff?.set(handoffMessage(value)) const optimisticBusy = !input.adapter.working() if (optimisticBusy) session.data.session.setStatus(session.id, "running") const sending = sendPrompt(session, value).then( @@ -88,6 +88,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) { (error) => ({ ok: false as const, error }), ) await started.cleanupReady + await started.complete?.() input.adapter.submitted() submission.context .filter((item) => !!item.comment?.trim()) @@ -104,6 +105,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) { } await started.cleanupReady + await started.complete?.() input.adapter.submitted() if (value.mode === "shell") { @@ -122,7 +124,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) { ) return } - } finally { submitting.delete(input.adapter.state) } @@ -147,6 +148,19 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser { })), metadata: { displayText: value.text, + comments: value.context.flatMap((item) => + item.comment?.trim() + ? [ + { + path: item.path, + comment: item.comment.trim(), + ...(item.selection ? { selection: { ...item.selection } } : {}), + ...(item.preview !== undefined ? { preview: item.preview } : {}), + ...(item.commentOrigin ? { origin: item.commentOrigin } : {}), + }, + ] + : [], + ), agent: value.selection.agent, model: { ...value.selection.model, diff --git a/packages/app/src/new-session/composer-adapter.ts b/packages/app/src/new-session/composer-adapter.ts index 52657b829a7..892d2c5e9a7 100644 --- a/packages/app/src/new-session/composer-adapter.ts +++ b/packages/app/src/new-session/composer-adapter.ts @@ -1,6 +1,7 @@ import { base64Encode } from "@opencode-ai/util/encode" import { getDirectory } from "@opencode-ai/util/path" import type { SessionMessageUser } from "@opencode-ai/client/promise" +import { Session } from "@opencode-ai/schema/session" import { startTransition } from "solid-js" import type { NewSessionComposerAdapter } from "@/composer/adapter" import { useComposerState } from "@/composer/persistence" @@ -43,20 +44,32 @@ export function createNewSessionComposerAdapter(props: { controls, working: () => false, submitted: props.submitted, - async start(selection, submission) { + async start(selection, submission, message) { + const draftID = props.draftID const projectDirectory = location().directory const worktree = props.worktree() + const branch = props.branch() + const id = Session.ID.create() + const pending = + worktree === "create" + ? tabs.prepareSession(draftID, { server: server.key, sessionId: id }, { message, selection }) + : undefined + await pending?.ready const sessionDirectory = await resolveSessionDirectory({ projectDirectory, worktree, - branch: props.branch(), + branch, data, serverSDK, language, }) - if (!sessionDirectory) return + if (!sessionDirectory) { + await pending?.rollback() + return + } const created = data.session.create({ + id, agent: selection.agent, model: { id: selection.model.modelID, @@ -75,6 +88,13 @@ export function createNewSessionComposerAdapter(props: { return { ok: false as const, error } }, ) + if (pending && !(await creation).ok) { + // Keep retries on the worktree that was already created, not another new checkout. + data.project.invalidate() + await data.project.sync().catch(() => undefined) + await pending.rollback(sessionDirectory) + return + } const afterCreation = async (run: () => Promise) => { const result = await creation if (!result.ok) throw result.error @@ -85,13 +105,13 @@ export function createNewSessionComposerAdapter(props: { SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id), ) const cleanupReady = startTransition(() => { - tabs.updateDraft(props.draftID, { worktree: undefined, branch: undefined }) + if (!pending) tabs.updateDraft(draftID, { worktree: undefined, branch: undefined }) local.session.promote(sessionDirectory, created.id, { agent: selection.agent, model: selection.model, variant: selection.variant ?? null, }) - tabs.promoteDraft(props.draftID, { server: server.key, sessionId: created.id }) + if (!pending) tabs.promoteDraft(draftID, { server: server.key, sessionId: created.id }) submission.retarget( prompt.capture( { dir: base64Encode(sessionDirectory), id: created.id }, @@ -102,6 +122,7 @@ export function createNewSessionComposerAdapter(props: { return { cleanupReady, + complete: pending?.complete, session: { id: created.id, directory: sessionDirectory, diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 9c03fc161c8..9c116e603e8 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -734,6 +734,7 @@ export const dict = { "session.new.worktree.main": "Main branch", "session.new.worktree.mainWithBranch": "Main branch ({{branch}})", "session.new.worktree.create": "Create new worktree", + "session.new.worktree.creating": "Creating worktree", "session.new.workspace.runIn": "Run session in", "session.new.workspace.triggerLocal": "Local", "session.new.workspace.local": "Local repository", diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx index 73be45a31c8..1d326bc4bbc 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -1,9 +1,15 @@ import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from "solid-js" import { useParams } from "@solidjs/router" +import { DataProvider } from "@opencode-ai/session-ui/context" +import { SessionUserMessage } from "@opencode-ai/session-ui/message" +import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { CommentsProvider } from "@/composer/comments" +import { readPromptPresentation } from "@/composer/comment-note" import { FileProvider } from "@/workspaces/files/model" import { LocationProvider } from "@/workspaces/location" import { ModelsProvider } from "@/providers/models/models" +import { useProviders } from "@/providers/catalog/providers" +import { useLanguage } from "@/runtime/i18n/language" import { useNotification } from "@/shell/notifications/notification" import { ComposerPersistenceProvider } from "@/composer/persistence" import { useData, useServer } from "@/runtime/server/current" @@ -11,7 +17,7 @@ import { ServerConnection } from "@/runtime/server/registry" import { TerminalProvider } from "@/session/terminal/context" import { useSettingsCommand } from "@/settings/command" import { SessionUIProvider } from "@/shell/routes/session-ui-provider" -import { useTabs } from "@/shell/tabs/tabs" +import { useTabs, type PendingSession } from "@/shell/tabs/tabs" import { requireServerKey } from "@/shell/routes/session" import { useSessionModel } from "./model" import { SessionPanelFrame } from "./session-frame" @@ -24,6 +30,8 @@ import { SessionScreen } from "./screen" export function TargetSessionRouteContent() { const params = useParams<{ serverKey: string; id: string }>() const data = useData() + const server = useServer() + const tabs = useTabs() const directory = createMemo(() => data.session.get(params.id)?.location.directory) return ( @@ -32,13 +40,55 @@ export function TargetSessionRouteContent() { - + }> + {(pending) => } + ) } +function PreparingSession(props: { sessionID: string; pending: PendingSession }) { + const language = useLanguage() + const providers = useProviders(() => props.pending.draft.directory) + return ( + + +
+
+ +
+ +
+
+
+
+
+ ) +} + function TargetSessionSettingsCommand() { useSettingsCommand() return null diff --git a/packages/app/src/shell/tabs/tabs.tsx b/packages/app/src/shell/tabs/tabs.tsx index c9234d16f1d..9297c436d67 100644 --- a/packages/app/src/shell/tabs/tabs.tsx +++ b/packages/app/src/shell/tabs/tabs.tsx @@ -1,4 +1,5 @@ -import type { SessionInfo } from "@opencode-ai/client/promise" +import type { SessionInfo, SessionMessageUser } from "@opencode-ai/client/promise" +import type { ComposerSelection } from "@/composer/adapter" import { createSimpleContext } from "@opencode-ai/ui/context" import { createStore, produce } from "solid-js/store" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/runtime/persistence/storage" @@ -34,6 +35,12 @@ export type DraftTab = { export type Tab = SessionTab | DraftTab +export type PendingSession = { + draft: DraftTab + message: SessionMessageUser + selection: ComposerSelection +} + export type TabInfo = { title?: string directory?: string @@ -83,6 +90,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ createStore>({}), ) const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore([])) + const [pending, setPending] = createStore>({}) const params = useParams() const navigate = useNavigate() @@ -271,6 +279,74 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ memory.remove(`draft:${draftID}`) removeDraftPersisted(draftID) }, + pendingSession(server: ServerConnection.Key, sessionID: string): PendingSession | undefined { + return pending[tabKey({ type: "session", server, sessionId: sessionID })] + }, + prepareSession( + draftID: string, + session: Omit, + preview: { message: SessionMessageUser; selection: ComposerSelection }, + ) { + // Snapshot the draft before replacing its store entry; keep its composer alive for rollback. + const draft = { ...actions.draft(draftID) } + const next = { type: "session" as const, ...session } + const key = tabKey(next) + const ready = startTransition(() => { + setPending(key, { draft, ...preview }) + const index = store.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) + if (index === -1) return + const active = location.pathname === "/new-session" && location.query.draftId === draftID + setStore( + produce((tabs) => { + tabs[index] = next + }), + ) + if (recentKey() === tabKey(draft)) setRecentKey(key) + if (active) navigateTab(next) + }) + + return { + ready, + async complete() { + await ready + if (!pending[key]) return + await startTransition(() => setPending(key, undefined)) + memory.remove(tabKey(draft)) + removeDraftPersisted(draftID) + }, + async rollback(worktree?: string) { + await ready + if (!pending[key]) return + await startTransition(() => { + const index = store.findIndex((tab) => tabKey(tab) === key) + if (index !== -1) { + const restored = worktree === undefined ? draft : { ...draft, worktree, branch: undefined } + const route = currentRoute() + setStore( + produce((tabs) => { + tabs[index] = restored + }), + ) + if (recentKey() === key) setRecentKey(tabKey(restored)) + if ( + route.type === "session" && + route.server === session.server && + route.sessionId === session.sessionId + ) { + navigateTab(restored) + } + } + setPending(key, undefined) + }) + updateClosed((stack) => removeClosedTabs(stack, session.server, [session.sessionId])) + memory.remove(key) + removeInfo(key) + if (store.some((tab) => tab.type === "draft" && tab.draftID === draftID)) return + memory.remove(tabKey(draft)) + removeDraftPersisted(draftID) + }, + } + }, removeTab, // User-initiated close: records the tab so it can be reopened. // Cleanup paths (missing sessions, archive, server removal) go through diff --git a/packages/app/src/shell/titlebar/tab-strip.tsx b/packages/app/src/shell/titlebar/tab-strip.tsx index 8ae11ba749c..0c31517b29a 100644 --- a/packages/app/src/shell/titlebar/tab-strip.tsx +++ b/packages/app/src/shell/titlebar/tab-strip.tsx @@ -92,10 +92,12 @@ function SessionTabEntry(props: { const tabs = useTabs() const language = useLanguage() const sdk = createMemo(() => props.serverCtx?.sdk ?? null) + const pending = createMemo(() => tabs.pendingSession(props.tab.server, props.tab.sessionId)) const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId)) const persisted = createMemo(() => tabs.info[props.id]) const [loadedSession] = createResource( () => { + if (pending()) return null const ctx = props.serverCtx return ctx ? { id: props.tab.sessionId, ctx } : null }, @@ -105,9 +107,9 @@ function SessionTabEntry(props: { .then(() => ctx.data.session.get(id)) .catch(() => undefined), ) - const session = createMemo(() => cachedSession() ?? loadedSession()) - const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session()) - const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title) + const session = createMemo(() => (pending() ? undefined : (cachedSession() ?? loadedSession()))) + const missingSession = createMemo(() => !pending() && !!props.serverCtx && !loadedSession.loading && !session()) + const visible = createMemo(() => !!pending() || !!session() || missingSession() || !!persisted()?.title) const rename = async (title: string) => { const value = session() @@ -170,7 +172,11 @@ function SessionTabEntry(props: { forceTruncate={props.forceTruncate} orientation={props.orientation} session={session()} - fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)} + fallbackTitle={ + pending() + ? language.t("command.session.new") + : (persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)) + } onRename={rename} onNavigate={props.onNavigate} onClose={props.onClose} diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index 5c8254eee93..c06dd97dfc2 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -161,6 +161,7 @@ export function Titlebar(props: { () => { const route = layout.route() if (route.type !== "session") return undefined + if (tabs.pendingSession(route.server, route.sessionId)) return undefined const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server) return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined }, @@ -169,6 +170,7 @@ export function Titlebar(props: { const session = createMemo(() => { const route = layout.route() if (route.type !== "session") return + if (tabs.pendingSession(route.server, route.sessionId)) return const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server) const cached = conn ? global.ensureServerCtx(conn).data.session.get(route.sessionId) : undefined if (cached) return cached @@ -220,6 +222,10 @@ export function Titlebar(props: { } if (route.type === "session") { + if (tabs.pendingSession(route.server, route.sessionId)) { + tabsStoreActions.addSessionTab({ server: route.server, sessionId: route.sessionId }) + return + } const s = session() if (!s) return const sessionId = s.parentID ?? s.id @@ -238,6 +244,12 @@ export function Titlebar(props: { const route = layout.route() switch (route.type) { case "session": { + const pending = tabs.pendingSession(route.server, route.sessionId) + if (pending) { + const model = tabs.stateValue(pending.draft, "prompt")?.model.current() + void tabs.newDraft({ server: route.server, directory: pending.draft.directory }, "", model) + return + } const activeSession = session() if (!activeSession) return