Fix optimistic workspace creation on Android
This commit is contained in:
@@ -110,6 +110,33 @@ See `image-picker-repro.yaml` for an example.
|
|||||||
|
|
||||||
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
|
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
|
||||||
|
|
||||||
|
### New Workspace Creation
|
||||||
|
|
||||||
|
The Android workspace-creation regression has a dedicated harness:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/test-workspace-create-android-crash.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For a short recording that starts after launch/connection/sidebar setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/record-workspace-create-android-focus.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The flow details are documented in `packages/app/maestro/README.md`. The important rule is that a valid new-workspace assertion must prove the redirect completed: select a real model, tap `Create`, wait for `workspace-header-title`, wait for `message-input-root`, assert `New workspace` is gone, and assert the Android redbox strings are absent. Waiting for the composer alone is too weak because it can still be the `/new` route after a validation error.
|
||||||
|
|
||||||
|
New workspace scenarios should compose the reusable subflows in `packages/app/maestro/flows/`:
|
||||||
|
|
||||||
|
- `android-dev-client.yaml`
|
||||||
|
- `connect-direct-if-welcome.yaml`
|
||||||
|
- `open-prepared-project-sidebar.yaml`
|
||||||
|
- `new-workspace-open-from-sidebar.yaml`
|
||||||
|
- `new-workspace-select-codex-gpt54.yaml`
|
||||||
|
- `new-workspace-submit-and-assert-created.yaml`
|
||||||
|
|
||||||
|
The workspace-create shell scripts render those subflows into a temp directory before running Maestro, which keeps nested `runFlow` paths and `${PASEO_MAESTRO_*}` placeholders working together.
|
||||||
|
|
||||||
### Inputs that Maestro types into
|
### Inputs that Maestro types into
|
||||||
|
|
||||||
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
|
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { expect, test } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
import {
|
import {
|
||||||
archiveWorkspaceFromDaemon,
|
archiveWorkspaceFromDaemon,
|
||||||
@@ -28,6 +28,108 @@ import {
|
|||||||
workspaceLabelFromPath,
|
workspaceLabelFromPath,
|
||||||
} from "./helpers/workspace-ui";
|
} from "./helpers/workspace-ui";
|
||||||
|
|
||||||
|
type WebSocketMessage = string | Buffer;
|
||||||
|
|
||||||
|
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||||
|
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawMessage);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||||
|
const envelope = parseWebSocketJson(message);
|
||||||
|
if (!envelope || typeof envelope !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||||
|
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof maybeEnvelope.message !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return maybeEnvelope.message as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringField(input: Record<string, unknown>, key: string): string | null {
|
||||||
|
const value = input[key];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delayBrowserAgentCreatedStatus(page: Page) {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||||
|
const createRequestIds = new Set<string>();
|
||||||
|
const delayedForwards: Array<() => void> = [];
|
||||||
|
let releaseRequested = false;
|
||||||
|
let resolveCreateRequest: (() => void) | null = null;
|
||||||
|
let resolveDelayedCreatedStatus: (() => void) | null = null;
|
||||||
|
const createRequestSeen = new Promise<void>((resolve) => {
|
||||||
|
resolveCreateRequest = resolve;
|
||||||
|
});
|
||||||
|
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
|
||||||
|
resolveDelayedCreatedStatus = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||||
|
const server = ws.connectToServer();
|
||||||
|
|
||||||
|
ws.onMessage((message) => {
|
||||||
|
const sessionMessage = getSessionMessage(message);
|
||||||
|
if (sessionMessage?.type === "create_agent_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 === "status" && typeof sessionMessage.payload === "object"
|
||||||
|
? (sessionMessage.payload as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||||
|
|
||||||
|
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
|
||||||
|
resolveDelayedCreatedStatus?.();
|
||||||
|
if (releaseRequested) {
|
||||||
|
ws.send(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delayedForwards.push(() => ws.send(message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send(message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
release() {
|
||||||
|
releaseRequested = true;
|
||||||
|
for (const forward of delayedForwards.splice(0)) {
|
||||||
|
forward();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
waitForCreateRequest: () => createRequestSeen,
|
||||||
|
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("New workspace flow", () => {
|
test.describe("New workspace flow", () => {
|
||||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||||
const localWorkspaceIds = new Set<string>();
|
const localWorkspaceIds = new Set<string>();
|
||||||
@@ -191,7 +293,7 @@ test.describe("New workspace flow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
|
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
@@ -262,12 +364,87 @@ test.describe("New workspace flow", () => {
|
|||||||
await agentTabs.first().click();
|
await agentTabs.first().click();
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||||
} finally {
|
} finally {
|
||||||
await tempRepo.cleanup();
|
await tempRepo.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
|
||||||
|
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||||
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: openedProject.workspaceId,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: openedProject.workspaceName,
|
||||||
|
subtitle: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
await openNewWorkspaceComposer(page, {
|
||||||
|
projectKey: openedProject.projectKey,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createButton = page
|
||||||
|
.getByTestId("message-input-root")
|
||||||
|
.getByRole("button", { name: "Create" });
|
||||||
|
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
await agentCreatedDelay.waitForCreateRequest();
|
||||||
|
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||||
|
|
||||||
|
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||||
|
serverId,
|
||||||
|
previousWorkspaceId: openedProject.workspaceId,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
|
||||||
|
{
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeWorkspaceDeckEntry = page
|
||||||
|
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
|
||||||
|
.filter({ visible: true });
|
||||||
|
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
|
||||||
|
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
await expect(
|
||||||
|
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
agentCreatedDelay.release();
|
||||||
|
await expect(
|
||||||
|
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
||||||
|
).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
agentCreatedDelay.release();
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Maestro Flows
|
||||||
|
|
||||||
|
This directory contains local mobile UI flows. Keep flows small enough that a
|
||||||
|
failure screenshot proves the intended behavior, not just that the app launched.
|
||||||
|
|
||||||
|
## New Workspace Android Flow
|
||||||
|
|
||||||
|
Use these files when debugging or extending workspace creation on Android:
|
||||||
|
|
||||||
|
- `test-workspace-create-android-crash.sh` runs the full regression harness.
|
||||||
|
- `workspace-create-android-crash.yaml` is the full Maestro flow used by the
|
||||||
|
harness.
|
||||||
|
- `record-workspace-create-android-focus.sh` records only the focused repro
|
||||||
|
window after setup.
|
||||||
|
- `workspace-create-android-ready-sidebar.yaml` stages the app with the Android
|
||||||
|
sidebar open and a prepared project visible.
|
||||||
|
- `workspace-create-android-create-focused.yaml` starts from that staged sidebar
|
||||||
|
and performs the actual workspace creation.
|
||||||
|
|
||||||
|
The reusable pieces live in `flows/`:
|
||||||
|
|
||||||
|
- `flows/android-dev-client.yaml` handles Expo dev launcher/dev menu screens.
|
||||||
|
- `flows/connect-direct-if-welcome.yaml` connects to the local daemon only when
|
||||||
|
the welcome screen is visible.
|
||||||
|
- `flows/open-prepared-project-sidebar.yaml` waits for the home screen, opens
|
||||||
|
the compact Android sidebar, and waits for the prepared project.
|
||||||
|
- `flows/new-workspace-open-from-sidebar.yaml` taps the project row's
|
||||||
|
new-workspace action and waits for `/new`.
|
||||||
|
- `flows/new-workspace-select-codex-gpt54.yaml` selects a real provider/model.
|
||||||
|
- `flows/new-workspace-submit-and-assert-created.yaml` taps `Create` and proves
|
||||||
|
the app landed on the created workspace.
|
||||||
|
|
||||||
|
Compose new workspace scenarios out of these primitives instead of copying the
|
||||||
|
old full flow. The shell scripts render the top-level flows and every `flows/*.yaml`
|
||||||
|
file into the same temp directory, so nested `runFlow: flows/...` paths keep
|
||||||
|
working with `${PASEO_MAESTRO_*}` placeholders.
|
||||||
|
|
||||||
|
The flow is intentionally strict. It must:
|
||||||
|
|
||||||
|
1. Open a prepared project from the daemon.
|
||||||
|
2. Tap the project row's new-workspace action.
|
||||||
|
3. Select an actual provider/model before tapping `Create`.
|
||||||
|
4. Tap `Create`.
|
||||||
|
5. Assert the app lands on a workspace header and the draft composer.
|
||||||
|
6. Assert `New workspace`, `Select a model`, and the Android redbox text are not
|
||||||
|
visible.
|
||||||
|
7. For the shell harness, grep logcat for `failed to insert view` and
|
||||||
|
`specified child already has a parent`.
|
||||||
|
|
||||||
|
Do not weaken this flow to only wait for `message-input-root`. That can pass on
|
||||||
|
the wrong route. The header assertion and the `New workspace` negative assertion
|
||||||
|
are what prove the redirect actually completed.
|
||||||
|
|
||||||
|
The scripts assume a development build with package id `sh.paseo.debug`, an
|
||||||
|
already-running local daemon on `127.0.0.1:6767`, and a connected Android device
|
||||||
|
or emulator. They call `adb reverse tcp:6767 tcp:6767`; they do not restart the
|
||||||
|
daemon.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/test-workspace-create-android-crash.sh
|
||||||
|
bash packages/app/maestro/record-workspace-create-android-focus.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PASEO_MAESTRO_APP_ID=sh.paseo.debug
|
||||||
|
PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
|
||||||
|
PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
|
||||||
|
PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
|
||||||
|
```
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Handle Expo dev launcher/dev menu screens when testing Android development
|
||||||
|
# builds. Release builds no-op through these optional/conditional steps.
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "DEVELOPMENT SERVERS"
|
||||||
|
commands:
|
||||||
|
# Expo dev launcher text matching is flaky on Android here; tap the first
|
||||||
|
# development server row by coordinate as the fallback.
|
||||||
|
- tapOn:
|
||||||
|
point: "50%,8%"
|
||||||
|
|
||||||
|
- waitForAnimationToEnd
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Continue"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Continue"
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
text: "Continue"
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
- waitForAnimationToEnd
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Performance monitor"
|
||||||
|
commands:
|
||||||
|
# Close the Expo dev menu if it opens as a bottom sheet.
|
||||||
|
- tapOn:
|
||||||
|
point: "89%,9%"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Connect to the local daemon only when the app is on the welcome screen.
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: "welcome-screen"
|
||||||
|
commands:
|
||||||
|
- tapOn:
|
||||||
|
id: "welcome-direct-connection"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "add-host-modal"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "direct-host-input"
|
||||||
|
- eraseText
|
||||||
|
- inputText: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
id: "direct-host-input"
|
||||||
|
text: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "direct-host-submit"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Start from an open Android sidebar with ${PASEO_MAESTRO_PROJECT_NAME}
|
||||||
|
# visible. Ends on the New Workspace screen.
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
id: "sidebar-project-list"
|
||||||
|
|
||||||
|
- assertVisible: ${PASEO_MAESTRO_PROJECT_NAME}
|
||||||
|
|
||||||
|
- tapOn: "Create a new workspace for ${PASEO_MAESTRO_PROJECT_NAME}"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "New workspace"
|
||||||
|
timeout: 30000
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Select a real provider/model on the New Workspace screen. Do not skip this
|
||||||
|
# in redirect tests: without a selected model, Create only shows validation and
|
||||||
|
# never exercises workspace creation.
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "agent-preferences-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "agent-preferences-sheet"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "combined-model-selector"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Select model"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- tapOn: "Codex"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "GPT-5.4"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- tapOn: "GPT-5.4"
|
||||||
|
|
||||||
|
- tapOn: "Close"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "GPT-5.4"
|
||||||
|
timeout: 10000
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Start on the New Workspace screen after model selection. Taps Create and
|
||||||
|
# proves the redirect completed into a workspace draft-agent tab.
|
||||||
|
|
||||||
|
- tapOn: "Create"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "workspace-header-title"
|
||||||
|
timeout: 60000
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "message-input-root"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- assertNotVisible: "New workspace"
|
||||||
|
- assertNotVisible: "Select a model"
|
||||||
|
- assertNotVisible: "There was a problem loading the project."
|
||||||
|
- assertNotVisible: "The specified child already has a parent"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# NOTE: Render through the workspace-create shell scripts before running when
|
||||||
|
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Start from the connected home screen and leave the Android sidebar open with
|
||||||
|
# the prepared project visible.
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "What shall we build today?"
|
||||||
|
timeout: 60000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "menu-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "sidebar-project-list"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ${PASEO_MAESTRO_PROJECT_NAME}
|
||||||
|
timeout: 30000
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Records only the Android workspace-creation repro window.
|
||||||
|
#
|
||||||
|
# The setup Maestro flow gets the app to the open sidebar with a prepared
|
||||||
|
# project visible. Recording starts after that, then the focused flow taps the
|
||||||
|
# new-workspace button, selects a provider/model, taps Create, and asserts the
|
||||||
|
# app lands on the created workspace rather than remaining on /new.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||||
|
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
|
||||||
|
SETUP_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-ready-sidebar.yaml"
|
||||||
|
FOCUS_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-create-focused.yaml"
|
||||||
|
OUT_DIR="/tmp/paseo-workspace-create-android-focus-$(date +%s)"
|
||||||
|
VIDEO_DIR="/tmp/paseo-maestro-videos"
|
||||||
|
DEVICE_VIDEO="/sdcard/paseo-maestro-workspace-create-focused.mp4"
|
||||||
|
LOCAL_VIDEO="$VIDEO_DIR/paseo-maestro-workspace-create-focused.mp4"
|
||||||
|
|
||||||
|
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
|
||||||
|
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
|
||||||
|
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "Missing required command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
render_flow() {
|
||||||
|
local source="$1"
|
||||||
|
local target="$2"
|
||||||
|
mkdir -p "$(dirname "$target")"
|
||||||
|
perl -0pe '
|
||||||
|
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
|
||||||
|
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
|
||||||
|
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
|
||||||
|
' "$source" > "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_flow_tree() {
|
||||||
|
mkdir -p "$OUT_DIR/flows"
|
||||||
|
render_flow "$SETUP_TEMPLATE" "$SETUP_FLOW"
|
||||||
|
render_flow "$FOCUS_TEMPLATE" "$FOCUS_FLOW"
|
||||||
|
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
|
||||||
|
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command adb
|
||||||
|
require_command git
|
||||||
|
require_command maestro
|
||||||
|
require_command node
|
||||||
|
require_command perl
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR" "$VIDEO_DIR"
|
||||||
|
|
||||||
|
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
|
||||||
|
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
|
||||||
|
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
|
||||||
|
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
|
||||||
|
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
|
||||||
|
printf "# Workspace create Android focused recording\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
|
||||||
|
|
||||||
|
SETUP_FLOW="$OUT_DIR/workspace-create-android-ready-sidebar.rendered.yaml"
|
||||||
|
FOCUS_FLOW="$OUT_DIR/workspace-create-android-create-focused.rendered.yaml"
|
||||||
|
render_flow_tree
|
||||||
|
|
||||||
|
echo "=== Focused Android Workspace Create Recording ==="
|
||||||
|
echo "Output dir: $OUT_DIR"
|
||||||
|
echo "Video: $LOCAL_VIDEO"
|
||||||
|
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
|
||||||
|
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
|
||||||
|
|
||||||
|
adb reverse tcp:6767 tcp:6767 >/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Opening project in daemon..."
|
||||||
|
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
const repoRoot = process.env.REPO_ROOT;
|
||||||
|
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
|
||||||
|
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
|
||||||
|
if (!repoRoot || !projectPath || !daemonUrl) {
|
||||||
|
throw new Error("Missing required environment for daemon project setup.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
|
||||||
|
const { DaemonClient } = await import(moduleUrl);
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: daemonUrl,
|
||||||
|
clientId: `maestro-workspace-create-focus-${Date.now()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const payload = await client.openProject(projectPath);
|
||||||
|
if (payload.error || !payload.workspace) {
|
||||||
|
throw new Error(payload.error ?? "openProject returned no workspace");
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
workspaceId: payload.workspace.id,
|
||||||
|
projectDisplayName: payload.workspace.projectDisplayName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Staging app at open sidebar..."
|
||||||
|
(cd "$OUT_DIR" && maestro test "$SETUP_FLOW") 2>&1 | tee "$OUT_DIR/setup.log"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Recording focused create flow..."
|
||||||
|
adb shell rm -f "$DEVICE_VIDEO" >/dev/null 2>&1 || true
|
||||||
|
adb shell screenrecord --time-limit 90 "$DEVICE_VIDEO" &
|
||||||
|
SCREENRECORD_PID=$!
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
set +e
|
||||||
|
(cd "$OUT_DIR" && maestro test "$FOCUS_FLOW") 2>&1 | tee "$OUT_DIR/focus.log"
|
||||||
|
FOCUS_STATUS=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
|
||||||
|
kill -INT "$SCREENRECORD_PID" >/dev/null 2>&1 || true
|
||||||
|
wait "$SCREENRECORD_PID" >/dev/null 2>&1 || true
|
||||||
|
adb shell pkill -INT screenrecord >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
adb pull "$DEVICE_VIDEO" "$LOCAL_VIDEO" >/dev/null
|
||||||
|
ls -lh "$LOCAL_VIDEO"
|
||||||
|
|
||||||
|
if [ "$FOCUS_STATUS" -ne 0 ]; then
|
||||||
|
echo "Focused Maestro flow failed. Artifacts: $OUT_DIR" >&2
|
||||||
|
exit "$FOCUS_STATUS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Focused recording complete."
|
||||||
|
echo "Artifacts: $OUT_DIR"
|
||||||
+187
@@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Android Maestro harness for the workspace-creation redirect crash.
|
||||||
|
#
|
||||||
|
# Starts from a clean app state, connects the Android app to the local daemon,
|
||||||
|
# opens a prepared git project, creates a workspace through the UI, and captures
|
||||||
|
# adb logcat around the redirect window.
|
||||||
|
#
|
||||||
|
# This harness is deliberately stronger than "composer is visible": it selects
|
||||||
|
# a model, taps Create, asserts the workspace header, asserts the New Workspace
|
||||||
|
# route is gone, and fails if logcat contains the Android Fabric view-parent
|
||||||
|
# crash signature.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash packages/app/maestro/test-workspace-create-android-crash.sh
|
||||||
|
#
|
||||||
|
# Optional environment:
|
||||||
|
# PASEO_MAESTRO_APP_ID=sh.paseo.debug
|
||||||
|
# PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
|
||||||
|
# PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
|
||||||
|
# PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||||
|
FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-crash.yaml"
|
||||||
|
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
|
||||||
|
OUT_DIR="/tmp/paseo-workspace-create-android-$(date +%s)"
|
||||||
|
SERVER_EXPORTS="$REPO_ROOT/packages/server/dist/server/server/exports.js"
|
||||||
|
|
||||||
|
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
|
||||||
|
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
|
||||||
|
export PASEO_MAESTRO_DAEMON_WS_URL="${PASEO_MAESTRO_DAEMON_WS_URL:-ws://127.0.0.1:6767/ws}"
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
echo "Missing required command: $1" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command adb
|
||||||
|
require_command git
|
||||||
|
require_command maestro
|
||||||
|
require_command node
|
||||||
|
require_command perl
|
||||||
|
|
||||||
|
render_flow() {
|
||||||
|
local source="$1"
|
||||||
|
local target="$2"
|
||||||
|
mkdir -p "$(dirname "$target")"
|
||||||
|
perl -0pe '
|
||||||
|
s/\$\{PASEO_MAESTRO_APP_ID\}/$ENV{PASEO_MAESTRO_APP_ID}/g;
|
||||||
|
s/\$\{PASEO_MAESTRO_DIRECT_ENDPOINT\}/$ENV{PASEO_MAESTRO_DIRECT_ENDPOINT}/g;
|
||||||
|
s/\$\{PASEO_MAESTRO_PROJECT_NAME\}/$ENV{PASEO_MAESTRO_PROJECT_NAME}/g;
|
||||||
|
' "$source" > "$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_flow_tree() {
|
||||||
|
mkdir -p "$OUT_DIR/flows"
|
||||||
|
render_flow "$FLOW_TEMPLATE" "$FLOW"
|
||||||
|
for source in "$FLOW_TEMPLATE_DIR"/flows/*.yaml; do
|
||||||
|
render_flow "$source" "$OUT_DIR/flows/$(basename "$source")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f "$SERVER_EXPORTS" ]; then
|
||||||
|
echo "Missing server build artifact: $SERVER_EXPORTS" >&2
|
||||||
|
echo "Run: npm run build --workspace=@getpaseo/server" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
|
||||||
|
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
|
||||||
|
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
|
||||||
|
export PASEO_MAESTRO_PROJECT_PATH="$PROJECT_PARENT/$PROJECT_BASENAME"
|
||||||
|
mkdir -p "$PASEO_MAESTRO_PROJECT_PATH"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" init >/dev/null
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" checkout -b main >/dev/null 2>&1 || true
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.name "Paseo Maestro"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" config user.email "maestro@getpaseo.local"
|
||||||
|
printf "# Workspace create Android repro\n" > "$PASEO_MAESTRO_PROJECT_PATH/README.md"
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" add README.md
|
||||||
|
git -C "$PASEO_MAESTRO_PROJECT_PATH" commit -m "Initial commit" >/dev/null
|
||||||
|
else
|
||||||
|
PROJECT_PARENT=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PASEO_MAESTRO_PROJECT_NAME="${PASEO_MAESTRO_PROJECT_NAME:-$(basename "$PASEO_MAESTRO_PROJECT_PATH")}"
|
||||||
|
|
||||||
|
echo "=== Workspace Create Android Crash Harness ==="
|
||||||
|
echo "Output dir: $OUT_DIR"
|
||||||
|
echo "App id: $PASEO_MAESTRO_APP_ID"
|
||||||
|
echo "Android direct endpoint: $PASEO_MAESTRO_DIRECT_ENDPOINT"
|
||||||
|
echo "Daemon websocket: $PASEO_MAESTRO_DAEMON_WS_URL"
|
||||||
|
echo "Project: $PASEO_MAESTRO_PROJECT_PATH"
|
||||||
|
echo "Project name: $PASEO_MAESTRO_PROJECT_NAME"
|
||||||
|
|
||||||
|
FLOW="$OUT_DIR/workspace-create-android-crash.rendered.yaml"
|
||||||
|
render_flow_tree
|
||||||
|
echo "Rendered flow: $FLOW"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Preparing Android port reverse..."
|
||||||
|
adb reverse tcp:6767 tcp:6767 >/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Opening project in daemon..."
|
||||||
|
REPO_ROOT="$REPO_ROOT" node --input-type=module <<'NODE'
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
const repoRoot = process.env.REPO_ROOT;
|
||||||
|
const projectPath = process.env.PASEO_MAESTRO_PROJECT_PATH;
|
||||||
|
const daemonUrl = process.env.PASEO_MAESTRO_DAEMON_WS_URL;
|
||||||
|
if (!repoRoot || !projectPath || !daemonUrl) {
|
||||||
|
throw new Error("Missing required environment for daemon project setup.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
|
||||||
|
const { DaemonClient } = await import(moduleUrl);
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: daemonUrl,
|
||||||
|
clientId: `maestro-workspace-create-${Date.now()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
webSocketFactory: (url, options) => new WebSocket(url, { headers: options?.headers }),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const payload = await client.openProject(projectPath);
|
||||||
|
if (payload.error || !payload.workspace) {
|
||||||
|
throw new Error(payload.error ?? "openProject returned no workspace");
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
workspaceId: payload.workspace.id,
|
||||||
|
projectId: payload.workspace.projectId,
|
||||||
|
projectDisplayName: payload.workspace.projectDisplayName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
|
||||||
|
LOGCAT_PID=""
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$LOGCAT_PID" ]; then
|
||||||
|
kill "$LOGCAT_PID" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Capturing Android logcat..."
|
||||||
|
adb logcat -c || true
|
||||||
|
adb logcat -v time > "$OUT_DIR/logcat.txt" &
|
||||||
|
LOGCAT_PID="$!"
|
||||||
|
|
||||||
|
echo "Running Maestro flow..."
|
||||||
|
set +e
|
||||||
|
(cd "$OUT_DIR" && maestro test "$FLOW") 2>&1 | tee "$OUT_DIR/maestro.log"
|
||||||
|
MAESTRO_STATUS=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cleanup
|
||||||
|
LOGCAT_PID=""
|
||||||
|
|
||||||
|
if [ "$MAESTRO_STATUS" -ne 0 ]; then
|
||||||
|
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
echo "Maestro failed. Artifacts: $OUT_DIR" >&2
|
||||||
|
exit "$MAESTRO_STATUS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >/dev/null; then
|
||||||
|
adb exec-out screencap -p > "$OUT_DIR/failure-state.png" 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
echo "Android native view crash signature found in logcat. Artifacts: $OUT_DIR" >&2
|
||||||
|
grep -n -E "failed to insert view|specified child already has a parent" "$OUT_DIR/logcat.txt" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "PASS: workspace creation flow completed without the Android view-parent crash signature."
|
||||||
|
echo "Artifacts: $OUT_DIR"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Android regression/reproduction flow for creating a workspace from an existing project.
|
||||||
|
# The shell wrapper prepares the daemon/project and captures logcat around this flow.
|
||||||
|
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
- runFlow: flows/android-dev-client.yaml
|
||||||
|
- runFlow: flows/connect-direct-if-welcome.yaml
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "What shall we build today?"
|
||||||
|
timeout: 60000
|
||||||
|
|
||||||
|
- takeScreenshot: 01-connected
|
||||||
|
|
||||||
|
- runFlow: flows/open-prepared-project-sidebar.yaml
|
||||||
|
- runFlow: flows/new-workspace-open-from-sidebar.yaml
|
||||||
|
|
||||||
|
- takeScreenshot: 02-new-workspace
|
||||||
|
|
||||||
|
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
|
||||||
|
- runFlow: flows/new-workspace-submit-and-assert-created.yaml
|
||||||
|
|
||||||
|
- takeScreenshot: 03-created-workspace
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Focused repro window. Assumes `workspace-create-android-ready-sidebar.yaml`
|
||||||
|
# already left the sidebar open with the prepared project visible.
|
||||||
|
|
||||||
|
- runFlow: flows/new-workspace-open-from-sidebar.yaml
|
||||||
|
- runFlow: flows/new-workspace-select-codex-gpt54.yaml
|
||||||
|
- runFlow: flows/new-workspace-submit-and-assert-created.yaml
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
appId: ${PASEO_MAESTRO_APP_ID}
|
||||||
|
---
|
||||||
|
# Setup-only flow. It stops with the Android sidebar open and the prepared
|
||||||
|
# project visible, so focused recordings can start at the actual repro window.
|
||||||
|
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
|
||||||
|
- runFlow: flows/android-dev-client.yaml
|
||||||
|
- runFlow: flows/connect-direct-if-welcome.yaml
|
||||||
|
- runFlow: flows/open-prepared-project-sidebar.yaml
|
||||||
@@ -69,9 +69,11 @@ function clearConsumedOpenIntent(input: {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}) {
|
}) {
|
||||||
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
|
||||||
input.navigation.setParams({ open: undefined });
|
input.navigation.setParams({ open: undefined });
|
||||||
stripOpenSearchParamFromBrowserUrl();
|
if (isWeb) {
|
||||||
|
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
||||||
|
stripOpenSearchParamFromBrowserUrl();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ const {
|
|||||||
theme,
|
theme,
|
||||||
mockClient,
|
mockClient,
|
||||||
mergeWorkspacesMock,
|
mergeWorkspacesMock,
|
||||||
setAgentsMock,
|
|
||||||
navigateMock,
|
navigateMock,
|
||||||
|
saveDraftInputMock,
|
||||||
|
queueDraftSubmissionMock,
|
||||||
createdAgent,
|
createdAgent,
|
||||||
createdWorkspace,
|
createdWorkspace,
|
||||||
prItem,
|
prItem,
|
||||||
@@ -113,8 +114,9 @@ const {
|
|||||||
theme,
|
theme,
|
||||||
mockClient,
|
mockClient,
|
||||||
mergeWorkspacesMock: vi.fn(),
|
mergeWorkspacesMock: vi.fn(),
|
||||||
setAgentsMock: vi.fn(),
|
|
||||||
navigateMock: vi.fn(),
|
navigateMock: vi.fn(),
|
||||||
|
saveDraftInputMock: vi.fn(),
|
||||||
|
queueDraftSubmissionMock: vi.fn(),
|
||||||
createdAgent,
|
createdAgent,
|
||||||
createdWorkspace,
|
createdWorkspace,
|
||||||
prItem,
|
prItem,
|
||||||
@@ -159,6 +161,21 @@ vi.mock("lucide-react-native", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-reanimated", () => ({
|
||||||
|
default: {
|
||||||
|
View: ({
|
||||||
|
testID,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & { testID?: string; style?: unknown }) => {
|
||||||
|
const flattenedStyle = Array.isArray(style)
|
||||||
|
? Object.assign({}, ...style.filter(Boolean))
|
||||||
|
: style;
|
||||||
|
return <div {...props} data-testid={testID} style={flattenedStyle as React.CSSProperties} />;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
useHostRuntimeClient: () => mockClient,
|
useHostRuntimeClient: () => mockClient,
|
||||||
useHostRuntimeIsConnected: () => true,
|
useHostRuntimeIsConnected: () => true,
|
||||||
@@ -168,7 +185,6 @@ vi.mock("@/stores/session-store", () => ({
|
|||||||
useSessionStore: (selector: (state: unknown) => unknown) =>
|
useSessionStore: (selector: (state: unknown) => unknown) =>
|
||||||
selector({
|
selector({
|
||||||
mergeWorkspaces: mergeWorkspacesMock,
|
mergeWorkspaces: mergeWorkspacesMock,
|
||||||
setAgents: setAgentsMock,
|
|
||||||
}),
|
}),
|
||||||
normalizeWorkspaceDescriptor: (workspace: unknown) => workspace,
|
normalizeWorkspaceDescriptor: (workspace: unknown) => workspace,
|
||||||
}));
|
}));
|
||||||
@@ -195,6 +211,35 @@ vi.mock("@/utils/workspace-navigation", () => ({
|
|||||||
navigateToPreparedWorkspaceTab: navigateMock,
|
navigateToPreparedWorkspaceTab: navigateMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/draft-keys", () => ({
|
||||||
|
buildDraftStoreKey: ({
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
draftId,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
draftId?: string | null;
|
||||||
|
}) => (draftId ? `draft:${serverId}:${draftId}` : `agent:${serverId}:${agentId}`),
|
||||||
|
generateDraftId: () => "draft-new-workspace",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/draft-store", () => ({
|
||||||
|
useDraftStore: {
|
||||||
|
getState: () => ({
|
||||||
|
saveDraftInput: saveDraftInputMock,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/workspace-draft-submission-store", () => ({
|
||||||
|
useWorkspaceDraftSubmissionStore: {
|
||||||
|
getState: () => ({
|
||||||
|
setPending: queueDraftSubmissionMock,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/contexts/toast-context", () => ({
|
vi.mock("@/contexts/toast-context", () => ({
|
||||||
useToast: () => ({ error: vi.fn() }),
|
useToast: () => ({ error: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
@@ -222,6 +267,10 @@ vi.mock("@/hooks/use-agent-input-draft", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
|
||||||
|
useKeyboardShiftStyle: () => ({ style: { transform: "translateY(-216px)" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/composer", () => ({
|
vi.mock("@/components/composer", () => ({
|
||||||
Composer: ({
|
Composer: ({
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
@@ -444,6 +493,9 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
mockClient.createPaseoWorktree.mockClear();
|
mockClient.createPaseoWorktree.mockClear();
|
||||||
mockClient.createAgent.mockClear();
|
mockClient.createAgent.mockClear();
|
||||||
|
saveDraftInputMock.mockClear();
|
||||||
|
queueDraftSubmissionMock.mockClear();
|
||||||
|
navigateMock.mockClear();
|
||||||
initialAttachments.length = 0;
|
initialAttachments.length = 0;
|
||||||
initialDraftState.text = "";
|
initialDraftState.text = "";
|
||||||
});
|
});
|
||||||
@@ -522,6 +574,14 @@ function firstCreateWorktreeCall(): CreatePaseoWorktreeArg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("NewWorkspaceScreen picker payload", () => {
|
describe("NewWorkspaceScreen picker payload", () => {
|
||||||
|
it("moves the ref picker row with the mobile keyboard shift", async () => {
|
||||||
|
renderScreen();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const pickerRow = await findByTestId("new-workspace-ref-picker-row");
|
||||||
|
expect(pickerRow.style.transform).toBe("translateY(-216px)");
|
||||||
|
});
|
||||||
|
|
||||||
it("searches only GitHub PRs for the picker", async () => {
|
it("searches only GitHub PRs for the picker", async () => {
|
||||||
renderScreen();
|
renderScreen();
|
||||||
await flush();
|
await flush();
|
||||||
@@ -552,6 +612,43 @@ describe("NewWorkspaceScreen picker payload", () => {
|
|||||||
expect(call).not.toHaveProperty("githubPrNumber");
|
expect(call).not.toHaveProperty("githubPrNumber");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the new workspace draft tab as soon as the worktree is ready", async () => {
|
||||||
|
initialDraftState.text = "please review this change";
|
||||||
|
renderScreen();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
click(await findByTestId("test-composer-submit"));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClient.createAgent).not.toHaveBeenCalled();
|
||||||
|
expect(saveDraftInputMock).toHaveBeenCalledWith({
|
||||||
|
draftKey: "draft:server:draft-new-workspace",
|
||||||
|
draft: {
|
||||||
|
text: "please review this change",
|
||||||
|
attachments: [],
|
||||||
|
cwd: createdWorkspace.workspaceDirectory,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(queueDraftSubmissionMock).toHaveBeenCalledWith({
|
||||||
|
serverId: "server",
|
||||||
|
workspaceId: createdWorkspace.id,
|
||||||
|
draftId: "draft-new-workspace",
|
||||||
|
text: "please review this change",
|
||||||
|
attachments: [],
|
||||||
|
cwd: createdWorkspace.workspaceDirectory,
|
||||||
|
provider: "claude-code",
|
||||||
|
allowEmptyText: true,
|
||||||
|
});
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith({
|
||||||
|
serverId: "server",
|
||||||
|
workspaceId: createdWorkspace.id,
|
||||||
|
target: { kind: "draft", draftId: "draft-new-workspace" },
|
||||||
|
navigationMethod: "replace",
|
||||||
|
});
|
||||||
|
expect(document.querySelector("textarea")).toHaveProperty("disabled", true);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows the selected PR number, title, and PR icon in the picker trigger", async () => {
|
it("shows the selected PR number, title, and PR icon in the picker trigger", async () => {
|
||||||
renderScreen();
|
renderScreen();
|
||||||
await flush();
|
await flush();
|
||||||
@@ -747,10 +844,13 @@ describe("NewWorkspaceScreen picker payload", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves and locks the composer and picker while chat creation is pending, then unlocks on error", async () => {
|
it("preserves and locks the composer and picker while worktree creation is pending, then unlocks on error", async () => {
|
||||||
initialDraftState.text = "please review this change";
|
initialDraftState.text = "please review this change";
|
||||||
const createAgent = createDeferredPromise<typeof createdAgent>();
|
const createWorktree = createDeferredPromise<{
|
||||||
mockClient.createAgent.mockImplementationOnce(async () => await createAgent.promise);
|
workspace: typeof createdWorkspace;
|
||||||
|
error: null;
|
||||||
|
}>();
|
||||||
|
mockClient.createPaseoWorktree.mockImplementationOnce(async () => await createWorktree.promise);
|
||||||
renderScreen();
|
renderScreen();
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
@@ -778,7 +878,7 @@ describe("NewWorkspaceScreen picker payload", () => {
|
|||||||
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true);
|
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true);
|
||||||
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", true);
|
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", true);
|
||||||
|
|
||||||
createAgent.reject(new Error("Create agent failed"));
|
createWorktree.reject(new Error("Create worktree failed"));
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
expect(textInput).toHaveProperty("value", "please review this change");
|
expect(textInput).toHaveProperty("value", "please review this change");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import Animated from "react-native-reanimated";
|
||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
|
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||||
@@ -16,12 +17,13 @@ import { ScreenHeader } from "@/components/headers/screen-header";
|
|||||||
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
|
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import type { ComposerAttachment } from "@/attachments/types";
|
import type { ComposerAttachment } from "@/attachments/types";
|
||||||
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
|
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
|
||||||
@@ -105,9 +107,11 @@ export function NewWorkspaceScreen({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
|
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
|
||||||
|
mode: "translate",
|
||||||
|
});
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
const setAgents = useSessionStore((state) => state.setAgents);
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||||
typeof normalizeWorkspaceDescriptor
|
typeof normalizeWorkspaceDescriptor
|
||||||
@@ -319,10 +323,6 @@ export function NewWorkspaceScreen({
|
|||||||
try {
|
try {
|
||||||
setPendingAction("chat");
|
setPendingAction("chat");
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
const { images, attachments: reviewAttachments } =
|
|
||||||
splitComposerAttachmentsForSubmit(attachments);
|
|
||||||
const workspace = await ensureWorkspace({ cwd, attachments: reviewAttachments });
|
|
||||||
const connectedClient = withConnectedClient();
|
|
||||||
if (!composerState) {
|
if (!composerState) {
|
||||||
throw new Error("Composer state is required");
|
throw new Error("Composer state is required");
|
||||||
}
|
}
|
||||||
@@ -330,15 +330,30 @@ export function NewWorkspaceScreen({
|
|||||||
throw new Error("Select a model");
|
throw new Error("Select a model");
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialPrompt = text.trim();
|
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
|
||||||
const encodedImages = await encodeImages(images);
|
const workspace = await ensureWorkspace({ cwd, attachments: reviewAttachments });
|
||||||
const workspaceDirectory = requireWorkspaceExecutionAuthority({
|
const draftId = generateDraftId();
|
||||||
workspace,
|
const workspaceDirectory = workspace.workspaceDirectory;
|
||||||
}).workspaceDirectory;
|
useDraftStore.getState().saveDraftInput({
|
||||||
const agent = await connectedClient.createAgent({
|
draftKey: buildDraftStoreKey({
|
||||||
provider: composerState.selectedProvider,
|
serverId,
|
||||||
cwd: workspaceDirectory,
|
agentId: draftId,
|
||||||
|
draftId,
|
||||||
|
}),
|
||||||
|
draft: {
|
||||||
|
text,
|
||||||
|
attachments,
|
||||||
|
cwd: workspaceDirectory,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
useWorkspaceDraftSubmissionStore.getState().setPending({
|
||||||
|
serverId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
|
draftId,
|
||||||
|
text,
|
||||||
|
attachments,
|
||||||
|
cwd: workspaceDirectory,
|
||||||
|
provider: composerState.selectedProvider,
|
||||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
? { modeId: composerState.selectedMode }
|
? { modeId: composerState.selectedMode }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -346,31 +361,23 @@ export function NewWorkspaceScreen({
|
|||||||
...(composerState.effectiveThinkingOptionId
|
...(composerState.effectiveThinkingOptionId
|
||||||
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
||||||
: {}),
|
: {}),
|
||||||
...(initialPrompt ? { initialPrompt } : {}),
|
...(composerState.featureValues ? { featureValues: composerState.featureValues } : {}),
|
||||||
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
|
allowEmptyText: true,
|
||||||
...(reviewAttachments.length > 0 ? { attachments: reviewAttachments } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
setAgents(serverId, (previous) => {
|
|
||||||
const next = new Map(previous);
|
|
||||||
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
navigateToPreparedWorkspaceTab({
|
navigateToPreparedWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
target: { kind: "agent", agentId: agent.id },
|
target: { kind: "draft", draftId },
|
||||||
navigationMethod: "replace",
|
navigationMethod: "replace",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = toErrorMessage(error);
|
const message = toErrorMessage(error);
|
||||||
|
setPendingAction(null);
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
|
||||||
setPendingAction(null);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[composerState, ensureWorkspace, serverId, setAgents, toast, withConnectedClient],
|
[composerState, ensureWorkspace, serverId, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const workspaceTitle =
|
const workspaceTitle =
|
||||||
@@ -494,7 +501,10 @@ export function NewWorkspaceScreen({
|
|||||||
}
|
}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
/>
|
/>
|
||||||
<View style={styles.optionsRow}>
|
<Animated.View
|
||||||
|
testID="new-workspace-ref-picker-row"
|
||||||
|
style={[styles.optionsRow, keyboardAnimatedStyle]}
|
||||||
|
>
|
||||||
<View>
|
<View>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
@@ -557,7 +567,7 @@ export function NewWorkspaceScreen({
|
|||||||
renderOption={renderPickerOption}
|
renderOption={renderPickerOption}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { Keyboard, ScrollView, Text, View } from "react-native";
|
import { Keyboard, ScrollView, Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -14,6 +14,7 @@ import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-dr
|
|||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||||
|
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||||
@@ -80,6 +81,23 @@ export function WorkspaceDraftAgentTab({
|
|||||||
if (!composerState) {
|
if (!composerState) {
|
||||||
throw new Error("Workspace draft composer state is required");
|
throw new Error("Workspace draft composer state is required");
|
||||||
}
|
}
|
||||||
|
const pendingAutoSubmit = useWorkspaceDraftSubmissionStore((state) => {
|
||||||
|
const pending = state.pendingByDraftId[draftId] ?? null;
|
||||||
|
return pending?.serverId === serverId && pending.workspaceId === workspaceId ? pending : null;
|
||||||
|
});
|
||||||
|
const consumePendingAutoSubmit = useWorkspaceDraftSubmissionStore(
|
||||||
|
(state) => state.consumePending,
|
||||||
|
);
|
||||||
|
const autoSubmitConfig = pendingAutoSubmit
|
||||||
|
? {
|
||||||
|
provider: pendingAutoSubmit.provider,
|
||||||
|
modeId: pendingAutoSubmit.modeId ?? null,
|
||||||
|
model: pendingAutoSubmit.model ?? null,
|
||||||
|
thinkingOptionId: pendingAutoSubmit.thinkingOptionId ?? null,
|
||||||
|
featureValues: pendingAutoSubmit.featureValues,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
formErrorMessage,
|
formErrorMessage,
|
||||||
@@ -90,20 +108,21 @@ export function WorkspaceDraftAgentTab({
|
|||||||
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
||||||
draftId,
|
draftId,
|
||||||
getPendingServerId: () => serverId,
|
getPendingServerId: () => serverId,
|
||||||
|
allowEmptyText: allowsEmptyAutoSubmit,
|
||||||
validateBeforeSubmit: ({ text }) => {
|
validateBeforeSubmit: ({ text }) => {
|
||||||
if (!text.trim()) {
|
if (!allowsEmptyAutoSubmit && !text.trim()) {
|
||||||
return "Initial prompt is required";
|
return "Initial prompt is required";
|
||||||
}
|
}
|
||||||
if (composerState.providerDefinitions.length === 0) {
|
if (composerState.providerDefinitions.length === 0) {
|
||||||
return "No available providers on the selected host";
|
return "No available providers on the selected host";
|
||||||
}
|
}
|
||||||
if (!composerState.selectedProvider) {
|
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
|
||||||
return "Select a model";
|
return "Select a model";
|
||||||
}
|
}
|
||||||
if (composerState.isModelLoading) {
|
if (composerState.isModelLoading) {
|
||||||
return "Model defaults are still loading";
|
return "Model defaults are still loading";
|
||||||
}
|
}
|
||||||
if (!composerState.effectiveModelId) {
|
if (!(autoSubmitConfig?.model ?? composerState.effectiveModelId)) {
|
||||||
return "No model is available for the selected provider";
|
return "No model is available for the selected provider";
|
||||||
}
|
}
|
||||||
if (!workspaceDirectory) {
|
if (!workspaceDirectory) {
|
||||||
@@ -124,13 +143,15 @@ export function WorkspaceDraftAgentTab({
|
|||||||
buildDraftAgent: (attempt) => {
|
buildDraftAgent: (attempt) => {
|
||||||
invariant(workspaceDirectory, "Workspace directory is required");
|
invariant(workspaceDirectory, "Workspace directory is required");
|
||||||
const now = attempt.timestamp;
|
const now = attempt.timestamp;
|
||||||
const model = composerState.effectiveModelId || null;
|
const model = autoSubmitConfig?.model ?? (composerState.effectiveModelId || null);
|
||||||
const thinkingOptionId = composerState.effectiveThinkingOptionId || null;
|
const thinkingOptionId =
|
||||||
|
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null);
|
||||||
const modeId =
|
const modeId =
|
||||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
autoSubmitConfig?.modeId ??
|
||||||
|
(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
? composerState.selectedMode
|
? composerState.selectedMode
|
||||||
: null;
|
: null);
|
||||||
const provider = composerState.selectedProvider;
|
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
throw new Error("Select a model");
|
throw new Error("Select a model");
|
||||||
}
|
}
|
||||||
@@ -164,19 +185,23 @@ export function WorkspaceDraftAgentTab({
|
|||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = composerState.selectedProvider;
|
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
throw new Error("Select a model");
|
throw new Error("Select a model");
|
||||||
}
|
}
|
||||||
const config = buildWorkspaceDraftAgentConfig({
|
const config = buildWorkspaceDraftAgentConfig({
|
||||||
provider,
|
provider,
|
||||||
cwd: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
...(autoSubmitConfig?.modeId
|
||||||
? { modeId: composerState.selectedMode }
|
? { modeId: autoSubmitConfig.modeId }
|
||||||
: {}),
|
: composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
model: composerState.effectiveModelId || undefined,
|
? { modeId: composerState.selectedMode }
|
||||||
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
: {}),
|
||||||
featureValues: composerState.featureValues,
|
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
|
||||||
|
thinkingOptionId:
|
||||||
|
autoSubmitConfig?.thinkingOptionId ??
|
||||||
|
(composerState.effectiveThinkingOptionId || undefined),
|
||||||
|
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const imagesData = await encodeImages(images);
|
const imagesData = await encodeImages(images);
|
||||||
@@ -199,6 +224,44 @@ export function WorkspaceDraftAgentTab({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isReadyForPendingAutoSubmit = Boolean(
|
||||||
|
pendingAutoSubmit &&
|
||||||
|
draftInput.isHydrated &&
|
||||||
|
workspaceDirectory &&
|
||||||
|
client &&
|
||||||
|
!isSubmitting &&
|
||||||
|
!composerState.isModelLoading,
|
||||||
|
);
|
||||||
|
const autoSubmitKeyRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isReadyForPendingAutoSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const submitKey = `${serverId}:${workspaceId}:${draftId}`;
|
||||||
|
if (autoSubmitKeyRef.current === submitKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const submission = consumePendingAutoSubmit({ serverId, workspaceId, draftId });
|
||||||
|
if (!submission) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
autoSubmitKeyRef.current = submitKey;
|
||||||
|
void handleCreateFromInput({
|
||||||
|
text: submission.text,
|
||||||
|
attachments: submission.attachments,
|
||||||
|
cwd: submission.cwd,
|
||||||
|
}).catch(() => {
|
||||||
|
autoSubmitKeyRef.current = null;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
consumePendingAutoSubmit,
|
||||||
|
draftId,
|
||||||
|
handleCreateFromInput,
|
||||||
|
isReadyForPendingAutoSubmit,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||||
addImagesRef.current?.(files);
|
addImagesRef.current?.(files);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -112,10 +112,18 @@ describe("navigation active workspace store", () => {
|
|||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
workspaceId: "workspace-a",
|
workspaceId: "workspace-a",
|
||||||
});
|
});
|
||||||
|
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
});
|
||||||
|
|
||||||
store.syncNavigationActiveWorkspace(createNavigationPathRef("/h/server-1/sessions"));
|
store.syncNavigationActiveWorkspace(createNavigationPathRef("/h/server-1/sessions"));
|
||||||
|
|
||||||
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
|
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
|
||||||
|
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears stale workspace params when navigation sync reports a non-workspace path", async () => {
|
it("clears stale workspace params when navigation sync reports a non-workspace path", async () => {
|
||||||
@@ -134,4 +142,35 @@ describe("navigation active workspace store", () => {
|
|||||||
|
|
||||||
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
|
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses a one-shot workspace route override when returning to a retained shell", async () => {
|
||||||
|
installWindowStub("/h/server-1/workspace/workspace-a");
|
||||||
|
const store = await import("@/stores/navigation-active-workspace-store");
|
||||||
|
|
||||||
|
store.syncNavigationActiveWorkspace(
|
||||||
|
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
|
||||||
|
);
|
||||||
|
store.overrideNextNavigationWorkspaceRouteSelection({
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-b",
|
||||||
|
});
|
||||||
|
|
||||||
|
store.syncNavigationActiveWorkspace(
|
||||||
|
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-b",
|
||||||
|
});
|
||||||
|
|
||||||
|
store.syncNavigationActiveWorkspace(
|
||||||
|
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ interface NavigationObserverRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let snapshot: ActiveWorkspaceSelection | null = null;
|
let snapshot: ActiveWorkspaceSelection | null = null;
|
||||||
|
let lastWorkspaceRouteSelection: ActiveWorkspaceSelection | null = null;
|
||||||
|
let nextWorkspaceRouteSelectionOverride: ActiveWorkspaceSelection | null = null;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
function subscribe(listener: () => void): () => void {
|
function subscribe(listener: () => void): () => void {
|
||||||
@@ -165,7 +167,18 @@ function getActiveWorkspaceForNavigationSync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverRef) {
|
export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverRef) {
|
||||||
emitIfChanged(getActiveWorkspaceForNavigationSync(navigationRef.current?.getCurrentRoute()));
|
const route = navigationRef.current?.getCurrentRoute();
|
||||||
|
const routeState = classifyNavigationWorkspaceRoute(route);
|
||||||
|
if (routeState.kind === "workspace") {
|
||||||
|
lastWorkspaceRouteSelection = routeState.selection;
|
||||||
|
if (nextWorkspaceRouteSelectionOverride) {
|
||||||
|
const overrideSelection = nextWorkspaceRouteSelectionOverride;
|
||||||
|
nextWorkspaceRouteSelectionOverride = null;
|
||||||
|
emitIfChanged(overrideSelection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitIfChanged(getActiveWorkspaceForNavigationSync(route));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activateNavigationWorkspaceSelection(
|
export function activateNavigationWorkspaceSelection(
|
||||||
@@ -180,6 +193,14 @@ export function getNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelectio
|
|||||||
return getSnapshot();
|
return getSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelection | null {
|
||||||
|
return lastWorkspaceRouteSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overrideNextNavigationWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
|
||||||
|
nextWorkspaceRouteSelectionOverride = next;
|
||||||
|
}
|
||||||
|
|
||||||
export function syncBrowserActiveWorkspaceFromLocation() {
|
export function syncBrowserActiveWorkspaceFromLocation() {
|
||||||
emitIfChanged(getBrowserLocationWorkspace());
|
emitIfChanged(getBrowserLocationWorkspace());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { ComposerAttachment } from "@/attachments/types";
|
||||||
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
|
export interface PendingWorkspaceDraftSubmission {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
draftId: string;
|
||||||
|
text: string;
|
||||||
|
attachments: ComposerAttachment[];
|
||||||
|
cwd: string;
|
||||||
|
provider: AgentProvider;
|
||||||
|
modeId?: string;
|
||||||
|
model?: string;
|
||||||
|
thinkingOptionId?: string;
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
|
allowEmptyText?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceDraftSubmissionState = {
|
||||||
|
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
|
||||||
|
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
|
||||||
|
consumePending: (input: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
draftId: string;
|
||||||
|
}) => PendingWorkspaceDraftSubmission | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function matchesPendingSubmission(
|
||||||
|
pending: PendingWorkspaceDraftSubmission | null | undefined,
|
||||||
|
input: { serverId: string; workspaceId: string; draftId: string },
|
||||||
|
): pending is PendingWorkspaceDraftSubmission {
|
||||||
|
return (
|
||||||
|
pending?.serverId === input.serverId &&
|
||||||
|
pending.workspaceId === input.workspaceId &&
|
||||||
|
pending.draftId === input.draftId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionState>(
|
||||||
|
(set, get) => ({
|
||||||
|
pendingByDraftId: {},
|
||||||
|
setPending: (submission) =>
|
||||||
|
set((state) => ({
|
||||||
|
pendingByDraftId: {
|
||||||
|
...state.pendingByDraftId,
|
||||||
|
[submission.draftId]: submission,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
consumePending: (input) => {
|
||||||
|
const pending = get().pendingByDraftId[input.draftId];
|
||||||
|
if (!matchesPendingSubmission(pending, input)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
set((state) => {
|
||||||
|
if (!matchesPendingSubmission(state.pendingByDraftId[input.draftId], input)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const { [input.draftId]: _removed, ...rest } = state.pendingByDraftId;
|
||||||
|
return { pendingByDraftId: rest };
|
||||||
|
});
|
||||||
|
return pending;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("expo-router", () => ({
|
const { platformState, routerMock } = vi.hoisted(() => ({
|
||||||
router: {
|
platformState: {
|
||||||
|
isNative: false,
|
||||||
|
isWeb: true,
|
||||||
|
},
|
||||||
|
routerMock: {
|
||||||
|
back: vi.fn(),
|
||||||
|
canGoBack: vi.fn(() => false),
|
||||||
navigate: vi.fn(),
|
navigate: vi.fn(),
|
||||||
replace: vi.fn(),
|
replace: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("expo-router", () => ({
|
||||||
|
router: routerMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
get isNative() {
|
||||||
|
return platformState.isNative;
|
||||||
|
},
|
||||||
|
get isWeb() {
|
||||||
|
return platformState.isWeb;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||||
const storage = new Map<string, string>();
|
const storage = new Map<string, string>();
|
||||||
return {
|
return {
|
||||||
@@ -23,7 +42,11 @@ vi.mock("@react-native-async-storage/async-storage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import {
|
||||||
|
getNavigationActiveWorkspaceSelection,
|
||||||
|
syncNavigationActiveWorkspace,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
|
import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
const SERVER_ID = "server-1";
|
const SERVER_ID = "server-1";
|
||||||
const WORKSPACE_ID = "/repo/worktree";
|
const WORKSPACE_ID = "/repo/worktree";
|
||||||
@@ -31,6 +54,15 @@ const AGENT_ID = "agent-1";
|
|||||||
|
|
||||||
describe("prepareWorkspaceTab", () => {
|
describe("prepareWorkspaceTab", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
platformState.isNative = false;
|
||||||
|
platformState.isWeb = true;
|
||||||
|
routerMock.back.mockReset();
|
||||||
|
routerMock.canGoBack.mockReset();
|
||||||
|
routerMock.canGoBack.mockReturnValue(false);
|
||||||
|
routerMock.navigate.mockReset();
|
||||||
|
routerMock.replace.mockReset();
|
||||||
|
syncNavigationActiveWorkspace({ current: null });
|
||||||
useWorkspaceLayoutStore.setState({
|
useWorkspaceLayoutStore.setState({
|
||||||
layoutByWorkspace: {},
|
layoutByWorkspace: {},
|
||||||
splitSizesByWorkspace: {},
|
splitSizesByWorkspace: {},
|
||||||
@@ -50,4 +82,37 @@ describe("prepareWorkspaceTab", () => {
|
|||||||
const key = "server-1:/repo/worktree";
|
const key = "server-1:/repo/worktree";
|
||||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toHaveLength(1);
|
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pops back to the retained workspace shell for native replace navigation", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
platformState.isNative = true;
|
||||||
|
platformState.isWeb = false;
|
||||||
|
routerMock.canGoBack.mockReturnValue(true);
|
||||||
|
|
||||||
|
syncNavigationActiveWorkspace({
|
||||||
|
current: {
|
||||||
|
getCurrentRoute: () => ({
|
||||||
|
path: "/h/server-1/workspace/source-workspace",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = navigateToPreparedWorkspaceTab({
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
target: { kind: "agent", agentId: AGENT_ID },
|
||||||
|
navigationMethod: "replace",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU");
|
||||||
|
expect(routerMock.back).toHaveBeenCalledOnce();
|
||||||
|
expect(routerMock.replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(getNavigationActiveWorkspaceSelection()).toEqual({
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
import {
|
||||||
|
activateNavigationWorkspaceSelection,
|
||||||
|
getLastNavigationWorkspaceRouteSelection,
|
||||||
|
overrideNextNavigationWorkspaceRouteSelection,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +51,20 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput) {
|
|||||||
export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string {
|
export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string {
|
||||||
const route = prepareWorkspaceTab(input);
|
const route = prepareWorkspaceTab(input);
|
||||||
if (input.navigationMethod === "replace") {
|
if (input.navigationMethod === "replace") {
|
||||||
|
const canReturnToWorkspaceShell =
|
||||||
|
isNative && getLastNavigationWorkspaceRouteSelection() !== null && router.canGoBack();
|
||||||
|
if (canReturnToWorkspaceShell) {
|
||||||
|
const nextSelection = {
|
||||||
|
serverId: input.serverId,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
};
|
||||||
|
overrideNextNavigationWorkspaceRouteSelection(nextSelection);
|
||||||
|
router.back();
|
||||||
|
setTimeout(() => {
|
||||||
|
activateNavigationWorkspaceSelection(nextSelection);
|
||||||
|
}, 0);
|
||||||
|
return route;
|
||||||
|
}
|
||||||
router.replace(route as any);
|
router.replace(route as any);
|
||||||
} else {
|
} else {
|
||||||
router.navigate(route as any);
|
router.navigate(route as any);
|
||||||
|
|||||||
@@ -238,11 +238,8 @@ function createPaseoWorktreeForMcpTest(options: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
primeWorkspaceGitWatchFingerprints: async () => {},
|
|
||||||
broadcastWorkspaceUpdate: async (workspaceId) => {
|
|
||||||
options.broadcasts.push(workspaceId);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
options.broadcasts.push(result.workspace.workspaceId);
|
||||||
options.createdWorkspaceIds?.push(result.workspace.workspaceId);
|
options.createdWorkspaceIds?.push(result.workspace.workspaceId);
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -505,9 +505,9 @@ export async function createPaseoDaemon(
|
|||||||
providerRegistry,
|
providerRegistry,
|
||||||
github,
|
github,
|
||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
createPaseoWorktree: (input, serviceOptions) => {
|
createPaseoWorktree: async (input, serviceOptions) => {
|
||||||
const coreDeps = createWorktreeCoreDeps(github);
|
const coreDeps = createWorktreeCoreDeps(github);
|
||||||
return createPaseoWorktree(input, {
|
const result = await createPaseoWorktree(input, {
|
||||||
...coreDeps,
|
...coreDeps,
|
||||||
...(serviceOptions?.resolveDefaultBranch
|
...(serviceOptions?.resolveDefaultBranch
|
||||||
? {
|
? {
|
||||||
@@ -517,23 +517,13 @@ export async function createPaseoDaemon(
|
|||||||
projectRegistry,
|
projectRegistry,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
primeWorkspaceGitWatchFingerprints: async (workspace) => {
|
|
||||||
await Promise.all(
|
|
||||||
wsServer
|
|
||||||
?.listActiveSessions()
|
|
||||||
.map((session) =>
|
|
||||||
session.primeWorkspaceGitWatchFingerprintForWorkspace(workspace),
|
|
||||||
) ?? [],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
broadcastWorkspaceUpdate: async (workspaceId) => {
|
|
||||||
await Promise.all(
|
|
||||||
wsServer
|
|
||||||
?.listActiveSessions()
|
|
||||||
.map((session) => session.emitWorkspaceUpdateForWorkspaceId(workspaceId)) ?? [],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
await Promise.all(
|
||||||
|
wsServer
|
||||||
|
?.listActiveSessions()
|
||||||
|
.map((session) => session.warmWorkspaceGitDataForWorkspace(result.workspace)) ?? [],
|
||||||
|
);
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
callerAgentId,
|
callerAgentId,
|
||||||
|
|||||||
@@ -20,11 +20,26 @@ describe("createPaseoWorktree", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates a worktree, registers the workspace, primes fingerprints, and broadcasts in order", async () => {
|
test("creates a worktree and registers it in the source workspace project without git snapshot lookup", async () => {
|
||||||
const { repoDir, tempDir } = createGitRepo();
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const deps = createDeps({ events });
|
const deps = createDeps({ events });
|
||||||
|
const sourceProject = createPersistedProjectRecordForTest({
|
||||||
|
projectId: "remote:github.com/acme/repo",
|
||||||
|
rootPath: repoDir,
|
||||||
|
displayName: "acme/repo",
|
||||||
|
});
|
||||||
|
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
|
||||||
|
workspaceId: repoDir,
|
||||||
|
projectId: sourceProject.projectId,
|
||||||
|
cwd: repoDir,
|
||||||
|
kind: "local_checkout",
|
||||||
|
displayName: "main",
|
||||||
|
});
|
||||||
|
deps.projects.set(sourceProject.projectId, sourceProject);
|
||||||
|
deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace);
|
||||||
|
deps.workspaceGitService.getSnapshot = vi.fn(deps.workspaceGitService.getSnapshot);
|
||||||
|
|
||||||
const result = await createPaseoWorktree(
|
const result = await createPaseoWorktree(
|
||||||
{
|
{
|
||||||
@@ -39,15 +54,16 @@ describe("createPaseoWorktree", () => {
|
|||||||
expect(result.created).toBe(true);
|
expect(result.created).toBe(true);
|
||||||
expect(result.workspace.cwd).toBe(result.worktree.worktreePath);
|
expect(result.workspace.cwd).toBe(result.worktree.worktreePath);
|
||||||
expect(result.workspace.kind).toBe("worktree");
|
expect(result.workspace.kind).toBe("worktree");
|
||||||
|
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
|
||||||
|
expect(result.workspace.displayName).toBe("feature-one");
|
||||||
|
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
expect(events).toEqual([
|
expect(events).toEqual([
|
||||||
`project:${result.workspace.projectId}`,
|
"project:remote:github.com/acme/repo",
|
||||||
`workspace:${result.workspace.workspaceId}`,
|
`workspace:${result.workspace.workspaceId}`,
|
||||||
`prime:${result.workspace.workspaceId}`,
|
|
||||||
`broadcast:${result.workspace.workspaceId}`,
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reuses an existing worktree and still upserts and broadcasts", async () => {
|
test("reuses an existing worktree and still upserts the workspace", async () => {
|
||||||
const { repoDir, tempDir } = createGitRepo();
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
const paseoHome = path.join(tempDir, ".paseo");
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
@@ -81,7 +97,6 @@ describe("createPaseoWorktree", () => {
|
|||||||
expect(second.created).toBe(false);
|
expect(second.created).toBe(false);
|
||||||
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
|
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
|
||||||
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
|
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
|
||||||
expect(events).toContain(`broadcast:${second.workspace.workspaceId}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not mutate registries or broadcast when core worktree creation fails", async () => {
|
test("does not mutate registries or broadcast when core worktree creation fails", async () => {
|
||||||
@@ -103,8 +118,6 @@ describe("createPaseoWorktree", () => {
|
|||||||
|
|
||||||
expect(deps.projects.size).toBe(0);
|
expect(deps.projects.size).toBe(0);
|
||||||
expect(deps.workspaces.size).toBe(0);
|
expect(deps.workspaces.size).toBe(0);
|
||||||
expect(deps.broadcastWorkspaceUpdate).not.toHaveBeenCalled();
|
|
||||||
expect(deps.primeWorkspaceGitWatchFingerprints).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps direct core worktree creation calls behind the service boundary", () => {
|
test("keeps direct core worktree creation calls behind the service boundary", () => {
|
||||||
@@ -157,12 +170,41 @@ function createDeps(options?: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
workspaceGitService: createWorkspaceGitServiceStub(),
|
workspaceGitService: createWorkspaceGitServiceStub(),
|
||||||
primeWorkspaceGitWatchFingerprints: vi.fn(async (workspace: PersistedWorkspaceRecord) => {
|
};
|
||||||
events.push(`prime:${workspace.workspaceId}`);
|
}
|
||||||
}),
|
|
||||||
broadcastWorkspaceUpdate: vi.fn(async (workspaceId: string) => {
|
function createPersistedProjectRecordForTest(input: {
|
||||||
events.push(`broadcast:${workspaceId}`);
|
projectId: string;
|
||||||
}),
|
rootPath: string;
|
||||||
|
displayName: string;
|
||||||
|
}): PersistedProjectRecord {
|
||||||
|
return {
|
||||||
|
projectId: input.projectId,
|
||||||
|
rootPath: input.rootPath,
|
||||||
|
kind: "git",
|
||||||
|
displayName: input.displayName,
|
||||||
|
createdAt: "2026-04-22T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-22T00:00:00.000Z",
|
||||||
|
archivedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPersistedWorkspaceRecordForTest(input: {
|
||||||
|
workspaceId: string;
|
||||||
|
projectId: string;
|
||||||
|
cwd: string;
|
||||||
|
kind: PersistedWorkspaceRecord["kind"];
|
||||||
|
displayName: string;
|
||||||
|
}): PersistedWorkspaceRecord {
|
||||||
|
return {
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
cwd: input.cwd,
|
||||||
|
kind: input.kind,
|
||||||
|
displayName: input.displayName,
|
||||||
|
createdAt: "2026-04-22T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-22T00:00:00.000Z",
|
||||||
|
archivedAt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,7 @@ import {
|
|||||||
createPersistedProjectRecord,
|
createPersistedProjectRecord,
|
||||||
createPersistedWorkspaceRecord,
|
createPersistedWorkspaceRecord,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
import {
|
import { deriveProjectGroupingName, normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||||
buildProjectPlacementForCwd,
|
|
||||||
deriveProjectKind,
|
|
||||||
deriveProjectRootPath,
|
|
||||||
deriveWorkspaceDisplayName,
|
|
||||||
deriveWorkspaceId,
|
|
||||||
deriveWorkspaceKind,
|
|
||||||
normalizeWorkspaceId,
|
|
||||||
} from "./workspace-registry-model.js";
|
|
||||||
import {
|
import {
|
||||||
createWorktreeCore,
|
createWorktreeCore,
|
||||||
type CreateWorktreeCoreDeps,
|
type CreateWorktreeCoreDeps,
|
||||||
@@ -44,8 +36,6 @@ export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
|
|||||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
||||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
||||||
workspaceGitService: WorkspaceGitService;
|
workspaceGitService: WorkspaceGitService;
|
||||||
broadcastWorkspaceUpdate: (workspaceId: string) => Promise<void> | void;
|
|
||||||
primeWorkspaceGitWatchFingerprints: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPaseoWorktree(
|
export async function createPaseoWorktree(
|
||||||
@@ -54,13 +44,13 @@ export async function createPaseoWorktree(
|
|||||||
): Promise<CreatePaseoWorktreeResult> {
|
): Promise<CreatePaseoWorktreeResult> {
|
||||||
const createdWorktree = await createWorktreeCore(input, deps);
|
const createdWorktree = await createWorktreeCore(input, deps);
|
||||||
const workspace = await upsertWorkspaceForWorktree({
|
const workspace = await upsertWorkspaceForWorktree({
|
||||||
|
inputCwd: input.cwd,
|
||||||
|
repoRoot: createdWorktree.repoRoot,
|
||||||
worktree: createdWorktree.worktree,
|
worktree: createdWorktree.worktree,
|
||||||
deps,
|
deps,
|
||||||
});
|
});
|
||||||
|
|
||||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||||
await deps.primeWorkspaceGitWatchFingerprints(workspace);
|
|
||||||
await deps.broadcastWorkspaceUpdate(workspace.workspaceId);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
worktree: createdWorktree.worktree,
|
worktree: createdWorktree.worktree,
|
||||||
@@ -72,35 +62,34 @@ export async function createPaseoWorktree(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function upsertWorkspaceForWorktree(options: {
|
async function upsertWorkspaceForWorktree(options: {
|
||||||
|
inputCwd: string;
|
||||||
|
repoRoot: string;
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
deps: Pick<
|
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
||||||
CreatePaseoWorktreeDeps,
|
|
||||||
"projectRegistry" | "workspaceRegistry" | "workspaceGitService"
|
|
||||||
>;
|
|
||||||
}): Promise<PersistedWorkspaceRecord> {
|
}): Promise<PersistedWorkspaceRecord> {
|
||||||
const normalizedCwd = normalizeWorkspaceId(options.worktree.worktreePath);
|
const normalizedCwd = normalizeWorkspaceId(options.worktree.worktreePath);
|
||||||
const placement = await buildProjectPlacementForCwd({
|
const normalizedInputCwd = normalizeWorkspaceId(options.inputCwd);
|
||||||
cwd: normalizedCwd,
|
const normalizedRepoRoot = normalizeWorkspaceId(options.repoRoot);
|
||||||
workspaceGitService: options.deps.workspaceGitService,
|
|
||||||
});
|
|
||||||
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const existingProject = await options.deps.projectRegistry.get(placement.projectKey);
|
|
||||||
const existingWorkspace = await findWorkspaceByDirectory(
|
const existingWorkspace = await findWorkspaceByDirectory(
|
||||||
normalizedCwd,
|
normalizedCwd,
|
||||||
options.deps.workspaceRegistry,
|
options.deps.workspaceRegistry,
|
||||||
);
|
);
|
||||||
|
const sourceProject = await resolveSourceProjectForWorktree({
|
||||||
|
inputCwd: normalizedInputCwd,
|
||||||
|
repoRoot: normalizedRepoRoot,
|
||||||
|
existingWorkspace,
|
||||||
|
deps: options.deps,
|
||||||
|
});
|
||||||
|
const workspaceId = normalizedCwd;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
await options.deps.projectRegistry.upsert(
|
await options.deps.projectRegistry.upsert(
|
||||||
createPersistedProjectRecord({
|
createPersistedProjectRecord({
|
||||||
projectId: placement.projectKey,
|
projectId: sourceProject.projectId,
|
||||||
rootPath: deriveProjectRootPath({
|
rootPath: sourceProject.rootPath,
|
||||||
cwd: normalizedCwd,
|
kind: sourceProject.kind,
|
||||||
checkout: placement.checkout,
|
displayName: sourceProject.displayName,
|
||||||
}),
|
createdAt: sourceProject.createdAt ?? now,
|
||||||
kind: deriveProjectKind(placement.checkout),
|
|
||||||
displayName: placement.projectName,
|
|
||||||
createdAt: existingProject?.createdAt ?? now,
|
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
}),
|
}),
|
||||||
@@ -108,13 +97,10 @@ async function upsertWorkspaceForWorktree(options: {
|
|||||||
|
|
||||||
const workspace = createPersistedWorkspaceRecord({
|
const workspace = createPersistedWorkspaceRecord({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
projectId: placement.projectKey,
|
projectId: sourceProject.projectId,
|
||||||
cwd: normalizedCwd,
|
cwd: normalizedCwd,
|
||||||
kind: deriveWorkspaceKind(placement.checkout),
|
kind: "worktree",
|
||||||
displayName: deriveWorkspaceDisplayName({
|
displayName: options.worktree.branchName || normalizedCwd,
|
||||||
cwd: normalizedCwd,
|
|
||||||
checkout: placement.checkout,
|
|
||||||
}),
|
|
||||||
createdAt: existingWorkspace?.createdAt ?? now,
|
createdAt: existingWorkspace?.createdAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
@@ -124,6 +110,64 @@ async function upsertWorkspaceForWorktree(options: {
|
|||||||
return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace;
|
return (await options.deps.workspaceRegistry.get(workspace.workspaceId)) ?? workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveSourceProjectForWorktree(options: {
|
||||||
|
inputCwd: string;
|
||||||
|
repoRoot: string;
|
||||||
|
existingWorkspace: PersistedWorkspaceRecord | null;
|
||||||
|
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
||||||
|
}): Promise<{
|
||||||
|
projectId: string;
|
||||||
|
rootPath: string;
|
||||||
|
kind: "git";
|
||||||
|
displayName: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
}> {
|
||||||
|
const sourceWorkspace =
|
||||||
|
options.existingWorkspace ??
|
||||||
|
(await findWorkspaceForSource({
|
||||||
|
inputCwd: options.inputCwd,
|
||||||
|
repoRoot: options.repoRoot,
|
||||||
|
workspaceRegistry: options.deps.workspaceRegistry,
|
||||||
|
}));
|
||||||
|
const sourceProject = sourceWorkspace
|
||||||
|
? await options.deps.projectRegistry.get(sourceWorkspace.projectId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (sourceWorkspace) {
|
||||||
|
return {
|
||||||
|
projectId: sourceWorkspace.projectId,
|
||||||
|
rootPath: sourceProject?.rootPath ?? options.repoRoot,
|
||||||
|
kind: "git",
|
||||||
|
displayName:
|
||||||
|
sourceProject?.displayName ?? deriveProjectGroupingName(sourceWorkspace.projectId),
|
||||||
|
createdAt: sourceProject?.createdAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingFallbackProject = await options.deps.projectRegistry.get(options.repoRoot);
|
||||||
|
return {
|
||||||
|
projectId: options.repoRoot,
|
||||||
|
rootPath: existingFallbackProject?.rootPath ?? options.repoRoot,
|
||||||
|
kind: "git",
|
||||||
|
displayName:
|
||||||
|
existingFallbackProject?.displayName ?? deriveProjectGroupingName(options.repoRoot),
|
||||||
|
createdAt: existingFallbackProject?.createdAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findWorkspaceForSource(options: {
|
||||||
|
inputCwd: string;
|
||||||
|
repoRoot: string;
|
||||||
|
workspaceRegistry: Pick<WorkspaceRegistry, "list">;
|
||||||
|
}): Promise<PersistedWorkspaceRecord | null> {
|
||||||
|
const workspaces = await options.workspaceRegistry.list();
|
||||||
|
return (
|
||||||
|
workspaces.find((workspace) => workspace.cwd === options.inputCwd && !workspace.archivedAt) ??
|
||||||
|
workspaces.find((workspace) => workspace.cwd === options.repoRoot && !workspace.archivedAt) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function findWorkspaceByDirectory(
|
async function findWorkspaceByDirectory(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
workspaceRegistry: Pick<WorkspaceRegistry, "list">,
|
workspaceRegistry: Pick<WorkspaceRegistry, "list">,
|
||||||
|
|||||||
@@ -900,6 +900,11 @@ export class Session {
|
|||||||
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
|
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||||
|
await this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace);
|
||||||
|
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the client's current activity state
|
* Get the client's current activity state
|
||||||
*/
|
*/
|
||||||
@@ -5764,6 +5769,36 @@ export class Session {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async describeCreatedWorktreeWorkspace(
|
||||||
|
result: CreatePaseoWorktreeResult,
|
||||||
|
): Promise<WorkspaceDescriptorPayload> {
|
||||||
|
const projectRecord = await this.projectRegistry.get(result.workspace.projectId);
|
||||||
|
return {
|
||||||
|
id: result.workspace.workspaceId,
|
||||||
|
projectId: result.workspace.projectId,
|
||||||
|
projectDisplayName: projectRecord?.displayName ?? String(result.workspace.projectId),
|
||||||
|
projectRootPath: projectRecord?.rootPath ?? result.repoRoot,
|
||||||
|
workspaceDirectory: result.workspace.cwd,
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: result.workspace.kind,
|
||||||
|
name: result.worktree.branchName || result.workspace.displayName,
|
||||||
|
status: "done",
|
||||||
|
activityAt: null,
|
||||||
|
diffStat: { additions: 0, deletions: 0 },
|
||||||
|
scripts: [],
|
||||||
|
gitRuntime: {
|
||||||
|
currentBranch: result.worktree.branchName || null,
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
|
isDirty: false,
|
||||||
|
aheadBehind: null,
|
||||||
|
aheadOfOrigin: null,
|
||||||
|
behindOfOrigin: null,
|
||||||
|
},
|
||||||
|
githubRuntime: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async buildWorkspaceDescriptor(input: {
|
private async buildWorkspaceDescriptor(input: {
|
||||||
workspace: PersistedWorkspaceRecord;
|
workspace: PersistedWorkspaceRecord;
|
||||||
projectRecord?: PersistedProjectRecord | null;
|
projectRecord?: PersistedProjectRecord | null;
|
||||||
@@ -6252,15 +6287,16 @@ export class Session {
|
|||||||
projectRegistry: this.projectRegistry,
|
projectRegistry: this.projectRegistry,
|
||||||
workspaceRegistry: this.workspaceRegistry,
|
workspaceRegistry: this.workspaceRegistry,
|
||||||
workspaceGitService: this.workspaceGitService,
|
workspaceGitService: this.workspaceGitService,
|
||||||
primeWorkspaceGitWatchFingerprints: (workspace) =>
|
|
||||||
this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace),
|
|
||||||
broadcastWorkspaceUpdate: (workspaceId) =>
|
|
||||||
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
|
||||||
});
|
});
|
||||||
await Promise.all([
|
void Promise.all([
|
||||||
this.notifyGitMutation(input.cwd, "create-worktree"),
|
this.notifyGitMutation(input.cwd, "create-worktree"),
|
||||||
this.notifyGitMutation(result.worktree.worktreePath, "create-worktree"),
|
this.notifyGitMutation(result.worktree.worktreePath, "create-worktree"),
|
||||||
]);
|
]).catch((error) => {
|
||||||
|
this.sessionLogger.warn(
|
||||||
|
{ err: error, cwd: input.cwd, worktreePath: result.worktree.worktreePath },
|
||||||
|
"Failed to warm git snapshots after creating worktree",
|
||||||
|
);
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6758,9 +6794,10 @@ export class Session {
|
|||||||
return handleCreateWorktreeRequest(
|
return handleCreateWorktreeRequest(
|
||||||
{
|
{
|
||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
|
describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result),
|
||||||
emit: (message) => this.emit(message),
|
emit: (message) => this.emit(message),
|
||||||
createPaseoWorktree: (input) => this.createPaseoWorktree(input),
|
createPaseoWorktree: (input) => this.createPaseoWorktree(input),
|
||||||
|
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
||||||
sessionLogger: this.sessionLogger,
|
sessionLogger: this.sessionLogger,
|
||||||
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
|
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -236,12 +236,6 @@ function createPaseoWorktreeForTest(options: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
primeWorkspaceGitWatchFingerprints: async (workspace) => {
|
|
||||||
options.events?.push(`prime:${workspace.workspaceId}`);
|
|
||||||
},
|
|
||||||
broadcastWorkspaceUpdate: async (workspaceId) => {
|
|
||||||
options.events?.push(`broadcast:${workspaceId}`);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1041,10 +1035,11 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
await handleCreatePaseoWorktreeRequest(
|
await handleCreatePaseoWorktreeRequest(
|
||||||
{
|
{
|
||||||
paseoHome,
|
paseoHome,
|
||||||
describeWorkspaceRecord: async (workspace) =>
|
describeWorkspaceRecord: async (result) =>
|
||||||
createWorkspaceDescriptor({ workspace, repoDir }),
|
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||||
emit: (message) => emitted.push(message),
|
emit: (message) => emitted.push(message),
|
||||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||||
|
warmWorkspaceGitData: async () => {},
|
||||||
sessionLogger: logger,
|
sessionLogger: logger,
|
||||||
runWorktreeSetupInBackground: async () => {},
|
runWorktreeSetupInBackground: async () => {},
|
||||||
},
|
},
|
||||||
@@ -1137,7 +1132,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature/review-pr");
|
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature/review-pr");
|
||||||
expect(result.worktreeBootstrap?.worktree.worktreePath).toContain("agent-review-pr-123");
|
expect(result.worktreeBootstrap?.worktree.worktreePath).toContain("agent-review-pr-123");
|
||||||
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
|
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
|
||||||
expect(events.some((event) => event.startsWith("broadcast:"))).toBe(true);
|
|
||||||
|
|
||||||
const branch = execSync("git branch --show-current", {
|
const branch = execSync("git branch --show-current", {
|
||||||
cwd: result.sessionConfig.cwd,
|
cwd: result.sessionConfig.cwd,
|
||||||
@@ -1281,9 +1275,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
sessionLogger: createLogger(),
|
sessionLogger: createLogger(),
|
||||||
emit: (message) => emitted.push(message),
|
emit: (message) => emitted.push(message),
|
||||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }),
|
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }),
|
||||||
describeWorkspaceRecord: vi.fn(async (workspace) => ({
|
describeWorkspaceRecord: vi.fn(async (result) => ({
|
||||||
id: workspace.workspaceId,
|
id: result.workspace.workspaceId,
|
||||||
projectId: workspace.projectId,
|
projectId: result.workspace.projectId,
|
||||||
projectDisplayName: path.basename(repoDir),
|
projectDisplayName: path.basename(repoDir),
|
||||||
projectRootPath: repoDir,
|
projectRootPath: repoDir,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
@@ -1291,7 +1285,20 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
name: "single-call",
|
name: "single-call",
|
||||||
status: "done",
|
status: "done",
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
|
diffStat: { additions: 0, deletions: 0 },
|
||||||
|
scripts: [],
|
||||||
|
gitRuntime: {
|
||||||
|
currentBranch: "single-call",
|
||||||
|
remoteUrl: null,
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
|
isDirty: false,
|
||||||
|
aheadBehind: null,
|
||||||
|
aheadOfOrigin: null,
|
||||||
|
behindOfOrigin: null,
|
||||||
|
},
|
||||||
|
githubRuntime: null,
|
||||||
})),
|
})),
|
||||||
|
warmWorkspaceGitData: async () => {},
|
||||||
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1303,7 +1310,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
|
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
|
||||||
expect(events.some((event) => event.startsWith("broadcast:"))).toBe(true);
|
|
||||||
const response = emitted.find(
|
const response = emitted.find(
|
||||||
(
|
(
|
||||||
message,
|
message,
|
||||||
@@ -1321,6 +1327,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
const paseoHome = path.join(tempDir, ".paseo");
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const backgroundWork = vi.fn(async () => {});
|
const backgroundWork = vi.fn(async () => {});
|
||||||
|
const warmWorkspaceGitData = vi.fn(async () => {});
|
||||||
let registeredWorktreePath: string | null = null;
|
let registeredWorktreePath: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1335,9 +1342,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
registeredWorktreePath = result.worktree.worktreePath;
|
registeredWorktreePath = result.worktree.worktreePath;
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
describeWorkspaceRecord: vi.fn(async (workspace) =>
|
describeWorkspaceRecord: vi.fn(async (result) =>
|
||||||
createWorkspaceDescriptor({ workspace, repoDir }),
|
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||||
),
|
),
|
||||||
|
warmWorkspaceGitData,
|
||||||
runWorktreeSetupInBackground: backgroundWork,
|
runWorktreeSetupInBackground: backgroundWork,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1356,8 +1364,26 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
);
|
);
|
||||||
expect(response?.payload.error).toBeNull();
|
expect(response?.payload.error).toBeNull();
|
||||||
expect(response?.payload.workspace?.id).toBeTruthy();
|
expect(response?.payload.workspace?.id).toBeTruthy();
|
||||||
|
expect(emitted.map((message) => message.type).slice(0, 2)).toEqual([
|
||||||
|
"create_paseo_worktree_response",
|
||||||
|
"workspace_update",
|
||||||
|
]);
|
||||||
|
const workspaceUpdate = emitted[1];
|
||||||
|
expect(workspaceUpdate).toMatchObject({
|
||||||
|
type: "workspace_update",
|
||||||
|
payload: {
|
||||||
|
kind: "upsert",
|
||||||
|
workspace: response?.payload.workspace,
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(registeredWorktreePath).toBeTruthy();
|
expect(registeredWorktreePath).toBeTruthy();
|
||||||
expect(existsSync(registeredWorktreePath!)).toBe(true);
|
expect(existsSync(registeredWorktreePath!)).toBe(true);
|
||||||
|
expect(warmWorkspaceGitData).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
workspaceId: response?.payload.workspace?.id,
|
||||||
|
cwd: registeredWorktreePath,
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(backgroundWork).toHaveBeenCalledWith(
|
expect(backgroundWork).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
requestCwd: repoDir,
|
requestCwd: repoDir,
|
||||||
@@ -1386,9 +1412,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
sessionLogger: createLogger(),
|
sessionLogger: createLogger(),
|
||||||
emit: (message) => emitted.push(message),
|
emit: (message) => emitted.push(message),
|
||||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||||
describeWorkspaceRecord: vi.fn(async (workspace) =>
|
describeWorkspaceRecord: vi.fn(async (result) =>
|
||||||
createWorkspaceDescriptor({ workspace, repoDir }),
|
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||||
),
|
),
|
||||||
|
warmWorkspaceGitData: async () => {},
|
||||||
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1426,9 +1453,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
|||||||
sessionLogger: createLogger(),
|
sessionLogger: createLogger(),
|
||||||
emit: (message) => emitted.push(message),
|
emit: (message) => emitted.push(message),
|
||||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||||
describeWorkspaceRecord: vi.fn(async (workspace) =>
|
describeWorkspaceRecord: vi.fn(async (result) =>
|
||||||
createWorkspaceDescriptor({ workspace, repoDir }),
|
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||||
),
|
),
|
||||||
|
warmWorkspaceGitData: async () => {},
|
||||||
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -119,10 +119,11 @@ type HandleWorkspaceSetupStatusRequestDependencies = {
|
|||||||
type HandleCreatePaseoWorktreeRequestDependencies = {
|
type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
describeWorkspaceRecord: (
|
describeWorkspaceRecord: (
|
||||||
workspace: PersistedWorkspaceRecord,
|
result: CreatePaseoWorktreeResult,
|
||||||
) => Promise<WorkspaceDescriptorPayload>;
|
) => Promise<WorkspaceDescriptorPayload>;
|
||||||
emit: EmitSessionMessage;
|
emit: EmitSessionMessage;
|
||||||
createPaseoWorktree: (input: CreatePaseoWorktreeInput) => Promise<CreatePaseoWorktreeResult>;
|
createPaseoWorktree: (input: CreatePaseoWorktreeInput) => Promise<CreatePaseoWorktreeResult>;
|
||||||
|
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||||
sessionLogger: Logger;
|
sessionLogger: Logger;
|
||||||
runWorktreeSetupInBackground: (options: {
|
runWorktreeSetupInBackground: (options: {
|
||||||
requestCwd: string;
|
requestCwd: string;
|
||||||
@@ -604,7 +605,7 @@ export async function handleCreatePaseoWorktreeRequest(
|
|||||||
const slug = basename(createdWorktree.worktree.worktreePath);
|
const slug = basename(createdWorktree.worktree.worktreePath);
|
||||||
const workspace = createdWorktree.workspace;
|
const workspace = createdWorktree.workspace;
|
||||||
|
|
||||||
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
|
const descriptor = await dependencies.describeWorkspaceRecord(createdWorktree);
|
||||||
dependencies.emit({
|
dependencies.emit({
|
||||||
type: "create_paseo_worktree_response",
|
type: "create_paseo_worktree_response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -614,7 +615,20 @@ export async function handleCreatePaseoWorktreeRequest(
|
|||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
dependencies.emit({
|
||||||
|
type: "workspace_update",
|
||||||
|
payload: {
|
||||||
|
kind: "upsert",
|
||||||
|
workspace: descriptor,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
void dependencies.warmWorkspaceGitData(workspace).catch((error) => {
|
||||||
|
dependencies.sessionLogger.warn(
|
||||||
|
{ err: error, workspaceId: workspace.workspaceId },
|
||||||
|
"Failed to warm workspace git data after creating worktree",
|
||||||
|
);
|
||||||
|
});
|
||||||
void dependencies.runWorktreeSetupInBackground({
|
void dependencies.runWorktreeSetupInBackground({
|
||||||
requestCwd: request.cwd,
|
requestCwd: request.cwd,
|
||||||
repoRoot: createdWorktree.repoRoot,
|
repoRoot: createdWorktree.repoRoot,
|
||||||
|
|||||||
Reference in New Issue
Block a user