mirror of
https://github.com/getpaseo/paseo.git
synced 2026-09-14 20:36:44 +08:00
fix(app): preserve submitted draft ownership during workspace creation
This commit is contained in:
@@ -1,216 +1,22 @@
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
countWorkspaceAgents,
|
||||
countWorkspaceTerminals,
|
||||
delayBrowserWorkspaceCreatedResponse,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
waitForCreatedWorkspace,
|
||||
} 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";
|
||||
import { test } from "../support/fixtures";
|
||||
import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation";
|
||||
|
||||
const PROMPT = "Hello from the navigation guard";
|
||||
test.describe("Delayed workspace creation", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
const NAVIGATION_SETTLE_MS = 5_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");
|
||||
});
|
||||
}
|
||||
|
||||
// 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}}}"],
|
||||
};
|
||||
|
||||
test.describe("New workspace navigation guard", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
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.
|
||||
const createdWorkspace = await waitForCreatedWorkspace(client, knownWorkspaceIds);
|
||||
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();
|
||||
|
||||
const createdWorkspace = await waitForCreatedWorkspace(client, knownWorkspaceIds);
|
||||
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();
|
||||
}
|
||||
test("preserves a newer draft opened while creation is pending", async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "chat", "new-draft");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -626,6 +627,7 @@ export async function delayBrowserAgentCreatedStatus(
|
||||
}
|
||||
|
||||
export interface WorkspaceCreatedDelayControl {
|
||||
agentRequests: readonly CreateAgentRequestMessage[];
|
||||
release(): void;
|
||||
waitForCreateRequest(): Promise<void>;
|
||||
}
|
||||
@@ -638,6 +640,7 @@ export async function delayBrowserWorkspaceCreatedResponse(
|
||||
page: Page,
|
||||
): Promise<WorkspaceCreatedDelayControl> {
|
||||
const daemonPortPattern = daemonWsRoutePattern();
|
||||
const agentRequests: CreateAgentRequestMessage[] = [];
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
@@ -651,6 +654,8 @@ export async function delayBrowserWorkspaceCreatedResponse(
|
||||
|
||||
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) {
|
||||
@@ -680,6 +685,7 @@ export async function delayBrowserWorkspaceCreatedResponse(
|
||||
});
|
||||
|
||||
return {
|
||||
agentRequests,
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import {
|
||||
@@ -761,6 +762,7 @@ function normalizeBranchDetails(
|
||||
type SubmitOutcome = "navigated" | "background";
|
||||
|
||||
interface SubmitDraftInput {
|
||||
draftVersionAtSubmit: number | undefined;
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
@@ -941,6 +943,7 @@ function buildComposerInitialValues(input: {
|
||||
|
||||
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
|
||||
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
|
||||
const draftVersionAtSubmit = useDraftStore.getState().drafts[input.draftKey]?.version;
|
||||
const { text, attachments, cwd } = payload;
|
||||
if (!composerState) {
|
||||
throw new Error(input.labels.composerStateRequired);
|
||||
@@ -969,6 +972,7 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOu
|
||||
composerState,
|
||||
});
|
||||
return await submitWorkspaceDraft({
|
||||
draftVersionAtSubmit,
|
||||
serverId,
|
||||
clearDraft,
|
||||
draftKey: input.draftKey,
|
||||
@@ -1078,6 +1082,7 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutc
|
||||
// screen's draft tab will never mount to issue create_agent, so this path does it instead.
|
||||
if (!input.isStillOnCreateScreen()) {
|
||||
await createWorkspaceAgentInBackground({
|
||||
draftVersionAtSubmit: input.draftVersionAtSubmit,
|
||||
draftId,
|
||||
draftKey: input.draftKey,
|
||||
clearDraft,
|
||||
@@ -1752,17 +1757,13 @@ 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(() => {
|
||||
const connection = connectionRef.current;
|
||||
if (!connection.client || !connection.isConnected) {
|
||||
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
|
||||
if (!connectedClient?.isConnected) {
|
||||
throw new Error(t("newWorkspace.errors.hostDisconnected"));
|
||||
}
|
||||
return connection.client;
|
||||
}, [t]);
|
||||
return connectedClient;
|
||||
}, [selectedServerId, t]);
|
||||
|
||||
const clientReady = isConnected && Boolean(client);
|
||||
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
|
||||
|
||||
@@ -19,11 +19,16 @@ function draftText(): string | undefined {
|
||||
return useDraftStore.getState().drafts[DRAFT_KEY]?.input.text;
|
||||
}
|
||||
|
||||
function run(createAgent: () => Promise<unknown>) {
|
||||
function run(
|
||||
createAgent: () => Promise<unknown>,
|
||||
draftVersionAtSubmit = useDraftStore.getState().drafts[DRAFT_KEY]?.version,
|
||||
) {
|
||||
return createWorkspaceAgentInBackground({
|
||||
draftId: DRAFT_ID,
|
||||
draftKey: DRAFT_KEY,
|
||||
clearDraft: (lifecycle) => useDraftStore.getState().clearDraftInput({ draftKey: DRAFT_KEY, lifecycle }),
|
||||
draftVersionAtSubmit,
|
||||
clearDraft: (lifecycle) =>
|
||||
useDraftStore.getState().clearDraftInput({ draftKey: DRAFT_KEY, lifecycle }),
|
||||
draftContextScopeKey: SCOPE_KEY,
|
||||
createAgent,
|
||||
});
|
||||
@@ -87,4 +92,10 @@ describe("createWorkspaceAgentInBackground", () => {
|
||||
|
||||
expect(draftText()).toBe("a different idea");
|
||||
});
|
||||
it("keeps a newer draft written while workspace creation was pending", async () => {
|
||||
const submittedVersion = useDraftStore.getState().drafts[DRAFT_KEY]?.version;
|
||||
saveDraft("a newer workspace idea");
|
||||
await run(async () => undefined, submittedVersion);
|
||||
expect(draftText()).toBe("a newer workspace idea");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi
|
||||
export interface CreateWorkspaceAgentInBackgroundInput {
|
||||
draftId: string;
|
||||
draftKey: string;
|
||||
draftVersionAtSubmit: number | undefined;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
draftContextScopeKey: string | null;
|
||||
createAgent: () => Promise<unknown>;
|
||||
@@ -23,8 +24,6 @@ export interface CreateWorkspaceAgentInBackgroundInput {
|
||||
export async function createWorkspaceAgentInBackground(
|
||||
input: CreateWorkspaceAgentInBackgroundInput,
|
||||
): Promise<void> {
|
||||
const draftVersionAtSubmit = useDraftStore.getState().drafts[input.draftKey]?.version ?? null;
|
||||
|
||||
await input.createAgent();
|
||||
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId: input.draftId });
|
||||
@@ -37,7 +36,7 @@ export async function createWorkspaceAgentInBackground(
|
||||
// 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) {
|
||||
if (useDraftStore.getState().drafts[input.draftKey]?.version === input.draftVersionAtSubmit) {
|
||||
input.clearDraft("sent");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user