From 42cdabdf573ca5430100a629c294bacf2504dc99 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Apr 2026 11:03:35 +0700 Subject: [PATCH] Fix optimistic workspace creation on Android --- docs/MOBILE_TESTING.md | 27 +++ packages/app/e2e/new-workspace.spec.ts | 183 ++++++++++++++++- packages/app/maestro/README.md | 71 +++++++ .../app/maestro/flows/android-dev-client.yaml | 41 ++++ .../flows/connect-direct-if-welcome.yaml | 30 +++ .../new-workspace-open-from-sidebar.yaml | 17 ++ .../new-workspace-select-codex-gpt54.yaml | 36 ++++ ...w-workspace-submit-and-assert-created.yaml | 23 +++ .../flows/open-prepared-project-sidebar.yaml | 22 +++ .../record-workspace-create-android-focus.sh | 154 +++++++++++++++ .../test-workspace-create-android-crash.sh | 187 ++++++++++++++++++ .../workspace-create-android-crash.yaml | 26 +++ ...rkspace-create-android-create-focused.yaml | 8 + ...orkspace-create-android-ready-sidebar.yaml | 11 ++ .../workspace/[workspaceId]/_layout.tsx | 6 +- .../src/screens/new-workspace-screen.test.tsx | 114 ++++++++++- .../app/src/screens/new-workspace-screen.tsx | 72 ++++--- .../workspace/workspace-draft-agent-tab.tsx | 95 +++++++-- .../navigation-active-workspace-store.test.ts | 39 ++++ .../navigation-active-workspace-store.ts | 23 ++- .../workspace-draft-submission-store.ts | 66 +++++++ .../src/utils/workspace-navigation.test.ts | 71 ++++++- .../app/src/utils/workspace-navigation.ts | 20 ++ .../src/server/agent/mcp-server.test.ts | 5 +- packages/server/src/server/bootstrap.ts | 26 +-- .../src/server/paseo-worktree-service.test.ts | 70 +++++-- .../src/server/paseo-worktree-service.ts | 120 +++++++---- packages/server/src/server/session.ts | 51 ++++- .../src/server/worktree-session.test.ts | 66 +++++-- .../server/src/server/worktree-session.ts | 18 +- 30 files changed, 1533 insertions(+), 165 deletions(-) create mode 100644 packages/app/maestro/README.md create mode 100644 packages/app/maestro/flows/android-dev-client.yaml create mode 100644 packages/app/maestro/flows/connect-direct-if-welcome.yaml create mode 100644 packages/app/maestro/flows/new-workspace-open-from-sidebar.yaml create mode 100644 packages/app/maestro/flows/new-workspace-select-codex-gpt54.yaml create mode 100644 packages/app/maestro/flows/new-workspace-submit-and-assert-created.yaml create mode 100644 packages/app/maestro/flows/open-prepared-project-sidebar.yaml create mode 100755 packages/app/maestro/record-workspace-create-android-focus.sh create mode 100755 packages/app/maestro/test-workspace-create-android-crash.sh create mode 100644 packages/app/maestro/workspace-create-android-crash.yaml create mode 100644 packages/app/maestro/workspace-create-android-create-focused.yaml create mode 100644 packages/app/maestro/workspace-create-android-ready-sidebar.yaml create mode 100644 packages/app/src/stores/workspace-draft-submission-store.ts diff --git a/docs/MOBILE_TESTING.md b/docs/MOBILE_TESTING.md index 2120a1743..ee99568bf 100644 --- a/docs/MOBILE_TESTING.md +++ b/docs/MOBILE_TESTING.md @@ -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." diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index ace56ff39..62e67d598 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -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 | 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; +} + +function getStringField(input: Record, 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(); + const delayedForwards: Array<() => void> = []; + let releaseRequested = false; + let resolveCreateRequest: (() => void) | null = null; + let resolveDelayedCreatedStatus: (() => void) | null = null; + const createRequestSeen = new Promise((resolve) => { + resolveCreateRequest = resolve; + }); + const delayedCreatedStatusSeen = new Promise((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) + : 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>; const localWorkspaceIds = new Set(); @@ -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) { diff --git a/packages/app/maestro/README.md b/packages/app/maestro/README.md new file mode 100644 index 000000000..88bf8f249 --- /dev/null +++ b/packages/app/maestro/README.md @@ -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 +``` diff --git a/packages/app/maestro/flows/android-dev-client.yaml b/packages/app/maestro/flows/android-dev-client.yaml new file mode 100644 index 000000000..d79267ebd --- /dev/null +++ b/packages/app/maestro/flows/android-dev-client.yaml @@ -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%" diff --git a/packages/app/maestro/flows/connect-direct-if-welcome.yaml b/packages/app/maestro/flows/connect-direct-if-welcome.yaml new file mode 100644 index 000000000..fea359331 --- /dev/null +++ b/packages/app/maestro/flows/connect-direct-if-welcome.yaml @@ -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" diff --git a/packages/app/maestro/flows/new-workspace-open-from-sidebar.yaml b/packages/app/maestro/flows/new-workspace-open-from-sidebar.yaml new file mode 100644 index 000000000..2b3f1d8e3 --- /dev/null +++ b/packages/app/maestro/flows/new-workspace-open-from-sidebar.yaml @@ -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 diff --git a/packages/app/maestro/flows/new-workspace-select-codex-gpt54.yaml b/packages/app/maestro/flows/new-workspace-select-codex-gpt54.yaml new file mode 100644 index 000000000..545eb7f6e --- /dev/null +++ b/packages/app/maestro/flows/new-workspace-select-codex-gpt54.yaml @@ -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 diff --git a/packages/app/maestro/flows/new-workspace-submit-and-assert-created.yaml b/packages/app/maestro/flows/new-workspace-submit-and-assert-created.yaml new file mode 100644 index 000000000..d8800ffd6 --- /dev/null +++ b/packages/app/maestro/flows/new-workspace-submit-and-assert-created.yaml @@ -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" diff --git a/packages/app/maestro/flows/open-prepared-project-sidebar.yaml b/packages/app/maestro/flows/open-prepared-project-sidebar.yaml new file mode 100644 index 000000000..27b37fb68 --- /dev/null +++ b/packages/app/maestro/flows/open-prepared-project-sidebar.yaml @@ -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 diff --git a/packages/app/maestro/record-workspace-create-android-focus.sh b/packages/app/maestro/record-workspace-create-android-focus.sh new file mode 100755 index 000000000..748981738 --- /dev/null +++ b/packages/app/maestro/record-workspace-create-android-focus.sh @@ -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" diff --git a/packages/app/maestro/test-workspace-create-android-crash.sh b/packages/app/maestro/test-workspace-create-android-crash.sh new file mode 100755 index 000000000..e9cccc543 --- /dev/null +++ b/packages/app/maestro/test-workspace-create-android-crash.sh @@ -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" diff --git a/packages/app/maestro/workspace-create-android-crash.yaml b/packages/app/maestro/workspace-create-android-crash.yaml new file mode 100644 index 000000000..e688405f6 --- /dev/null +++ b/packages/app/maestro/workspace-create-android-crash.yaml @@ -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 diff --git a/packages/app/maestro/workspace-create-android-create-focused.yaml b/packages/app/maestro/workspace-create-android-create-focused.yaml new file mode 100644 index 000000000..94af5c225 --- /dev/null +++ b/packages/app/maestro/workspace-create-android-create-focused.yaml @@ -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 diff --git a/packages/app/maestro/workspace-create-android-ready-sidebar.yaml b/packages/app/maestro/workspace-create-android-ready-sidebar.yaml new file mode 100644 index 000000000..73849a178 --- /dev/null +++ b/packages/app/maestro/workspace-create-android-ready-sidebar.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 diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index cab1aebae..4a649976d 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -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() { diff --git a/packages/app/src/screens/new-workspace-screen.test.tsx b/packages/app/src/screens/new-workspace-screen.test.tsx index 509841d65..955ce16ab 100644 --- a/packages/app/src/screens/new-workspace-screen.test.tsx +++ b/packages/app/src/screens/new-workspace-screen.test.tsx @@ -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 & { testID?: string; style?: unknown }) => { + const flattenedStyle = Array.isArray(style) + ? Object.assign({}, ...style.filter(Boolean)) + : style; + return
; + }, + }, +})); + 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(); - 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"); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 902295ca1..b0a6c2904 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -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(null); const [createdWorkspace, setCreatedWorkspace] = useState 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} /> - + @@ -557,7 +567,7 @@ export function NewWorkspaceScreen({ renderOption={renderPickerOption} /> - + {errorMessage ? {errorMessage} : null} diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 797dc5fcb..506b36bb1 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -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({ 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(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); }, []); diff --git a/packages/app/src/stores/navigation-active-workspace-store.test.ts b/packages/app/src/stores/navigation-active-workspace-store.test.ts index d007c7c09..cf9937e2d 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.test.ts @@ -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", + }); + }); }); diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store.ts index 0def2873f..35eb2f086 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.ts @@ -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()); } diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts new file mode 100644 index 000000000..5f8b5cbfb --- /dev/null +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -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; + allowEmptyText?: boolean; +} + +type WorkspaceDraftSubmissionState = { + pendingByDraftId: Record; + 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( + (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; + }, + }), +); diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts index 00cb78169..8ed2f05a1 100644 --- a/packages/app/src/utils/workspace-navigation.test.ts +++ b/packages/app/src/utils/workspace-navigation.test.ts @@ -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(); 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, + }); + }); }); diff --git a/packages/app/src/utils/workspace-navigation.ts b/packages/app/src/utils/workspace-navigation.ts index 50abeb0de..8e100408b 100644 --- a/packages/app/src/utils/workspace-navigation.ts +++ b/packages/app/src/utils/workspace-navigation.ts @@ -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); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index b0b429b26..afc63e782 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -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; }; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 6485b1cc0..05da8e8da 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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, diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index bd5db2491..d4ede9deb 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -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, }; } diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 502d336ee..f6ea174df 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -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; workspaceRegistry: Pick; workspaceGitService: WorkspaceGitService; - broadcastWorkspaceUpdate: (workspaceId: string) => Promise | void; - primeWorkspaceGitWatchFingerprints: (workspace: PersistedWorkspaceRecord) => Promise; } export async function createPaseoWorktree( @@ -54,13 +44,13 @@ export async function createPaseoWorktree( ): Promise { 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; }): Promise { 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; +}): 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; +}): Promise { + 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, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8c85da290..aa0102582 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -900,6 +900,11 @@ export class Session { await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true }); } + async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise { + 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 { + 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), }, diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index c8b0580bd..c4295d758 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -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 () => {}), }, { diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index cb0bbf590..4a28ef6f7 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -119,10 +119,11 @@ type HandleWorkspaceSetupStatusRequestDependencies = { type HandleCreatePaseoWorktreeRequestDependencies = { paseoHome?: string; describeWorkspaceRecord: ( - workspace: PersistedWorkspaceRecord, + result: CreatePaseoWorktreeResult, ) => Promise; emit: EmitSessionMessage; createPaseoWorktree: (input: CreatePaseoWorktreeInput) => Promise; + warmWorkspaceGitData: (workspace: PersistedWorkspaceRecord) => Promise; 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,