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.
This commit is contained in:
Christoph Leiter
2026-09-09 18:35:07 +02:00
committed by Mohamed Boudra
parent 433e67b18b
commit 003b6139f0
9 changed files with 667 additions and 18 deletions
@@ -0,0 +1,255 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
delayBrowserWorkspaceCreatedResponse,
openNewWorkspaceComposer,
openProjectViaDaemon,
} 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";
const PROMPT = "Hello from the navigation guard";
const NAVIGATION_SETTLE_MS = 5_000;
// 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}}}"],
};
async function findUnknownWorkspace(
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
knownWorkspaceIds: ReadonlySet<string>,
) {
const payload = await client.fetchWorkspaces();
return payload.entries.find((candidate) => !knownWorkspaceIds.has(candidate.id)) ?? null;
}
async function countWorkspaceAgents(
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
workspaceId: string,
): Promise<number> {
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length;
}
async function countWorkspaceTerminals(
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
workspaceId: string,
): Promise<number> {
const payload = await client.listTerminals(undefined, undefined, { workspaceId });
return payload.terminals.length;
}
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.
await expect
.poll(async () => (await findUnknownWorkspace(client, knownWorkspaceIds))?.id ?? null, {
timeout: 60_000,
})
.not.toBeNull();
const createdWorkspace = await findUnknownWorkspace(client, knownWorkspaceIds);
if (!createdWorkspace) {
throw new Error("Created workspace disappeared from the daemon");
}
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();
await expect
.poll(async () => (await findUnknownWorkspace(client, knownWorkspaceIds))?.id ?? null, {
timeout: 60_000,
})
.not.toBeNull();
const createdWorkspace = await findUnknownWorkspace(client, knownWorkspaceIds);
if (!createdWorkspace) {
throw new Error("Created workspace disappeared from the daemon");
}
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();
}
});
});
@@ -16,6 +16,7 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
@@ -24,6 +25,7 @@ type NewWorkspaceDaemonClient = Pick<
| "enablePlugin"
| "inspectWorkspaceRecovery"
| "listProjects"
| "listTerminals"
| "on"
| "patchDaemonConfig"
| "removeProject"
@@ -622,3 +624,68 @@ export async function delayBrowserAgentCreatedStatus(
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
};
}
export interface WorkspaceCreatedDelayControl {
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 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 === "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 {
release() {
releaseRequested = true;
for (const forward of delayedForwards.splice(0)) {
forward();
}
},
waitForCreateRequest: () => createRequestSeen,
};
}
@@ -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 {
+109 -12
View File
@@ -85,10 +85,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 +128,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 +754,18 @@ 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 {
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
draftContextScopeKey: string | null;
initialSetup?: WorkspaceDraftTabSetup;
workspaceId: string;
workspaceDirectory: string;
@@ -759,6 +774,8 @@ interface SubmitDraftInput {
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
supportsForgeSearch: boolean;
resolveClient: () => DaemonClient;
isStillOnCreateScreen: () => boolean;
}
type NewWorkspaceComposerState = NonNullable<
@@ -860,9 +877,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,7 +939,7 @@ 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 { text, attachments, cwd } = payload;
if (!composerState) {
@@ -947,10 +968,12 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
provider,
composerState,
});
submitWorkspaceDraft({
return await submitWorkspaceDraft({
serverId,
clearDraft,
draftKey: input.draftKey,
draftId: input.draftId,
draftContextScopeKey: input.draftContextScopeKey,
initialSetup,
workspaceId: ensuredWorkspace.id,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
@@ -959,6 +982,8 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
provider,
composerState,
supportsForgeSearch: input.supportsForgeSearch,
resolveClient: input.resolveClient,
isStillOnCreateScreen: input.isStillOnCreateScreen,
});
}
@@ -1021,7 +1046,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
};
}
function submitWorkspaceDraft(input: SubmitDraftInput): void {
async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutcome> {
const {
serverId,
clearDraft,
@@ -1049,6 +1074,36 @@ 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({
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 +1137,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
workspaceId,
target: submission.target,
});
return "navigated";
}
function useNewWorkspaceHostSelector(input: {
@@ -1591,6 +1647,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
@@ -1695,12 +1752,17 @@ 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(() => {
if (!client || !isConnected) {
const connection = connectionRef.current;
if (!connection.client || !connection.isConnected) {
throw new Error(t("newWorkspace.errors.hostDisconnected"));
}
return client;
}, [client, isConnected, t]);
return connection.client;
}, [t]);
const clientReady = isConnected && Boolean(client);
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
@@ -2043,31 +2105,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 +2157,20 @@ export function NewWorkspaceScreen({
},
[
composerState,
draftContextScopeKey,
draftId,
chatDraft.clear,
draftKey,
ensureWorkspace,
forkDraftSetup,
isStillOnCreateScreen,
launchTarget,
selectedServerId,
supportsForgeSearch,
t,
toast,
updateFormPreferences,
withConnectedClient,
],
);
@@ -2095,6 +2179,7 @@ export function NewWorkspaceScreen({
setErrorMessage(null);
await updateFormPreferences({ launchTarget });
setPendingAction("terminal");
let outcome: SubmitOutcome = "background";
await runCreateTerminalWorkspace({
cwd: selectedSourceDirectory ?? "",
prompt: terminalPromptText,
@@ -2128,9 +2213,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 +2235,7 @@ export function NewWorkspaceScreen({
}
}, [
ensureWorkspace,
isStillOnCreateScreen,
launchTarget,
queryClient,
selectedServerId,
@@ -0,0 +1,90 @@
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>) {
return createWorkspaceAgentInBackground({
draftId: DRAFT_ID,
draftKey: DRAFT_KEY,
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");
});
});
@@ -0,0 +1,43 @@
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;
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> {
const draftVersionAtSubmit = useDraftStore.getState().drafts[input.draftKey]?.version ?? null;
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 === 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,
}),
[],
);
}