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, + }), + [], + ); +}