From bc385b5930c012bdb92b0e983530e2edb57f234d Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Wed, 9 Sep 2026 19:45:24 +0200 Subject: [PATCH] fix(app): don't steal focus when workspace creation finishes late (#2986) Preserve the selected workspace when delayed creation finishes, while still starting the requested agent or terminal. Keep normal navigation when the initiating screen remains active, and preserve newer draft text and context. Verified with before/after recordings, ten passing real-browser journeys using isolated daemons, focused regression tests, green CI, and independent review. QA: https://github.com/getpaseo/paseo/tree/qa/workspace-focus-2986 Co-authored-by: Christoph Leiter --- .../new-workspace-navigation-guard.spec.ts | 22 ++ .../helpers/new-workspace-navigation.ts | 197 ++++++++++++++++++ .../app/e2e/support/helpers/new-workspace.ts | 120 +++++++++++ .../composer/draft/create-agent-request.ts | 35 ++++ .../app/src/composer/draft/workspace-tab.tsx | 11 +- .../app/src/screens/new-workspace-screen.tsx | 120 +++++++++-- .../new-workspace/background-handoff.test.ts | 176 ++++++++++++++++ .../new-workspace/background-handoff.ts | 54 +++++ .../new-workspace/screen-presence.test.ts | 22 ++ .../screens/new-workspace/screen-presence.ts | 41 ++++ 10 files changed, 780 insertions(+), 18 deletions(-) create mode 100644 packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts create mode 100644 packages/app/e2e/support/helpers/new-workspace-navigation.ts create mode 100644 packages/app/src/composer/draft/create-agent-request.ts create mode 100644 packages/app/src/screens/new-workspace/background-handoff.test.ts create mode 100644 packages/app/src/screens/new-workspace/background-handoff.ts create mode 100644 packages/app/src/screens/new-workspace/screen-presence.test.ts create mode 100644 packages/app/src/screens/new-workspace/screen-presence.ts 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, + }), + [], + ); +}