From 0b55d63ab15bed71c64c86a60b8d415b80d28610 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 10:29:24 +0100 Subject: [PATCH] fix(app): preserve workspace creation state through adoption Publish the last draft edit before capturing cleanup ownership and keep published workspaces working while their initial agent creation is pending. Update browser journeys for the normal editable agent panel and make the placement assertion portable across platforms. --- .../e2e/browser/composer-attachments.spec.ts | 11 ++++--- .../new-workspace-composer-draft.spec.ts | 32 +++++++++++-------- .../new-workspace-dictation-submit.spec.ts | 9 ++++-- .../app/e2e/browser/new-workspace.spec.ts | 6 ++-- .../app/src/composer/draft/input-draft.ts | 4 +++ .../hooks/sidebar-workspaces-view-model.ts | 6 ++++ .../hooks/use-sidebar-workspace-entries.ts | 1 + .../src/hooks/use-sidebar-workspaces-list.ts | 4 ++- packages/app/src/panels/agent-panel.tsx | 3 ++ .../app/src/screens/new-workspace-screen.tsx | 4 +++ packages/app/src/workspace-status/index.ts | 13 ++++++++ .../server/agent/create-agent/intent.test.ts | 3 +- 12 files changed, 70 insertions(+), 26 deletions(-) diff --git a/packages/app/e2e/browser/composer-attachments.spec.ts b/packages/app/e2e/browser/composer-attachments.spec.ts index 94a52ace16..694bfa0f80 100644 --- a/packages/app/e2e/browser/composer-attachments.spec.ts +++ b/packages/app/e2e/browser/composer-attachments.spec.ts @@ -13,9 +13,7 @@ import { closeImageLightbox, pressInterruptShortcut, expectComposerDraft, - expectComposerDisabled, expectComposerEditable, - expectAttachButtonDisabled, fillComposerDraft, dropFileOnComposer, sendDraftToQueue, @@ -268,7 +266,9 @@ test.describe("Composer attachments", () => { } }); - test("composer is locked while new workspace agent is being created", async ({ page }) => { + test("composer and attachments stay available while new workspace agent is being created", async ({ + page, + }) => { test.setTimeout(120_000); const serverId = getServerId(); @@ -298,8 +298,9 @@ test.describe("Composer attachments", () => { await agentCreatedDelay.waitForCreateRequest(); await agentCreatedDelay.waitForDelayedCreatedStatus(); - await expectComposerDisabled(page); - await expectAttachButtonDisabled(page); + await expectComposerEditable(page); + await attachImageFromMenu(page, TEST_IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); agentCreatedDelay.release(); diff --git a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts index 906efcfb42..3b4d706dca 100644 --- a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts +++ b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts @@ -1,7 +1,8 @@ +import { observeCreationRequests } from "../support/helpers/creation"; import { composerLocator, enterComposerFullscreen, - exitComposerFullscreen, + fillComposerDraft, resizeComposerViewport, } from "../support/helpers/composer"; import { expect, test } from "../support/fixtures"; @@ -95,6 +96,7 @@ test.describe("New workspace composer draft", () => { test("does not restore a submitted draft after deferred publication", async ({ page }) => { const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" }); + const requests = await observeCreationRequests(page); try { await gotoAppShell(page); @@ -134,6 +136,9 @@ test.describe("New workspace composer draft", () => { }, DRAFT); await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 }); + // Navigation now precedes creation; wait for success before checking consumed input. + await expect.poll(requests.workspaceRequests).toBe(1); + await requests.settled(); await openGlobalNewWorkspaceComposer(page); await expectNewWorkspaceDraft(page, ""); } finally { @@ -142,10 +147,11 @@ test.describe("New workspace composer draft", () => { }); }); -test("fullscreen creation returns inline and keeps submission locked when reopened", async ({ +test("fullscreen creation opens the inline agent composer and keeps follow-up editing available", async ({ page, }) => { const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" }); + const followUpDraft = `${DRAFT}\nKeep this follow-up draft.`; const delay = await delayBrowserWorkspaceCreatedResponse(page); try { await gotoAppShell(page); @@ -157,25 +163,23 @@ test("fullscreen creation returns inline and keeps submission locked when reopen await page.getByRole("button", { name: "Create", exact: true }).click(); await delay.waitForCreateRequest(); - await test.step("submission returns inline with the original draft and spinner", async () => { + await test.step("submission opens the normal agent panel inline", async () => { + await expect(page).toHaveURL(/\/workspace\//); await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0); - await expect(composerLocator(page)).toHaveValue(DRAFT); - await expect(composerLocator(page)).not.toBeEditable(); - await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled(); - await expect(page.getByRole("progressbar")).toBeVisible(); + await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(DRAFT); + await expect(composerLocator(page)).toHaveValue(""); + await expect(composerLocator(page)).toBeEditable(); }); - await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => { + await test.step("fullscreen can reopen to edit a follow-up while creation is pending", async () => { + await fillComposerDraft(page, followUpDraft); await enterComposerFullscreen(page); await expect(page.getByTestId("composer-fullscreen")).toBeVisible(); - await expect(composerLocator(page)).toHaveValue(DRAFT); - await expect(composerLocator(page)).not.toBeEditable(); - await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled(); - await expect(page.getByRole("progressbar")).toBeVisible(); - await exitComposerFullscreen(page); + await expect(composerLocator(page)).toHaveValue(followUpDraft); + await expect(composerLocator(page)).toBeEditable(); }); delay.release(); await page.waitForURL((url) => url.pathname.includes("/workspace/")); - await expect(composerLocator(page)).toHaveValue(""); + await expect(composerLocator(page)).toHaveValue(followUpDraft); await expect(composerLocator(page)).toBeEditable(); expect(delay.agentRequests).toHaveLength(1); } finally { diff --git a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts index 22b2b8a6c5..43ea1b8db5 100644 --- a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts +++ b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts @@ -192,11 +192,16 @@ test.describe("New Workspace dictation submit", () => { await harness.waitForCreateRequest(); const composer = page.getByRole("textbox", { name: "Message agent..." }); - await expect(composer).toHaveValue(TRANSCRIPT); - await expect(composer).not.toBeEditable(); + await expect(page).toHaveURL(/\/workspace\//); + await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText( + TRANSCRIPT, + ); + await expect(composer).toHaveValue(""); + await expect(composer).toBeEditable(); harness.failWorkspaceCreation(); + await expect(page).toHaveURL(/\/new\?/); await expect(composer).toBeEditable(); await expect(composer).toHaveValue(TRANSCRIPT); await expect(page.getByText(CREATE_FAILURE).first()).toBeVisible(); diff --git a/packages/app/e2e/browser/new-workspace.spec.ts b/packages/app/e2e/browser/new-workspace.spec.ts index 7206c9f52e..7b5b321cf4 100644 --- a/packages/app/e2e/browser/new-workspace.spec.ts +++ b/packages/app/e2e/browser/new-workspace.spec.ts @@ -504,7 +504,7 @@ test.describe("New workspace flow", () => { } }); - test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => { + test("opens the final agent tab before agent creation resolves", async ({ page }) => { const serverId = getServerId(); const tempRepo = await createTempGitRepo("new-workspace-optimistic-"); @@ -568,10 +568,10 @@ test.describe("New workspace flow", () => { await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 }); const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]'); - await expect(draftTabs).toHaveCount(1, { timeout: 30_000 }); + await expect(draftTabs).toHaveCount(0); await expect( activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'), - ).toHaveCount(0); + ).toHaveCount(1, { timeout: 30_000 }); agentCreatedDelay.release(); await expect( diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index c14db9a119..c5d66ede09 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -63,6 +63,7 @@ export interface AgentInputDraft { attachments: UserComposerAttachment[]; setAttachments: (updater: AttachmentUpdater) => void; clear: (lifecycle: "sent" | "abandoned") => void; + flush: () => void; isHydrated: boolean; attachmentFocusRequestId: number; composerState: DraftComposerState | null; @@ -172,6 +173,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [draftKey, textPublication], ); + const flushDraft = useCallback(() => textPublication.flush(), [textPublication]); + const replaceText = useCallback( (nextText: string) => { textPublication.cancel(); @@ -351,6 +354,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr return { textSource, editText, + flush: flushDraft, replaceText, textReplacement, attachments, diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index da1086da45..70f7280c9a 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -1,3 +1,4 @@ +import type { CreationAttempt } from "@/workspace-creation/model"; import { deriveEffectiveWorkspaceStatus } from "@/workspace-status"; import equal from "fast-deep-equal"; import type { PrHint } from "@/git/pr-hint"; @@ -146,6 +147,7 @@ export function createSidebarWorkspaceEntry(input: { workspace: WorkspaceDescriptor; projectViewKey?: string; pendingCreateAttempts?: Record; + workspaceCreations?: readonly CreationAttempt[]; workspaceAgentActivity?: ReadonlyMap; }): SidebarWorkspaceEntry { const projectViewKey = input.projectViewKey ?? input.workspace.projectId; @@ -203,6 +205,7 @@ export function deriveProjectStatusBucket(input: { workspaces: readonly SidebarWorkspacePlacement[]; sessions: Record; pendingCreateAttempts?: Record; + workspaceCreations?: readonly CreationAttempt[]; }): SidebarStateBucket { const workspaceIdsByServer = new Map(); for (const placement of input.workspaces) { @@ -230,6 +233,7 @@ export function deriveProjectStatusBucket(input: { serverId, workspace, pendingCreateAttempts: input.pendingCreateAttempts, + workspaceCreations: input.workspaceCreations, workspaceAgentActivity: session.workspaceAgentActivity, }).status, ); @@ -348,6 +352,7 @@ export function buildSidebarWorkspaceEntries(input: { placements: readonly SidebarWorkspacePlacement[]; sessions: SidebarWorkspaceSession[]; pendingCreateAttempts?: Record; + workspaceCreations?: readonly CreationAttempt[]; previousEntries?: ReadonlyMap; }): ReadonlyMap { if (input.placements.length === 0 || input.sessions.length === 0) { @@ -374,6 +379,7 @@ export function buildSidebarWorkspaceEntries(input: { workspace, projectViewKey: placement.projectViewKey, pendingCreateAttempts: input.pendingCreateAttempts, + workspaceCreations: input.workspaceCreations, workspaceAgentActivity: session.workspaceAgentActivity, }); const previousEntry = input.previousEntries?.get(placement.workspaceKey); diff --git a/packages/app/src/hooks/use-sidebar-workspace-entries.ts b/packages/app/src/hooks/use-sidebar-workspace-entries.ts index 8bea1109eb..cf5c0403a7 100644 --- a/packages/app/src/hooks/use-sidebar-workspace-entries.ts +++ b/packages/app/src/hooks/use-sidebar-workspace-entries.ts @@ -55,6 +55,7 @@ export function useSidebarWorkspaceEntries( placements, sessions, pendingCreateAttempts, + workspaceCreations: creations, previousEntries: previousEntriesRef.current, }), ); diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 90745ec57b..b2d2c48190 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -62,6 +62,7 @@ export function useSidebarProjectStatusBucket(input: { enabled: boolean; }): SidebarStateBucket | null { const { workspaces, enabled } = input; + const workspaceCreations = useWorkspaceCreations(); const pendingCreateAttempts = useStoreWithEqualityFn( useCreateFlowStore, (state) => state.pendingByDraftId, @@ -75,9 +76,10 @@ export function useSidebarProjectStatusBucket(input: { workspaces, sessions: state.sessions, pendingCreateAttempts, + workspaceCreations, }); }, - [enabled, pendingCreateAttempts, workspaces], + [enabled, pendingCreateAttempts, workspaces, workspaceCreations], ); return useStoreWithEqualityFn(useSessionStore, selector, Object.is); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 1dc8a59730..18eaacead6 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1151,6 +1151,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ const { textSource, editText, + flush, replaceText, textReplacement, attachments, @@ -1164,6 +1165,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ (): AgentInputDraft => ({ textSource, editText, + flush, replaceText, textReplacement, attachments, @@ -1176,6 +1178,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ [ textSource, editText, + flush, replaceText, textReplacement, attachments, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 3ba75ac3c4..1fbad2174e 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -688,6 +688,7 @@ export function NewWorkspaceScreen({ cwd: selectedSourceDirectory ?? "", }); + const flushChatDraft = chatDraft.flush; const worktreeSupport = selectedProject ? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId }) : "unsupported"; @@ -970,6 +971,8 @@ export function NewWorkspaceScreen({ return; } + // Capture the submitted version after publishing any last-keystroke edit. + flushChatDraft(); const attempt = submitWorkspaceChat({ serverId: selectedServerId, key: creationIdentity.draftId, @@ -1013,6 +1016,7 @@ export function NewWorkspaceScreen({ draftContextScopeKey, creationIdentity, chatDraft.clear, + flushChatDraft, draftKey, ensureWorkspace, forkDraftSetup, diff --git a/packages/app/src/workspace-status/index.ts b/packages/app/src/workspace-status/index.ts index 45bf68ab8e..32dd3133f8 100644 --- a/packages/app/src/workspace-status/index.ts +++ b/packages/app/src/workspace-status/index.ts @@ -1,3 +1,5 @@ +import { useWorkspaceCreations } from "@/workspace-creation"; +import type { CreationAttempt } from "@/workspace-creation/model"; import { useShallow } from "zustand/shallow"; import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; @@ -13,12 +15,21 @@ export function deriveEffectiveWorkspaceStatus(input: { serverId: string; workspace: WorkspaceDescriptor; pendingCreateAttempts?: Record; + workspaceCreations?: readonly CreationAttempt[]; workspaceAgentActivity?: ReadonlyMap; }): EffectiveWorkspaceStatus { if (input.workspace.status !== "done") { return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; } + const creation = input.workspaceCreations?.find( + (attempt) => + attempt.serverId === input.serverId && + attempt.workspaceId === input.workspace.id && + attempt.phase !== "complete", + ); + if (creation) return { status: "running", enteredAt: creation.message?.timestamp ?? null }; + const pendingStartedAt = getPendingInitialAgentCreateStartedAt({ serverId: input.serverId, workspaceId: input.workspace.id, @@ -55,6 +66,7 @@ function getPendingInitialAgentCreateStartedAt(input: { } export function useWorkspaceStatus(serverId: string, workspaceId: string) { + const workspaceCreations = useWorkspaceCreations(); const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId); return useSessionStore( useShallow((state) => { @@ -67,6 +79,7 @@ export function useWorkspaceStatus(serverId: string, workspaceId: string) { serverId, workspace, pendingCreateAttempts, + workspaceCreations, workspaceAgentActivity: session.workspaceAgentActivity, }).status; }), diff --git a/packages/server/src/server/agent/create-agent/intent.test.ts b/packages/server/src/server/agent/create-agent/intent.test.ts index f666206ea2..8419337c29 100644 --- a/packages/server/src/server/agent/create-agent/intent.test.ts +++ b/packages/server/src/server/agent/create-agent/intent.test.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { resolveCreateAgentIntent } from "./intent.js"; @@ -33,7 +34,7 @@ describe("resolveCreateAgentIntent", () => { throw new Error("Unexpected workspace creation"); }, }); - expect(intent.cwd).toBe("/new/packages/app"); + expect(intent.cwd).toBe(resolve("/new", "packages/app")); expect(intent.workspaceId).toBe("workspace-new"); expect(intent.parentAgentId).toBe("parent"); });