Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 9457bb6f40 fix(app): preserve submitted draft ownership during workspace creation 2026-09-09 18:54:56 +02:00
Christoph Leiter 7e6ad30f9b test(app): lift new-workspace daemon lookups into shared helpers
The navigation guard tests polled for the created workspace and then
fetched it a second time, with a throw in between to narrow away a null
the poll had already ruled out. `waitForCreatedWorkspace` polls once and
returns the entry, so both tests read as user-level flows again.

Moves the agent and terminal counters alongside it, since they answer
the same question — what did the daemon actually end up with — for any
test that creates a workspace without navigating to it.
2026-09-09 18:35:07 +02:00
Christoph Leiter 003b6139f0 fix(app): don't steal focus when workspace creation finishes late
Creating a workspace blocks on one RPC while the daemon runs
`git worktree add` plus branch and forge resolution. If you switch to
another workspace during that wait, the response navigates anyway, and
`navigateToHostWorkspaceRoute` does a stack pop that yanks you off the
workspace you deliberately went to.

Auto-navigation now only fires if you are still on the same New
workspace screen. Creation itself is untouched: the workspace, the
agent, and the terminal are all created either way.

`create_agent` is normally issued by the draft tab once it mounts on
the destination screen, so suppressing the navigation would also
suppress the agent. The suppressed path issues the request itself and
writes no pending entry to the draft-submission or create-flow stores,
which is what keeps a later visit from creating a second agent — the
daemon does not dedupe `create_agent` by `clientMessageId`. The agent
and terminal tabs open on their own when the workspace is next
visited, since `reconcileTabs` auto-opens agents and standalone
terminals.

