Fix optimistic workspace creation on Android

This commit is contained in:
Mohamed Boudra
2026-04-22 11:03:35 +07:00
parent 7b9cc3d423
commit 42cdabdf57
30 changed files with 1533 additions and 165 deletions
+27
View File
@@ -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."
+180 -3
View File
@@ -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) {
+71
View File
@@ -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
View File
@@ -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;
};
+8 -18
View File
@@ -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">,
+44 -7
View File
@@ -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 () => {}),
},
{
+16 -2
View File
@@ -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,