Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 57ce016293 docs(hub): explain full prompts and credential recovery 2026-09-09 19:58:09 +02:00
11 changed files with 21 additions and 696 deletions
@@ -1,22 +0,0 @@
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");
});
});
@@ -1,197 +0,0 @@
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,5 +1,4 @@
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";
@@ -17,7 +16,6 @@ type NewWorkspaceDaemonClient = Pick<
| "connect"
| "createPaseoWorktree"
| "createWorkspace"
| "fetchAgents"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
@@ -26,7 +24,6 @@ type NewWorkspaceDaemonClient = Pick<
| "enablePlugin"
| "inspectWorkspaceRecovery"
| "listProjects"
| "listTerminals"
| "on"
| "patchDaemonConfig"
| "removeProject"
@@ -625,120 +622,3 @@ 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;
}
@@ -1,35 +0,0 @@
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 { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import {
@@ -194,14 +194,15 @@ async function submitDraftCreateRequest(input: {
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
});
const imagesData = await encodeImages(images);
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const result = await requestWorkspaceDraftAgent(client, {
const result = await client.createAgent({
config,
workspaceId,
text,
...(text ? { initialPrompt: text } : {}),
clientMessageId: attempt.clientMessageId,
...(images ? { images } : {}),
...(attachmentsArray ? { attachments: attachmentsArray } : {}),
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
});
return {
+12 -110
View File
@@ -57,7 +57,6 @@ 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 {
@@ -86,15 +85,10 @@ 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,
DaemonClient,
} from "@getpaseo/client/internal/daemon-client";
import type { CreatePaseoWorktreeInput } 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";
@@ -129,8 +123,6 @@ 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 });
@@ -755,19 +747,10 @@ 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;
@@ -776,8 +759,6 @@ interface SubmitDraftInput {
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
supportsForgeSearch: boolean;
resolveClient: () => DaemonClient;
isStillOnCreateScreen: () => boolean;
}
type NewWorkspaceComposerState = NonNullable<
@@ -879,13 +860,9 @@ 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;
@@ -941,9 +918,8 @@ function buildComposerInitialValues(input: {
return undefined;
}
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
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);
@@ -971,13 +947,10 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOu
provider,
composerState,
});
return await submitWorkspaceDraft({
draftVersionAtSubmit,
submitWorkspaceDraft({
serverId,
clearDraft,
draftKey: input.draftKey,
draftId: input.draftId,
draftContextScopeKey: input.draftContextScopeKey,
initialSetup,
workspaceId: ensuredWorkspace.id,
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
@@ -986,8 +959,6 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOu
provider,
composerState,
supportsForgeSearch: input.supportsForgeSearch,
resolveClient: input.resolveClient,
isStillOnCreateScreen: input.isStillOnCreateScreen,
});
}
@@ -1050,7 +1021,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
};
}
async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutcome> {
function submitWorkspaceDraft(input: SubmitDraftInput): void {
const {
serverId,
clearDraft,
@@ -1078,37 +1049,6 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutc
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,
@@ -1142,7 +1082,6 @@ async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutc
workspaceId,
target: submission.target,
});
return "navigated";
}
function useNewWorkspaceHostSelector(input: {
@@ -1652,7 +1591,6 @@ 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
@@ -1758,12 +1696,11 @@ export function NewWorkspaceScreen({
}, []);
const withConnectedClient = useCallback(() => {
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
if (!connectedClient?.isConnected) {
if (!client || !isConnected) {
throw new Error(t("newWorkspace.errors.hostDisconnected"));
}
return connectedClient;
}, [selectedServerId, t]);
return client;
}, [client, isConnected, t]);
const clientReady = isConnected && Boolean(client);
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
@@ -2106,49 +2043,31 @@ export function NewWorkspaceScreen({
await updateFormPreferences({ launchTarget });
if (isEmptyWorkspaceSubmission(payload)) {
setPendingAction("empty");
let outcome: SubmitOutcome = "background";
await runCreateEmptyWorkspace({
payload,
ensureWorkspace,
serverId: selectedServerId,
navigate: (targetServerId, workspaceId) => {
if (!isStillOnCreateScreen()) {
return;
}
outcome = "navigated";
navigateToWorkspace({ serverId: targetServerId, workspaceId });
},
navigate: (targetServerId, workspaceId) =>
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");
const outcome = await runCreateChatAgent({
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);
@@ -2158,20 +2077,16 @@ export function NewWorkspaceScreen({
},
[
composerState,
draftContextScopeKey,
draftId,
chatDraft.clear,
draftKey,
ensureWorkspace,
forkDraftSetup,
isStillOnCreateScreen,
launchTarget,
selectedServerId,
supportsForgeSearch,
t,
toast,
updateFormPreferences,
withConnectedClient,
],
);
@@ -2180,7 +2095,6 @@ export function NewWorkspaceScreen({
setErrorMessage(null);
await updateFormPreferences({ launchTarget });
setPendingAction("terminal");
let outcome: SubmitOutcome = "background";
await runCreateTerminalWorkspace({
cwd: selectedSourceDirectory ?? "",
prompt: terminalPromptText,
@@ -2214,20 +2128,9 @@ export function NewWorkspaceScreen({
withConnectedClient().sendTerminalInput(terminalId, { type: "input", data });
},
serverId: selectedServerId,
// 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 });
},
navigate: (targetServerId, workspaceId, target) =>
navigateToWorkspace({ serverId: targetServerId, workspaceId, target }),
});
if (outcome === "background") {
setPendingAction(null);
}
} catch (error) {
const message = toErrorMessage(error);
setPendingAction(null);
@@ -2236,7 +2139,6 @@ export function NewWorkspaceScreen({
}
}, [
ensureWorkspace,
isStillOnCreateScreen,
launchTarget,
queryClient,
selectedServerId,
@@ -1,101 +0,0 @@
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");
});
});
@@ -1,42 +0,0 @@
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");
}
}
@@ -1,22 +0,0 @@
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,
);
});
});
@@ -1,41 +0,0 @@
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,
}),
[],
);
}
+3 -1
View File
@@ -169,7 +169,7 @@ The workflow keeps one classifier branch and one worker branch. It does not dupl
## Prompt and context
Prompt blocks remain ordered. Includes are literal file contents; Hub does not recursively scan partial text.
Prompt blocks remain ordered. Includes are literal file contents; Hub does not recursively scan partial text. Hub sends the rendered prompt separately from the agent title, so the 200-character title limit does not limit your instructions or context.
```yaml
prompt:
@@ -246,4 +246,6 @@ allow_outputs:
The workflow `max_runtime` limits the complete run. Every step has its own `max_runtime` and `idle_timeout`; remaining workflow time caps both. A timeout fails the run and stops later steps.
Hub reconnects to the existing agent after a connection loss. If a run used temporary environment credentials and Hub no longer owns their live lease, recovery fails with `execution_credentials_unavailable`. This includes credentials revoked during Hub shutdown. Start a new run to obtain fresh credentials; an existing agent cannot receive a replacement process environment.
The [configuration reference](/docs/hub/configuration/hub-yml) lists every field. Provider filters and invocation text are in [Triggers](/docs/hub/triggers).