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"); });