Nothing navigating also means nothing unmounts the screen, so both
paths now release `pendingAction` themselves.
2026-09-09 18:35:07 +02:00
10 changed files with 695 additions and 18 deletions
@@ -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 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";
@@ -16,6 +17,7 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
@@ -24,6 +26,7 @@ type NewWorkspaceDaemonClient = Pick<
| "enablePlugin"
| "inspectWorkspaceRecovery"
| "listProjects"
| "listTerminals"
| "on"
| "patchDaemonConfig"
| "removeProject"
@@ -622,3 +625,120 @@ export async function delayBrowserAgentCreatedStatus(
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 { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
import { encodeImages } from "@/utils/encode-images";
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import {
@@ -194,15 +194,14 @@ async function submitDraftCreateRequest(input: {
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
});
const imagesData = await encodeImages(images);
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const result = await client.createAgent({
const result = await requestWorkspaceDraftAgent(client, {
config,
workspaceId,
...(text ? { initialPrompt: text } : {}),
text,
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
...(images ? { images } : {}),
...(attachmentsArray ? { attachments: attachmentsArray } : {}),
});
return {
+110 -12
View File
@@ -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 {
@@ -85,10 +86,15 @@ import { useProjectIcons } from "@/projects/icons";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import type { MessagePayload } from "@/composer/types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
import type {
CreatePaseoWorktreeInput,
DaemonClient,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
@@ -123,6 +129,8 @@ import {
type ListTerminalsPayload,
upsertCreatedTerminalPayload,
} from "./workspace/terminals/state";
import { createWorkspaceAgentInBackground } from "./new-workspace/background-handoff";
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
const ThemedFolderPlus = withUnistyles(FolderPlus);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -747,10 +755,19 @@ function normalizeBranchDetails(
return names.map((name) => ({ name, committerDate: 0 }));
}
/**
* "background" means the user left the New workspace screen mid-creation, so nothing navigated
* and the screen — if still mounted under another route — has to drop its pending state itself.
*/
type SubmitOutcome = "navigated" | "background";
interface SubmitDraftInput {
draftVersionAtSubmit: number | undefined;
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
draftContextScopeKey: string | null;
initialSetup?: WorkspaceDraftTabSetup;
workspaceId: string;
workspaceDirectory: string;
@@ -759,6 +776,8 @@ interface SubmitDraftInput {
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
supportsForgeSearch: boolean;
resolveClient: () => DaemonClient;
isStillOnCreateScreen: () => boolean;
}
type NewWorkspaceComposerState = NonNullable<
@@ -860,9 +879,13 @@ interface CreateChatAgentInput {
withInitialAgent: boolean;
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
draftContextScopeKey: string | null;
supportsForgeSearch: boolean;
resolveClient: () => DaemonClient;
isStillOnCreateScreen: () => boolean;
labels: {
composerStateRequired: string;
selectModel: string;
@@ -918,8 +941,9 @@ function buildComposerInitialValues(input: {
return undefined;
}
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
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);
@@ -947,10 +971,13 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
provider,
composerState,
});
submitWorkspaceDraft({
return await submitWorkspaceDraft({
draftVersionAtSubmit,
serverId,
clearDraft,
draftKey: input.draftKey,
draftId: input.draftId,
draftContextScopeKey: input.draftContextScopeKey,
initialSetup,
workspaceId: ensuredWorkspace.id,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
@@ -959,6 +986,8 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
provider,
composerState,
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 {
serverId,
clearDraft,
@@ -1049,6 +1078,37 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
composerState,
initialSetup,
});
// Creation blocks on a slow daemon RPC. If the user moved on while it ran, the destination
// screen's draft tab will never mount to issue create_agent, so this path does it instead.
if (!input.isStillOnCreateScreen()) {
await createWorkspaceAgentInBackground({
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({
serverId,
draftId,
@@ -1082,6 +1142,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
workspaceId,
target: submission.target,
});
return "navigated";
}
function useNewWorkspaceHostSelector(input: {
@@ -1591,6 +1652,7 @@ export function NewWorkspaceScreen({
const isolationPickerAnchorRef = useRef<View>(null);
const hostPickerAnchorRef = useRef<View | null>(null);
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
// Launch target: what the composer submits to (chat agent, or a terminal
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
@@ -1696,11 +1758,12 @@ export function NewWorkspaceScreen({
}, []);
const withConnectedClient = useCallback(() => {
if (!client || !isConnected) {
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
if (!connectedClient?.isConnected) {
throw new Error(t("newWorkspace.errors.hostDisconnected"));
}
return client;
}, [client, isConnected, t]);
return connectedClient;
}, [selectedServerId, t]);
const clientReady = isConnected && Boolean(client);
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
@@ -2043,31 +2106,49 @@ export function NewWorkspaceScreen({
await updateFormPreferences({ launchTarget });
if (isEmptyWorkspaceSubmission(payload)) {
setPendingAction("empty");
let outcome: SubmitOutcome = "background";
await runCreateEmptyWorkspace({
payload,
ensureWorkspace,
serverId: selectedServerId,
navigate: (targetServerId, workspaceId) =>
navigateToWorkspace({ serverId: targetServerId, workspaceId }),
navigate: (targetServerId, workspaceId) => {
if (!isStillOnCreateScreen()) {
return;
}
outcome = "navigated";
navigateToWorkspace({ serverId: targetServerId, workspaceId });
},
});
// Nothing navigated, so this screen may still be mounted under another route. Release
// the pending lock it would otherwise keep forever.
if (outcome === "background") {
setPendingAction(null);
}
return;
}
setPendingAction("chat");
await runCreateChatAgent({
const outcome = await runCreateChatAgent({
payload,
composerState,
forkDraftSetup,
ensureWorkspace,
serverId: selectedServerId,
clearDraft: chatDraft.clear,
draftKey,
draftId,
draftContextScopeKey,
supportsForgeSearch,
resolveClient: withConnectedClient,
isStillOnCreateScreen,
labels: {
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
selectModel: t("newWorkspace.errors.selectModel"),
},
});
if (outcome === "background") {
setPendingAction(null);
}
} catch (error) {
const message = toErrorMessage(error);
setPendingAction(null);
@@ -2077,16 +2158,20 @@ export function NewWorkspaceScreen({
},
[
composerState,
draftContextScopeKey,
draftId,
chatDraft.clear,
draftKey,
ensureWorkspace,
forkDraftSetup,
isStillOnCreateScreen,
launchTarget,
selectedServerId,
supportsForgeSearch,
t,
toast,
updateFormPreferences,
withConnectedClient,
],
);
@@ -2095,6 +2180,7 @@ export function NewWorkspaceScreen({
setErrorMessage(null);
await updateFormPreferences({ launchTarget });
setPendingAction("terminal");
let outcome: SubmitOutcome = "background";
await runCreateTerminalWorkspace({
cwd: selectedSourceDirectory ?? "",
prompt: terminalPromptText,
@@ -2128,9 +2214,20 @@ export function NewWorkspaceScreen({
withConnectedClient().sendTerminalInput(terminalId, { type: "input", data });
},
serverId: selectedServerId,
navigate: (targetServerId, workspaceId, target) =>
navigateToWorkspace({ serverId: targetServerId, workspaceId, target }),
// The terminal is spawned and fed its command before this runs, so skipping the
// navigation costs nothing: it is standalone, and `reconcileTabs` auto-opens standalone
// terminals when the workspace is next visited.
navigate: (targetServerId, workspaceId, target) => {
if (!isStillOnCreateScreen()) {
return;
}
outcome = "navigated";
navigateToWorkspace({ serverId: targetServerId, workspaceId, target });
},
});
if (outcome === "background") {
setPendingAction(null);
}
} catch (error) {
const message = toErrorMessage(error);
setPendingAction(null);
@@ -2139,6 +2236,7 @@ export function NewWorkspaceScreen({
}
}, [
ensureWorkspace,
isStillOnCreateScreen,
launchTarget,
queryClient,
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,
}),
[],
);
}