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.
This commit is contained in:
Mohamed Boudra
2026-10-26 10:29:24 +01:00
parent 3a3ccd5385
commit 0b55d63ab1
12 changed files with 70 additions and 26 deletions
@@ -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();
@@ -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 {
@@ -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();
@@ -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(
@@ -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,
@@ -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<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry {
const projectViewKey = input.projectViewKey ?? input.workspace.projectId;
@@ -203,6 +205,7 @@ export function deriveProjectStatusBucket(input: {
workspaces: readonly SidebarWorkspacePlacement[];
sessions: Record<string, ProjectStatusSession | undefined>;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
}): SidebarStateBucket {
const workspaceIdsByServer = new Map<string, string[]>();
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<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
previousEntries?: ReadonlyMap<string, SidebarWorkspaceEntry>;
}): ReadonlyMap<string, SidebarWorkspaceEntry> {
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);
@@ -55,6 +55,7 @@ export function useSidebarWorkspaceEntries(
placements,
sessions,
pendingCreateAttempts,
workspaceCreations: creations,
previousEntries: previousEntriesRef.current,
}),
);
@@ -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);
+3
View File
@@ -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,
@@ -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,
@@ -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<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): 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;
}),
@@ -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");
});