From 003b6139f0d2660c863d8e0636ff913ac1dfc84c Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Fri, 7 Aug 2026 16:08:08 +0200 Subject: [PATCH] fix(app): don't steal focus when workspace creation finishes late MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a workspace blocks on one RPC while the daemon runs `git worktree add` plus branch and forge resolution. If you switch to another workspace during that wait, the response navigates anyway, and `navigateToHostWorkspaceRoute` does a stack pop that yanks you off the workspace you deliberately went to. Auto-navigation now only fires if you are still on the same New workspace screen. Creation itself is untouched: the workspace, the agent, and the terminal are all created either way. `create_agent` is normally issued by the draft tab once it mounts on the destination screen, so suppressing the navigation would also suppress the agent. The suppressed path issues the request itself and writes no pending entry to the draft-submission or create-flow stores, which is what keeps a later visit from creating a second agent — the daemon does not dedupe `create_agent` by `clientMessageId`. The agent and terminal tabs open on their own when the workspace is next visited, since `reconcileTabs` auto-opens agents and standalone terminals. Nothing navigating also means nothing unmounts the screen, so both paths now release `pendingAction` themselves. --- .../new-workspace-navigation-guard.spec.ts | 255 ++++++++++++++++++ .../app/e2e/support/helpers/new-workspace.ts | 67 +++++ .../composer/draft/create-agent-request.ts | 35 +++ .../app/src/composer/draft/workspace-tab.tsx | 11 +- .../app/src/screens/new-workspace-screen.tsx | 121 ++++++++- .../new-workspace/background-handoff.test.ts | 90 +++++++ .../new-workspace/background-handoff.ts | 43 +++ .../new-workspace/screen-presence.test.ts | 22 ++ .../screens/new-workspace/screen-presence.ts | 41 +++ 9 files changed, 667 insertions(+), 18 deletions(-) create mode 100644 packages/app/e2e/browser/new-workspace-navigation-guard.spec.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..0bbe9c3286 --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-navigation-guard.spec.ts @@ -0,0 +1,255 @@ +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { + archiveLocalWorkspaceFromDaemon, + archiveWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, + delayBrowserWorkspaceCreatedResponse, + openNewWorkspaceComposer, + openProjectViaDaemon, +} from "../support/helpers/new-workspace"; +import { createTempGitRepo } from "../support/helpers/workspace"; +import { expectAppRoute } from "../support/helpers/route-assertions"; +import { getServerId } from "../support/helpers/server-id"; +import { + expectTerminalOutputContains, + expectWorkspaceOpensWithTerminalTab, + fillTerminalPrompt, + seedTerminalProfiles, + selectLaunchOption, + submitTerminalLaunch, + type TerminalProfile, +} from "../support/helpers/new-workspace-launch"; +import { + expectWorkspaceHeader, + switchWorkspaceViaSidebar, + waitForSidebarHydration, +} from "../support/helpers/workspace-ui"; + +const PROMPT = "Hello from the navigation guard"; + +const NAVIGATION_SETTLE_MS = 5_000; + +// The prompt is substituted in as `$0`, so it reaches the process exactly as typed. `sleep` holds +// the terminal open past the daemon polling this test does before it attaches — a process that has +// already exited has no output left to render. +const TERMINAL_PROFILE: TerminalProfile = { + id: "e2e-nav-guard-echo", + name: "Nav Guard Echo", + command: "/bin/sh", + args: ["-c", 'echo captured: "$0"; sleep 120', "{{{prompt}}}"], +}; + +async function findUnknownWorkspace( + client: Awaited>, + knownWorkspaceIds: ReadonlySet, +) { + const payload = await client.fetchWorkspaces(); + return payload.entries.find((candidate) => !knownWorkspaceIds.has(candidate.id)) ?? null; +} + +async function countWorkspaceAgents( + client: Awaited>, + workspaceId: string, +): Promise { + const payload = await client.fetchAgents({ filter: { includeArchived: true } }); + return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length; +} + +async function countWorkspaceTerminals( + client: Awaited>, + workspaceId: string, +): Promise { + const payload = await client.listTerminals(undefined, undefined, { workspaceId }); + return payload.terminals.length; +} + +test.describe("New workspace navigation guard", () => { + let client: Awaited>; + const localWorkspaceIds = new Set(); + const createdWorktreeDirectories = new Set(); + + test.describe.configure({ timeout: 240_000 }); + + test.beforeEach(async () => { + client = await connectNewWorkspaceDaemonClient(); + }); + + test.afterEach(async () => { + if (client) { + for (const workspaceDirectory of createdWorktreeDirectories) { + await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined); + } + for (const workspaceId of localWorkspaceIds) { + await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined); + } + } + createdWorktreeDirectories.clear(); + localWorkspaceIds.clear(); + await client?.close().catch(() => undefined); + }); + + test("stays put when creation resolves after the user moved on, and still starts the agent", async ({ + page, + }) => { + const serverId = getServerId(); + const tempRepo = await createTempGitRepo("new-workspace-nav-guard-"); + const createDelay = await delayBrowserWorkspaceCreatedResponse(page); + + try { + const openedProject = await openProjectViaDaemon(client, tempRepo.path); + localWorkspaceIds.add(openedProject.workspaceId); + const knownWorkspaceIds = new Set( + (await client.fetchWorkspaces()).entries.map((entry) => entry.id), + ); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await switchWorkspaceViaSidebar({ + page, + serverId, + workspaceId: openedProject.workspaceId, + }); + await expectWorkspaceHeader(page, { + title: openedProject.workspaceName, + subtitle: openedProject.projectDisplayName, + }); + + await openNewWorkspaceComposer(page, { + projectKey: openedProject.projectKey, + projectDisplayName: openedProject.projectDisplayName, + }); + + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await expect(composer).toBeVisible({ timeout: 30_000 }); + await composer.fill(PROMPT); + await page.getByTestId("message-input-root").getByRole("button", { name: "Create" }).click(); + + // The daemon is still running `git worktree add`; leave before it answers. + await createDelay.waitForCreateRequest(); + const originalRoute = buildHostWorkspaceRoute(serverId, openedProject.workspaceId); + await switchWorkspaceViaSidebar({ + page, + serverId, + workspaceId: openedProject.workspaceId, + }); + createDelay.release(); + + // Wait for the agent the background path is responsible for creating, then prove the app + // never followed it. Polling the daemon rather than the URL keeps this ordering explicit. + await expect + .poll(async () => (await findUnknownWorkspace(client, knownWorkspaceIds))?.id ?? null, { + timeout: 60_000, + }) + .not.toBeNull(); + + const createdWorkspace = await findUnknownWorkspace(client, knownWorkspaceIds); + if (!createdWorkspace) { + throw new Error("Created workspace disappeared from the daemon"); + } + createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory); + + await expect + .poll(() => countWorkspaceAgents(client, createdWorkspace.id), { timeout: 60_000 }) + .toBe(1); + await expectAppRoute(page, originalRoute); + + // Opening the workspace shows one real agent tab — not an orphaned draft tab, which would + // create a second agent on mount. + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: createdWorkspace.id }); + const deckEntry = page + .getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.id}`) + .filter({ visible: true }); + await expect(deckEntry.locator('[data-testid^="workspace-tab-agent_"]')).toHaveCount(1, { + timeout: 30_000, + }); + await expect(deckEntry.locator('[data-testid^="workspace-tab-draft_"]')).toHaveCount(0); + await expect(deckEntry.getByText(PROMPT).first()).toBeVisible({ timeout: 30_000 }); + expect(await countWorkspaceAgents(client, createdWorkspace.id)).toBe(1); + } finally { + createDelay.release(); + await tempRepo.cleanup(); + } + }); + + test("stays put when a terminal launch resolves after the user moved on, and still spawns the terminal", async ({ + page, + }) => { + const serverId = getServerId(); + const tempRepo = await createTempGitRepo("new-workspace-nav-guard-terminal-"); + const profileSeed = await seedTerminalProfiles([TERMINAL_PROFILE]); + const createDelay = await delayBrowserWorkspaceCreatedResponse(page); + + try { + const openedProject = await openProjectViaDaemon(client, tempRepo.path); + localWorkspaceIds.add(openedProject.workspaceId); + const knownWorkspaceIds = new Set( + (await client.fetchWorkspaces()).entries.map((entry) => entry.id), + ); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await switchWorkspaceViaSidebar({ + page, + serverId, + workspaceId: openedProject.workspaceId, + }); + await expectWorkspaceHeader(page, { + title: openedProject.workspaceName, + subtitle: openedProject.projectDisplayName, + }); + + await openNewWorkspaceComposer(page, { + projectKey: openedProject.projectKey, + projectDisplayName: openedProject.projectDisplayName, + }); + await selectLaunchOption(page, TERMINAL_PROFILE.id); + await fillTerminalPrompt(page, PROMPT); + await submitTerminalLaunch(page); + + await createDelay.waitForCreateRequest(); + const originalRoute = buildHostWorkspaceRoute(serverId, openedProject.workspaceId); + await switchWorkspaceViaSidebar({ + page, + serverId, + workspaceId: openedProject.workspaceId, + }); + createDelay.release(); + + await expect + .poll(async () => (await findUnknownWorkspace(client, knownWorkspaceIds))?.id ?? null, { + timeout: 60_000, + }) + .not.toBeNull(); + + const createdWorkspace = await findUnknownWorkspace(client, knownWorkspaceIds); + if (!createdWorkspace) { + throw new Error("Created workspace disappeared from the daemon"); + } + createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory); + + // Wait for the terminal, not just the workspace. The launch path spawns the terminal and + // then navigates, so asserting the route on the workspace alone would race ahead of the + // navigation this test exists to catch and pass either way. + await expect + .poll(() => countWorkspaceTerminals(client, createdWorkspace.id), { timeout: 60_000 }) + .toBe(1); + // "Nothing navigated" is a negative, so it needs a window rather than a single poll, which + // would pass on its first sample. The daemon lists the terminal before the browser has its + // create response back, and the unguarded code navigates a couple of seconds after that. + await page.waitForTimeout(NAVIGATION_SETTLE_MS); + await expectAppRoute(page, originalRoute); + + // The terminal spawned with the prompt even though nothing navigated, and its tab opens on + // its own when the workspace is finally visited. + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: createdWorkspace.id }); + await expectWorkspaceOpensWithTerminalTab(page); + await expectTerminalOutputContains(page, `captured: ${PROMPT}`); + } finally { + createDelay.release(); + await profileSeed.restore(); + await tempRepo.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 05e8cddc9a..f188c47e5c 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -16,6 +16,7 @@ type NewWorkspaceDaemonClient = Pick< | "connect" | "createPaseoWorktree" | "createWorkspace" + | "fetchAgents" | "fetchWorkspaces" | "getPaseoWorktreeList" | "getDaemonConfig" @@ -24,6 +25,7 @@ type NewWorkspaceDaemonClient = Pick< | "enablePlugin" | "inspectWorkspaceRecovery" | "listProjects" + | "listTerminals" | "on" | "patchDaemonConfig" | "removeProject" @@ -622,3 +624,68 @@ export async function delayBrowserAgentCreatedStatus( waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen, }; } + +export interface WorkspaceCreatedDelayControl { + 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 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 === "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 { + release() { + releaseRequested = true; + for (const forward of delayedForwards.splice(0)) { + forward(); + } + }, + waitForCreateRequest: () => createRequestSeen, + }; +} 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..e06e77bfab 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,8 @@ import { type ListTerminalsPayload, upsertCreatedTerminalPayload, } from "./workspace/terminals/state"; +import { 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 +754,18 @@ 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 { serverId: string; + draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; draftId?: string; + draftContextScopeKey: string | null; initialSetup?: WorkspaceDraftTabSetup; workspaceId: string; workspaceDirectory: string; @@ -759,6 +774,8 @@ interface SubmitDraftInput { provider: AgentProvider; composerState: NewWorkspaceComposerState; supportsForgeSearch: boolean; + resolveClient: () => DaemonClient; + isStillOnCreateScreen: () => boolean; } type NewWorkspaceComposerState = NonNullable< @@ -860,9 +877,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,7 +939,7 @@ 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 { text, attachments, cwd } = payload; if (!composerState) { @@ -947,10 +968,12 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { provider, composerState, }); - submitWorkspaceDraft({ + return await submitWorkspaceDraft({ serverId, clearDraft, + draftKey: input.draftKey, draftId: input.draftId, + draftContextScopeKey: input.draftContextScopeKey, initialSetup, workspaceId: ensuredWorkspace.id, workspaceDirectory: ensuredWorkspace.workspaceDirectory, @@ -959,6 +982,8 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { provider, composerState, supportsForgeSearch: input.supportsForgeSearch, + resolveClient: input.resolveClient, + isStillOnCreateScreen: input.isStillOnCreateScreen, }); } @@ -1021,7 +1046,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: { }; } -function submitWorkspaceDraft(input: SubmitDraftInput): void { +async function submitWorkspaceDraft(input: SubmitDraftInput): Promise { const { serverId, clearDraft, @@ -1049,6 +1074,36 @@ 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({ + draftId, + draftKey: input.draftKey, + clearDraft, + draftContextScopeKey: input.draftContextScopeKey, + 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 +1137,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { workspaceId, target: submission.target, }); + return "navigated"; } function useNewWorkspaceHostSelector(input: { @@ -1591,6 +1647,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 @@ -1695,12 +1752,17 @@ export function NewWorkspaceScreen({ }); }, []); + // Read through refs: creation can outlive the render that started it, and the host runtime + // replaces the DaemonClient on reconnect. A captured client would be closed by then. + const connectionRef = useRef({ client, isConnected }); + connectionRef.current = { client, isConnected }; const withConnectedClient = useCallback(() => { - if (!client || !isConnected) { + const connection = connectionRef.current; + if (!connection.client || !connection.isConnected) { throw new Error(t("newWorkspace.errors.hostDisconnected")); } - return client; - }, [client, isConnected, t]); + return connection.client; + }, [t]); const clientReady = isConnected && Boolean(client); const hasSelectedSourceDirectory = selectedSourceDirectory !== null; @@ -2043,31 +2105,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 +2157,20 @@ export function NewWorkspaceScreen({ }, [ composerState, + draftContextScopeKey, draftId, chatDraft.clear, + draftKey, ensureWorkspace, forkDraftSetup, + isStillOnCreateScreen, launchTarget, selectedServerId, supportsForgeSearch, t, toast, updateFormPreferences, + withConnectedClient, ], ); @@ -2095,6 +2179,7 @@ export function NewWorkspaceScreen({ setErrorMessage(null); await updateFormPreferences({ launchTarget }); setPendingAction("terminal"); + let outcome: SubmitOutcome = "background"; await runCreateTerminalWorkspace({ cwd: selectedSourceDirectory ?? "", prompt: terminalPromptText, @@ -2128,9 +2213,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 +2235,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..953036c88a --- /dev/null +++ b/packages/app/src/screens/new-workspace/background-handoff.test.ts @@ -0,0 +1,90 @@ +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 { 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 run(createAgent: () => Promise) { + return createWorkspaceAgentInBackground({ + draftId: DRAFT_ID, + draftKey: DRAFT_KEY, + clearDraft: (lifecycle) => useDraftStore.getState().clearDraftInput({ draftKey: DRAFT_KEY, lifecycle }), + draftContextScopeKey: SCOPE_KEY, + createAgent, + }); +} + +beforeEach(() => { + useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} }); + useCreateFlowStore.setState({ pendingByDraftId: {} }); + useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } }); + saveDraft("do the thing"); +}); + +describe("createWorkspaceAgentInBackground", () => { + 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("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"); + }); +}); 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..33e99c156f --- /dev/null +++ b/packages/app/src/screens/new-workspace/background-handoff.ts @@ -0,0 +1,43 @@ +import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; +import { useDraftStore } from "@/stores/draft-store"; +import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; + +export interface CreateWorkspaceAgentInBackgroundInput { + draftId: string; + draftKey: string; + clearDraft: (lifecycle: "sent" | "abandoned") => void; + draftContextScopeKey: string | null; + createAgent: () => Promise; +} + +/** + * 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: CreateWorkspaceAgentInBackgroundInput, +): Promise { + const draftVersionAtSubmit = useDraftStore.getState().drafts[input.draftKey]?.version ?? null; + + await input.createAgent(); + + useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId: input.draftId }); + if (input.draftContextScopeKey) { + useWorkspaceAttachmentsStore + .getState() + .clearWorkspaceAttachments({ scopeKey: input.draftContextScopeKey }); + } + // Only on success — a failed creation has no pending-store handoff holding the prompt, so + // clearing would destroy the text needed to retry. And only if nothing else has written to the + // draft since: the New workspace draft key is shared, so the user may have reopened the screen + // and started typing something new while this was in flight. + if (useDraftStore.getState().drafts[input.draftKey]?.version === draftVersionAtSubmit) { + input.clearDraft("sent"); + } +} 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, + }), + [], + ); +}