diff --git a/packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts b/packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts new file mode 100644 index 0000000000..69becc94ab --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts @@ -0,0 +1,22 @@ +import { test } from "../support/fixtures"; +import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation"; + +test.describe("Delayed workspace creation", () => { + test.describe.configure({ timeout: 120_000 }); + + for (const launch of ["chat", "terminal", "empty"] as const) { + test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => { + await verifyDelayedWorkspaceCreation(page, launch, "leave"); + }); + test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => { + await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree"); + }); + test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => { + await verifyDelayedWorkspaceCreation(page, launch, "stay"); + }); + } + + test("preserves a newer draft opened while creation is pending", async ({ page }) => { + await verifyDelayedWorkspaceCreation(page, "chat", "new-draft"); + }); +}); diff --git a/packages/app/e2e/support/helpers/new-workspace-navigation.ts b/packages/app/e2e/support/helpers/new-workspace-navigation.ts new file mode 100644 index 0000000000..6e6220c581 --- /dev/null +++ b/packages/app/e2e/support/helpers/new-workspace-navigation.ts @@ -0,0 +1,197 @@ +import { expect, test, type Page } from "../fixtures"; +import { readFile } from "node:fs/promises"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { gotoAppShell } from "./app"; +import { scrollTimelineToOldestLoadedEdge } from "./timeline-pagination"; +import { + archiveLocalWorkspaceFromDaemon, + archiveWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, + countWorkspaceAgents, + countWorkspaceTerminals, + delayBrowserWorkspaceCreatedResponse, + expectNewWorkspaceDraft, + fillNewWorkspaceDraft, + openNewWorkspaceComposer, + openProjectViaDaemon, + selectWorkspaceIsolation, + submitNewWorkspaceEmpty, + submitNewWorkspacePrompt, + waitForCreatedWorkspace, +} from "./new-workspace"; +import { createTempGitRepo } from "./workspace"; +import { expectAppRoute } from "./route-assertions"; +import { getServerId } from "./server-id"; +import { + expectTerminalOutputContains, + expectWorkspaceOpensWithTerminalTab, + fillTerminalPrompt, + seedTerminalProfiles, + selectLaunchOption, + submitTerminalLaunch, + type TerminalProfile, +} from "./new-workspace-launch"; +import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./workspace-ui"; +import { dropFileOnComposer, expectAttachmentPill } from "./composer"; + +const PROMPT = "Hello from the navigation guard"; +const NEXT_PROMPT = "Keep this newer draft"; +const CONTEXT = { + name: "context.json", + mimeType: "application/json", + buffer: Buffer.from('{"preserve":"attachment"}'), +}; +const PROFILE: TerminalProfile = { + id: "e2e-nav-guard-echo", + name: "Nav Guard Echo", + command: "/bin/sh", + args: ["-c", 'echo captured: "$0"; sleep 120', "{{{prompt}}}"], +}; + +export async function verifyDelayedWorkspaceCreation( + page: Page, + launch: "chat" | "terminal" | "empty", + destination: "leave" | "stay" | "new-draft", + isolation: "local" | "worktree" = "local", +): Promise { + const client = await connectNewWorkspaceDaemonClient(); + const repo = await createTempGitRepo("workspace-focus-"); + const profileSeed = await seedTerminalProfiles([PROFILE]); + const delay = await delayBrowserWorkspaceCreatedResponse(page); + const serverId = getServerId(); + let localWorkspaceId: string | undefined; + let createdDirectory: string | undefined; + let newDraftRoute = ""; + + try { + const project = await openProjectViaDaemon(client, repo.path); + localWorkspaceId = project.workspaceId; + const knownIds = new Set((await client.fetchWorkspaces()).entries.map((entry) => entry.id)); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId }); + + await test.step("Submit workspace creation with the requested content", async () => { + await openNewWorkspaceComposer(page, project); + await selectWorkspaceIsolation(page, isolation); + if (launch === "chat") { + await dropFileOnComposer(page, CONTEXT); + await expectAttachmentPill(page, "composer-file-attachment-pill"); + await submitNewWorkspacePrompt(page, PROMPT); + } else if (launch === "terminal") { + await selectLaunchOption(page, PROFILE.id); + await fillTerminalPrompt(page, PROMPT); + await submitTerminalLaunch(page); + } else { + await submitNewWorkspaceEmpty(page); + } + await delay.waitForCreateRequest(); + }); + + await test.step("Choose where to work while the creation response is held", async () => { + if (destination !== "stay") { + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId }); + } + if (destination === "new-draft") { + await openNewWorkspaceComposer(page, project); + await fillNewWorkspaceDraft(page, NEXT_PROMPT); + const url = new URL(page.url()); + newDraftRoute = url.pathname + url.search; + } + // Recording pacing only; the response is held deterministically above. + if (process.env.E2E_RECORD_VIDEO === "1") { + await page.mouse.move(1100, 80); + await page.waitForTimeout(1500); + } + delay.release(); + }); + + const created = await waitForCreatedWorkspace(client, knownIds); + createdDirectory = created.workspaceDirectory; + expect(created.workspaceKind).toBe(isolation === "worktree" ? "worktree" : "local_checkout"); + await test.step("Completion creates exactly the requested resource and preserves focus", async () => { + await expect + .poll(() => countWorkspaceAgents(client, created.id)) + .toBe(launch === "chat" ? 1 : 0); + await expect + .poll(() => countWorkspaceTerminals(client, created.id)) + .toBe(launch === "terminal" ? 1 : 0); + // A negative navigation assertion needs an observation window after completion. + await page.waitForTimeout(5000); + const expectedRoute = + destination === "new-draft" + ? newDraftRoute + : buildHostWorkspaceRoute( + serverId, + destination === "stay" ? created.id : project.workspaceId, + ); + await expectAppRoute(page, expectedRoute); + if (destination === "new-draft") await expectNewWorkspaceDraft(page, NEXT_PROMPT); + if (launch === "chat") { + expect(delay.agentRequests).toHaveLength(1); + const upload = delay.agentRequests[0]?.attachments?.find( + (item) => item.type === "uploaded_file", + ); + if (!upload) throw new Error("The create request did not contain the submitted file"); + expect(await readFile(upload.path)).toEqual(CONTEXT.buffer); + expect(delay.agentRequests[0]).toMatchObject({ + workspaceId: created.id, + initialPrompt: PROMPT, + config: { + provider: "mock", + model: "ten-second-stream", + modeId: "load-test", + thinkingOptionId: "low", + cwd: created.workspaceDirectory, + }, + attachments: [ + expect.objectContaining({ + fileName: CONTEXT.name, + mimeType: CONTEXT.mimeType, + size: CONTEXT.buffer.length, + }), + ], + }); + } + }); + + await test.step("Visit the new workspace and return without submitting a second draft", async () => { + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id }); + if (launch === "chat") { + await scrollTimelineToOldestLoadedEdge(page); + await expect(page.getByText(PROMPT, { exact: true }).first()).toBeVisible(); + } else if (launch === "terminal") { + await expectWorkspaceOpensWithTerminalTab(page); + await expectTerminalOutputContains(page, `captured: ${PROMPT}`); + } + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId }); + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id }); + await expect + .poll(() => countWorkspaceAgents(client, created.id)) + .toBe(launch === "chat" ? 1 : 0); + await expect + .poll(() => countWorkspaceTerminals(client, created.id)) + .toBe(launch === "terminal" ? 1 : 0); + if (launch === "chat") { + expect(delay.agentRequests).toHaveLength(1); + await openNewWorkspaceComposer(page, project); + await expectNewWorkspaceDraft(page, destination === "new-draft" ? NEXT_PROMPT : ""); + } + if (process.env.E2E_RECORD_VIDEO === "1") { + await page.mouse.move(1100, 80); + await page.waitForTimeout(1500); + } + }); + } catch (error) { + await page.screenshot({ path: test.info().outputPath("before-cleanup.png") }); + console.error(error); + throw error; + } finally { + delay.release(); + if (createdDirectory) await archiveWorkspaceFromDaemon(client, createdDirectory); + if (localWorkspaceId) await archiveLocalWorkspaceFromDaemon(client, localWorkspaceId); + await profileSeed.restore(); + await client.close(); + await repo.cleanup(); + } +} diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 05e8cddc9a..5cebc309b1 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -1,4 +1,5 @@ import { expect, type BrowserContext, type Page } from "@playwright/test"; +import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages"; import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client"; import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; import { connectDaemonClient } from "./daemon-client-loader"; @@ -16,6 +17,7 @@ type NewWorkspaceDaemonClient = Pick< | "connect" | "createPaseoWorktree" | "createWorkspace" + | "fetchAgents" | "fetchWorkspaces" | "getPaseoWorktreeList" | "getDaemonConfig" @@ -24,6 +26,7 @@ type NewWorkspaceDaemonClient = Pick< | "enablePlugin" | "inspectWorkspaceRecovery" | "listProjects" + | "listTerminals" | "on" | "patchDaemonConfig" | "removeProject" @@ -622,3 +625,120 @@ export async function delayBrowserAgentCreatedStatus( waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen, }; } + +export interface WorkspaceCreatedDelayControl { + agentRequests: readonly CreateAgentRequestMessage[]; + release(): void; + waitForCreateRequest(): Promise; +} + +/** + * Holds `workspace.create.response` so a test can act as the user does while the daemon is still + * running `git worktree add` — most importantly, navigate somewhere else. + */ +export async function delayBrowserWorkspaceCreatedResponse( + page: Page, +): Promise { + const daemonPortPattern = daemonWsRoutePattern(); + const agentRequests: CreateAgentRequestMessage[] = []; + const createRequestIds = new Set(); + const delayedForwards: Array<() => void> = []; + let releaseRequested = false; + let resolveCreateRequest: (() => void) | null = null; + const createRequestSeen = new Promise((resolve) => { + resolveCreateRequest = resolve; + }); + + await page.routeWebSocket(daemonPortPattern, (ws) => { + const server = ws.connectToServer(); + + ws.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + if (sessionMessage?.type === "create_agent_request") + agentRequests.push(sessionMessage as CreateAgentRequestMessage); + if (sessionMessage?.type === "workspace.create.request") { + const requestId = getStringField(sessionMessage, "requestId"); + if (requestId) { + createRequestIds.add(requestId); + resolveCreateRequest?.(); + } + } + server.send(message); + }); + + server.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + const payload = + sessionMessage?.type === "workspace.create.response" && + typeof sessionMessage.payload === "object" + ? (sessionMessage.payload as Record) + : null; + const requestId = payload ? getStringField(payload, "requestId") : null; + + if (requestId && createRequestIds.has(requestId) && !releaseRequested) { + delayedForwards.push(() => ws.send(message)); + return; + } + + ws.send(message); + }); + }); + + return { + agentRequests, + release() { + releaseRequested = true; + for (const forward of delayedForwards.splice(0)) { + forward(); + } + }, + waitForCreateRequest: () => createRequestSeen, + }; +} + +type WorkspaceEntry = Awaited< + ReturnType +>["entries"][number]; + +/** + * Waits for a workspace the caller did not already know about, and returns it. Tests that create a + * workspace without navigating to it have no route to assert on, so the daemon's own list is the + * signal that creation finished. + */ +export async function waitForCreatedWorkspace( + client: NewWorkspaceDaemonClient, + knownWorkspaceIds: ReadonlySet, + options?: { timeout?: number }, +): Promise { + let created: WorkspaceEntry | undefined; + await expect + .poll( + async () => { + const payload = await client.fetchWorkspaces(); + created = payload.entries.find((entry) => !knownWorkspaceIds.has(entry.id)); + return created !== undefined; + }, + { timeout: options?.timeout ?? 60_000 }, + ) + .toBe(true); + if (!created) { + throw new Error("Workspace list reported a new workspace but it could not be read back"); + } + return created; +} + +export async function countWorkspaceAgents( + client: NewWorkspaceDaemonClient, + workspaceId: string, +): Promise { + const payload = await client.fetchAgents({ filter: { includeArchived: true } }); + return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length; +} + +export async function countWorkspaceTerminals( + client: NewWorkspaceDaemonClient, + workspaceId: string, +): Promise { + const payload = await client.listTerminals(undefined, undefined, { workspaceId }); + return payload.terminals.length; +} diff --git a/packages/app/src/composer/draft/create-agent-request.ts b/packages/app/src/composer/draft/create-agent-request.ts new file mode 100644 index 0000000000..23fa22e8c9 --- /dev/null +++ b/packages/app/src/composer/draft/create-agent-request.ts @@ -0,0 +1,35 @@ +import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types"; +import type { AgentSnapshotPayload, CreateAgentRequestMessage } from "@getpaseo/protocol/messages"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { encodeImages } from "@/utils/encode-images"; +import type { UserMessageImageAttachment } from "@/types/stream"; + +export interface WorkspaceDraftAgentRequest { + workspaceId: string; + config: AgentSessionConfig; + text: string; + clientMessageId: string; + images?: UserMessageImageAttachment[]; + attachments?: CreateAgentRequestMessage["attachments"]; +} + +/** + * Shared by the workspace draft tab and by the new-workspace screen when it finishes creation + * after the user has already navigated away and no draft tab will ever mount. + */ +export async function requestWorkspaceDraftAgent( + client: DaemonClient, + request: WorkspaceDraftAgentRequest, +): Promise { + const images = await encodeImages(request.images); + return await client.createAgent({ + config: request.config, + workspaceId: request.workspaceId, + clientMessageId: request.clientMessageId, + ...(request.text ? { initialPrompt: request.text } : {}), + ...(images && images.length > 0 ? { images } : {}), + ...(request.attachments && request.attachments.length > 0 + ? { attachments: request.attachments } + : {}), + }); +} diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 93c800cbf9..4dca9df166 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -24,7 +24,7 @@ import type { Agent } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; -import { encodeImages } from "@/utils/encode-images"; +import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import { @@ -194,15 +194,14 @@ async function submitDraftCreateRequest(input: { featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues, }); - const imagesData = await encodeImages(images); const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; - const result = await client.createAgent({ + const result = await requestWorkspaceDraftAgent(client, { config, workspaceId, - ...(text ? { initialPrompt: text } : {}), + text, clientMessageId: attempt.clientMessageId, - ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - ...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}), + ...(images ? { images } : {}), + ...(attachmentsArray ? { attachments: attachmentsArray } : {}), }); return { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index ca189cf7b4..7f7b6b13a8 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -85,10 +85,15 @@ import { useProjectIcons } from "@/projects/icons"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; +import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request"; +import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import type { MessagePayload } from "@/composer/types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; -import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; +import type { + CreatePaseoWorktreeInput, + DaemonClient, +} from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; @@ -123,6 +128,11 @@ import { type ListTerminalsPayload, upsertCreatedTerminalPayload, } from "./workspace/terminals/state"; +import { + captureWorkspaceDraftCleanup, + createWorkspaceAgentInBackground, +} from "./new-workspace/background-handoff"; +import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence"; const ThemedFolderPlus = withUnistyles(FolderPlus); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -747,10 +757,19 @@ function normalizeBranchDetails( return names.map((name) => ({ name, committerDate: 0 })); } +/** + * "background" means the user left the New workspace screen mid-creation, so nothing navigated + * and the screen — if still mounted under another route — has to drop its pending state itself. + */ +type SubmitOutcome = "navigated" | "background"; + interface SubmitDraftInput { + clearConsumedDraft: () => void; serverId: string; + draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; draftId?: string; + draftContextScopeKey: string | null; initialSetup?: WorkspaceDraftTabSetup; workspaceId: string; workspaceDirectory: string; @@ -759,6 +778,8 @@ interface SubmitDraftInput { provider: AgentProvider; composerState: NewWorkspaceComposerState; supportsForgeSearch: boolean; + resolveClient: () => DaemonClient; + isStillOnCreateScreen: () => boolean; } type NewWorkspaceComposerState = NonNullable< @@ -860,9 +881,13 @@ interface CreateChatAgentInput { withInitialAgent: boolean; }) => Promise>; serverId: string; + draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; draftId?: string; + draftContextScopeKey: string | null; supportsForgeSearch: boolean; + resolveClient: () => DaemonClient; + isStillOnCreateScreen: () => boolean; labels: { composerStateRequired: string; selectModel: string; @@ -918,8 +943,9 @@ function buildComposerInitialValues(input: { return undefined; } -async function runCreateChatAgent(input: CreateChatAgentInput): Promise { +async function runCreateChatAgent(input: CreateChatAgentInput): Promise { const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input; + const clearConsumedDraft = captureWorkspaceDraftCleanup(input); const { text, attachments, cwd } = payload; if (!composerState) { throw new Error(input.labels.composerStateRequired); @@ -947,10 +973,13 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { provider, composerState, }); - submitWorkspaceDraft({ + return await submitWorkspaceDraft({ + clearConsumedDraft, serverId, clearDraft, + draftKey: input.draftKey, draftId: input.draftId, + draftContextScopeKey: input.draftContextScopeKey, initialSetup, workspaceId: ensuredWorkspace.id, workspaceDirectory: ensuredWorkspace.workspaceDirectory, @@ -959,6 +988,8 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { provider, composerState, supportsForgeSearch: input.supportsForgeSearch, + resolveClient: input.resolveClient, + isStillOnCreateScreen: input.isStillOnCreateScreen, }); } @@ -1021,7 +1052,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: { }; } -function submitWorkspaceDraft(input: SubmitDraftInput): void { +async function submitWorkspaceDraft(input: SubmitDraftInput): Promise { const { serverId, clearDraft, @@ -1049,6 +1080,33 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { composerState, initialSetup, }); + // Creation blocks on a slow daemon RPC. If the user moved on while it ran, the destination + // screen's draft tab will never mount to issue create_agent, so this path does it instead. + if (!input.isStillOnCreateScreen()) { + await createWorkspaceAgentInBackground({ + clearConsumedDraft: input.clearConsumedDraft, + createAgent: () => + requestWorkspaceDraftAgent(input.resolveClient(), { + workspaceId, + config: buildWorkspaceDraftAgentConfig({ + provider: submission.provider, + cwd: submission.cwd, + ...(submission.modeId ? { modeId: submission.modeId } : {}), + ...(submission.model ? { model: submission.model } : {}), + ...(submission.thinkingOptionId + ? { thinkingOptionId: submission.thinkingOptionId } + : {}), + ...(submission.featureValues ? { featureValues: submission.featureValues } : {}), + }), + text: text.trim(), + clientMessageId, + ...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}), + ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), + }), + }); + return "background"; + } + useCreateFlowStore.getState().setPending({ serverId, draftId, @@ -1082,6 +1140,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { workspaceId, target: submission.target, }); + return "navigated"; } function useNewWorkspaceHostSelector(input: { @@ -1591,6 +1650,7 @@ export function NewWorkspaceScreen({ const isolationPickerAnchorRef = useRef(null); const hostPickerAnchorRef = useRef(null); const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId }); + const isStillOnCreateScreen = useNewWorkspaceScreenPresence(); // Launch target: what the composer submits to (chat agent, or a terminal // profile). Mirrors useWorkspaceIsolation's pattern below: the derived @@ -1696,11 +1756,12 @@ export function NewWorkspaceScreen({ }, []); const withConnectedClient = useCallback(() => { - if (!client || !isConnected) { + const connectedClient = getHostRuntimeStore().getClient(selectedServerId); + if (!connectedClient?.isConnected) { throw new Error(t("newWorkspace.errors.hostDisconnected")); } - return client; - }, [client, isConnected, t]); + return connectedClient; + }, [selectedServerId, t]); const clientReady = isConnected && Boolean(client); const hasSelectedSourceDirectory = selectedSourceDirectory !== null; @@ -2043,31 +2104,49 @@ export function NewWorkspaceScreen({ await updateFormPreferences({ launchTarget }); if (isEmptyWorkspaceSubmission(payload)) { setPendingAction("empty"); + let outcome: SubmitOutcome = "background"; await runCreateEmptyWorkspace({ payload, ensureWorkspace, serverId: selectedServerId, - navigate: (targetServerId, workspaceId) => - navigateToWorkspace({ serverId: targetServerId, workspaceId }), + navigate: (targetServerId, workspaceId) => { + if (!isStillOnCreateScreen()) { + return; + } + outcome = "navigated"; + navigateToWorkspace({ serverId: targetServerId, workspaceId }); + }, }); + // Nothing navigated, so this screen may still be mounted under another route. Release + // the pending lock it would otherwise keep forever. + if (outcome === "background") { + setPendingAction(null); + } return; } setPendingAction("chat"); - await runCreateChatAgent({ + const outcome = await runCreateChatAgent({ payload, composerState, forkDraftSetup, ensureWorkspace, serverId: selectedServerId, clearDraft: chatDraft.clear, + draftKey, draftId, + draftContextScopeKey, supportsForgeSearch, + resolveClient: withConnectedClient, + isStillOnCreateScreen, labels: { composerStateRequired: t("newWorkspace.errors.composerStateRequired"), selectModel: t("newWorkspace.errors.selectModel"), }, }); + if (outcome === "background") { + setPendingAction(null); + } } catch (error) { const message = toErrorMessage(error); setPendingAction(null); @@ -2077,16 +2156,20 @@ export function NewWorkspaceScreen({ }, [ composerState, + draftContextScopeKey, draftId, chatDraft.clear, + draftKey, ensureWorkspace, forkDraftSetup, + isStillOnCreateScreen, launchTarget, selectedServerId, supportsForgeSearch, t, toast, updateFormPreferences, + withConnectedClient, ], ); @@ -2095,6 +2178,7 @@ export function NewWorkspaceScreen({ setErrorMessage(null); await updateFormPreferences({ launchTarget }); setPendingAction("terminal"); + let outcome: SubmitOutcome = "background"; await runCreateTerminalWorkspace({ cwd: selectedSourceDirectory ?? "", prompt: terminalPromptText, @@ -2128,9 +2212,20 @@ export function NewWorkspaceScreen({ withConnectedClient().sendTerminalInput(terminalId, { type: "input", data }); }, serverId: selectedServerId, - navigate: (targetServerId, workspaceId, target) => - navigateToWorkspace({ serverId: targetServerId, workspaceId, target }), + // The terminal is spawned and fed its command before this runs, so skipping the + // navigation costs nothing: it is standalone, and `reconcileTabs` auto-opens standalone + // terminals when the workspace is next visited. + navigate: (targetServerId, workspaceId, target) => { + if (!isStillOnCreateScreen()) { + return; + } + outcome = "navigated"; + navigateToWorkspace({ serverId: targetServerId, workspaceId, target }); + }, }); + if (outcome === "background") { + setPendingAction(null); + } } catch (error) { const message = toErrorMessage(error); setPendingAction(null); @@ -2139,6 +2234,7 @@ export function NewWorkspaceScreen({ } }, [ ensureWorkspace, + isStillOnCreateScreen, launchTarget, queryClient, selectedServerId, diff --git a/packages/app/src/screens/new-workspace/background-handoff.test.ts b/packages/app/src/screens/new-workspace/background-handoff.test.ts new file mode 100644 index 0000000000..a84369fdf4 --- /dev/null +++ b/packages/app/src/screens/new-workspace/background-handoff.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { useDraftStore } from "@/stores/draft-store"; +import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; +import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; +import { + captureWorkspaceDraftCleanup, + createWorkspaceAgentInBackground, +} from "./background-handoff"; + +const DRAFT_ID = "draft_1"; +const DRAFT_KEY = "new-workspace"; +const SCOPE_KEY = "draft:draft_1"; + +function saveDraft(text: string) { + useDraftStore + .getState() + .saveDraftInput({ draftKey: DRAFT_KEY, draft: { text, attachments: [] } }); +} + +function draftText(): string | undefined { + return useDraftStore.getState().drafts[DRAFT_KEY]?.input.text; +} + +function captureCleanup() { + return captureWorkspaceDraftCleanup({ + draftId: DRAFT_ID, + draftKey: DRAFT_KEY, + clearDraft: (lifecycle) => + useDraftStore.getState().clearDraftInput({ draftKey: DRAFT_KEY, lifecycle }), + draftContextScopeKey: SCOPE_KEY, + }); +} + +function run(createAgent: () => Promise, clearConsumedDraft = captureCleanup()) { + return createWorkspaceAgentInBackground({ createAgent, clearConsumedDraft }); +} + +beforeEach(() => { + useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} }); + useCreateFlowStore.setState({ pendingByDraftId: {} }); + useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } }); + saveDraft("do the thing"); +}); + +describe("createWorkspaceAgentInBackground", () => { + it("preserves context added to a newer draft without editing its text", async () => { + await run(async () => { + useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({ + scopeKey: SCOPE_KEY, + attachment: { + kind: "chat_history", + id: "fork-context", + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Preserve this context", + }, + source: { serverId: "local", agentId: "agent-1" }, + }, + }); + useWorkspaceDraftSubmissionStore.getState().setDraftSetup({ + draftId: DRAFT_ID, + setup: { + cwd: "/work/new-repo", + provider: "codex", + modeId: null, + model: null, + thinkingOptionId: null, + featureValues: {}, + }, + }); + }); + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1); + expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe( + "/work/new-repo", + ); + }); + + it("creates one agent and clears the consumed draft setup, attachments and input", async () => { + let calls = 0; + useWorkspaceDraftSubmissionStore.getState().setDraftSetup({ + draftId: DRAFT_ID, + setup: { + cwd: "/work/repo", + provider: "claude", + modeId: null, + model: null, + thinkingOptionId: null, + featureValues: {}, + }, + }); + + await run(async () => { + calls += 1; + }); + + expect(calls).toBe(1); + expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId).toEqual({}); + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toBeUndefined(); + expect(draftText()).toBe(""); + }); + + it("preserves context changed while workspace creation was pending", async () => { + const clearConsumedDraft = captureCleanup(); + useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({ + scopeKey: SCOPE_KEY, + attachment: { + kind: "chat_history", + id: "fork-context", + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Preserve this context", + }, + source: { serverId: "local", agentId: "agent-1" }, + }, + }); + await run(async () => undefined, clearConsumedDraft); + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1); + expect(draftText()).toBe("do the thing"); + }); + + it("leaves no pending submission behind, so no draft tab can create a second agent", async () => { + await run(async () => undefined); + + expect(useWorkspaceDraftSubmissionStore.getState().pendingByDraftId).toEqual({}); + expect(useCreateFlowStore.getState().pendingByDraftId).toEqual({}); + }); + + it("rethrows a failed creation and keeps the prompt for retry", async () => { + const failure = new Error("daemon exploded"); + + await expect( + run(async () => { + throw failure; + }), + ).rejects.toThrow(failure); + + expect(draftText()).toBe("do the thing"); + }); + + it("keeps a prompt typed into the shared draft while creation was in flight", async () => { + await run(async () => { + saveDraft("a different idea"); + }); + + expect(draftText()).toBe("a different idea"); + }); + it("keeps a newer draft written while workspace creation was pending", async () => { + useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({ + scopeKey: SCOPE_KEY, + attachment: { + kind: "chat_history", + id: "fork-context", + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Preserve this context", + }, + source: { serverId: "local", agentId: "agent-1" }, + }, + }); + const clearConsumedDraft = captureCleanup(); + saveDraft("a newer workspace idea"); + await run(async () => undefined, clearConsumedDraft); + expect(draftText()).toBe("a newer workspace idea"); + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1); + }); +}); diff --git a/packages/app/src/screens/new-workspace/background-handoff.ts b/packages/app/src/screens/new-workspace/background-handoff.ts new file mode 100644 index 0000000000..598772f1a1 --- /dev/null +++ b/packages/app/src/screens/new-workspace/background-handoff.ts @@ -0,0 +1,54 @@ +import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; +import { useDraftStore } from "@/stores/draft-store"; +import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; + +interface WorkspaceDraftCleanupInput { + draftId?: string; + draftKey: string; + clearDraft: (lifecycle: "sent" | "abandoned") => void; + draftContextScopeKey: string | null; +} + +/** Capture ownership before workspace creation starts, not when its response arrives. */ +export function captureWorkspaceDraftCleanup(input: WorkspaceDraftCleanupInput): () => void { + const draftId = input.draftId?.trim() ?? ""; + const scopeKey = input.draftContextScopeKey ?? ""; + const draftVersion = useDraftStore.getState().drafts[input.draftKey]?.version; + const setup = useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId]; + const attachments = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]; + + return () => { + // These stores update independently. A context-only edit does not bump the text version. + // Any newer draft state owns the whole draft, including its unchanged context. + if ( + useDraftStore.getState().drafts[input.draftKey]?.version !== draftVersion || + useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId] !== setup || + useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey] !== attachments + ) { + return; + } + useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId }); + if (scopeKey) { + useWorkspaceAttachmentsStore.getState().clearWorkspaceAttachments({ scopeKey }); + } + input.clearDraft("sent"); + }; +} + +/** + * The workspace draft tab normally issues create_agent once it mounts on the destination screen. + * When the user leaves the New workspace screen before creation resolves, that tab never mounts, + * so this path issues the request itself. The agent tab opens on its own when the workspace is + * next visited — `reconcileTabs` auto-opens agents. + * + * The caller must not write a pending entry to the draft-submission or create-flow stores on this + * path: those drive the draft tab's auto-submit, and the daemon does not dedupe create_agent by + * clientMessageId, so a leftover entry would create a second agent when the workspace is opened. + */ +export async function createWorkspaceAgentInBackground(input: { + clearConsumedDraft: () => void; + createAgent: () => Promise; +}): Promise { + await input.createAgent(); + input.clearConsumedDraft(); +} diff --git a/packages/app/src/screens/new-workspace/screen-presence.test.ts b/packages/app/src/screens/new-workspace/screen-presence.test.ts new file mode 100644 index 0000000000..7213583c13 --- /dev/null +++ b/packages/app/src/screens/new-workspace/screen-presence.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { isNewWorkspaceScreenActive } from "./screen-presence"; + +describe("isNewWorkspaceScreenActive", () => { + it("is active while the screen is mounted and the route is /new", () => { + expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/new" })).toBe(true); + }); + + it("is inactive once the screen has been popped off the stack", () => { + expect(isNewWorkspaceScreenActive({ isMounted: false, pathname: "/new" })).toBe(false); + }); + + it("is inactive while another route sits on top of the screen", () => { + expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/settings" })).toBe(false); + }); + + it("is inactive on a workspace route", () => { + expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/host/workspace/wks_1" })).toBe( + false, + ); + }); +}); diff --git a/packages/app/src/screens/new-workspace/screen-presence.ts b/packages/app/src/screens/new-workspace/screen-presence.ts new file mode 100644 index 0000000000..812138e399 --- /dev/null +++ b/packages/app/src/screens/new-workspace/screen-presence.ts @@ -0,0 +1,41 @@ +import { usePathname } from "expo-router"; +import { useCallback, useEffect, useRef } from "react"; + +const NEW_WORKSPACE_PATHNAME = "/new"; + +export interface NewWorkspaceScreenPresence { + isMounted: boolean; + pathname: string; +} + +/** + * Workspace creation blocks on a slow daemon RPC, so by the time it resolves the user may have + * moved on. Both signals are needed: popping to a workspace unmounts the screen while its last + * observed pathname stays "/new", and pushing a route on top of it keeps it mounted underneath. + */ +export function isNewWorkspaceScreenActive(input: NewWorkspaceScreenPresence): boolean { + return input.isMounted && input.pathname === NEW_WORKSPACE_PATHNAME; +} + +export function useNewWorkspaceScreenPresence(): () => boolean { + const pathname = usePathname(); + const pathnameRef = useRef(pathname); + pathnameRef.current = pathname; + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + return useCallback( + () => + isNewWorkspaceScreenActive({ + isMounted: isMountedRef.current, + pathname: pathnameRef.current, + }), + [], + ); +}