mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9457bb6f40 | ||
|
|
7e6ad30f9b | ||
|
|
003b6139f0 |
@@ -0,0 +1,22 @@
|
|||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation";
|
||||||
|
|
||||||
|
test.describe("Delayed workspace creation", () => {
|
||||||
|
test.describe.configure({ timeout: 120_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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { 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 type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||||
import { connectDaemonClient } from "./daemon-client-loader";
|
import { connectDaemonClient } from "./daemon-client-loader";
|
||||||
@@ -16,6 +17,7 @@ type NewWorkspaceDaemonClient = Pick<
|
|||||||
| "connect"
|
| "connect"
|
||||||
| "createPaseoWorktree"
|
| "createPaseoWorktree"
|
||||||
| "createWorkspace"
|
| "createWorkspace"
|
||||||
|
| "fetchAgents"
|
||||||
| "fetchWorkspaces"
|
| "fetchWorkspaces"
|
||||||
| "getPaseoWorktreeList"
|
| "getPaseoWorktreeList"
|
||||||
| "getDaemonConfig"
|
| "getDaemonConfig"
|
||||||
@@ -24,6 +26,7 @@ type NewWorkspaceDaemonClient = Pick<
|
|||||||
| "enablePlugin"
|
| "enablePlugin"
|
||||||
| "inspectWorkspaceRecovery"
|
| "inspectWorkspaceRecovery"
|
||||||
| "listProjects"
|
| "listProjects"
|
||||||
|
| "listTerminals"
|
||||||
| "on"
|
| "on"
|
||||||
| "patchDaemonConfig"
|
| "patchDaemonConfig"
|
||||||
| "removeProject"
|
| "removeProject"
|
||||||
@@ -622,3 +625,120 @@ export async function delayBrowserAgentCreatedStatus(
|
|||||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceCreatedDelayControl {
|
||||||
|
agentRequests: readonly CreateAgentRequestMessage[];
|
||||||
|
release(): void;
|
||||||
|
waitForCreateRequest(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<WorkspaceCreatedDelayControl> {
|
||||||
|
const daemonPortPattern = daemonWsRoutePattern();
|
||||||
|
const agentRequests: CreateAgentRequestMessage[] = [];
|
||||||
|
const createRequestIds = new Set<string>();
|
||||||
|
const delayedForwards: Array<() => void> = [];
|
||||||
|
let releaseRequested = false;
|
||||||
|
let resolveCreateRequest: (() => void) | null = null;
|
||||||
|
const createRequestSeen = new Promise<void>((resolve) => {
|
||||||
|
resolveCreateRequest = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||||
|
const server = ws.connectToServer();
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||||
|
|
||||||
|
if (requestId && createRequestIds.has(requestId) && !releaseRequested) {
|
||||||
|
delayedForwards.push(() => ws.send(message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentRequests,
|
||||||
|
release() {
|
||||||
|
releaseRequested = true;
|
||||||
|
for (const forward of delayedForwards.splice(0)) {
|
||||||
|
forward();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
waitForCreateRequest: () => createRequestSeen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceEntry = Awaited<
|
||||||
|
ReturnType<NewWorkspaceDaemonClient["fetchWorkspaces"]>
|
||||||
|
>["entries"][number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for a workspace the caller did not already know about, and returns it. Tests that create a
|
||||||
|
* workspace without navigating to it have no route to assert on, so the daemon's own list is the
|
||||||
|
* signal that creation finished.
|
||||||
|
*/
|
||||||
|
export async function waitForCreatedWorkspace(
|
||||||
|
client: NewWorkspaceDaemonClient,
|
||||||
|
knownWorkspaceIds: ReadonlySet<string>,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
): Promise<WorkspaceEntry> {
|
||||||
|
let created: WorkspaceEntry | undefined;
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const payload = await client.fetchWorkspaces();
|
||||||
|
created = payload.entries.find((entry) => !knownWorkspaceIds.has(entry.id));
|
||||||
|
return created !== undefined;
|
||||||
|
},
|
||||||
|
{ timeout: options?.timeout ?? 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
if (!created) {
|
||||||
|
throw new Error("Workspace list reported a new workspace but it could not be read back");
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countWorkspaceAgents(
|
||||||
|
client: NewWorkspaceDaemonClient,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||||
|
return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countWorkspaceTerminals(
|
||||||
|
client: NewWorkspaceDaemonClient,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const payload = await client.listTerminals(undefined, undefined, { workspaceId });
|
||||||
|
return payload.terminals.length;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<AgentSnapshotPayload> {
|
||||||
|
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 }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ import type { Agent } from "@/stores/session-store";
|
|||||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||||
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
|
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 type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||||
import {
|
import {
|
||||||
@@ -194,15 +194,14 @@ async function submitDraftCreateRequest(input: {
|
|||||||
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
|
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const imagesData = await encodeImages(images);
|
|
||||||
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
||||||
const result = await client.createAgent({
|
const result = await requestWorkspaceDraftAgent(client, {
|
||||||
config,
|
config,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
...(text ? { initialPrompt: text } : {}),
|
text,
|
||||||
clientMessageId: attempt.clientMessageId,
|
clientMessageId: attempt.clientMessageId,
|
||||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
...(images ? { images } : {}),
|
||||||
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
...(attachmentsArray ? { attachments: attachmentsArray } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||||
import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
|
import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
|
||||||
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import {
|
import {
|
||||||
@@ -85,10 +86,15 @@ import { useProjectIcons } from "@/projects/icons";
|
|||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import type { ComposerAttachment } from "@/attachments/types";
|
import type { ComposerAttachment } from "@/attachments/types";
|
||||||
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
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 { MessagePayload } from "@/composer/types";
|
||||||
import type { UserComposerAttachment } from "@/attachments/types";
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
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 { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
||||||
@@ -123,6 +129,8 @@ import {
|
|||||||
type ListTerminalsPayload,
|
type ListTerminalsPayload,
|
||||||
upsertCreatedTerminalPayload,
|
upsertCreatedTerminalPayload,
|
||||||
} from "./workspace/terminals/state";
|
} from "./workspace/terminals/state";
|
||||||
|
import { createWorkspaceAgentInBackground } from "./new-workspace/background-handoff";
|
||||||
|
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
|
||||||
|
|
||||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
@@ -747,10 +755,19 @@ function normalizeBranchDetails(
|
|||||||
return names.map((name) => ({ name, committerDate: 0 }));
|
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 {
|
interface SubmitDraftInput {
|
||||||
|
draftVersionAtSubmit: number | undefined;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
draftKey: string;
|
||||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||||
draftId?: string;
|
draftId?: string;
|
||||||
|
draftContextScopeKey: string | null;
|
||||||
initialSetup?: WorkspaceDraftTabSetup;
|
initialSetup?: WorkspaceDraftTabSetup;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
@@ -759,6 +776,8 @@ interface SubmitDraftInput {
|
|||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
composerState: NewWorkspaceComposerState;
|
composerState: NewWorkspaceComposerState;
|
||||||
supportsForgeSearch: boolean;
|
supportsForgeSearch: boolean;
|
||||||
|
resolveClient: () => DaemonClient;
|
||||||
|
isStillOnCreateScreen: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type NewWorkspaceComposerState = NonNullable<
|
type NewWorkspaceComposerState = NonNullable<
|
||||||
@@ -860,9 +879,13 @@ interface CreateChatAgentInput {
|
|||||||
withInitialAgent: boolean;
|
withInitialAgent: boolean;
|
||||||
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
draftKey: string;
|
||||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||||
draftId?: string;
|
draftId?: string;
|
||||||
|
draftContextScopeKey: string | null;
|
||||||
supportsForgeSearch: boolean;
|
supportsForgeSearch: boolean;
|
||||||
|
resolveClient: () => DaemonClient;
|
||||||
|
isStillOnCreateScreen: () => boolean;
|
||||||
labels: {
|
labels: {
|
||||||
composerStateRequired: string;
|
composerStateRequired: string;
|
||||||
selectModel: string;
|
selectModel: string;
|
||||||
@@ -918,8 +941,9 @@ function buildComposerInitialValues(input: {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
|
||||||
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
|
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
|
||||||
|
const draftVersionAtSubmit = useDraftStore.getState().drafts[input.draftKey]?.version;
|
||||||
const { text, attachments, cwd } = payload;
|
const { text, attachments, cwd } = payload;
|
||||||
if (!composerState) {
|
if (!composerState) {
|
||||||
throw new Error(input.labels.composerStateRequired);
|
throw new Error(input.labels.composerStateRequired);
|
||||||
@@ -947,10 +971,13 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
|||||||
provider,
|
provider,
|
||||||
composerState,
|
composerState,
|
||||||
});
|
});
|
||||||
submitWorkspaceDraft({
|
return await submitWorkspaceDraft({
|
||||||
|
draftVersionAtSubmit,
|
||||||
serverId,
|
serverId,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
|
draftKey: input.draftKey,
|
||||||
draftId: input.draftId,
|
draftId: input.draftId,
|
||||||
|
draftContextScopeKey: input.draftContextScopeKey,
|
||||||
initialSetup,
|
initialSetup,
|
||||||
workspaceId: ensuredWorkspace.id,
|
workspaceId: ensuredWorkspace.id,
|
||||||
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
||||||
@@ -959,6 +986,8 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
|||||||
provider,
|
provider,
|
||||||
composerState,
|
composerState,
|
||||||
supportsForgeSearch: input.supportsForgeSearch,
|
supportsForgeSearch: input.supportsForgeSearch,
|
||||||
|
resolveClient: input.resolveClient,
|
||||||
|
isStillOnCreateScreen: input.isStillOnCreateScreen,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,7 +1050,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutcome> {
|
||||||
const {
|
const {
|
||||||
serverId,
|
serverId,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
@@ -1049,6 +1078,37 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
|||||||
composerState,
|
composerState,
|
||||||
initialSetup,
|
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({
|
||||||
|
draftVersionAtSubmit: input.draftVersionAtSubmit,
|
||||||
|
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({
|
useCreateFlowStore.getState().setPending({
|
||||||
serverId,
|
serverId,
|
||||||
draftId,
|
draftId,
|
||||||
@@ -1082,6 +1142,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
target: submission.target,
|
target: submission.target,
|
||||||
});
|
});
|
||||||
|
return "navigated";
|
||||||
}
|
}
|
||||||
|
|
||||||
function useNewWorkspaceHostSelector(input: {
|
function useNewWorkspaceHostSelector(input: {
|
||||||
@@ -1591,6 +1652,7 @@ export function NewWorkspaceScreen({
|
|||||||
const isolationPickerAnchorRef = useRef<View>(null);
|
const isolationPickerAnchorRef = useRef<View>(null);
|
||||||
const hostPickerAnchorRef = useRef<View | null>(null);
|
const hostPickerAnchorRef = useRef<View | null>(null);
|
||||||
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
|
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
|
||||||
|
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
|
||||||
|
|
||||||
// Launch target: what the composer submits to (chat agent, or a terminal
|
// Launch target: what the composer submits to (chat agent, or a terminal
|
||||||
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
|
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
|
||||||
@@ -1696,11 +1758,12 @@ export function NewWorkspaceScreen({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const withConnectedClient = useCallback(() => {
|
const withConnectedClient = useCallback(() => {
|
||||||
if (!client || !isConnected) {
|
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
|
||||||
|
if (!connectedClient?.isConnected) {
|
||||||
throw new Error(t("newWorkspace.errors.hostDisconnected"));
|
throw new Error(t("newWorkspace.errors.hostDisconnected"));
|
||||||
}
|
}
|
||||||
return client;
|
return connectedClient;
|
||||||
}, [client, isConnected, t]);
|
}, [selectedServerId, t]);
|
||||||
|
|
||||||
const clientReady = isConnected && Boolean(client);
|
const clientReady = isConnected && Boolean(client);
|
||||||
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
|
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
|
||||||
@@ -2043,31 +2106,49 @@ export function NewWorkspaceScreen({
|
|||||||
await updateFormPreferences({ launchTarget });
|
await updateFormPreferences({ launchTarget });
|
||||||
if (isEmptyWorkspaceSubmission(payload)) {
|
if (isEmptyWorkspaceSubmission(payload)) {
|
||||||
setPendingAction("empty");
|
setPendingAction("empty");
|
||||||
|
let outcome: SubmitOutcome = "background";
|
||||||
await runCreateEmptyWorkspace({
|
await runCreateEmptyWorkspace({
|
||||||
payload,
|
payload,
|
||||||
ensureWorkspace,
|
ensureWorkspace,
|
||||||
serverId: selectedServerId,
|
serverId: selectedServerId,
|
||||||
navigate: (targetServerId, workspaceId) =>
|
navigate: (targetServerId, workspaceId) => {
|
||||||
navigateToWorkspace({ serverId: 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPendingAction("chat");
|
setPendingAction("chat");
|
||||||
await runCreateChatAgent({
|
const outcome = await runCreateChatAgent({
|
||||||
payload,
|
payload,
|
||||||
composerState,
|
composerState,
|
||||||
forkDraftSetup,
|
forkDraftSetup,
|
||||||
ensureWorkspace,
|
ensureWorkspace,
|
||||||
serverId: selectedServerId,
|
serverId: selectedServerId,
|
||||||
clearDraft: chatDraft.clear,
|
clearDraft: chatDraft.clear,
|
||||||
|
draftKey,
|
||||||
draftId,
|
draftId,
|
||||||
|
draftContextScopeKey,
|
||||||
supportsForgeSearch,
|
supportsForgeSearch,
|
||||||
|
resolveClient: withConnectedClient,
|
||||||
|
isStillOnCreateScreen,
|
||||||
labels: {
|
labels: {
|
||||||
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
|
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
|
||||||
selectModel: t("newWorkspace.errors.selectModel"),
|
selectModel: t("newWorkspace.errors.selectModel"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (outcome === "background") {
|
||||||
|
setPendingAction(null);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = toErrorMessage(error);
|
const message = toErrorMessage(error);
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
@@ -2077,16 +2158,20 @@ export function NewWorkspaceScreen({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
composerState,
|
composerState,
|
||||||
|
draftContextScopeKey,
|
||||||
draftId,
|
draftId,
|
||||||
chatDraft.clear,
|
chatDraft.clear,
|
||||||
|
draftKey,
|
||||||
ensureWorkspace,
|
ensureWorkspace,
|
||||||
forkDraftSetup,
|
forkDraftSetup,
|
||||||
|
isStillOnCreateScreen,
|
||||||
launchTarget,
|
launchTarget,
|
||||||
selectedServerId,
|
selectedServerId,
|
||||||
supportsForgeSearch,
|
supportsForgeSearch,
|
||||||
t,
|
t,
|
||||||
toast,
|
toast,
|
||||||
updateFormPreferences,
|
updateFormPreferences,
|
||||||
|
withConnectedClient,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2095,6 +2180,7 @@ export function NewWorkspaceScreen({
|
|||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
await updateFormPreferences({ launchTarget });
|
await updateFormPreferences({ launchTarget });
|
||||||
setPendingAction("terminal");
|
setPendingAction("terminal");
|
||||||
|
let outcome: SubmitOutcome = "background";
|
||||||
await runCreateTerminalWorkspace({
|
await runCreateTerminalWorkspace({
|
||||||
cwd: selectedSourceDirectory ?? "",
|
cwd: selectedSourceDirectory ?? "",
|
||||||
prompt: terminalPromptText,
|
prompt: terminalPromptText,
|
||||||
@@ -2128,9 +2214,20 @@ export function NewWorkspaceScreen({
|
|||||||
withConnectedClient().sendTerminalInput(terminalId, { type: "input", data });
|
withConnectedClient().sendTerminalInput(terminalId, { type: "input", data });
|
||||||
},
|
},
|
||||||
serverId: selectedServerId,
|
serverId: selectedServerId,
|
||||||
navigate: (targetServerId, workspaceId, target) =>
|
// The terminal is spawned and fed its command before this runs, so skipping the
|
||||||
navigateToWorkspace({ serverId: targetServerId, workspaceId, target }),
|
// 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) {
|
} catch (error) {
|
||||||
const message = toErrorMessage(error);
|
const message = toErrorMessage(error);
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
@@ -2139,6 +2236,7 @@ export function NewWorkspaceScreen({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
ensureWorkspace,
|
ensureWorkspace,
|
||||||
|
isStillOnCreateScreen,
|
||||||
launchTarget,
|
launchTarget,
|
||||||
queryClient,
|
queryClient,
|
||||||
selectedServerId,
|
selectedServerId,
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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<unknown>,
|
||||||
|
draftVersionAtSubmit = useDraftStore.getState().drafts[DRAFT_KEY]?.version,
|
||||||
|
) {
|
||||||
|
return createWorkspaceAgentInBackground({
|
||||||
|
draftId: DRAFT_ID,
|
||||||
|
draftKey: DRAFT_KEY,
|
||||||
|
draftVersionAtSubmit,
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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;
|
||||||
|
draftVersionAtSubmit: number | undefined;
|
||||||
|
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||||
|
draftContextScopeKey: string | null;
|
||||||
|
createAgent: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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 === input.draftVersionAtSubmit) {
|
||||||
|
input.clearDraft("sent");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user