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.
|
||||
|
||||
### 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
|
||||
|
||||
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 path from "node:path";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
@@ -28,6 +28,108 @@ import {
|
||||
workspaceLabelFromPath,
|
||||
} 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", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
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,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
@@ -262,12 +364,87 @@ test.describe("New workspace flow", () => {
|
||||
await agentTabs.first().click();
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
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 }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
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;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
||||
input.navigation.setParams({ open: undefined });
|
||||
stripOpenSearchParamFromBrowserUrl();
|
||||
if (isWeb) {
|
||||
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
||||
stripOpenSearchParamFromBrowserUrl();
|
||||
}
|
||||
}
|
||||
|
||||
export default function HostWorkspaceLayout() {
|
||||
|
||||
@@ -13,8 +13,9 @@ const {
|
||||
theme,
|
||||
mockClient,
|
||||
mergeWorkspacesMock,
|
||||
setAgentsMock,
|
||||
navigateMock,
|
||||
saveDraftInputMock,
|
||||
queueDraftSubmissionMock,
|
||||
createdAgent,
|
||||
createdWorkspace,
|
||||
prItem,
|
||||
@@ -113,8 +114,9 @@ const {
|
||||
theme,
|
||||
mockClient,
|
||||
mergeWorkspacesMock: vi.fn(),
|
||||
setAgentsMock: vi.fn(),
|
||||
navigateMock: vi.fn(),
|
||||
saveDraftInputMock: vi.fn(),
|
||||
queueDraftSubmissionMock: vi.fn(),
|
||||
createdAgent,
|
||||
createdWorkspace,
|
||||
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", () => ({
|
||||
useHostRuntimeClient: () => mockClient,
|
||||
useHostRuntimeIsConnected: () => true,
|
||||
@@ -168,7 +185,6 @@ vi.mock("@/stores/session-store", () => ({
|
||||
useSessionStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
mergeWorkspaces: mergeWorkspacesMock,
|
||||
setAgents: setAgentsMock,
|
||||
}),
|
||||
normalizeWorkspaceDescriptor: (workspace: unknown) => workspace,
|
||||
}));
|
||||
@@ -195,6 +211,35 @@ vi.mock("@/utils/workspace-navigation", () => ({
|
||||
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", () => ({
|
||||
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", () => ({
|
||||
Composer: ({
|
||||
onSubmitMessage,
|
||||
@@ -444,6 +493,9 @@ beforeEach(() => {
|
||||
});
|
||||
mockClient.createPaseoWorktree.mockClear();
|
||||
mockClient.createAgent.mockClear();
|
||||
saveDraftInputMock.mockClear();
|
||||
queueDraftSubmissionMock.mockClear();
|
||||
navigateMock.mockClear();
|
||||
initialAttachments.length = 0;
|
||||
initialDraftState.text = "";
|
||||
});
|
||||
@@ -522,6 +574,14 @@ function firstCreateWorktreeCall(): CreatePaseoWorktreeArg {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
renderScreen();
|
||||
await flush();
|
||||
@@ -552,6 +612,43 @@ describe("NewWorkspaceScreen picker payload", () => {
|
||||
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 () => {
|
||||
renderScreen();
|
||||
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";
|
||||
const createAgent = createDeferredPromise<typeof createdAgent>();
|
||||
mockClient.createAgent.mockImplementationOnce(async () => await createAgent.promise);
|
||||
const createWorktree = createDeferredPromise<{
|
||||
workspace: typeof createdWorkspace;
|
||||
error: null;
|
||||
}>();
|
||||
mockClient.createPaseoWorktree.mockImplementationOnce(async () => await createWorktree.promise);
|
||||
renderScreen();
|
||||
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-branch-dev")).toHaveProperty("disabled", true);
|
||||
|
||||
createAgent.reject(new Error("Create agent failed"));
|
||||
createWorktree.reject(new Error("Create worktree failed"));
|
||||
await flush();
|
||||
|
||||
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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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 { useToast } from "@/contexts/toast-context";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
|
||||
@@ -105,9 +107,11 @@ export function NewWorkspaceScreen({
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
|
||||
mode: "translate",
|
||||
});
|
||||
const toast = useToast();
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||
typeof normalizeWorkspaceDescriptor
|
||||
@@ -319,10 +323,6 @@ export function NewWorkspaceScreen({
|
||||
try {
|
||||
setPendingAction("chat");
|
||||
setErrorMessage(null);
|
||||
const { images, attachments: reviewAttachments } =
|
||||
splitComposerAttachmentsForSubmit(attachments);
|
||||
const workspace = await ensureWorkspace({ cwd, attachments: reviewAttachments });
|
||||
const connectedClient = withConnectedClient();
|
||||
if (!composerState) {
|
||||
throw new Error("Composer state is required");
|
||||
}
|
||||
@@ -330,15 +330,30 @@ export function NewWorkspaceScreen({
|
||||
throw new Error("Select a model");
|
||||
}
|
||||
|
||||
const initialPrompt = text.trim();
|
||||
const encodedImages = await encodeImages(images);
|
||||
const workspaceDirectory = requireWorkspaceExecutionAuthority({
|
||||
workspace,
|
||||
}).workspaceDirectory;
|
||||
const agent = await connectedClient.createAgent({
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: workspaceDirectory,
|
||||
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
|
||||
const workspace = await ensureWorkspace({ cwd, attachments: reviewAttachments });
|
||||
const draftId = generateDraftId();
|
||||
const workspaceDirectory = workspace.workspaceDirectory;
|
||||
useDraftStore.getState().saveDraftInput({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
agentId: draftId,
|
||||
draftId,
|
||||
}),
|
||||
draft: {
|
||||
text,
|
||||
attachments,
|
||||
cwd: workspaceDirectory,
|
||||
},
|
||||
});
|
||||
useWorkspaceDraftSubmissionStore.getState().setPending({
|
||||
serverId,
|
||||
workspaceId: workspace.id,
|
||||
draftId,
|
||||
text,
|
||||
attachments,
|
||||
cwd: workspaceDirectory,
|
||||
provider: composerState.selectedProvider,
|
||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? { modeId: composerState.selectedMode }
|
||||
: {}),
|
||||
@@ -346,31 +361,23 @@ export function NewWorkspaceScreen({
|
||||
...(composerState.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
...(initialPrompt ? { initialPrompt } : {}),
|
||||
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
|
||||
...(reviewAttachments.length > 0 ? { attachments: reviewAttachments } : {}),
|
||||
});
|
||||
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
|
||||
return next;
|
||||
...(composerState.featureValues ? { featureValues: composerState.featureValues } : {}),
|
||||
allowEmptyText: true,
|
||||
});
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: workspace.id,
|
||||
target: { kind: "agent", agentId: agent.id },
|
||||
target: { kind: "draft", draftId },
|
||||
navigationMethod: "replace",
|
||||
});
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setPendingAction(null);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
},
|
||||
[composerState, ensureWorkspace, serverId, setAgents, toast, withConnectedClient],
|
||||
[composerState, ensureWorkspace, serverId, toast],
|
||||
);
|
||||
|
||||
const workspaceTitle =
|
||||
@@ -494,7 +501,10 @@ export function NewWorkspaceScreen({
|
||||
}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
/>
|
||||
<View style={styles.optionsRow}>
|
||||
<Animated.View
|
||||
testID="new-workspace-ref-picker-row"
|
||||
style={[styles.optionsRow, keyboardAnimatedStyle]}
|
||||
>
|
||||
<View>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
@@ -557,7 +567,7 @@ export function NewWorkspaceScreen({
|
||||
renderOption={renderPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</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 { StyleSheet } from "react-native-unistyles";
|
||||
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 type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
@@ -80,6 +81,23 @@ export function WorkspaceDraftAgentTab({
|
||||
if (!composerState) {
|
||||
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 {
|
||||
formErrorMessage,
|
||||
@@ -90,20 +108,21 @@ export function WorkspaceDraftAgentTab({
|
||||
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
||||
draftId,
|
||||
getPendingServerId: () => serverId,
|
||||
allowEmptyText: allowsEmptyAutoSubmit,
|
||||
validateBeforeSubmit: ({ text }) => {
|
||||
if (!text.trim()) {
|
||||
if (!allowsEmptyAutoSubmit && !text.trim()) {
|
||||
return "Initial prompt is required";
|
||||
}
|
||||
if (composerState.providerDefinitions.length === 0) {
|
||||
return "No available providers on the selected host";
|
||||
}
|
||||
if (!composerState.selectedProvider) {
|
||||
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
|
||||
return "Select a model";
|
||||
}
|
||||
if (composerState.isModelLoading) {
|
||||
return "Model defaults are still loading";
|
||||
}
|
||||
if (!composerState.effectiveModelId) {
|
||||
if (!(autoSubmitConfig?.model ?? composerState.effectiveModelId)) {
|
||||
return "No model is available for the selected provider";
|
||||
}
|
||||
if (!workspaceDirectory) {
|
||||
@@ -124,13 +143,15 @@ export function WorkspaceDraftAgentTab({
|
||||
buildDraftAgent: (attempt) => {
|
||||
invariant(workspaceDirectory, "Workspace directory is required");
|
||||
const now = attempt.timestamp;
|
||||
const model = composerState.effectiveModelId || null;
|
||||
const thinkingOptionId = composerState.effectiveThinkingOptionId || null;
|
||||
const model = autoSubmitConfig?.model ?? (composerState.effectiveModelId || null);
|
||||
const thinkingOptionId =
|
||||
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null);
|
||||
const modeId =
|
||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
autoSubmitConfig?.modeId ??
|
||||
(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? composerState.selectedMode
|
||||
: null;
|
||||
const provider = composerState.selectedProvider;
|
||||
: null);
|
||||
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
throw new Error("Select a model");
|
||||
}
|
||||
@@ -164,19 +185,23 @@ export function WorkspaceDraftAgentTab({
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
|
||||
const provider = composerState.selectedProvider;
|
||||
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
throw new Error("Select a model");
|
||||
}
|
||||
const config = buildWorkspaceDraftAgentConfig({
|
||||
provider,
|
||||
cwd: workspaceDirectory,
|
||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? { modeId: composerState.selectedMode }
|
||||
: {}),
|
||||
model: composerState.effectiveModelId || undefined,
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
||||
featureValues: composerState.featureValues,
|
||||
...(autoSubmitConfig?.modeId
|
||||
? { modeId: autoSubmitConfig.modeId }
|
||||
: composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? { modeId: composerState.selectedMode }
|
||||
: {}),
|
||||
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
|
||||
thinkingOptionId:
|
||||
autoSubmitConfig?.thinkingOptionId ??
|
||||
(composerState.effectiveThinkingOptionId || undefined),
|
||||
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
|
||||
});
|
||||
|
||||
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[]) => {
|
||||
addImagesRef.current?.(files);
|
||||
}, []);
|
||||
|
||||
@@ -112,10 +112,18 @@ describe("navigation active workspace store", () => {
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
});
|
||||
|
||||
store.syncNavigationActiveWorkspace(createNavigationPathRef("/h/server-1/sessions"));
|
||||
|
||||
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 () => {
|
||||
@@ -134,4 +142,35 @@ describe("navigation active workspace store", () => {
|
||||
|
||||
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 lastWorkspaceRouteSelection: ActiveWorkspaceSelection | null = null;
|
||||
let nextWorkspaceRouteSelectionOverride: ActiveWorkspaceSelection | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
@@ -165,7 +167,18 @@ function getActiveWorkspaceForNavigationSync(
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -180,6 +193,14 @@ export function getNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelectio
|
||||
return getSnapshot();
|
||||
}
|
||||
|
||||
export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelection | null {
|
||||
return lastWorkspaceRouteSelection;
|
||||
}
|
||||
|
||||
export function overrideNextNavigationWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
|
||||
nextWorkspaceRouteSelectionOverride = next;
|
||||
}
|
||||
|
||||
export function syncBrowserActiveWorkspaceFromLocation() {
|
||||
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";
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
const { platformState, routerMock } = vi.hoisted(() => ({
|
||||
platformState: {
|
||||
isNative: false,
|
||||
isWeb: true,
|
||||
},
|
||||
routerMock: {
|
||||
back: vi.fn(),
|
||||
canGoBack: vi.fn(() => false),
|
||||
navigate: 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", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
@@ -23,7 +42,11 @@ vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
});
|
||||
|
||||
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 WORKSPACE_ID = "/repo/worktree";
|
||||
@@ -31,6 +54,15 @@ const AGENT_ID = "agent-1";
|
||||
|
||||
describe("prepareWorkspaceTab", () => {
|
||||
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({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
@@ -50,4 +82,37 @@ describe("prepareWorkspaceTab", () => {
|
||||
const key = "server-1:/repo/worktree";
|
||||
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 { isNative } from "@/constants/platform";
|
||||
import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
getLastNavigationWorkspaceRouteSelection,
|
||||
overrideNextNavigationWorkspaceRouteSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
@@ -45,6 +51,20 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput) {
|
||||
export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string {
|
||||
const route = prepareWorkspaceTab(input);
|
||||
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);
|
||||
} else {
|
||||
router.navigate(route as any);
|
||||
|
||||
@@ -238,11 +238,8 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
},
|
||||
},
|
||||
workspaceGitService,
|
||||
primeWorkspaceGitWatchFingerprints: async () => {},
|
||||
broadcastWorkspaceUpdate: async (workspaceId) => {
|
||||
options.broadcasts.push(workspaceId);
|
||||
},
|
||||
});
|
||||
options.broadcasts.push(result.workspace.workspaceId);
|
||||
options.createdWorkspaceIds?.push(result.workspace.workspaceId);
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -505,9 +505,9 @@ export async function createPaseoDaemon(
|
||||
providerRegistry,
|
||||
github,
|
||||
workspaceGitService,
|
||||
createPaseoWorktree: (input, serviceOptions) => {
|
||||
createPaseoWorktree: async (input, serviceOptions) => {
|
||||
const coreDeps = createWorktreeCoreDeps(github);
|
||||
return createPaseoWorktree(input, {
|
||||
const result = await createPaseoWorktree(input, {
|
||||
...coreDeps,
|
||||
...(serviceOptions?.resolveDefaultBranch
|
||||
? {
|
||||
@@ -517,23 +517,13 @@ export async function createPaseoDaemon(
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
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,
|
||||
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();
|
||||
cleanupPaths.push(tempDir);
|
||||
const events: string[] = [];
|
||||
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(
|
||||
{
|
||||
@@ -39,15 +54,16 @@ describe("createPaseoWorktree", () => {
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.workspace.cwd).toBe(result.worktree.worktreePath);
|
||||
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([
|
||||
`project:${result.workspace.projectId}`,
|
||||
"project:remote:github.com/acme/repo",
|
||||
`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();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
@@ -81,7 +97,6 @@ describe("createPaseoWorktree", () => {
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath);
|
||||
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 () => {
|
||||
@@ -103,8 +118,6 @@ describe("createPaseoWorktree", () => {
|
||||
|
||||
expect(deps.projects.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", () => {
|
||||
@@ -157,12 +170,41 @@ function createDeps(options?: {
|
||||
},
|
||||
},
|
||||
workspaceGitService: createWorkspaceGitServiceStub(),
|
||||
primeWorkspaceGitWatchFingerprints: vi.fn(async (workspace: PersistedWorkspaceRecord) => {
|
||||
events.push(`prime:${workspace.workspaceId}`);
|
||||
}),
|
||||
broadcastWorkspaceUpdate: vi.fn(async (workspaceId: string) => {
|
||||
events.push(`broadcast:${workspaceId}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createPersistedProjectRecordForTest(input: {
|
||||
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,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import {
|
||||
buildProjectPlacementForCwd,
|
||||
deriveProjectKind,
|
||||
deriveProjectRootPath,
|
||||
deriveWorkspaceDisplayName,
|
||||
deriveWorkspaceId,
|
||||
deriveWorkspaceKind,
|
||||
normalizeWorkspaceId,
|
||||
} from "./workspace-registry-model.js";
|
||||
import { deriveProjectGroupingName, normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||
import {
|
||||
createWorktreeCore,
|
||||
type CreateWorktreeCoreDeps,
|
||||
@@ -44,8 +36,6 @@ export interface CreatePaseoWorktreeDeps extends CreateWorktreeCoreDeps {
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "upsert">;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
broadcastWorkspaceUpdate: (workspaceId: string) => Promise<void> | void;
|
||||
primeWorkspaceGitWatchFingerprints: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createPaseoWorktree(
|
||||
@@ -54,13 +44,13 @@ export async function createPaseoWorktree(
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
const createdWorktree = await createWorktreeCore(input, deps);
|
||||
const workspace = await upsertWorkspaceForWorktree({
|
||||
inputCwd: input.cwd,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
worktree: createdWorktree.worktree,
|
||||
deps,
|
||||
});
|
||||
|
||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||
await deps.primeWorkspaceGitWatchFingerprints(workspace);
|
||||
await deps.broadcastWorkspaceUpdate(workspace.workspaceId);
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
@@ -72,35 +62,34 @@ export async function createPaseoWorktree(
|
||||
}
|
||||
|
||||
async function upsertWorkspaceForWorktree(options: {
|
||||
inputCwd: string;
|
||||
repoRoot: string;
|
||||
worktree: WorktreeConfig;
|
||||
deps: Pick<
|
||||
CreatePaseoWorktreeDeps,
|
||||
"projectRegistry" | "workspaceRegistry" | "workspaceGitService"
|
||||
>;
|
||||
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = normalizeWorkspaceId(options.worktree.worktreePath);
|
||||
const placement = await buildProjectPlacementForCwd({
|
||||
cwd: normalizedCwd,
|
||||
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 normalizedInputCwd = normalizeWorkspaceId(options.inputCwd);
|
||||
const normalizedRepoRoot = normalizeWorkspaceId(options.repoRoot);
|
||||
const existingWorkspace = await findWorkspaceByDirectory(
|
||||
normalizedCwd,
|
||||
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(
|
||||
createPersistedProjectRecord({
|
||||
projectId: placement.projectKey,
|
||||
rootPath: deriveProjectRootPath({
|
||||
cwd: normalizedCwd,
|
||||
checkout: placement.checkout,
|
||||
}),
|
||||
kind: deriveProjectKind(placement.checkout),
|
||||
displayName: placement.projectName,
|
||||
createdAt: existingProject?.createdAt ?? now,
|
||||
projectId: sourceProject.projectId,
|
||||
rootPath: sourceProject.rootPath,
|
||||
kind: sourceProject.kind,
|
||||
displayName: sourceProject.displayName,
|
||||
createdAt: sourceProject.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
}),
|
||||
@@ -108,13 +97,10 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: placement.projectKey,
|
||||
projectId: sourceProject.projectId,
|
||||
cwd: normalizedCwd,
|
||||
kind: deriveWorkspaceKind(placement.checkout),
|
||||
displayName: deriveWorkspaceDisplayName({
|
||||
cwd: normalizedCwd,
|
||||
checkout: placement.checkout,
|
||||
}),
|
||||
kind: "worktree",
|
||||
displayName: options.worktree.branchName || normalizedCwd,
|
||||
createdAt: existingWorkspace?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
@@ -124,6 +110,64 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
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(
|
||||
cwd: string,
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "list">,
|
||||
|
||||
@@ -900,6 +900,11 @@ export class Session {
|
||||
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
|
||||
*/
|
||||
@@ -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: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
projectRecord?: PersistedProjectRecord | null;
|
||||
@@ -6252,15 +6287,16 @@ export class Session {
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
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(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;
|
||||
}
|
||||
|
||||
@@ -6758,9 +6794,10 @@ export class Session {
|
||||
return handleCreateWorktreeRequest(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
|
||||
describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result),
|
||||
emit: (message) => this.emit(message),
|
||||
createPaseoWorktree: (input) => this.createPaseoWorktree(input),
|
||||
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
||||
sessionLogger: this.sessionLogger,
|
||||
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
|
||||
},
|
||||
|
||||
@@ -236,12 +236,6 @@ function createPaseoWorktreeForTest(options: {
|
||||
},
|
||||
},
|
||||
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(
|
||||
{
|
||||
paseoHome,
|
||||
describeWorkspaceRecord: async (workspace) =>
|
||||
createWorkspaceDescriptor({ workspace, repoDir }),
|
||||
describeWorkspaceRecord: async (result) =>
|
||||
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||
emit: (message) => emitted.push(message),
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
sessionLogger: logger,
|
||||
runWorktreeSetupInBackground: async () => {},
|
||||
},
|
||||
@@ -1137,7 +1132,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
expect(result.worktreeBootstrap?.worktree.branchName).toBe("feature/review-pr");
|
||||
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("broadcast:"))).toBe(true);
|
||||
|
||||
const branch = execSync("git branch --show-current", {
|
||||
cwd: result.sessionConfig.cwd,
|
||||
@@ -1281,9 +1275,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
sessionLogger: createLogger(),
|
||||
emit: (message) => emitted.push(message),
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome, events }),
|
||||
describeWorkspaceRecord: vi.fn(async (workspace) => ({
|
||||
id: workspace.workspaceId,
|
||||
projectId: workspace.projectId,
|
||||
describeWorkspaceRecord: vi.fn(async (result) => ({
|
||||
id: result.workspace.workspaceId,
|
||||
projectId: result.workspace.projectId,
|
||||
projectDisplayName: path.basename(repoDir),
|
||||
projectRootPath: repoDir,
|
||||
projectKind: "git",
|
||||
@@ -1291,7 +1285,20 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
name: "single-call",
|
||||
status: "done",
|
||||
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 () => {}),
|
||||
},
|
||||
{
|
||||
@@ -1303,7 +1310,6 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
);
|
||||
|
||||
expect(events.some((event) => event.startsWith("workspace:"))).toBe(true);
|
||||
expect(events.some((event) => event.startsWith("broadcast:"))).toBe(true);
|
||||
const response = emitted.find(
|
||||
(
|
||||
message,
|
||||
@@ -1321,6 +1327,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const backgroundWork = vi.fn(async () => {});
|
||||
const warmWorkspaceGitData = vi.fn(async () => {});
|
||||
let registeredWorktreePath: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -1335,9 +1342,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
registeredWorktreePath = result.worktree.worktreePath;
|
||||
return result;
|
||||
},
|
||||
describeWorkspaceRecord: vi.fn(async (workspace) =>
|
||||
createWorkspaceDescriptor({ workspace, repoDir }),
|
||||
describeWorkspaceRecord: vi.fn(async (result) =>
|
||||
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||
),
|
||||
warmWorkspaceGitData,
|
||||
runWorktreeSetupInBackground: backgroundWork,
|
||||
},
|
||||
{
|
||||
@@ -1356,8 +1364,26 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
);
|
||||
expect(response?.payload.error).toBeNull();
|
||||
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(existsSync(registeredWorktreePath!)).toBe(true);
|
||||
expect(warmWorkspaceGitData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: response?.payload.workspace?.id,
|
||||
cwd: registeredWorktreePath,
|
||||
}),
|
||||
);
|
||||
expect(backgroundWork).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestCwd: repoDir,
|
||||
@@ -1386,9 +1412,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
sessionLogger: createLogger(),
|
||||
emit: (message) => emitted.push(message),
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
describeWorkspaceRecord: vi.fn(async (workspace) =>
|
||||
createWorkspaceDescriptor({ workspace, repoDir }),
|
||||
describeWorkspaceRecord: vi.fn(async (result) =>
|
||||
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||
),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
||||
},
|
||||
{
|
||||
@@ -1426,9 +1453,10 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
sessionLogger: createLogger(),
|
||||
emit: (message) => emitted.push(message),
|
||||
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
|
||||
describeWorkspaceRecord: vi.fn(async (workspace) =>
|
||||
createWorkspaceDescriptor({ workspace, repoDir }),
|
||||
describeWorkspaceRecord: vi.fn(async (result) =>
|
||||
createWorkspaceDescriptor({ workspace: result.workspace, repoDir }),
|
||||
),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
runWorktreeSetupInBackground: vi.fn(async () => {}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -119,10 +119,11 @@ type HandleWorkspaceSetupStatusRequestDependencies = {
|
||||
type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||
paseoHome?: string;
|
||||
describeWorkspaceRecord: (
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
result: CreatePaseoWorktreeResult,
|
||||
) => Promise<WorkspaceDescriptorPayload>;
|
||||
emit: EmitSessionMessage;
|
||||
createPaseoWorktree: (input: CreatePaseoWorktreeInput) => Promise<CreatePaseoWorktreeResult>;
|
||||
warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
sessionLogger: Logger;
|
||||
runWorktreeSetupInBackground: (options: {
|
||||
requestCwd: string;
|
||||
@@ -604,7 +605,7 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
const slug = basename(createdWorktree.worktree.worktreePath);
|
||||
const workspace = createdWorktree.workspace;
|
||||
|
||||
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
|
||||
const descriptor = await dependencies.describeWorkspaceRecord(createdWorktree);
|
||||
dependencies.emit({
|
||||
type: "create_paseo_worktree_response",
|
||||
payload: {
|
||||
@@ -614,7 +615,20 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
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({
|
||||
requestCwd: request.cwd,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
|
||||
Reference in New Issue
Block a user