From c34fb26359ec4fae6f93969441b4503a97c7d28e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 9 May 2026 18:44:40 +0800 Subject: [PATCH] feat(app): create empty workspace without a prompt (#834) * feat(app): create empty workspace from new-workspace composer Pressing Create with an empty composer (no text, no attachments) on the new-workspace screen now creates the workspace without an agent and lands the user on the empty-state, where they can open a terminal or start an agent on their own. Dispatching submit handler routes to a named runCreateEmptyWorkspace sibling of runCreateChatAgent so empty creation is a first-class action, not a hidden branch in the chat path. * refactor(app): extract empty-workspace helpers into own module Gives the empty-workspace dispatch a real home, shrinks the test mock graph from ~20 modules to one. * test(app): type prompt in new-workspace e2e to exercise chat path Empty-submission now creates a bare workspace (no agent), so tests that assert agent-tab/optimistic-draft behavior must type a prompt to take the chat path. --- packages/app/e2e/helpers/new-workspace.ts | 5 +- packages/app/e2e/new-workspace.spec.ts | 4 ++ .../src/screens/new-workspace-empty.test.ts | 55 +++++++++++++++++++ .../app/src/screens/new-workspace-empty.ts | 28 ++++++++++ .../app/src/screens/new-workspace-screen.tsx | 21 +++++-- 5 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 packages/app/src/screens/new-workspace-empty.test.ts create mode 100644 packages/app/src/screens/new-workspace-empty.ts diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 0d4794f95..4bee74449 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -170,9 +170,12 @@ export async function openNewWorkspaceComposer( export async function clickNewWorkspaceButton( page: Page, - input: { projectKey: string; projectDisplayName: string }, + input: { projectKey: string; projectDisplayName: string; prompt?: string }, ): Promise { await openNewWorkspaceComposer(page, input); + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await expect(composer).toBeVisible({ timeout: 30_000 }); + await composer.fill(input.prompt ?? "Hello from e2e"); const createButton = page .getByTestId("message-input-root") .getByRole("button", { name: "Create" }); diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index adcbb674a..5823510b1 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -306,6 +306,10 @@ test.describe("New workspace flow", () => { projectDisplayName: openedProject.projectDisplayName, }); + const composer = page.getByRole("textbox", { name: "Message agent..." }); + await expect(composer).toBeVisible({ timeout: 30_000 }); + await composer.fill("Hello from e2e"); + const createButton = page .getByTestId("message-input-root") .getByRole("button", { name: "Create" }); diff --git a/packages/app/src/screens/new-workspace-empty.test.ts b/packages/app/src/screens/new-workspace-empty.test.ts new file mode 100644 index 000000000..c73166648 --- /dev/null +++ b/packages/app/src/screens/new-workspace-empty.test.ts @@ -0,0 +1,55 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { ComposerAttachment } from "@/attachments/types"; +import type { MessagePayload } from "@/components/message-input"; + +const navigateToWorkspace = vi.hoisted(() => vi.fn()); +vi.mock("@/hooks/use-workspace-navigation", () => ({ navigateToWorkspace })); + +let isEmptyWorkspaceSubmission: typeof import("./new-workspace-empty").isEmptyWorkspaceSubmission; +let runCreateEmptyWorkspace: typeof import("./new-workspace-empty").runCreateEmptyWorkspace; + +beforeAll(async () => { + ({ isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } = await import("./new-workspace-empty")); +}); + +function payload( + input: { text?: string; attachments?: ComposerAttachment[] } = {}, +): MessagePayload { + return { text: input.text ?? "", attachments: input.attachments ?? [], cwd: "/sample/repo" }; +} + +describe("runCreateEmptyWorkspace", () => { + it("creates a workspace without prompt or attachments and navigates to it", async () => { + const workspace = { id: "workspace-123" }; + const ensureWorkspace = vi.fn().mockResolvedValue(workspace); + + await runCreateEmptyWorkspace({ payload: payload(), ensureWorkspace, serverId: "server-abc" }); + + expect(ensureWorkspace).toHaveBeenCalledOnce(); + expect(ensureWorkspace).toHaveBeenCalledWith({ + cwd: "/sample/repo", + prompt: "", + attachments: [], + }); + expect(navigateToWorkspace).toHaveBeenCalledOnce(); + expect(navigateToWorkspace).toHaveBeenCalledWith("server-abc", workspace.id); + }); +}); + +describe("isEmptyWorkspaceSubmission", () => { + it("treats whitespace-only text with no attachments as empty, but any attachment as non-empty", () => { + const attachment: ComposerAttachment = { + kind: "image", + metadata: { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "image-1", + createdAt: 0, + }, + }; + + expect(isEmptyWorkspaceSubmission(payload({ text: " \n\t " }))).toBe(true); + expect(isEmptyWorkspaceSubmission(payload({ attachments: [attachment] }))).toBe(false); + }); +}); diff --git a/packages/app/src/screens/new-workspace-empty.ts b/packages/app/src/screens/new-workspace-empty.ts new file mode 100644 index 000000000..c293d0e2b --- /dev/null +++ b/packages/app/src/screens/new-workspace-empty.ts @@ -0,0 +1,28 @@ +import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; +import type { normalizeWorkspaceDescriptor } from "@/stores/session-store"; +import type { MessagePayload } from "@/components/message-input"; +import type { AgentAttachment } from "@server/shared/messages"; + +export function isEmptyWorkspaceSubmission(payload: MessagePayload): boolean { + return !payload.text.trim() && payload.attachments.length === 0; +} + +export interface CreateEmptyWorkspaceInput { + payload: MessagePayload; + ensureWorkspace: (input: { + cwd: string; + prompt: string; + attachments: AgentAttachment[]; + }) => Promise>; + serverId: string; +} + +export async function runCreateEmptyWorkspace(input: CreateEmptyWorkspaceInput): Promise { + const { payload, ensureWorkspace, serverId } = input; + const ensuredWorkspace = await ensureWorkspace({ + cwd: payload.cwd, + prompt: "", + attachments: [], + }); + navigateToWorkspace(serverId, ensuredWorkspace.id); +} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 451dd1ca2..fe2ce1baf 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -30,6 +30,7 @@ import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/t import type { ImageAttachment, MessagePayload } from "@/components/message-input"; import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages"; import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item"; import { syncPickerPrAttachment } from "./new-workspace-picker-state"; @@ -398,7 +399,7 @@ export function NewWorkspaceScreen({ const [createdWorkspace, setCreatedWorkspace] = useState | null>(null); - const [pendingAction, setPendingAction] = useState<"chat" | null>(null); + const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null); const [pickerSelection, setPickerSelection] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [pickerSearchQuery, setPickerSearchQuery] = useState(""); @@ -602,11 +603,21 @@ export function NewWorkspaceScreen({ [buildCreateWorktreeInput, createdWorkspace, mergeWorkspaces, serverId, withConnectedClient], ); - const handleCreateChatAgent = useCallback( + const handleSubmitNewWorkspace = useCallback( async (payload: MessagePayload) => { try { - setPendingAction("chat"); setErrorMessage(null); + if (isEmptyWorkspaceSubmission(payload)) { + setPendingAction("empty"); + await runCreateEmptyWorkspace({ + payload, + ensureWorkspace, + serverId, + }); + return; + } + + setPendingAction("chat"); await runCreateChatAgent({ payload, composerState, @@ -729,11 +740,11 @@ export function NewWorkspaceScreen({ agentId={`new-workspace:${serverId}:${sourceDirectory}`} serverId={serverId} isPaneFocused={true} - onSubmitMessage={handleCreateChatAgent} + onSubmitMessage={handleSubmitNewWorkspace} allowEmptySubmit={true} submitButtonAccessibilityLabel="Create" submitIcon="return" - isSubmitLoading={pendingAction === "chat"} + isSubmitLoading={pendingAction !== null} submitBehavior="preserve-and-lock" blurOnSubmit={true} value={chatDraft.text}