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.
This commit is contained in:
@@ -170,9 +170,12 @@ export async function openNewWorkspaceComposer(
|
|||||||
|
|
||||||
export async function clickNewWorkspaceButton(
|
export async function clickNewWorkspaceButton(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { projectKey: string; projectDisplayName: string },
|
input: { projectKey: string; projectDisplayName: string; prompt?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await openNewWorkspaceComposer(page, input);
|
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
|
const createButton = page
|
||||||
.getByTestId("message-input-root")
|
.getByTestId("message-input-root")
|
||||||
.getByRole("button", { name: "Create" });
|
.getByRole("button", { name: "Create" });
|
||||||
|
|||||||
@@ -306,6 +306,10 @@ test.describe("New workspace flow", () => {
|
|||||||
projectDisplayName: openedProject.projectDisplayName,
|
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
|
const createButton = page
|
||||||
.getByTestId("message-input-root")
|
.getByTestId("message-input-root")
|
||||||
.getByRole("button", { name: "Create" });
|
.getByRole("button", { name: "Create" });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCreateEmptyWorkspace(input: CreateEmptyWorkspaceInput): Promise<void> {
|
||||||
|
const { payload, ensureWorkspace, serverId } = input;
|
||||||
|
const ensuredWorkspace = await ensureWorkspace({
|
||||||
|
cwd: payload.cwd,
|
||||||
|
prompt: "",
|
||||||
|
attachments: [],
|
||||||
|
});
|
||||||
|
navigateToWorkspace(serverId, ensuredWorkspace.id);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/t
|
|||||||
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
|
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
|
||||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
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 { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
|
||||||
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
|
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
|
||||||
|
|
||||||
@@ -398,7 +399,7 @@ export function NewWorkspaceScreen({
|
|||||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||||
typeof normalizeWorkspaceDescriptor
|
typeof normalizeWorkspaceDescriptor
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
const [pendingAction, setPendingAction] = useState<"chat" | null>(null);
|
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null);
|
||||||
const [pickerSelection, setPickerSelection] = useState<PickerSelection | null>(null);
|
const [pickerSelection, setPickerSelection] = useState<PickerSelection | null>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
|
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
|
||||||
@@ -602,11 +603,21 @@ export function NewWorkspaceScreen({
|
|||||||
[buildCreateWorktreeInput, createdWorkspace, mergeWorkspaces, serverId, withConnectedClient],
|
[buildCreateWorktreeInput, createdWorkspace, mergeWorkspaces, serverId, withConnectedClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateChatAgent = useCallback(
|
const handleSubmitNewWorkspace = useCallback(
|
||||||
async (payload: MessagePayload) => {
|
async (payload: MessagePayload) => {
|
||||||
try {
|
try {
|
||||||
setPendingAction("chat");
|
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
if (isEmptyWorkspaceSubmission(payload)) {
|
||||||
|
setPendingAction("empty");
|
||||||
|
await runCreateEmptyWorkspace({
|
||||||
|
payload,
|
||||||
|
ensureWorkspace,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingAction("chat");
|
||||||
await runCreateChatAgent({
|
await runCreateChatAgent({
|
||||||
payload,
|
payload,
|
||||||
composerState,
|
composerState,
|
||||||
@@ -729,11 +740,11 @@ export function NewWorkspaceScreen({
|
|||||||
agentId={`new-workspace:${serverId}:${sourceDirectory}`}
|
agentId={`new-workspace:${serverId}:${sourceDirectory}`}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
isPaneFocused={true}
|
isPaneFocused={true}
|
||||||
onSubmitMessage={handleCreateChatAgent}
|
onSubmitMessage={handleSubmitNewWorkspace}
|
||||||
allowEmptySubmit={true}
|
allowEmptySubmit={true}
|
||||||
submitButtonAccessibilityLabel="Create"
|
submitButtonAccessibilityLabel="Create"
|
||||||
submitIcon="return"
|
submitIcon="return"
|
||||||
isSubmitLoading={pendingAction === "chat"}
|
isSubmitLoading={pendingAction !== null}
|
||||||
submitBehavior="preserve-and-lock"
|
submitBehavior="preserve-and-lock"
|
||||||
blurOnSubmit={true}
|
blurOnSubmit={true}
|
||||||
value={chatDraft.text}
|
value={chatDraft.text}
|
||||||
|
|||||||
Reference in New Issue
Block a user