Compare commits

..
79 changed files with 973 additions and 1624 deletions
+6 -3
View File
@@ -1,8 +1,11 @@
name: Deploy Relay
on:
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
# A release or main push must not redeploy the temporary Cloudflare bridge.
push:
branches: [main]
paths:
- "packages/relay/**"
- ".github/workflows/deploy-relay.yml"
workflow_dispatch:
jobs:
@@ -24,6 +27,6 @@ jobs:
run: npm run typecheck --workspace=@getpaseo/relay
- name: Deploy worker
run: cd packages/relay && npx wrangler deploy
run: cd packages/relay && npx wrangler deploy -c wrangler.staging.toml
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
-17
View File
@@ -1,22 +1,5 @@
# Changelog
## 0.1.106 - 2026-07-12
### Added
- Approve Codex MCP permission requests in Paseo ([#2001](https://github.com/getpaseo/paseo/pull/2001))
### Improved
- ACP provider catalog updated to the latest registry versions
### Fixed
- Reduced mobile chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989))
- OpenCode sessions start reliably instead of occasionally losing the first turn ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger))
- Switching between workspaces no longer flashes a white screen
- Pi keeps your existing MCP tools and settings when Paseo adds its own ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger))
## 0.1.105 - 2026-07-10
### Added
+1 -1
View File
@@ -218,7 +218,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
-12
View File
@@ -96,18 +96,6 @@ Keep workspace identity and retention outside native-stack `getId` and
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
reordering an already-mounted workspace screen.
Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every
Expo Router stack. React Navigation otherwise paints each native stack screen
with its light default background. A screen-level wrapper can hide that surface
while settled, but Android may expose it for one frame when navigation crosses
from a nested stack to its parent stack. This is especially visible when an
app-wide route such as `/new` opens from a dark workspace.
Do not read the active theme with `useUnistyles()` in a layout to build
`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through
a small `withUnistyles` boundary without subscribing the route tree itself to
every Unistyles runtime update.
## Regression Shape
Pure helper tests are useful but not enough. The failure mode here is native
-1
View File
@@ -4,7 +4,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
+1 -1
View File
@@ -28,7 +28,7 @@ Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
-2
View File
@@ -52,8 +52,6 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
+1 -1
View File
@@ -1 +1 @@
sha256-bUcqRcbaOGK2TgXJ/H/eLg6Y5VcZeVwvu3OWn+nnmCw=
sha256-cU6qRXY9fnr80pllmqJts5w8+OiE6F99SRo2d9G0HDA=
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.106",
"version": "0.1.105",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.106",
"version": "0.1.105",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35152,7 +35152,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36170,12 +36170,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/server": "0.1.106",
"@getpaseo/client": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/server": "0.1.105",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36421,10 +36421,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36435,7 +36435,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.106",
"version": "0.1.105",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36678,7 +36678,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.106",
"version": "0.1.105",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37574,7 +37574,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37806,7 +37806,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37819,7 +37819,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38037,15 +38037,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.106",
"@getpaseo/highlight": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@getpaseo/client": "0.1.105",
"@getpaseo/highlight": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38582,7 +38582,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.106",
"version": "0.1.105",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.106",
"version": "0.1.105",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,5 +1,4 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { expectOpenedProject } from "./helpers/project-picker-ui";
@@ -12,7 +11,7 @@ function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
const row = page.getByTestId(workspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
@@ -22,6 +21,10 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
await expect(kebab).toBeVisible({ timeout: 10_000 });
await kebab.click();
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
// user-confirmed archive proceeds deterministically.
page.once("dialog", (dialog) => void dialog.accept());
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
await archiveItem.click();
@@ -156,21 +159,17 @@ test.describe("Project with no workspaces persists", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
await expect(workspaceRow).toBeVisible({
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
timeout: 30_000,
});
await workspaceRow.click();
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(0);
await archiveWorkspaceFromSidebar(page, workspace.workspaceId);
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
// The workspace row goes away, but its project parent stays and exposes a
// child row for creating the next workspace.
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
timeout: 30_000,
});
expect(existsSync(workspace.repoPath)).toBe(true);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
await expect(newWorkspaceRow).toContainText("New workspace");
+4 -3
View File
@@ -39,9 +39,10 @@ export async function clickArchiveWorkspaceMenuItem(
await archiveItem.click();
}
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
// A clean workspace archives with no prompt. Managed worktree backing may raise
// a browser confirm for unsynced work, so accept it when present.
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
// A clean worktree archives with no prompt; if the host reports unsynced work the app
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
// either way.
page.once("dialog", (dialog) => void dialog.accept());
await clickArchiveWorkspaceMenuItem(page, workspaceId);
}
@@ -1,26 +0,0 @@
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { seedWorkspace } from "./helpers/seed-client";
import { expectWorkspaceAbsentFromSidebar, selectWorkspaceInSidebar } from "./helpers/sidebar";
import { waitForSidebarHydration } from "./helpers/workspace-ui";
test.describe("Workspace archive shortcut", () => {
test("archives the selected workspace without removing its local checkout", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "archive-shortcut-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await selectWorkspaceInSidebar(page, workspace.workspaceId);
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Shift+Backspace`);
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
expect(existsSync(workspace.repoPath)).toBe(true);
} finally {
await workspace.cleanup();
}
});
});
@@ -76,7 +76,7 @@ async function clickArchiveAndAnswerWarning(
return warning;
}
test.describe("Workspace archive risk warning for worktree backing", () => {
test.describe("Worktree archive risk warning", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -97,7 +97,7 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
page,
}) => {
const serverId = getServerId();
+6 -44
View File
@@ -8,11 +8,11 @@ import {
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { getServerId } from "./helpers/server-id";
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
test.describe("Workspace archive with worktree backing", () => {
test.describe("Worktree archive", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -33,7 +33,9 @@ test.describe("Workspace archive with worktree backing", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
page,
}) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
@@ -47,51 +49,11 @@ test.describe("Workspace archive with worktree backing", () => {
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
});
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const first = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `shared-archive-${Date.now()}`,
});
createdWorktreeDirectories.add(first.workspaceDirectory);
const siblingPayload = await client.createWorkspace({
source: {
kind: "directory",
path: first.workspaceDirectory,
},
title: "Second workspace",
});
if (!siblingPayload.workspace) {
throw new Error(siblingPayload.error ?? "Failed to create a workspace on the worktree");
}
const sibling = siblingPayload.workspace;
expect(sibling.workspaceKind).toBe("worktree");
expect(sibling.workspaceDirectory).toBe(first.workspaceDirectory);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: first.workspaceId });
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
await archiveWorkspaceFromSidebar(page, first.workspaceId);
await expectWorkspaceAbsentFromSidebar(page, first.workspaceId);
expect(existsSync(first.workspaceDirectory)).toBe(true);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
await archiveWorkspaceFromSidebar(page, sibling.id);
await expectWorkspaceAbsentFromSidebar(page, sibling.id);
await expect.poll(() => existsSync(first.workspaceDirectory), { timeout: 30_000 }).toBe(false);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.106",
"version": "0.1.105",
"private": true,
"main": "index.ts",
"scripts": {
+13 -8
View File
@@ -52,7 +52,6 @@ import {
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
@@ -785,15 +784,21 @@ function FaviconStatusSync() {
return null;
}
const ROOT_STACK_SCREEN_OPTIONS = {
headerShown: false,
animation: "none" as const,
};
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
const stackScreenOptions = useMemo(
() => ({
headerShown: false,
animation: "none" as const,
contentStyle: {
backgroundColor: theme.colors.surface0,
},
}),
[theme.colors.surface0],
);
return (
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
<Stack screenOptions={stackScreenOptions}>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
@@ -810,7 +815,7 @@ function RootStack() {
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
</ThemedStack>
</Stack>
);
}
@@ -2,7 +2,6 @@ import { Redirect, Stack, useLocalSearchParams } from "expo-router";
import { useHostRuntimeBootstrapState } from "@/app/_layout";
import { HostRouteProvider } from "@/navigation/host-route-context";
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
import { ThemedStack } from "@/navigation/themed-stack";
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
const HOST_STACK_SCREEN_OPTIONS = {
@@ -34,14 +33,14 @@ function KnownHostRoute() {
}
const stack = (
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
<Stack.Screen name="index" />
<Stack.Screen name="workspace/[workspaceId]/index" />
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
<Stack.Screen name="sessions" />
<Stack.Screen name="open-project" />
<Stack.Screen name="settings" />
</ThemedStack>
</Stack>
);
if (!routeServerId) {
@@ -77,6 +77,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { SyncedLoader } from "@/components/synced-loader";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
@@ -111,7 +112,7 @@ import {
} from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import {
getCurrentProjectRemoveReadiness,
@@ -283,6 +284,16 @@ interface WorkspaceRowInnerProps {
archiveShortcutKeys?: ShortcutKey[][] | null;
}
function getWorkspaceArchiveStatus(
isWorktree: boolean,
archiveStatus: "idle" | "pending" | "success",
isArchivingWorkspace: boolean,
): "idle" | "pending" | "success" {
if (isWorktree) return archiveStatus;
if (isArchivingWorkspace) return "pending";
return "idle";
}
export function PrBadge({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
@@ -1443,7 +1454,20 @@ function WorkspaceRowWithMenu({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
@@ -1455,6 +1479,7 @@ function WorkspaceRowWithMenu({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -1521,7 +1546,7 @@ function WorkspaceRowWithMenu({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -1533,8 +1558,8 @@ function WorkspaceRowWithMenu({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -1559,7 +1584,11 @@ function WorkspaceRowWithMenu({
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={isArchiving ? "pending" : "idle"}
archiveStatus={getWorkspaceArchiveStatus(
isWorktree,
worktreeArchiveStatus,
isHidingWorkspace,
)}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
@@ -23,9 +23,10 @@ import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -329,7 +330,20 @@ function StatusWorkspaceRowWithMenu({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -344,6 +358,7 @@ function StatusWorkspaceRowWithMenu({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -393,7 +408,7 @@ function StatusWorkspaceRowWithMenu({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -405,8 +420,8 @@ function StatusWorkspaceRowWithMenu({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -415,6 +430,13 @@ function StatusWorkspaceRowWithMenu({
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
computedArchiveStatus = "pending";
}
return (
<>
<StatusWorkspaceRowInner
@@ -426,7 +448,7 @@ function StatusWorkspaceRowWithMenu({
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={isArchiving ? "pending" : "idle"}
archiveStatus={computedArchiveStatus}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
@@ -11,13 +11,14 @@ import { DiffStat } from "@/components/diff-stat";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { useToast } from "@/contexts/toast-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { isNative as platformIsNative } from "@/constants/platform";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
@@ -65,7 +66,20 @@ export function SidebarWorkspaceRow({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const workspaceDirectory = resolveWorkspaceDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -80,6 +94,7 @@ export function SidebarWorkspaceRow({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -146,7 +161,7 @@ export function SidebarWorkspaceRow({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -158,8 +173,8 @@ export function SidebarWorkspaceRow({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -168,6 +183,13 @@ export function SidebarWorkspaceRow({
},
});
let archiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
archiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
archiveStatus = "pending";
}
return (
<>
<WorkspaceRowBody
@@ -183,7 +205,7 @@ export function SidebarWorkspaceRow({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={isArchiving ? "pending" : "idle"}
archiveStatus={archiveStatus}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
@@ -1,8 +1,9 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
interface PendingWorkspaceArchive {
workspaceId: string;
workspaceDirectory: string | null;
}
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
@@ -14,6 +15,7 @@ function pendingArchiveKey(input: { serverId: string; workspaceId: string }): st
export function markWorkspaceArchivePending(input: {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}): void {
const serverId = input.serverId.trim();
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
@@ -24,6 +26,7 @@ export function markWorkspaceArchivePending(input: {
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
workspaceId,
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
});
pendingWorkspaceArchivesByServer.set(serverId, archives);
}
@@ -51,6 +54,7 @@ export function clearWorkspaceArchivePending(input: {
export function isWorkspaceArchivePending(input: {
serverId: string;
workspaceId?: string | null;
workspaceDirectory?: string | null;
}): boolean {
const serverId = input.serverId.trim();
if (!serverId) {
@@ -63,7 +67,21 @@ export function isWorkspaceArchivePending(input: {
}
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
return Boolean(workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId })));
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
return true;
}
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
if (!workspaceDirectory) {
return false;
}
for (const archive of archives.values()) {
if (archive.workspaceDirectory === workspaceDirectory) {
return true;
}
}
return false;
}
export function shouldSuppressWorkspaceForLocalArchive(input: {
@@ -73,5 +91,6 @@ export function shouldSuppressWorkspaceForLocalArchive(input: {
return isWorkspaceArchivePending({
serverId: input.serverId,
workspaceId: input.workspace.id,
workspaceDirectory: input.workspace.workspaceDirectory,
});
}
+10 -10
View File
@@ -68,10 +68,10 @@ const CATALOG_DATA = [
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.119.2",
version: "2.118.2",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.119.2", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.118.2", "--acp"],
},
{
id: "codewhale",
@@ -140,10 +140,10 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.27",
version: "0.2.26",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.27", "acp"],
command: ["npx", "-y", "dimcode@0.2.26", "acp"],
},
{
id: "dirac",
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.170.0",
version: "0.169.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.170.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.169.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.43",
version: "1.0.41",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.43", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.41", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.9",
version: "0.19.8",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.9", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.8", "--acp", "--experimental-skills"],
},
{
id: "sigit",
@@ -48,7 +48,7 @@ function GitActionMenuItem({
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
const trailing = useMemo(
() =>
action.id === "archive-workspace" && archiveShortcutKeys ? (
action.id === "archive-worktree" && archiveShortcutKeys ? (
<Shortcut chord={archiveShortcutKeys} />
) : undefined,
[action.id, archiveShortcutKeys],
@@ -58,7 +58,7 @@ function GitActionMenuItem({
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
testID={
action.id === "archive-workspace"
action.id === "archive-worktree"
? "workspace-archive-action"
: `changes-menu-${action.id}`
}
@@ -82,7 +82,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
const { theme } = useUnistyles();
const { t } = useTranslation();
const toast = useToast();
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const getActionDisplayLabel = useCallback((action: GitAction): string => {
if (action.status === "pending") return action.pendingLabel;
+141
View File
@@ -2,10 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { queryClient as appQueryClient } from "@/data/query-client";
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
__resetCheckoutGitActionsStoreForTests,
isLocalWorktreeArchivePending,
useCheckoutGitActionsStore,
} from "@/git/actions-store";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
@@ -25,13 +31,33 @@ function createDeferred<T>() {
return { promise, resolve, reject };
}
function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">) {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project",
projectRootPath: input.projectRootPath ?? "/tmp/repo",
workspaceDirectory: input.workspaceDirectory ?? "/tmp/repo/worktrees/feature",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
} satisfies WorkspaceDescriptor;
}
describe("checkout-git-actions-store", () => {
const serverId = "server-1";
const cwd = "/tmp/repo/worktrees/feature";
const workspaceId = "ws-feature";
beforeEach(() => {
vi.useFakeTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
@@ -39,6 +65,7 @@ describe("checkout-git-actions-store", () => {
afterEach(() => {
vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
@@ -267,4 +294,118 @@ describe("checkout-git-actions-store", () => {
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
).toBe("idle");
});
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({
id: workspaceId,
name: "feature",
workspaceDirectory: cwd,
});
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
appQueryClient.setQueryData(
["sidebarPaseoWorktreeList", serverId, "/tmp"],
[{ worktreePath: cwd }, { worktreePath: "/tmp/other" }],
);
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId });
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
{ worktreePath: "/tmp/other" },
]);
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
deferred.resolve({});
await archive;
expect(
isWorkspaceArchivePending({
serverId,
workspaceId,
}),
).toBe(true);
expect(
isWorkspaceArchivePending({
serverId,
workspaceId: cwd,
}),
).toBe(false);
});
it("archives on the server even when its workspace cannot be resolved", async () => {
const client = {
archivePaseoWorktree: vi.fn(async () => ({})),
};
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
await useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
// The server archive is keyed by worktreePath and must run regardless.
expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd });
// The optimistic client-side mark is never keyed by the path.
expect(isWorkspaceArchivePending({ serverId, workspaceId: cwd })).toBe(false);
});
it("restores an optimistically hidden worktree when archive fails", async () => {
const client = {
archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })),
};
const featureWorkspace = workspace({
id: workspaceId,
name: "feature",
workspaceDirectory: cwd,
});
const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }];
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
await expect(
useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId }),
).rejects.toThrow("archive failed");
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(
featureWorkspace,
);
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual(
listSnapshot,
);
});
it("reports local archive pending only while the archive action is in flight", async () => {
const deferred = createDeferred<Record<string, never>>();
const client = {
archivePaseoWorktree: vi.fn(() => deferred.promise),
};
const featureWorkspace = workspace({
id: workspaceId,
name: "feature",
workspaceDirectory: cwd,
});
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
const archive = useCheckoutGitActionsStore
.getState()
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
deferred.resolve({});
await archive;
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(false);
});
});
+187 -1
View File
@@ -1,7 +1,19 @@
import type { QueryKey } from "@tanstack/react-query";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import { queryClient as appQueryClient } from "@/data/query-client";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { useSessionStore } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { i18n } from "@/i18n/i18next";
@@ -24,7 +36,8 @@ export type CheckoutGitAsyncActionId =
| "enable-pr-auto-merge-rebase"
| "disable-pr-auto-merge"
| "merge-branch"
| "merge-from-base";
| "merge-from-base"
| "archive-worktree";
type CheckoutKey = string;
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
@@ -76,6 +89,119 @@ function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
}
function invalidateWorktreeList() {
void appQueryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
});
void appQueryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList",
});
}
function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath: string }): void {
const serverId = input.serverId.trim();
const worktreePath = input.worktreePath.trim();
if (!serverId || !worktreePath) {
return;
}
const removeFromList = (current: unknown) => {
if (!Array.isArray(current)) {
return current;
}
const filtered = current.filter((entry) => entry?.worktreePath !== worktreePath);
return filtered.length === current.length ? current : filtered;
};
appQueryClient.setQueriesData(
{
predicate: (query) =>
Array.isArray(query.queryKey) &&
query.queryKey[0] === "paseoWorktreeList" &&
query.queryKey[1] === serverId,
},
removeFromList,
);
appQueryClient.setQueriesData(
{
predicate: (query) =>
Array.isArray(query.queryKey) &&
query.queryKey[0] === "sidebarPaseoWorktreeList" &&
query.queryKey[1] === serverId,
},
removeFromList,
);
}
interface WorktreeArchiveSnapshot {
workspace: WorkspaceDescriptor | null;
worktreeLists: Array<[QueryKey, unknown]>;
}
function isWorktreeListQuery(input: { queryKey: QueryKey; serverId: string }): boolean {
return (
Array.isArray(input.queryKey) &&
(input.queryKey[0] === "paseoWorktreeList" ||
input.queryKey[0] === "sidebarPaseoWorktreeList") &&
input.queryKey[1] === input.serverId
);
}
function snapshotWorktreeArchiveState(input: {
serverId: string;
workspaceId: string | undefined;
}): WorktreeArchiveSnapshot {
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
const workspaceKey = input.workspaceId
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: input.workspaceId })
: null;
return {
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
worktreeLists: appQueryClient.getQueriesData({
predicate: (query) =>
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
}),
};
}
function removeWorktreeFromSessionStore(input: { serverId: string; workspaceId: string }): void {
const serverId = input.serverId.trim();
const workspaceId = input.workspaceId.trim();
if (!serverId || !workspaceId) {
return;
}
useSessionStore.getState().removeWorkspace(serverId, workspaceId);
}
function restoreWorktreeArchiveState(input: {
serverId: string;
snapshot: WorktreeArchiveSnapshot;
}): void {
if (input.snapshot.workspace) {
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]);
}
for (const [queryKey, data] of input.snapshot.worktreeLists) {
appQueryClient.setQueryData(queryKey, data);
}
}
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
const serverId = input.serverId.trim();
const workspaceId = input.workspaceId.trim();
if (!serverId || !workspaceId) {
return;
}
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (workspaceKey) {
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
}
useWorkspaceTabsStore.getState().purgeWorkspace({ serverId, workspaceId });
}
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
const inFlight = new Map<string, Promise<unknown>>();
@@ -83,6 +209,16 @@ function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): stri
return `${key}::${actionId}`;
}
export function isLocalWorktreeArchivePending(input: { serverId: string; cwd: string }): boolean {
return (
useCheckoutGitActionsStore.getState().getStatus({
serverId: input.serverId,
cwd: input.cwd,
actionId: "archive-worktree",
}) === "pending"
);
}
interface CheckoutGitActionsStoreState {
statusByCheckout: Record<CheckoutKey, StatusMap>;
@@ -111,6 +247,12 @@ interface CheckoutGitActionsStoreState {
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
archiveWorktree: (params: {
serverId: string;
cwd: string;
worktreePath: string;
workspaceId?: string;
}) => Promise<void>;
}
async function runCheckoutAction({
@@ -348,6 +490,50 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
},
});
},
archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "archive-worktree",
run: async () => {
const client = resolveClient(serverId);
const snapshot = snapshotWorktreeArchiveState({ serverId, workspaceId });
// The server archive is keyed by worktreePath and must always run. The
// optimistic client-side updates are keyed by workspace id, so they only
// apply when the caller passes the workspace id and it resolves in the
// local store.
const workspace = snapshot.workspace;
if (workspace) {
markWorkspaceArchivePending({
serverId,
workspaceId: workspace.id,
});
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
}
removeWorktreeFromCachedLists({ serverId, worktreePath });
try {
const payload = await client.archivePaseoWorktree({
worktreePath,
...(workspaceId !== undefined ? { workspaceId } : {}),
});
if (payload.error) {
throw new Error(payload.error.message);
}
} catch (error) {
if (workspace) {
clearWorkspaceArchivePending({ serverId, workspaceId: workspace.id });
}
restoreWorktreeArchiveState({ serverId, snapshot });
throw error;
}
invalidateWorktreeList();
if (workspace) {
purgeArchivedWorkspaceState({ serverId, workspaceId: workspace.id });
}
},
});
},
}));
export function __resetCheckoutGitActionsStoreForTests() {
+8 -40
View File
@@ -122,7 +122,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle",
handler: () => undefined,
},
"archive-workspace": {
"archive-worktree": {
disabled: false,
status: "idle",
handler: () => undefined,
@@ -140,13 +140,7 @@ describe("git-actions-policy", () => {
it("shows only remote sync actions on the base branch", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
expect(actions.primary).toBeNull();
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"archive-workspace",
]);
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push", "pull-and-push"]);
});
it("prioritizes pull when the branch is behind origin", () => {
@@ -278,7 +272,6 @@ describe("git-actions-policy", () => {
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
"archive-workspace",
]);
expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
@@ -359,33 +352,12 @@ describe("git-actions-policy", () => {
);
});
it("hides Git actions for a non-Git workspace", () => {
const directory = buildGitActions(createInput({ isGit: false }));
it("only shows archive worktree for paseo worktrees", () => {
const hidden = buildGitActions(createInput());
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
expect(directory).toEqual({ primary: null, secondary: [], menu: [] });
});
it("offers archive workspace for Git checkouts and worktrees", () => {
const localCheckout = buildGitActions(createInput({ hasUncommittedChanges: true }));
const worktree = buildGitActions(
createInput({ hasUncommittedChanges: true, isPaseoOwnedWorktree: true }),
);
expect(localCheckout.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
expect(worktree.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
});
it("does not promote archive to primary for an idle regular Git checkout", () => {
const actions = buildGitActions(createInput());
expect(actions.primary).toBeNull();
expect(actions.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
});
it("still promotes archive as primary for an idle Paseo-owned worktree", () => {
const actions = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
expect(actions.primary).toMatchObject({ id: "archive-workspace" });
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
});
it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => {
@@ -569,7 +541,6 @@ describe("git-actions-policy", () => {
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
"archive-workspace",
]);
});
@@ -746,7 +717,6 @@ describe("git-actions-policy", () => {
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
"archive-workspace",
]);
});
@@ -787,7 +757,6 @@ describe("git-actions-policy", () => {
"merge-branch",
"pr",
"enable-pr-auto-merge-squash",
"archive-workspace",
]);
expect(
actions.secondary.some((action) =>
@@ -930,7 +899,6 @@ describe("git-actions-policy", () => {
"merge-branch",
"pr",
"merge-pr-merge",
"archive-workspace",
]);
});
@@ -984,7 +952,7 @@ describe("git-actions-policy", () => {
.filter((action) => !action.startsGroup)
.map((action) => action.id);
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-workspace"]);
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-worktree"]);
expect(nonGroupStarters).toEqual([
"pull",
"push",
+17 -20
View File
@@ -23,7 +23,7 @@ export type GitActionId =
| "disable-pr-auto-merge"
| "merge-branch"
| "merge-from-base"
| "archive-workspace";
| "archive-worktree";
export interface GitAction {
id: GitActionId;
@@ -282,16 +282,20 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
handler: input.runtime["merge-from-base"].handler,
});
allActions.set("archive-workspace", {
id: "archive-workspace",
allActions.set("archive-worktree", {
id: "archive-worktree",
label: i18n.t("workspace.git.actions.archive.label"),
pendingLabel: i18n.t("workspace.git.actions.archive.pending"),
successLabel: i18n.t("workspace.git.actions.archive.success"),
disabled: input.runtime["archive-workspace"].disabled,
status: input.runtime["archive-workspace"].status,
icon: input.runtime["archive-workspace"].icon,
disabled: input.runtime["archive-worktree"].disabled,
status: input.runtime["archive-worktree"].status,
unavailableMessage:
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
? undefined
: i18n.t("workspace.git.actions.unavailable.archiveNotWorktree"),
icon: input.runtime["archive-worktree"].icon,
startsGroup: true,
handler: input.runtime["archive-workspace"].handler,
handler: input.runtime["archive-worktree"].handler,
});
const primaryActionId = getPrimaryActionId(input);
@@ -301,20 +305,20 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isOnBaseBranch) {
secondaryIds.push(...getFeatureActionIds(input));
}
secondaryIds.push("archive-workspace");
if (input.isPaseoOwnedWorktree) {
secondaryIds.push("archive-worktree");
}
return {
primary,
secondary: secondaryIds
.filter((id) => id !== "archive-workspace" || primaryActionId !== "archive-workspace")
.map((id) => allActions.get(id)!),
secondary: secondaryIds.map((id) => allActions.get(id)!),
menu: [],
};
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive) {
return "archive-workspace";
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
return "archive-worktree";
}
if (input.hasUncommittedChanges) {
return "commit";
@@ -346,13 +350,6 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr";
}
// Only Paseo-owned worktrees get Archive as a fallback primary action.
// Regular Git checkouts should not show the destructive archive CTA by default.
if (input.isPaseoOwnedWorktree) {
return "archive-workspace";
}
return null;
}
+39 -84
View File
@@ -14,7 +14,7 @@ import {
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import {
useActiveWorkspaceSelection,
type ActiveWorkspaceSelection,
@@ -22,7 +22,6 @@ import {
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -98,12 +97,15 @@ function extractGitCommitCounts(gitStatus: CheckoutStatusPayload | null): GitCom
}
function computeShouldPromoteArchive(input: {
isPaseoOwnedWorktree: boolean;
hasUncommittedChanges: boolean;
postShipArchiveSuggested: boolean;
isMergedPullRequest: boolean;
}): boolean {
return (
!input.hasUncommittedChanges && (input.postShipArchiveSuggested || input.isMergedPullRequest)
input.isPaseoOwnedWorktree &&
!input.hasUncommittedChanges &&
(input.postShipArchiveSuggested || input.isMergedPullRequest)
);
}
@@ -129,6 +131,7 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
isPaseoOwnedWorktree,
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
shouldPromoteArchive: computeShouldPromoteArchive({
isPaseoOwnedWorktree,
hasUncommittedChanges,
postShipArchiveSuggested,
isMergedPullRequest,
@@ -170,52 +173,6 @@ interface UseWorkspaceScreenArchiveControllerInput {
t: (key: string, options?: Record<string, unknown>) => string;
}
function resolveArchiveWorkspaceDescriptor(input: {
workspaces: Map<string, WorkspaceDescriptor> | undefined;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
workspaceDirectory: string | null | undefined;
}): WorkspaceDescriptor | null {
const activeWorkspaceKey = input.activeWorkspaceSelection
? resolveWorkspaceMapKeyByIdentity({
workspaces: input.workspaces,
workspaceId: input.activeWorkspaceSelection.workspaceId,
})
: null;
if (activeWorkspaceKey) {
return input.workspaces?.get(activeWorkspaceKey) ?? null;
}
if (!input.workspaceDirectory) {
return null;
}
for (const candidate of input.workspaces?.values() ?? []) {
if (candidate.workspaceDirectory === input.workspaceDirectory) {
return candidate;
}
}
return null;
}
function resolveWorkspaceArchiveRisk(
workspace: WorkspaceDescriptor | null,
gitStatus: CheckoutStatusPayload | null,
): { isDirty: boolean | null | undefined; aheadOfOrigin: number | null | undefined } {
return {
isDirty: gitStatus?.isDirty ?? workspace?.gitRuntime?.isDirty,
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? workspace?.gitRuntime?.aheadOfOrigin,
};
}
function canArchiveWorkspace(
workspace: WorkspaceDescriptor | null,
risk: ReturnType<typeof resolveWorkspaceArchiveRisk>,
): boolean {
return (
workspace !== null &&
(workspace.workspaceKind !== "worktree" ||
(risk.isDirty !== undefined && risk.aheadOfOrigin !== undefined))
);
}
function useWorkspaceScreenArchiveController({
serverId,
activeWorkspaceSelection,
@@ -225,28 +182,28 @@ function useWorkspaceScreenArchiveController({
t,
}: UseWorkspaceScreenArchiveControllerInput) {
const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const workspaceDescriptor = useMemo(
() =>
resolveArchiveWorkspaceDescriptor({
workspaces: sessionWorkspaces,
activeWorkspaceSelection,
workspaceDirectory,
}),
[activeWorkspaceSelection, sessionWorkspaces, workspaceDirectory],
);
const archiveRisk = resolveWorkspaceArchiveRisk(workspaceDescriptor, gitStatus);
const archiveWorkspaceRecord = useMemo(() => {
if (!workspaceDirectory) {
return null;
}
for (const candidate of sessionWorkspaces?.values() ?? []) {
if (candidate.workspaceDirectory === workspaceDirectory) {
return candidate;
}
}
return null;
}, [sessionWorkspaces, workspaceDirectory]);
const controller = useWorkspaceArchive({
return useWorkspaceArchive({
serverId,
workspaceId: workspaceDescriptor?.id ?? "",
workspaceKind: workspaceDescriptor?.workspaceKind ?? "directory",
name: workspaceDescriptor?.name ?? branchLabel,
isDirty: archiveRisk.isDirty,
aheadOfOrigin: archiveRisk.aheadOfOrigin,
diffStat: workspaceDescriptor?.diffStat ?? null,
workspaceId: activeWorkspaceSelection?.workspaceId ?? archiveWorkspaceRecord?.id ?? "",
workspaceDirectory,
workspaceKind: gitStatus?.isPaseoOwnedWorktree ? "worktree" : "local_checkout",
name: archiveWorkspaceRecord?.name ?? branchLabel,
isDirty: gitStatus?.isDirty,
aheadOfOrigin: gitStatus?.aheadOfOrigin,
diffStat: archiveWorkspaceRecord?.diffStat ?? null,
warningLabels: getWorktreeArchiveWarningLabels(t),
onSetHiding: setIsHidingWorkspace,
onArchiveStarted: () => {
if (!activeWorkspaceSelection) {
return;
@@ -258,12 +215,6 @@ function useWorkspaceScreenArchiveController({
});
},
});
return {
...controller,
isArchiving: workspaceDescriptor?.archivingAt != null || isHidingWorkspace,
canArchive: canArchiveWorkspace(workspaceDescriptor, archiveRisk),
};
}
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
@@ -391,6 +342,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const mergeFromBaseStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const archiveStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
);
const runCommit = useCheckoutGitActionsStore((s) => s.commit);
const runPull = useCheckoutGitActionsStore((s) => s.pull);
@@ -579,7 +533,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
t,
});
const handleArchiveWorkspace = useCallback(() => {
const handleArchiveWorktree = useCallback(() => {
archiveController.archive();
}, [archiveController]);
@@ -724,11 +678,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
icon: icons.mergeFromBase,
handler: handleMergeFromBase,
},
"archive-workspace": {
disabled: !archiveController.canArchive || archiveController.isArchiving,
status: archiveController.isArchiving ? "pending" : "idle",
"archive-worktree": {
disabled: isActionDisabled(actionsDisabled, archiveStatus),
status: archiveStatus,
icon: icons.archive,
handler: handleArchiveWorkspace,
handler: handleArchiveWorktree,
},
},
}),
@@ -769,8 +723,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveController.canArchive,
archiveController.isArchiving,
archiveStatus,
handleCommit,
handlePull,
handlePush,
@@ -781,7 +734,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorkspace,
handleArchiveWorktree,
icons,
baseRef,
],
@@ -937,7 +890,7 @@ function getTranslatedGitActionLabels(
pendingLabel: t("workspace.git.actions.mergeFromBase.pending"),
successLabel: t("workspace.git.actions.mergeFromBase.success"),
};
case "archive-workspace":
case "archive-worktree":
return {
label: t("workspace.git.actions.archive.label"),
pendingLabel: t("workspace.git.actions.archive.pending"),
@@ -992,6 +945,8 @@ function translateGitActionUnavailableMessage(
"workspace.git.actions.unavailable.updateNoBase",
"Update isn't available while you have local changes so commit or stash them first":
"workspace.git.actions.unavailable.updateDirty",
"Archive isn't available here because this workspace was not created as a Paseo worktree":
"workspace.git.actions.unavailable.archiveNotWorktree",
"Merge PR isn't available right now because GitHub isn't connected":
"workspace.git.actions.unavailable.mergePrNoGithub",
"Merge PR isn't available because there isn't a pull request yet":
@@ -1024,7 +979,7 @@ function getWorktreeArchiveWarningLabels(
t: (key: string, options?: Record<string, unknown>) => string,
): WorktreeArchiveWarningLabels {
return {
title: (workspaceName) => t("workspace.git.actions.archiveWarning.title", { workspaceName }),
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
confirm: t("workspace.git.actions.archiveWarning.confirm"),
cancel: t("workspace.git.actions.archiveWarning.cancel"),
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
+7 -3
View File
@@ -13,7 +13,7 @@ import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import type { Theme } from "@/styles/theme";
interface WorkspaceActionsProps {
interface WorkspaceGitActionsProps {
serverId: string;
cwd: string;
hideLabels?: boolean;
@@ -47,12 +47,16 @@ const ICONS = {
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
};
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
const { gitActions } = useGitActions({
export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) {
const { gitActions, isGit } = useGitActions({
serverId,
cwd,
icons: ICONS,
});
if (!isGit) {
return null;
}
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
}
@@ -6,11 +6,11 @@ import {
toWorktreeArchiveRisk,
} from "@/git/worktree-archive-warning";
describe("workspace archive warning for worktree backing", () => {
describe("worktree archive warning", () => {
it("does not require a confirmation for clean and pushed worktrees", () => {
expect(
buildWorktreeArchiveConfirmationMessage({
workspaceName: "feature",
worktreeName: "feature",
isDirty: false,
aheadOfOrigin: 0,
diffStat: null,
@@ -51,7 +51,7 @@ describe("workspace archive warning for worktree backing", () => {
it("includes every archive risk in the confirmation copy", () => {
expect(
buildWorktreeArchiveConfirmationMessage({
workspaceName: "risky-feature",
worktreeName: "risky-feature",
isDirty: true,
aheadOfOrigin: 1,
diffStat: { additions: 1, deletions: 3 },
@@ -14,11 +14,11 @@ export interface WorktreeArchiveRiskInput {
}
export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
workspaceName: string;
worktreeName: string;
}
export interface WorktreeArchiveWarningLabels {
title: (workspaceName: string) => string;
title: (worktreeName: string) => string;
confirm: string;
cancel: string;
uncommittedChanges: string;
@@ -29,7 +29,7 @@ export interface WorktreeArchiveWarningLabels {
}
export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabels = {
title: (workspaceName) => i18n.t("workspace.git.actions.archiveWarning.title", { workspaceName }),
title: (worktreeName) => i18n.t("workspace.git.actions.archiveWarning.title", { worktreeName }),
confirm: i18n.t("workspace.git.actions.archiveWarning.confirm"),
cancel: i18n.t("workspace.git.actions.archiveWarning.cancel"),
uncommittedChanges: i18n.t("workspace.git.actions.archiveWarning.uncommittedChanges"),
@@ -123,7 +123,7 @@ export async function confirmRiskyWorktreeArchive(
}
return await confirmDialog({
title: labels.title(input.workspaceName),
title: labels.title(input.worktreeName),
message,
confirmLabel: labels.confirm,
cancelLabel: labels.cancel,
+9 -5
View File
@@ -619,7 +619,7 @@ export const ar: TranslationResources = {
success: "تم التحديث",
},
archive: {
label: "أرشفة مساحة العمل",
label: "أرشفة شجرة العمل",
pending: "أرشفة...",
success: "مؤرشف",
},
@@ -661,6 +661,8 @@ export const ar: TranslationResources = {
updateNoBase: "التحديث غير متاح لأننا لم نتمكن من تحديد الفرع الأساسي",
updateDirty: "التحديث غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
updateCurrent: "التحديث غير متاح لأن هذا الفرع محدث بالفعل باستخدام{{baseRef}}",
archiveNotWorktree:
"الأرشيف غير متاح هنا لأنه لم يتم إنشاء مساحة العمل هذه كشجرة عمل Paseo",
mergePrNoGithub: "دمج PR غير متاح الآن لأن GitHub غير متصل",
mergePrMissing: "دمج PR غير متاح لأنه لا يوجد طلب سحب حتى الآن",
mergePrDraft: "دمج PR غير متاح لأن طلب السحب لا يزال مسودة",
@@ -683,9 +685,11 @@ export const ar: TranslationResources = {
baseRefUnavailable: "المرجع الأساسي غير متاح",
failedMerge: "فشل الدمج",
failedMergeFromBase: "فشل الدمج من القاعدة",
worktreePathUnavailable: "مسار شجرة العمل غير متوفر",
failedArchive: "فشل في أرشفة شجرة العمل",
},
archiveWarning: {
title: 'الأرشيف "{{workspaceName}}"؟',
title: 'الأرشيف "{{worktreeName}}"؟',
confirm: "أرشيف",
cancel: "يلغي",
uncommittedChanges: "تغييرات غير ملتزم بها",
@@ -835,7 +839,7 @@ export const ar: TranslationResources = {
copyBranchName: "انسخ اسم الفرع",
rename: "إعادة تسمية مساحة العمل",
archive: "أرشيف",
archiveWorkspace: "أرشفة مساحة العمل",
archiveWorktree: "أرشفة شجرة العمل",
hideFromSidebar: "إخفاء من الشريط الجانبي",
archiving: "أرشفة...",
hiding: "إخفاء...",
@@ -858,7 +862,7 @@ export const ar: TranslationResources = {
branchNameCopied: "تم نسخ اسم الفرع",
hostDisconnected: "Host غير متصل",
hideFailed: "فشل في إخفاء مساحة العمل",
archiveFailed: "فشل في أرشفة مساحة العمل",
archiveFailed: "فشل في أرشفة شجرة العمل",
},
},
},
@@ -1601,7 +1605,7 @@ export const ar: TranslationResources = {
openProject: "مشروع مفتوح",
newWorkspace: "مساحة عمل جديدة",
newWorktree: "شجرة عمل جديدة",
archiveWorkspace: "أرشفة مساحة العمل",
archiveWorktree: "أرشفة شجرة العمل",
newTab: "علامة تبويب جديدة",
closeCurrentTab: "إغلاق علامة التبويب الحالية",
jumpToWorkspace: "انتقل إلى مساحة العمل",
+9 -5
View File
@@ -618,7 +618,7 @@ export const en = {
success: "Updated",
},
archive: {
label: "Archive workspace",
label: "Archive worktree",
pending: "Archiving...",
success: "Archived",
},
@@ -667,6 +667,8 @@ export const en = {
"Update isn't available while you have local changes so commit or stash them first",
updateCurrent:
"Update isn't available because this branch is already up to date with {{baseRef}}",
archiveNotWorktree:
"Archive isn't available here because this workspace was not created as a Paseo worktree",
mergePrNoGithub: "Merge PR isn't available right now because GitHub isn't connected",
mergePrMissing: "Merge PR isn't available because there isn't a pull request yet",
mergePrDraft: "Merge PR isn't available because the pull request is still a draft",
@@ -690,9 +692,11 @@ export const en = {
baseRefUnavailable: "Base ref unavailable",
failedMerge: "Failed to merge",
failedMergeFromBase: "Failed to merge from base",
worktreePathUnavailable: "Worktree path unavailable",
failedArchive: "Failed to archive worktree",
},
archiveWarning: {
title: 'Archive "{{workspaceName}}"?',
title: 'Archive "{{worktreeName}}"?',
confirm: "Archive",
cancel: "Cancel",
uncommittedChanges: "Uncommitted changes",
@@ -842,7 +846,7 @@ export const en = {
copyBranchName: "Copy branch name",
rename: "Rename workspace",
archive: "Archive",
archiveWorkspace: "Archive workspace",
archiveWorktree: "Archive worktree",
hideFromSidebar: "Hide from sidebar",
archiving: "Archiving...",
hiding: "Hiding...",
@@ -865,7 +869,7 @@ export const en = {
branchNameCopied: "Branch name copied",
hostDisconnected: "Host is not connected",
hideFailed: "Failed to hide workspace",
archiveFailed: "Failed to archive workspace",
archiveFailed: "Failed to archive worktree",
},
},
},
@@ -1608,7 +1612,7 @@ export const en = {
openProject: "Open project",
newWorkspace: "New workspace",
newWorktree: "New worktree",
archiveWorkspace: "Archive workspace",
archiveWorktree: "Archive worktree",
newTab: "New tab",
closeCurrentTab: "Close current tab",
jumpToWorkspace: "Jump to workspace",
+9 -5
View File
@@ -625,7 +625,7 @@ export const es: TranslationResources = {
success: "Actualizado",
},
archive: {
label: "Archivar espacio de trabajo",
label: "Árbol de trabajo de archivo",
pending: "Archivando...",
success: "Archivado",
},
@@ -679,6 +679,8 @@ export const es: TranslationResources = {
"La actualización no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero",
updateCurrent:
"La actualización no está disponible porque esta rama ya está actualizada con{{baseRef}}",
archiveNotWorktree:
"El archivo no está disponible aquí porque este espacio de trabajo no se creó como un árbol de trabajoPaseo",
mergePrNoGithub:
"FusionarPRno está disponible en este momento porqueGitHubno está conectado",
mergePrMissing:
@@ -710,9 +712,11 @@ export const es: TranslationResources = {
baseRefUnavailable: "Referencia base no disponible",
failedMerge: "No se pudo fusionar",
failedMergeFromBase: "No se pudo fusionar desde la base",
worktreePathUnavailable: "Ruta del árbol de trabajo no disponible",
failedArchive: "No se pudo archivar el árbol de trabajo",
},
archiveWarning: {
title: '¿Archivo "{{workspaceName}}"?',
title: '¿Archivo "{{worktreeName}}"?',
confirm: "Archivo",
cancel: "Cancelar",
uncommittedChanges: "Cambios no confirmados",
@@ -862,7 +866,7 @@ export const es: TranslationResources = {
copyBranchName: "Copiar nombre de sucursal",
rename: "Cambiar nombre del espacio de trabajo",
archive: "Archivo",
archiveWorkspace: "Archivar espacio de trabajo",
archiveWorktree: "Árbol de trabajo de archivo",
hideFromSidebar: "Ocultar de la barra lateral",
archiving: "Archivando...",
hiding: "Ocultación...",
@@ -885,7 +889,7 @@ export const es: TranslationResources = {
branchNameCopied: "Nombre de la sucursal copiado",
hostDisconnected: "Hostno está conectado",
hideFailed: "No se pudo ocultar el espacio de trabajo",
archiveFailed: "No se pudo archivar el espacio de trabajo",
archiveFailed: "No se pudo archivar el árbol de trabajo",
},
},
},
@@ -1643,7 +1647,7 @@ export const es: TranslationResources = {
openProject: "Abrir proyecto",
newWorkspace: "Nuevo espacio de trabajo",
newWorktree: "Nuevo árbol de trabajo",
archiveWorkspace: "Archivar espacio de trabajo",
archiveWorktree: "Árbol de trabajo de archivo",
newTab: "Nueva pestaña",
closeCurrentTab: "Cerrar pestaña actual",
jumpToWorkspace: "Saltar al espacio de trabajo",
+9 -5
View File
@@ -625,7 +625,7 @@ export const fr: TranslationResources = {
success: "Mis à jour",
},
archive: {
label: "Archiver l’espace de travail",
label: "Arbre de travail d'archivage",
pending: "Archivage...",
success: "Archivé",
},
@@ -679,6 +679,8 @@ export const fr: TranslationResources = {
"La mise à jour n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord",
updateCurrent:
"La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}",
archiveNotWorktree:
"L'archive n'est pas disponible ici car cet espace de travail n'a pas été créé en tant qu'arbre de travailPaseo",
mergePrNoGithub:
"La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
mergePrMissing:
@@ -709,9 +711,11 @@ export const fr: TranslationResources = {
baseRefUnavailable: "Réf de base indisponible",
failedMerge: "Échec de la fusion",
failedMergeFromBase: "Échec de la fusion à partir de la base",
worktreePathUnavailable: "Chemin d'accès à l'arbre de travail indisponible",
failedArchive: "Échec de l'archivage de l'arbre de travail",
},
archiveWarning: {
title: "Archiver «{{workspaceName}}»?",
title: "Archiver «{{worktreeName}}»?",
confirm: "Archive",
cancel: "Annuler",
uncommittedChanges: "Modifications non validées",
@@ -861,7 +865,7 @@ export const fr: TranslationResources = {
copyBranchName: "Copier le nom de la branche",
rename: "Renommer l'espace de travail",
archive: "Archive",
archiveWorkspace: "Archiver l’espace de travail",
archiveWorktree: "Arbre de travail d'archivage",
hideFromSidebar: "Masquer de la barre latérale",
archiving: "Archivage...",
hiding: "Dissimulation...",
@@ -884,7 +888,7 @@ export const fr: TranslationResources = {
branchNameCopied: "Nom de la succursale copié",
hostDisconnected: "Hostn'est pas connecté",
hideFailed: "Échec du masquage de l'espace de travail",
archiveFailed: "Échec de l'archivage de l'espace de travail",
archiveFailed: "Échec de l'archivage de l'arbre de travail",
},
},
},
@@ -1646,7 +1650,7 @@ export const fr: TranslationResources = {
openProject: "Projet ouvert",
newWorkspace: "Nouvel espace de travail",
newWorktree: "Nouvel arbre de travail",
archiveWorkspace: "Archiver l’espace de travail",
archiveWorktree: "Arbre de travail d'archivage",
newTab: "Nouvel onglet",
closeCurrentTab: "Fermer l'onglet actuel",
jumpToWorkspace: "Accéder à l'espace de travail",
+9 -5
View File
@@ -623,7 +623,7 @@ export const ja: TranslationResources = {
success: "更新しました",
},
archive: {
label: "ワークスペースをアーカイブ",
label: "ワークツリーをアーカイブ",
pending: "アーカイブ中...",
success: "アーカイブしました",
},
@@ -670,6 +670,8 @@ export const ja: TranslationResources = {
updateDirty:
"ローカルに変更があるため更新は利用できません。先にコミットまたはスタッシュしてください",
updateCurrent: "このブランチはすでに{{baseRef}}と最新の状態のため、更新は利用できません",
archiveNotWorktree:
"このワークスペースはPaseoワークツリーとして作成されていないため、アーカイブはここでは利用できません",
mergePrNoGithub: "GitHubが接続されていないため、PRのマージは現在利用できません",
mergePrMissing: "プルリクエストがまだないため、PRのマージは利用できません",
mergePrDraft: "プルリクエストがまだドラフトのため、PRのマージは利用できません",
@@ -695,9 +697,11 @@ export const ja: TranslationResources = {
baseRefUnavailable: "ベースRefが利用できません",
failedMerge: "マージに失敗しました",
failedMergeFromBase: "ベースからのマージに失敗しました",
worktreePathUnavailable: "ワークツリーパスが利用できません",
failedArchive: "ワークツリーのアーカイブに失敗しました",
},
archiveWarning: {
title: '"{{workspaceName}}"をアーカイブしますか?',
title: '"{{worktreeName}}"をアーカイブしますか?',
confirm: "アーカイブ",
cancel: "キャンセル",
uncommittedChanges: "未コミットの変更",
@@ -847,7 +851,7 @@ export const ja: TranslationResources = {
copyBranchName: "ブランチ名をコピー",
rename: "ワークスペースの名前を変更",
archive: "アーカイブ",
archiveWorkspace: "ワークスペースをアーカイブ",
archiveWorktree: "ワークツリーをアーカイブ",
hideFromSidebar: "サイドバーから非表示",
archiving: "アーカイブ中...",
hiding: "非表示にしています...",
@@ -870,7 +874,7 @@ export const ja: TranslationResources = {
branchNameCopied: "ブランチ名をコピーしました",
hostDisconnected: "ホストが接続されていません",
hideFailed: "ワークスペースの非表示に失敗しました",
archiveFailed: "ワークスペースのアーカイブに失敗しました",
archiveFailed: "ワークツリーのアーカイブに失敗しました",
},
},
},
@@ -1618,7 +1622,7 @@ export const ja: TranslationResources = {
openProject: "プロジェクトを開く",
newWorkspace: "新しいワークスペース",
newWorktree: "新しいワークツリー",
archiveWorkspace: "ワークスペースをアーカイブ",
archiveWorktree: "ワークツリーをアーカイブ",
newTab: "新しいタブ",
closeCurrentTab: "現在のタブを閉じる",
jumpToWorkspace: "ワークスペースにジャンプ",
+9 -5
View File
@@ -623,7 +623,7 @@ export const ptBR: TranslationResources = {
success: "Atualizado",
},
archive: {
label: "Arquivar workspace",
label: "Arquivar worktree",
pending: "Arquivando...",
success: "Arquivado",
},
@@ -675,6 +675,8 @@ export const ptBR: TranslationResources = {
"Atualizar não está disponível enquanto há alterações locais. Faça commit ou stash primeiro",
updateCurrent:
"Atualizar não está disponível porque esta branch já está atualizada com {{baseRef}}",
archiveNotWorktree:
"Arquivar não está disponível aqui porque este workspace não foi criado como um worktree do Paseo",
mergePrNoGithub:
"Merge da PR não está disponível agora porque o GitHub não está conectado",
mergePrMissing: "Merge da PR não está disponível porque ainda não há uma pull request",
@@ -701,9 +703,11 @@ export const ptBR: TranslationResources = {
baseRefUnavailable: "Ref base indisponível",
failedMerge: "Falha ao fazer merge",
failedMergeFromBase: "Falha ao fazer merge da base",
worktreePathUnavailable: "Caminho do worktree indisponível",
failedArchive: "Falha ao arquivar worktree",
},
archiveWarning: {
title: 'Arquivar "{{workspaceName}}"?',
title: 'Arquivar "{{worktreeName}}"?',
confirm: "Arquivar",
cancel: "Cancelar",
uncommittedChanges: "Alterações sem commit",
@@ -853,7 +857,7 @@ export const ptBR: TranslationResources = {
copyBranchName: "Copiar nome da branch",
rename: "Renomear workspace",
archive: "Arquivar",
archiveWorkspace: "Arquivar workspace",
archiveWorktree: "Arquivar worktree",
hideFromSidebar: "Ocultar da barra lateral",
archiving: "Arquivando...",
hiding: "Ocultando...",
@@ -876,7 +880,7 @@ export const ptBR: TranslationResources = {
branchNameCopied: "Nome da branch copiado",
hostDisconnected: "Host não está conectado",
hideFailed: "Falha ao ocultar workspace",
archiveFailed: "Falha ao arquivar workspace",
archiveFailed: "Falha ao arquivar worktree",
},
},
},
@@ -1627,7 +1631,7 @@ export const ptBR: TranslationResources = {
openProject: "Abrir projeto",
newWorkspace: "Novo workspace",
newWorktree: "Novo worktree",
archiveWorkspace: "Arquivar workspace",
archiveWorktree: "Arquivar worktree",
newTab: "Nova aba",
closeCurrentTab: "Fechar aba atual",
jumpToWorkspace: "Ir para workspace",
+9 -5
View File
@@ -624,7 +624,7 @@ export const ru: TranslationResources = {
success: "Обновлено",
},
archive: {
label: "Архивировать рабочее пространство",
label: "Архив рабочего дерева",
pending: "Архивирование...",
success: "В архиве",
},
@@ -675,6 +675,8 @@ export const ru: TranslationResources = {
"Обновление недоступно, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.",
updateCurrent:
"Обновление недоступно, поскольку эта ветка уже обновлена ​​до версии{{baseRef}}.",
archiveNotWorktree:
"Архив здесь недоступен, поскольку это рабочее пространство не было создано как рабочее дерево Paseo.",
mergePrNoGithub: "Объединение PR сейчас недоступно, поскольку GitHub не подключен.",
mergePrMissing: "Объединение PR недоступно, поскольку еще нет запроса на включение",
mergePrDraft:
@@ -702,9 +704,11 @@ export const ru: TranslationResources = {
baseRefUnavailable: "Базовый номер недоступен.",
failedMerge: "Не удалось объединиться",
failedMergeFromBase: "Не удалось объединиться с базой.",
worktreePathUnavailable: "Путь к рабочему дереву недоступен.",
failedArchive: "Не удалось заархивировать рабочее дерево.",
},
archiveWarning: {
title: 'Архив "{{workspaceName}}"?',
title: 'Архив "{{worktreeName}}"?',
confirm: "Архив",
cancel: "Отмена",
uncommittedChanges: "Незафиксированные изменения",
@@ -854,7 +858,7 @@ export const ru: TranslationResources = {
copyBranchName: "Скопировать название ветки",
rename: "Переименовать рабочую область",
archive: "Архив",
archiveWorkspace: "Архивировать рабочее пространство",
archiveWorktree: "Архив рабочего дерева",
hideFromSidebar: "Скрыть с боковой панели",
archiving: "Архивирование...",
hiding: "Скрытие...",
@@ -877,7 +881,7 @@ export const ru: TranslationResources = {
branchNameCopied: "Название филиала скопировано.",
hostDisconnected: "Host не подключен",
hideFailed: "Не удалось скрыть рабочую область.",
archiveFailed: "Не удалось заархивировать рабочее пространство.",
archiveFailed: "Не удалось заархивировать рабочее дерево.",
},
},
},
@@ -1635,7 +1639,7 @@ export const ru: TranslationResources = {
openProject: "Открыть проект",
newWorkspace: "Новое рабочее пространство",
newWorktree: "Новое рабочее дерево",
archiveWorkspace: "Архивировать рабочее пространство",
archiveWorktree: "Архив рабочего дерева",
newTab: "Новая вкладка",
closeCurrentTab: "Закрыть текущую вкладку",
jumpToWorkspace: "Перейти в рабочую область",
+8 -5
View File
@@ -617,7 +617,7 @@ export const zhCN: TranslationResources = {
success: "已更新",
},
archive: {
label: "归档工作区",
label: "归档 worktree",
pending: "正在归档...",
success: "已归档",
},
@@ -656,6 +656,7 @@ export const zhCN: TranslationResources = {
updateNoBase: "无法更新,因为无法确定 base branch",
updateDirty: "有本地变更时无法更新,请先 commit 或 stash",
updateCurrent: "无法更新,因为此分支已与 {{baseRef}} 保持最新",
archiveNotWorktree: "此处无法归档,因为此 workspace 不是作为 Paseo worktree 创建的",
mergePrNoGithub: "当前无法 merge PR,因为 GitHub 未连接",
mergePrMissing: "无法 merge PR,因为还没有 pull request",
mergePrDraft: "无法 merge PR,因为 pull request 仍是 draft",
@@ -678,9 +679,11 @@ export const zhCN: TranslationResources = {
baseRefUnavailable: "Base ref 不可用",
failedMerge: "Merge 失败",
failedMergeFromBase: "从 base merge 失败",
worktreePathUnavailable: "Worktree 路径不可用",
failedArchive: "归档 worktree 失败",
},
archiveWarning: {
title: "归档「{{workspaceName}}」?",
title: "归档「{{worktreeName}}」?",
confirm: "归档",
cancel: "取消",
uncommittedChanges: "未 commit 的变更",
@@ -828,7 +831,7 @@ export const zhCN: TranslationResources = {
copyBranchName: "复制分支名称",
rename: "重命名 workspace",
archive: "归档",
archiveWorkspace: "归档工作区",
archiveWorktree: "归档 worktree",
hideFromSidebar: "从侧边栏隐藏",
archiving: "正在归档...",
hiding: "正在隐藏...",
@@ -850,7 +853,7 @@ export const zhCN: TranslationResources = {
branchNameCopied: "分支名称已复制",
hostDisconnected: "Host 未连接",
hideFailed: "隐藏 workspace 失败",
archiveFailed: "归档工作区失败",
archiveFailed: "归档 worktree 失败",
},
},
},
@@ -1583,7 +1586,7 @@ export const zhCN: TranslationResources = {
openProject: "打开项目",
newWorkspace: "新建 workspace",
newWorktree: "新建 worktree",
archiveWorkspace: "归档工作区",
archiveWorktree: "归档 worktree",
newTab: "新建标签",
closeCurrentTab: "关闭当前标签",
jumpToWorkspace: "跳转到 workspace",
+1 -1
View File
@@ -44,7 +44,7 @@ export type KeyboardActionId =
| "workspace.terminal.new"
| "workspace.new"
| "worktree.new"
| "workspace.archive"
| "worktree.archive"
| "view.toggle.focus"
| "theme.cycle"
| "message-input.action";
@@ -29,7 +29,7 @@ export type KeyboardActionId =
| "sidebar.toggle.right"
| "workspace.new"
| "worktree.new"
| "workspace.archive";
| "worktree.archive";
export type KeyboardActionDefinition =
| { id: "agent.interrupt"; scope: KeyboardActionScope }
@@ -60,7 +60,7 @@ export type KeyboardActionDefinition =
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
| { id: "workspace.new"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "workspace.archive"; scope: KeyboardActionScope };
| { id: "worktree.archive"; scope: KeyboardActionScope };
export interface KeyboardActionHandler {
handlerId: string;
@@ -121,7 +121,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"new-agent": "settings.shortcuts.help.openProject",
"new-workspace": "settings.shortcuts.help.newWorkspace",
"archive-workspace": "settings.shortcuts.help.archiveWorkspace",
"archive-worktree": "settings.shortcuts.help.archiveWorktree",
"workspace-tab-new": "settings.shortcuts.help.newTab",
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
"workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace",
@@ -221,32 +221,28 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- Archive workspace ---
// --- Archive worktree ---
{
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
id: "worktree-archive-cmd-shift-backspace-mac",
action: "workspace.archive",
action: "worktree.archive",
combo: "Cmd+Shift+Backspace",
when: { mac: true, commandCenter: false },
help: {
id: "archive-workspace",
id: "archive-worktree",
section: "projects",
label: "Archive workspace",
label: "Archive worktree",
keys: ["mod", "shift", "Backspace"],
},
},
{
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
id: "worktree-archive-ctrl-shift-backspace-non-mac",
action: "workspace.archive",
action: "worktree.archive",
combo: "Ctrl+Shift+Backspace",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "archive-workspace",
id: "archive-worktree",
section: "projects",
label: "Archive workspace",
label: "Archive worktree",
keys: ["mod", "shift", "Backspace"],
},
},
@@ -30,7 +30,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
["workspace.new", { id: "workspace.new", scope: "sidebar" }],
["workspace.archive", { id: "workspace.archive", scope: "sidebar" }],
["worktree.archive", { id: "worktree.archive", scope: "sidebar" }],
["worktree.new", { id: "worktree.new", scope: "sidebar" }],
["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }],
["workspace.tab.close.current", { id: "workspace.tab.close-current", scope: "workspace" }],
+1 -1
View File
@@ -46,7 +46,7 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
"workspace.new": { id: "workspace.new", scope: "sidebar" },
"workspace.archive": { id: "workspace.archive", scope: "sidebar" },
"worktree.archive": { id: "worktree.archive", scope: "sidebar" },
"worktree.new": { id: "worktree.new", scope: "sidebar" },
"workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" },
"workspace.tab.close.current": { id: "workspace.tab.close-current", scope: "workspace" },
@@ -1,26 +0,0 @@
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import { Stack } from "expo-router";
import { type ReactNode, useMemo } from "react";
import { withUnistyles } from "react-native-unistyles";
interface ThemedStackBaseProps {
backgroundColor: string;
children?: ReactNode;
screenOptions?: NativeStackNavigationOptions;
}
function ThemedStackBase({ backgroundColor, children, screenOptions }: ThemedStackBaseProps) {
const themedScreenOptions = useMemo<NativeStackNavigationOptions>(
() => ({
...screenOptions,
contentStyle: [{ backgroundColor }, screenOptions?.contentStyle],
}),
[backgroundColor, screenOptions],
);
return <Stack screenOptions={themedScreenOptions}>{children}</Stack>;
}
export const ThemedStack = withUnistyles(ThemedStackBase, (theme) => ({
backgroundColor: theme.colors.surface0,
}));
@@ -77,7 +77,11 @@ function hasPendingArchiveForProject(input: {
}
}
return false;
const workspaceDirectory = getHostProjectSourceDirectory(input.project, input.selectedServerId);
return isWorkspaceArchivePending({
serverId: input.selectedServerId,
workspaceDirectory,
});
}
export function useNewWorkspaceProjectPicker({
@@ -62,7 +62,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { SplitContainer } from "@/components/split-container";
import { RetainedPanel } from "@/components/retained-panel";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceActions } from "@/git/workspace-actions";
import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ImportSessionSheet } from "@/components/import-session-sheet";
@@ -3276,56 +3276,56 @@ function WorkspaceScreenContent({
hideLabels
/>
) : null}
{!isMobile && workspaceDirectory ? (
{!isMobile && isGitCheckout ? (
<>
<WorkspaceActions
serverId={normalizedServerId}
cwd={workspaceDirectory}
hideLabels={showCompactButtonLabels}
/>
{isGitCheckout ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
style={explorerToggleStyle}
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
return (
<>
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
{workspaceDescriptor?.diffStat ? (
<DiffStat
additions={workspaceDescriptor.diffStat.additions}
deletions={workspaceDescriptor.diffStat.deletions}
/>
) : null}
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent
testID="workspace-explorer-toggle-tooltip"
side="left"
align="center"
offset={8}
>
<View style={styles.explorerTooltipRow}>
<Text style={styles.explorerTooltipText}>
{t("workspace.tabs.explorer.toggle")}
</Text>
<Shortcut keys={EXPLORER_TOGGLE_KEYS} style={styles.explorerTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
{workspaceDirectory ? (
<WorkspaceGitActions
serverId={normalizedServerId}
cwd={workspaceDirectory}
hideLabels={showCompactButtonLabels}
/>
) : null}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={explorerToggleLabel}
accessibilityState={explorerToggleAccessibilityState}
style={explorerToggleStyle}
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
return (
<>
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
{workspaceDescriptor?.diffStat ? (
<DiffStat
additions={workspaceDescriptor.diffStat.additions}
deletions={workspaceDescriptor.diffStat.deletions}
/>
) : null}
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent
testID="workspace-explorer-toggle-tooltip"
side="left"
align="center"
offset={8}
>
<View style={styles.explorerTooltipRow}>
<Text style={styles.explorerTooltipText}>
{t("workspace.tabs.explorer.toggle")}
</Text>
<Shortcut keys={EXPLORER_TOGGLE_KEYS} style={styles.explorerTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
</>
) : null}
{!isMobile && !isGitCheckout ? (
@@ -31,7 +31,7 @@ describe("selectProjectWorkspacesToArchive", () => {
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
workspaceName: "feature/risky",
worktreeName: "feature/risky",
isDirty: true,
aheadOfOrigin: 2,
diffStat: { additions: 5, deletions: 1 },
@@ -73,7 +73,7 @@ describe("selectProjectWorkspacesToArchive", () => {
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
workspaceName: "feature/risky",
worktreeName: "feature/risky",
isDirty: true,
aheadOfOrigin: 2,
diffStat: { additions: 5, deletions: 1 },
@@ -24,7 +24,7 @@ export async function selectProjectWorkspacesToArchive(
for (const workspace of workspaces) {
if (workspace.workspaceKind === "worktree") {
const shouldArchive = await confirmWorktreeArchive({
workspaceName: workspace.name,
worktreeName: workspace.name,
...toWorktreeArchiveRisk(workspace),
});
if (!shouldArchive) {
@@ -2,30 +2,20 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import {
confirmRiskyWorktreeArchive,
DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
const workspaceKey = buildWorkspaceTabPersistenceKey(input);
if (workspaceKey) {
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
}
useWorkspaceTabsStore.getState().purgeWorkspace(input);
}
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
export interface ArchiveWorkspaceInput {
serverId: string;
workspaceId: string;
workspaceDirectory: string | null | undefined;
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
isDirty?: boolean | null;
@@ -44,6 +34,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
const {
serverId,
workspaceId,
workspaceDirectory,
workspaceKind,
name,
isDirty,
@@ -55,8 +46,37 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
} = input;
const { t } = useTranslation();
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const archiveWorkspaceRecord = useCallback(async () => {
const archiveWorktreeRecord = useCallback(() => {
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceDirectory({
workspaceId,
workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
onArchiveStarted();
void archiveWorktree({
serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
workspaceId,
}).catch((error) => {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
);
});
}, [archiveWorktree, onArchiveStarted, serverId, t, toast, workspaceDirectory, workspaceId]);
const archiveNonWorktreeRecord = useCallback(async () => {
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
@@ -64,18 +84,17 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
}
onSetHiding?.(true);
try {
onArchiveStarted();
await archiveWorkspaceOptimistically({
client,
workspace: {
serverId,
workspaceId,
},
afterHide: onArchiveStarted,
});
purgeArchivedWorkspaceState({ serverId, workspaceId });
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
);
} finally {
onSetHiding?.(false);
@@ -87,7 +106,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
if (workspaceKind === "worktree") {
const confirmed = await confirmRiskyWorktreeArchive(
{
workspaceName: name,
worktreeName: name,
isDirty,
aheadOfOrigin,
diffStat,
@@ -97,12 +116,15 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
if (!confirmed) {
return;
}
archiveWorktreeRecord();
return;
}
await archiveWorkspaceRecord();
await archiveNonWorktreeRecord();
})();
}, [
aheadOfOrigin,
archiveWorkspaceRecord,
archiveNonWorktreeRecord,
archiveWorktreeRecord,
diffStat,
isDirty,
name,
@@ -144,6 +144,23 @@ describe("archiveWorkspaceOptimistically", () => {
}),
).toBe(false);
});
it("runs the after-hide hook after local state is hidden", async () => {
const archived = workspace();
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
const client = createClient(vi.fn(async () => archivePayload({ workspaceId: archived.id })));
const afterHide = vi.fn(() => {
expect(storedWorkspace(archived.id)).toBeUndefined();
});
await archiveWorkspaceOptimistically({
client,
workspace: target(),
afterHide,
});
expect(afterHide).toHaveBeenCalledOnce();
});
});
describe("archiveWorkspacesOptimistically", () => {
@@ -166,7 +183,10 @@ describe("archiveWorkspacesOptimistically", () => {
const failures = await archiveWorkspacesOptimistically({
getClient: () => client,
workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })],
workspaces: [
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
],
});
expect(failures).toHaveLength(1);
@@ -199,10 +219,12 @@ describe("archiveWorkspacesOptimistically", () => {
target({
serverId: SERVER_ID,
workspaceId: first.id,
workspaceDirectory: first.workspaceDirectory,
}),
target({
serverId: SECOND_SERVER_ID,
workspaceId: second.id,
workspaceDirectory: second.workspaceDirectory,
}),
],
});
@@ -9,6 +9,7 @@ import { i18n } from "@/i18n/i18next";
export interface WorkspaceArchiveTarget {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}
interface WorkspaceArchiveClient {
@@ -49,6 +50,7 @@ function hideWorkspaceOptimistically(
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return { workspace: snapshot };
@@ -81,8 +83,10 @@ async function archiveWorkspaceOrThrow(input: {
export async function archiveWorkspaceOptimistically(input: {
client: WorkspaceArchiveClient;
workspace: WorkspaceArchiveTarget;
afterHide?: () => void;
}): Promise<void> {
const snapshot = hideWorkspaceOptimistically(input.workspace);
input.afterHide?.();
try {
await archiveWorkspaceOrThrow({
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.106",
"version": "0.1.105",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/server": "0.1.106",
"@getpaseo/client": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/server": "0.1.105",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.106",
"version": "0.1.105",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.106",
"version": "0.1.105",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.106",
"version": "0.1.105",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.106",
"version": "0.1.105",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.106",
"version": "0.1.105",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.106",
"version": "0.1.105",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
-6
View File
@@ -17,7 +17,6 @@
* ```
*/
import { createCutoverProxy } from "./cutover-proxy.js";
import type { ConnectionRole, RelaySessionAttachment } from "./types.js";
type RelayProtocolVersion = "1" | "2";
@@ -98,7 +97,6 @@ function getGlobalWebSocketPair(): (new () => WebSocketPair) | undefined {
interface Env {
RELAY: DurableObjectNamespace;
PASEO_RELAY_UPSTREAM?: string;
}
interface DurableObjectNamespace {
@@ -574,10 +572,6 @@ export class RelayDurableObject {
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (env.PASEO_RELAY_UPSTREAM) {
return createCutoverProxy(env.PASEO_RELAY_UPSTREAM).fetch(request);
}
const url = new URL(request.url);
// Health check
+7 -9
View File
@@ -2,9 +2,7 @@ import { createServer, type IncomingMessage, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import relayWorker from "./cloudflare-adapter.js";
type RelayEnv = Parameters<typeof relayWorker.fetch>[1];
import { createCutoverProxy } from "./cutover-proxy.js";
interface ReceivedRequest {
method: string;
@@ -66,19 +64,19 @@ describe("cutover proxy", () => {
const origin = new RecordingOrigin();
origins.push(origin);
const originUrl = await origin.start();
const proxy = createCutoverProxy(originUrl);
const response = await relayWorker.fetch(
new Request("https://relay.paseo.sh/ws?serverId=srv_prod&role=server&v=2", {
headers: { "x-relay-probe": "production" },
const response = await proxy.fetch(
new Request("https://relay-staging.example/ws?serverId=srv_stage&role=server&v=2", {
headers: { "x-relay-probe": "staging" },
}),
{ PASEO_RELAY_UPSTREAM: originUrl } as RelayEnv,
);
expect(origin.received()).toEqual([
{
method: "GET",
path: "/ws?serverId=srv_prod&role=server&v=2",
probe: "production",
path: "/ws?serverId=srv_stage&role=server&v=2",
probe: "staging",
},
]);
expect(response.status).toBe(202);
+2
View File
@@ -15,3 +15,5 @@ export function createCutoverProxy(origin: string): CutoverProxy {
},
};
}
export default createCutoverProxy("https://paseo-relay-next.fly.dev");
+8
View File
@@ -0,0 +1,8 @@
name = "paseo-relay-cutover-staging"
main = "src/cutover-proxy.ts"
compatibility_date = "2024-12-01"
workers_dev = true
preview_urls = false
[observability]
enabled = true
-3
View File
@@ -8,9 +8,6 @@ routes = [{ pattern = "relay.paseo.sh", custom_domain = true }]
[observability]
enabled = true
[vars]
PASEO_RELAY_UPSTREAM = "https://paseo-relay-next.fly.dev"
[[durable_objects.bindings]]
name = "RELAY"
class_name = "RelayDurableObject"
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.106",
"version": "0.1.105",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -67,10 +67,10 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.106",
"@getpaseo/highlight": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@getpaseo/client": "0.1.105",
"@getpaseo/highlight": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -1,6 +1,6 @@
import { expect, test, vi } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
@@ -8,7 +8,6 @@ import { randomUUID } from "node:crypto";
import { createTestLogger } from "../../test-utils/test-logger.js";
import {
AgentManager,
AgentManagerShuttingDownError,
commandMayHaveChangedExternalState,
type AgentManagerEvent,
type ManagedAgent,
@@ -140,117 +139,6 @@ class TestAgentClient implements AgentClient {
}
}
class HeldAgentCreationClient extends TestAgentClient {
private readonly creationStarted = deferred<void>();
private readonly creationAllowed = deferred<void>();
createdSessionClosed = false;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const recordSessionClosed = () => {
this.createdSessionClosed = true;
};
const session = new (class extends TestAgentSession {
override async close(): Promise<void> {
recordSessionClosed();
}
})(config);
this.creationStarted.resolve();
await this.creationAllowed.promise;
return session;
}
waitForCreationToStart(): Promise<void> {
return this.creationStarted.promise;
}
finishCreating(): void {
this.creationAllowed.resolve();
}
}
class HeldAgentCreationAndCloseClient extends TestAgentClient {
private readonly creationStarted = deferred<void>();
private readonly creationAllowed = deferred<void>();
private readonly closeStarted = deferred<void>();
private readonly closeAllowed = deferred<void>();
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.creationStarted.resolve();
await this.creationAllowed.promise;
const signalCloseStarted = () => this.closeStarted.resolve();
const waitForClose = () => this.closeAllowed.promise;
return new (class extends TestAgentSession {
override async close(): Promise<void> {
signalCloseStarted();
await waitForClose();
}
})(config);
}
waitForCreationToStart(): Promise<void> {
return this.creationStarted.promise;
}
finishCreating(): void {
this.creationAllowed.resolve();
}
waitForCloseToStart(): Promise<void> {
return this.closeStarted.promise;
}
finishClosing(): void {
this.closeAllowed.resolve();
}
}
class HeldReloadCloseClient extends TestAgentClient {
private readonly closeStarted = deferred<void>();
private readonly closeAllowed = deferred<void>();
originalSessionClosed = false;
replacementSessionClosed = false;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const signalCloseStarted = () => this.closeStarted.resolve();
const waitForClose = () => this.closeAllowed.promise;
const recordOriginalClosed = () => {
this.originalSessionClosed = true;
};
return new (class extends TestAgentSession {
override async close(): Promise<void> {
signalCloseStarted();
await waitForClose();
recordOriginalClosed();
}
})(config);
}
override async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
const recordReplacementClosed = () => {
this.replacementSessionClosed = true;
};
return new (class extends TestAgentSession {
override async close(): Promise<void> {
recordReplacementClosed();
}
})({
provider: "codex",
cwd: config?.cwd ?? process.cwd(),
});
}
waitForCloseToStart(): Promise<void> {
return this.closeStarted.promise;
}
finishClosing(): void {
this.closeAllowed.resolve();
}
}
class NativeArchiveRecordingClient extends TestAgentClient {
readonly archivedHandles: AgentPersistenceHandle[] = [];
readonly unarchivedHandles: AgentPersistenceHandle[] = [];
@@ -402,52 +290,6 @@ class TestAgentSession implements AgentSession {
async close(): Promise<void> {}
}
class HeldRuntimeInfoSession extends TestAgentSession {
private readonly runtimeInfoRequested = deferred<void>();
private readonly runtimeInfoAllowed = deferred<void>();
override async getRuntimeInfo() {
this.runtimeInfoRequested.resolve();
await this.runtimeInfoAllowed.promise;
return await super.getRuntimeInfo();
}
waitForRuntimeInfo(): Promise<void> {
return this.runtimeInfoRequested.promise;
}
finishRuntimeInfo(): void {
this.runtimeInfoAllowed.resolve();
}
}
class HeldRuntimeInfoClient extends TestAgentClient {
private readonly sessionCreated = deferred<HeldRuntimeInfoSession>();
private session: HeldRuntimeInfoSession | null = null;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.session = new HeldRuntimeInfoSession(config);
this.sessionCreated.resolve(this.session);
return this.session;
}
async waitForRuntimeInfo(): Promise<void> {
const session = await this.sessionCreated.promise;
await session.waitForRuntimeInfo();
}
finishRuntimeInfo(): void {
this.requireSession().finishRuntimeInfo();
}
private requireSession(): HeldRuntimeInfoSession {
if (!this.session) {
throw new Error("Expected a created session");
}
return this.session;
}
}
class StreamingAssistantSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
@@ -607,211 +449,6 @@ function fakeCodexEmitting(args: FakeCodexEmitterArgs): AgentClient {
const logger = createTestLogger();
test("does not register a session that finishes starting after shutdown begins", async () => {
const client = new HeldAgentCreationClient();
const manager = new AgentManager({
clients: { codex: client },
logger,
idFactory: () => "00000000-0000-4000-8000-000000000100",
});
const creation = manager.createAgent(
{
provider: "codex",
cwd: process.cwd(),
},
undefined,
{ workspaceId: undefined },
);
await client.waitForCreationToStart();
manager.prepareForShutdown();
client.finishCreating();
await expect(creation).rejects.toThrow("Agent manager is shutting down");
expect({ agents: manager.listAgents(), sessionClosed: client.createdSessionClosed }).toEqual({
agents: [],
sessionClosed: true,
});
});
test("flush waits for rejected session cleanup that starts after shutdown", async () => {
const client = new HeldAgentCreationAndCloseClient();
const manager = new AgentManager({
clients: { codex: client },
logger,
idFactory: () => "00000000-0000-4000-8000-000000000098",
});
const creation = manager
.createAgent(
{
provider: "codex",
cwd: process.cwd(),
},
undefined,
{ workspaceId: undefined },
)
.catch((error: unknown) => error);
await client.waitForCreationToStart();
manager.prepareForShutdown();
let flushResolved = false;
const flushing = manager.flushForShutdown().then(() => {
flushResolved = true;
return undefined;
});
client.finishCreating();
await client.waitForCloseToStart();
try {
expect(flushResolved).toBe(false);
} finally {
client.finishClosing();
}
expect(await creation).toBeInstanceOf(AgentManagerShuttingDownError);
await flushing;
expect(manager.listAgents()).toEqual([]);
});
test("does not persist an initializing session after shutdown closes it", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-shutdown-register-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldRuntimeInfoClient();
const agentId = "00000000-0000-4000-8000-000000000099";
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
idFactory: () => agentId,
});
try {
const creation = manager.createAgent(
{
provider: "codex",
cwd: workdir,
},
undefined,
{ workspaceId: undefined },
);
await client.waitForRuntimeInfo();
manager.prepareForShutdown();
const closing = manager.closeAgent(agentId);
client.finishRuntimeInfo();
await expect(creation).rejects.toBeInstanceOf(AgentManagerShuttingDownError);
await closing;
await storage.flush();
expect({ agents: manager.listAgents(), record: await storage.get(agentId) }).toMatchObject({
agents: [],
record: { lastStatus: "closed" },
});
} finally {
rmSync(workdir, { recursive: true, force: true });
}
});
test("reload leaves a closed durable snapshot when shutdown starts during the swap", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-shutdown-reload-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldReloadCloseClient();
const agentId = "00000000-0000-4000-8000-000000000097";
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
idFactory: () => agentId,
});
try {
await manager.createAgent(
{
provider: "codex",
cwd: workdir,
},
undefined,
{ workspaceId: undefined },
);
const reload = manager.reloadAgentSession(agentId).catch((error: unknown) => error);
await client.waitForCloseToStart();
manager.prepareForShutdown();
const closing = Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
client.finishClosing();
await closing;
expect(await reload).toBeInstanceOf(AgentManagerShuttingDownError);
await manager.flush();
await storage.flush();
expect({
agents: manager.listAgents(),
record: await storage.get(agentId),
replacementSessionClosed: client.replacementSessionClosed,
}).toMatchObject({
agents: [],
record: { lastStatus: "closed" },
replacementSessionClosed: true,
});
} finally {
client.finishClosing();
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("reload closes both sessions when the closed snapshot cannot be persisted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-persist-failure-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new HeldReloadCloseClient();
const agentId = "00000000-0000-4000-8000-000000000096";
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
idFactory: () => agentId,
});
try {
await manager.createAgent(
{
provider: "codex",
cwd: workdir,
},
undefined,
{ workspaceId: undefined },
);
await storage.flush();
rmSync(storagePath, { recursive: true, force: true });
writeFileSync(storagePath, "blocks the storage directory");
const reload = manager.reloadAgentSession(agentId).catch((error: unknown) => error);
await client.waitForCloseToStart();
client.finishClosing();
expect(await reload).toBeInstanceOf(Error);
await manager.flushForShutdown();
expect({
agents: manager.listAgents(),
originalSessionClosed: client.originalSessionClosed,
replacementSessionClosed: client.replacementSessionClosed,
}).toEqual({
agents: [],
originalSessionClosed: true,
replacementSessionClosed: true,
});
} finally {
client.finishClosing();
await manager.flushForShutdown().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("normalizeConfig injects the provider default model when omitted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
+86 -230
View File
@@ -82,13 +82,6 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
type TimeoutResult = "completed" | "timed_out";
export class AgentManagerShuttingDownError extends Error {
constructor() {
super("Agent manager is shutting down");
this.name = "AgentManagerShuttingDownError";
}
}
interface PreparedSessionConfig {
storedConfig: AgentSessionConfig;
launchConfig: AgentSessionConfig;
@@ -542,7 +535,6 @@ export class AgentManager {
private readonly durableTimelineStore?: AgentTimelineStore;
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly agentRegistrationTasks = new Set<Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null;
@@ -554,7 +546,6 @@ export class AgentManager {
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
private logger: Logger;
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
private acceptingAgentRegistrations = true;
constructor(options: AgentManagerOptions) {
this.idFactory = options?.idFactory ?? (() => randomUUID());
@@ -628,10 +619,6 @@ export class AgentManager {
this.mcpBaseUrl = url;
}
prepareForShutdown(): void {
this.acceptingAgentRegistrations = false;
}
setPaseoToolsEnabled(enabled: boolean): void {
this.paseoToolsEnabled = enabled;
}
@@ -935,20 +922,11 @@ export class AgentManager {
return this.timelineStore.fetch(id, options);
}
createAgent(
async createAgent(
config: AgentSessionConfig,
agentId: string | undefined,
options: CreateAgentOptions,
): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(this.createAgentInternal(config, agentId, options));
}
private async createAgentInternal(
config: AgentSessionConfig,
agentId: string | undefined,
options: CreateAgentOptions,
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
const { storedConfig, launchConfig } = await this.prepareSessionConfig(config, resolvedAgentId);
this.requireEnabledProvider(storedConfig.provider);
@@ -976,7 +954,7 @@ export class AgentManager {
// Reconstruct an agent from provider persistence. Callers should explicitly
// hydrate timeline history after resume.
resumeAgentFromPersistence(
async resumeAgentFromPersistence(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
agentId?: string,
@@ -988,24 +966,6 @@ export class AgentManager {
workspaceId?: string;
},
): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(
this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options),
);
}
private async resumeAgentFromPersistenceInternal(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
agentId?: string,
options?: {
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
workspaceId?: string;
},
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(
agentId ?? this.idFactory(),
"resumeAgentFromPersistence",
@@ -1034,24 +994,13 @@ export class AgentManager {
return this.registerSession(session, storedConfig, resolvedAgentId, options);
}
importProviderSession(input: {
async importProviderSession(input: {
provider: AgentProvider;
providerHandleId: string;
cwd: string;
workspaceId: string;
labels?: Record<string, string>;
}): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(this.importProviderSessionInternal(input));
}
private async importProviderSessionInternal(input: {
provider: AgentProvider;
providerHandleId: string;
cwd: string;
workspaceId: string;
labels?: Record<string, string>;
}): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession");
this.requireEnabledProvider(input.provider);
@@ -1076,30 +1025,20 @@ export class AgentManager {
},
{ config: providerLaunchConfig, storedConfig, launchContext },
);
let handedToRegistration = false;
try {
const importedConfig = await this.normalizeConfig(
stripInternalPaseoMcpServer(imported.config),
);
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
const importedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(imported.config));
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
handedToRegistration = true;
return this.registerSession(imported.session, importedConfig, resolvedAgentId, {
labels: input.labels,
workspaceId: input.workspaceId,
timelineRows,
timelineNextSeq: timelineRows.length + 1,
persistence: imported.persistence,
historyPrimed: true,
initialTitle,
publishWhenReady: true,
});
} finally {
if (!handedToRegistration) {
await this.closeUnregisteredSession(imported.session);
}
}
return this.registerSession(imported.session, importedConfig, resolvedAgentId, {
labels: input.labels,
workspaceId: input.workspaceId,
timelineRows,
timelineNextSeq: timelineRows.length + 1,
persistence: imported.persistence,
historyPrimed: true,
initialTitle,
publishWhenReady: true,
});
}
// Hot-reload an active agent session with config overrides. By default the
@@ -1108,22 +1047,11 @@ export class AgentManager {
// new epoch is minted and provider history is re-streamed — this is what the
// user-facing "Reload agent" action wants when the on-disk session was
// mutated outside Paseo.
reloadAgentSession(
async reloadAgentSession(
agentId: string,
overrides?: Partial<AgentSessionConfig>,
options?: { rehydrateFromDisk?: boolean },
): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(
this.reloadAgentSessionInternal(agentId, overrides, options),
);
}
private async reloadAgentSessionInternal(
agentId: string,
overrides?: Partial<AgentSessionConfig>,
options?: { rehydrateFromDisk?: boolean },
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
let existing = this.requireSessionAgent(agentId);
if (this.hasInFlightRun(agentId)) {
await this.cancelAgentRun(agentId);
@@ -1150,43 +1078,36 @@ export class AgentManager {
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
: await client.createSession(providerLaunchConfig, launchContext);
let handedToRegistration = false;
try {
this.assertAcceptingAgentRegistrations();
const closedExisting = this.prepareAgentForClosure(existing, "agent reloaded");
try {
await this.persistSnapshot(closedExisting);
} finally {
await this.closeReloadedSession(existing.session, agentId);
}
if (rehydrateFromDisk) {
// Wipe both durable and in-memory timeline so registerSession mints a
// new epoch and hydrateTimelineFromProvider re-streams the freshly read
// provider history into an empty timeline.
await this.deleteCommittedTimeline(agentId);
this.timelineStore.delete(agentId);
}
// Preserve existing labels and timeline during reload.
handedToRegistration = true;
return this.registerSession(session, storedConfig, agentId, {
labels: existing.labels,
workspaceId: existing.workspaceId,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
lastUserMessageAt: existing.lastUserMessageAt,
historyPrimed: rehydrateFromDisk ? false : preservedHistoryPrimed,
lastUsage: preservedLastUsage,
lastError: preservedLastError,
attention: preservedAttention,
});
} finally {
if (!handedToRegistration) {
await this.closeUnregisteredSession(session);
}
this.agentStreamCoalescer.flushAndDiscard(agentId);
// Remove the existing agent entry before swapping sessions
this.agents.delete(agentId);
if (existing.unsubscribeSession) {
existing.unsubscribeSession();
existing.unsubscribeSession = null;
}
this.foregroundRuns.clearAgent(agentId, existing);
await this.closeReloadedSession(existing.session, agentId);
if (rehydrateFromDisk) {
// Wipe both durable and in-memory timeline so registerSession mints a
// new epoch and hydrateTimelineFromProvider re-streams the freshly read
// provider history into an empty timeline.
await this.deleteCommittedTimeline(agentId);
this.timelineStore.delete(agentId);
}
// Preserve existing labels and timeline during reload.
return this.registerSession(session, storedConfig, agentId, {
labels: existing.labels,
workspaceId: existing.workspaceId,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
lastUserMessageAt: existing.lastUserMessageAt,
historyPrimed: rehydrateFromDisk ? false : preservedHistoryPrimed,
lastUsage: preservedLastUsage,
lastError: preservedLastError,
attention: preservedAttention,
});
}
private async closeReloadedSession(session: AgentSession, agentId: string): Promise<void> {
@@ -2553,84 +2474,49 @@ export class AgentManager {
workspaceId?: string;
},
): Promise<ManagedAgent> {
let registered = false;
try {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId, "registerSession");
if (this.agents.has(resolvedAgentId)) {
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
}
const initialPersistedTitle = await this.resolveInitialPersistedTitle(
resolvedAgentId,
config,
options?.initialTitle ?? null,
);
const resolvedAgentId = validateAgentId(agentId, "registerSession");
if (this.agents.has(resolvedAgentId)) {
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
}
const initialPersistedTitle = await this.resolveInitialPersistedTitle(
resolvedAgentId,
config,
options?.initialTitle ?? null,
);
const now = new Date();
const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({
agentId: resolvedAgentId,
now,
options,
});
const now = new Date();
const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({
agentId: resolvedAgentId,
now,
options,
});
const managed = this.buildManagedAgentForRegister({
resolvedAgentId,
session,
config,
now,
durableTimelineHasRows,
options,
});
const managed = this.buildManagedAgentForRegister({
resolvedAgentId,
session,
config,
now,
durableTimelineHasRows,
options,
});
this.assertAcceptingAgentRegistrations();
this.agents.set(resolvedAgentId, managed);
registered = true;
// Initialize previousStatus to track transitions
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
await this.refreshRuntimeInfo(managed, { emit: false });
this.assertAgentRegistrationActive(managed);
await this.persistSnapshot(managed, {
title: initialPersistedTitle,
});
this.assertAgentRegistrationActive(managed);
if (!options?.publishWhenReady) {
this.emitState(managed, { persist: false });
}
await this.refreshSessionState(managed, { emit: false });
this.assertAgentRegistrationActive(managed);
managed.lifecycle = "idle";
await this.persistSnapshot(managed);
this.assertAgentRegistrationActive(managed);
this.agents.set(resolvedAgentId, managed);
// Initialize previousStatus to track transitions
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady });
await this.persistSnapshot(managed, {
title: initialPersistedTitle,
});
if (!options?.publishWhenReady) {
this.emitState(managed, { persist: false });
this.subscribeToSession(managed);
return { ...managed };
} catch (error) {
if (!registered) {
await this.closeUnregisteredSession(session);
}
throw error;
}
}
private assertAcceptingAgentRegistrations(): void {
if (!this.acceptingAgentRegistrations) {
throw new AgentManagerShuttingDownError();
}
}
private assertAgentRegistrationActive(agent: ActiveManagedAgent): void {
if (!this.acceptingAgentRegistrations || this.agents.get(agent.id) !== agent) {
throw new AgentManagerShuttingDownError();
}
}
private async closeUnregisteredSession(session: AgentSession): Promise<void> {
try {
await session.close();
} catch (error) {
this.logger.warn({ err: error }, "Failed to close unregistered agent session");
}
await this.refreshSessionState(managed, { emit: !options?.publishWhenReady });
managed.lifecycle = "idle";
await this.persistSnapshot(managed);
this.emitState(managed, { persist: false });
this.subscribeToSession(managed);
return { ...managed };
}
private async initializeAgentTimelineForRegister(params: {
@@ -3679,45 +3565,15 @@ export class AgentManager {
});
}
private trackAgentRegistrationOperation<T>(result: Promise<T>): Promise<T> {
const settled = result.then(
() => undefined,
() => undefined,
);
this.agentRegistrationTasks.add(settled);
void settled.then(() => {
this.agentRegistrationTasks.delete(settled);
return undefined;
});
return result;
}
/**
* Flush any background persistence work (best-effort).
* Used by daemon shutdown paths to avoid unhandled rejections after cleanup.
*/
async flush(): Promise<void> {
await this.flushTasks({ includeAgentRegistrations: false });
}
/**
* Flush persistence and agent registrations that crossed the synchronous
* shutdown barrier. Those registrations own provider sessions until they
* either install them or close them.
*/
async flushForShutdown(): Promise<void> {
await this.flushTasks({ includeAgentRegistrations: true });
}
private async flushTasks(options: { includeAgentRegistrations: boolean }): Promise<void> {
this.agentStreamCoalescer.flushAll();
// Drain tasks, including tasks spawned while awaiting.
while (
this.backgroundTasks.size > 0 ||
(options.includeAgentRegistrations && this.agentRegistrationTasks.size > 0)
) {
const pending = options.includeAgentRegistrations
? [...this.backgroundTasks, ...this.agentRegistrationTasks]
: [...this.backgroundTasks];
while (this.backgroundTasks.size > 0) {
const pending = Array.from(this.backgroundTasks);
await Promise.allSettled(pending);
}
}
@@ -1197,7 +1197,6 @@ describe("OpenCode adapter startTurn error handling", () => {
test("keeps a turn active while OpenCode is retrying", async () => {
vi.useFakeTimers();
const eventsGate = createTestDeferred<void>();
let eventStreamSignal: AbortSignal | undefined;
const retryStream: AsyncIterable<unknown> = {
[Symbol.asyncIterator]: () => {
let emitted = false;
@@ -1223,27 +1222,14 @@ describe("OpenCode adapter startTurn error handling", () => {
},
};
}
return new Promise<IteratorResult<unknown>>((resolve) => {
if (eventStreamSignal?.aborted) {
resolve({ done: true, value: undefined });
return;
}
eventStreamSignal?.addEventListener(
"abort",
() => resolve({ done: true, value: undefined }),
{ once: true },
);
});
return new Promise(() => {});
},
};
},
};
const fakeClient = {
global: {
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => {
eventStreamSignal = signal;
return { stream: retryStream };
}),
event: vi.fn().mockResolvedValue({ stream: retryStream }),
},
session: {
abort: vi.fn().mockResolvedValue({ error: null }),
@@ -1333,52 +1319,6 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(fakeClient.session.delete).not.toHaveBeenCalled();
});
test("waits for the OpenCode event stream to finish after close aborts it", async () => {
const streamAborted = createTestDeferred<void>();
const finishStreamCleanup = createTestDeferred<void>();
const fakeClient = {
global: {
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({
stream: {
[Symbol.asyncIterator]: () => ({
next: async () => {
if (!signal.aborted) {
await waitForAbort(signal);
}
streamAborted.resolve();
await finishStreamCleanup.promise;
return { done: true, value: undefined };
},
}),
},
})),
},
session: {
abort: vi.fn().mockResolvedValue({ error: null }),
update: vi.fn().mockResolvedValue({ error: null }),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/test" },
fakeClient,
"ses_unit_test",
createTestLogger(),
);
let closeSettled = false;
const closePromise = session.close().then(() => {
closeSettled = true;
return undefined;
});
await streamAborted.promise;
await new Promise<void>((resolve) => setImmediate(resolve));
expect(closeSettled).toBe(false);
finishStreamCleanup.resolve();
await closePromise;
});
test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => {
const fakeClient = {
session: {
@@ -2238,15 +2178,6 @@ function createTestDeferred<T>(): {
return { promise, resolve, reject };
}
function waitForAbort(signal: AbortSignal): Promise<void> {
if (signal.aborted) {
return Promise.resolve();
}
return new Promise((resolve) => {
signal.addEventListener("abort", () => resolve(), { once: true });
});
}
function abortableOpenCodeStream(signal: AbortSignal): AsyncIterable<OpenCodeEvent> {
return {
[Symbol.asyncIterator]: () => {
@@ -2789,7 +2789,6 @@ class OpenCodeAgentSession implements AgentSession {
private releaseServer: (() => void) | null;
private eventStreamAbortController: AbortController | null = null;
private eventStreamReady: Deferred<void> | null = null;
private eventStreamTask: Promise<void> | null = null;
private suppressTerminalUntilNextUserMessage = false;
private closed = false;
private readonly persistSession: boolean;
@@ -3161,24 +3160,11 @@ class OpenCodeAgentSession implements AgentSession {
const eventStreamReady = createDeferred<void>();
this.eventStreamAbortController = eventStreamAbortController;
this.eventStreamReady = eventStreamReady;
const eventStreamTask = this.consumeEventStream(
eventStreamAbortController,
eventStreamReady,
).finally(() => {
void this.consumeEventStream(eventStreamAbortController, eventStreamReady).finally(() => {
if (this.eventStreamAbortController === eventStreamAbortController) {
this.eventStreamAbortController = null;
this.eventStreamReady = null;
}
if (this.eventStreamTask === eventStreamTask) {
this.eventStreamTask = null;
}
});
this.eventStreamTask = eventStreamTask;
void eventStreamTask.catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId },
"OpenCode event stream task failed",
);
});
return eventStreamReady.promise;
@@ -3575,19 +3561,9 @@ class OpenCodeAgentSession implements AgentSession {
// unhandled rejection in whichever test the daemon hops to next.
this.closed = true;
this.abortController?.abort();
const eventStreamTask = this.eventStreamTask;
this.eventStreamAbortController?.abort();
if (eventStreamTask) {
await eventStreamTask.catch((error) => {
this.logger.debug(
{ err: error, sessionId: this.sessionId },
"OpenCode event stream failed during close",
);
});
}
this.eventStreamAbortController = null;
this.eventStreamReady = null;
this.eventStreamTask = null;
this.subscribers.clear();
await reconcileOpenCodeSessionClose({
client: this.client,
@@ -156,10 +156,7 @@ export class TestOpenCodeClient {
global: {
event: async (options: unknown) => {
this.calls.globalEvent.push(options);
const signal = (options as { signal?: AbortSignal }).signal;
return {
stream: signal ? stopEventStreamOnAbort(this.eventStream, signal) : this.eventStream,
};
return { stream: this.eventStream };
},
},
mcp: {
@@ -250,38 +247,6 @@ export class TestOpenCodeClient {
}
}
function stopEventStreamOnAbort(
stream: AsyncIterable<unknown>,
signal: AbortSignal,
): AsyncIterable<unknown> {
return {
[Symbol.asyncIterator]: () => {
const iterator = stream[Symbol.asyncIterator]();
return {
next: () => {
if (signal.aborted) {
return Promise.resolve({ done: true, value: undefined });
}
return new Promise<IteratorResult<unknown>>((resolve, reject) => {
const onAbort = () => resolve({ done: true, value: undefined });
signal.addEventListener("abort", onAbort, { once: true });
void iterator.next().then(
(result) => {
signal.removeEventListener("abort", onAbort);
return resolve(result);
},
(error) => {
signal.removeEventListener("abort", onAbort);
return reject(error);
},
);
});
},
};
},
};
}
export function createEventStream(events: unknown[]): AsyncGenerator<unknown> {
return (async function* () {
for (const event of events) {
@@ -12,7 +12,7 @@ import {
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { describe, expect, onTestFinished, test } from "vitest";
import { describe, expect, test } from "vitest";
import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
@@ -550,14 +550,12 @@ describe("PiRpcAgentSession", () => {
},
},
{},
{ env: { RESUME_PROBE: "expected" } },
);
expect(pi.recordedLaunches).toHaveLength(1);
const actualLaunch = pi.recordedLaunches[0]!;
expect(actualLaunch).toMatchObject({
cwd: "/workspace/project",
env: { RESUME_PROBE: "expected" },
session: "/tmp/native-pi-session",
});
expect(actualLaunch.extensionPaths).toHaveLength(1);
@@ -1223,21 +1221,7 @@ describe("PiRpcAgentClient", () => {
expect(pi.latestSession().treeNavigationRequests).toEqual(["entry-1"]);
});
test("injects MCP servers without replacing the Pi global MCP config", async () => {
const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-"));
onTestFinished(() => rmSync(agentDir, { recursive: true, force: true }));
writeFileSync(
path.join(agentDir, "mcp.json"),
JSON.stringify({
settings: { toolPrefix: "none", disableProxyTool: true },
"mcp-servers": {
"brave-search": {
url: "https://example.com/mcp/brave",
directTools: ["brave_llm_context"],
},
},
}),
);
test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => {
const pi = new FakePi();
pi.queueCommands([
{
@@ -1264,7 +1248,6 @@ describe("PiRpcAgentClient", () => {
},
},
}),
{ env: { PI_CODING_AGENT_DIR: agentDir } },
);
expect(pi.recordedLaunches).toHaveLength(2);
@@ -1293,12 +1276,7 @@ describe("PiRpcAgentClient", () => {
mcpServers: Record<string, unknown>;
};
expect(injectedConfig).toEqual({
settings: { toolPrefix: "none", disableProxyTool: true },
mcpServers: {
"brave-search": {
url: "https://example.com/mcp/brave",
directTools: ["brave_llm_context"],
},
paseo: {
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
auth: false,
@@ -1316,27 +1294,6 @@ describe("PiRpcAgentClient", () => {
expect(existsSync(configPath!)).toBe(false);
});
test("reports the path of a malformed Pi global MCP config", async () => {
const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-"));
onTestFinished(() => rmSync(agentDir, { recursive: true, force: true }));
const configPath = path.join(agentDir, "mcp.json");
writeFileSync(configPath, "{ invalid");
const pi = new FakePi();
pi.queueCommands([{ name: "mcp", source: "extension" }]);
const client = createClient(pi);
await expect(
client.createSession(
createConfig({
mcpServers: {
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" },
},
}),
{ env: { PI_CODING_AGENT_DIR: agentDir } },
),
).rejects.toThrow(`Failed to parse Pi MCP config: ${configPath}`);
});
test("does not pass MCP config when pi-mcp-adapter is not loaded", async () => {
const pi = new FakePi();
pi.queueCommands([]);
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import { join } from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
@@ -497,63 +497,14 @@ function toPiMcpConfig(config: McpServerConfig): PiMcpServerConfig {
};
}
function resolvePiAgentDir(env: Record<string, string> | undefined): string {
// Match pi-mcp-adapter's agent-directory resolution so we preserve the config it replaces.
const configured = env?.PI_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
if (!configured) {
return join(homedir(), ".pi", "agent");
}
if (configured === "~") {
return homedir();
}
if (configured.startsWith("~/")) {
return resolvePath(homedir(), configured.slice(2));
}
return resolvePath(configured);
}
function createPiMcpConfigFile(
servers: Record<string, McpServerConfig>,
env: Record<string, string> | undefined,
): PiMcpConfigFile {
// pi-mcp-adapter treats --mcp-config as a replacement for its Pi global layer, not an
// additional layer. Rebuild that layer here; the adapter still loads shared and project files.
const globalConfigPath = join(resolvePiAgentDir(env), "mcp.json");
let globalConfig: unknown = {};
if (existsSync(globalConfigPath)) {
const contents = readFileSync(globalConfigPath, "utf8");
try {
globalConfig = JSON.parse(contents) as unknown;
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`Failed to parse Pi MCP config: ${globalConfigPath}`, { cause: error });
}
throw error;
}
}
if (!isRecord(globalConfig)) {
throw new Error(`Pi MCP config must contain a JSON object: ${globalConfigPath}`);
}
let configuredServers: Record<string, unknown> = {};
if (isRecord(globalConfig.mcpServers)) {
configuredServers = globalConfig.mcpServers;
} else if (isRecord(globalConfig["mcp-servers"])) {
configuredServers = globalConfig["mcp-servers"];
}
const mcpServers: Record<string, unknown> = { ...configuredServers };
function createPiMcpConfigFile(servers: Record<string, McpServerConfig>): PiMcpConfigFile {
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-"));
const filePath = join(dir, "mcp.json");
const mcpServers: Record<string, PiMcpServerConfig> = {};
for (const [name, serverConfig] of Object.entries(servers)) {
mcpServers[name] = toPiMcpConfig(serverConfig);
}
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-"));
const filePath = join(dir, "mcp.json");
const mergedConfig: Record<string, unknown> = { ...globalConfig, mcpServers };
// Emit one canonical server key so a stale alias cannot shadow the injected definitions.
delete mergedConfig["mcp-servers"];
writeFileSync(filePath, `${JSON.stringify(mergedConfig, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
writeFileSync(filePath, `${JSON.stringify({ mcpServers }, null, 2)}\n`, "utf8");
return {
path: filePath,
cleanup: () => rmSync(dir, { recursive: true, force: true }),
@@ -1960,10 +1911,7 @@ export class PiRpcAgentClient implements AgentClient {
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, {
...this.runtimeSettings?.env,
...launchContext?.env,
});
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
const paseoExtension = createPiPaseoExtensionFile();
let runtimeSession: PiRuntimeSession;
try {
@@ -2005,7 +1953,7 @@ export class PiRpcAgentClient implements AgentClient {
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const sessionFile = handle.nativeHandle;
if (!sessionFile) {
@@ -2015,20 +1963,12 @@ export class PiRpcAgentClient implements AgentClient {
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides);
const mcpConfig = await this.prepareMcpConfig(
resumeConfig.cwd,
resumeConfig.config.mcpServers,
{
...this.runtimeSettings?.env,
...launchContext?.env,
},
);
const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers);
const paseoExtension = createPiPaseoExtensionFile();
let runtimeSession: PiRuntimeSession;
try {
runtimeSession = await this.runtime.startSession({
cwd: resumeConfig.cwd,
env: launchContext?.env,
session: sessionFile,
model: resumeConfig.model,
thinkingOptionId: normalizePiThinkingOption(resumeConfig.thinkingOptionId) ?? undefined,
@@ -2139,19 +2079,18 @@ export class PiRpcAgentClient implements AgentClient {
private async prepareMcpConfig(
cwd: string,
servers: Record<string, McpServerConfig> | undefined,
env: Record<string, string> | undefined,
): Promise<PiMcpConfigFile | null> {
if (!servers || Object.keys(servers).length === 0) {
return null;
}
if (!(await this.detectMcpAdapter(cwd, env))) {
if (!(await this.detectMcpAdapter(cwd))) {
return null;
}
return createPiMcpConfigFile(servers, env);
return createPiMcpConfigFile(servers);
}
private async detectMcpAdapter(cwd: string, env?: Record<string, string>): Promise<boolean> {
const runtimeSession = await this.runtime.startSession({ cwd, env }).catch((error) => {
private async detectMcpAdapter(cwd: string): Promise<boolean> {
const runtimeSession = await this.runtime.startSession({ cwd }).catch((error) => {
this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed to start");
return null;
});
@@ -7,7 +7,6 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { AgentManagerShuttingDownError } from "./agent/agent-manager.js";
import { hashDaemonPassword } from "./auth.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
@@ -15,23 +14,6 @@ import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { isPlatform } from "../test-utils/platform.js";
import { findFreePort } from "./service-proxy.js";
interface HeldAgentClose {
started: Promise<void>;
arm(): void;
closeSession(): Promise<void>;
finish(): void;
}
interface BlockedDaemonShutdown {
probeReconnect(): Promise<WebSocketProbeResult>;
tryCreateAgent(): Promise<"created" | "rejected">;
finish(): Promise<void>;
}
type WebSocketProbeResult =
| { status: "connected" }
| { status: "rejected"; statusCode: number | null };
describe("paseo daemon bootstrap", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -224,17 +206,6 @@ describe("paseo daemon bootstrap", () => {
}
});
test("stops new connections and agent registrations before closing agents", async () => {
const shutdown = await beginDaemonShutdownWithAgentClosing();
try {
await expect(
Promise.all([shutdown.probeReconnect(), shutdown.tryCreateAgent()]),
).resolves.toEqual([{ status: "rejected", statusCode: 503 }, "rejected"]);
} finally {
await shutdown.finish();
}
});
test("standalone listener exposes services only", async () => {
const standalonePort = await findFreePort();
const upstream = http.createServer((_req, res) => {
@@ -551,97 +522,3 @@ describe("paseo daemon bootstrap", () => {
},
);
});
function holdAgentClose(): HeldAgentClose {
let armed = false;
let markStarted = () => {};
let finish = () => {};
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
return {
started,
arm() {
armed = true;
},
async closeSession() {
if (!armed) {
return;
}
markStarted();
await finished;
},
finish: () => finish(),
};
}
async function beginDaemonShutdownWithAgentClosing(): Promise<BlockedDaemonShutdown> {
const heldAgentClose = holdAgentClose();
const daemonHandle = await createTestPaseoDaemon({
cleanup: false,
agentClients: createTestAgentClients({ closeSession: heldAgentClose.closeSession }),
});
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-shutdown-agent-"));
await daemonHandle.daemon.agentManager.createAgent(
{
provider: "codex",
cwd: agentCwd,
},
undefined,
{ workspaceId: undefined },
);
heldAgentClose.arm();
const stopPromise = daemonHandle.daemon.stop();
await heldAgentClose.started;
return {
probeReconnect: () => probeWebSocketConnection(`ws://127.0.0.1:${daemonHandle.port}/ws`),
async tryCreateAgent() {
try {
await daemonHandle.daemon.agentManager.createAgent(
{
provider: "codex",
cwd: agentCwd,
},
undefined,
{ workspaceId: undefined },
);
return "created";
} catch (error) {
if (error instanceof AgentManagerShuttingDownError) {
return "rejected";
}
throw error;
}
},
async finish() {
heldAgentClose.finish();
await stopPromise;
await daemonHandle.daemon.agentManager.flush().catch(() => undefined);
await Promise.all([
rm(path.dirname(daemonHandle.paseoHome), { recursive: true, force: true }),
rm(daemonHandle.staticDir, { recursive: true, force: true }),
rm(agentCwd, { recursive: true, force: true }),
]);
},
};
}
function probeWebSocketConnection(url: string): Promise<WebSocketProbeResult> {
const ws = new WebSocket(url);
return new Promise((resolve) => {
ws.once("open", () => {
ws.close();
resolve({ status: "connected" });
});
ws.once("error", () => resolve({ status: "rejected", statusCode: null }));
ws.once("unexpected-response", (_request, response) => {
response.resume();
resolve({ status: "rejected", statusCode: response.statusCode ?? null });
});
});
}
+1 -4
View File
@@ -1443,11 +1443,8 @@ export async function createPaseoDaemon(
const stop = async () => {
scriptHealthMonitor.stop();
// Freeze both ingress and registration before taking the agent closure snapshot.
wsServer?.prepareForShutdown();
agentManager.prepareForShutdown();
await closeAllAgents(logger, agentManager);
await agentManager.flushForShutdown().catch(() => undefined);
await agentManager.flush().catch(() => undefined);
detachAgentStoragePersistence();
await agentStorage.flush().catch(() => undefined);
await providerSnapshotManager.shutdown();
@@ -51,18 +51,6 @@ interface Deferred<T> {
reject: (err: unknown) => void;
}
interface FakeAgentSessionOptions {
providerName: string;
config: AgentSessionConfig;
sessionId?: string;
memoryMarker?: string | null;
closeSession?: () => Promise<void>;
}
export interface TestAgentClientOptions {
closeSession?: () => Promise<void>;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (err: unknown) => void;
@@ -328,14 +316,16 @@ class FakeAgentSession implements AgentSession {
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private readonly closeSession: (() => Promise<void>) | undefined;
constructor(options: FakeAgentSessionOptions) {
this.providerName = options.providerName;
this.config = options.config;
this.id = options.sessionId ?? randomUUID();
this.memoryMarker = options.memoryMarker ?? null;
this.closeSession = options.closeSession;
constructor(
providerName: string,
config: AgentSessionConfig,
sessionId?: string,
memoryMarker?: string | null,
) {
this.providerName = providerName;
this.config = config;
this.id = sessionId ?? randomUUID();
this.memoryMarker = memoryMarker ?? null;
this.historyPath = path.join(
tmpdir(),
"paseo-fake-provider-history",
@@ -857,9 +847,7 @@ class FakeAgentSession implements AgentSession {
this.interruptSignal.resolve();
}
async close(): Promise<void> {
await this.closeSession?.();
}
async close(): Promise<void> {}
async listCommands(): Promise<AgentSlashCommand[]> {
if (this.providerName === "codex") {
@@ -1167,20 +1155,13 @@ class FakeAgentSession implements AgentSession {
class FakeAgentClient implements AgentClient {
readonly capabilities = TEST_CAPABILITIES;
constructor(
public readonly provider: string,
private readonly options: TestAgentClientOptions,
) {}
constructor(public readonly provider: string) {}
async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
return new FakeAgentSession({
providerName: this.provider,
config: { ...config },
closeSession: this.options.closeSession,
});
return new FakeAgentSession(this.provider, { ...config });
}
async resumeSession(
@@ -1197,13 +1178,12 @@ class FakeAgentClient implements AgentClient {
(handle.metadata as Record<string, unknown> | undefined)?.marker ??
(handle.metadata as Record<string, unknown> | undefined)?.conversationId ??
null;
return new FakeAgentSession({
providerName: this.provider,
config: cfg,
sessionId: handle.sessionId,
memoryMarker: typeof marker === "string" ? marker : null,
closeSession: this.options.closeSession,
});
return new FakeAgentSession(
this.provider,
cfg,
handle.sessionId,
typeof marker === "string" ? marker : null,
);
}
async fetchCatalog(
@@ -1242,12 +1222,10 @@ class FakeAgentClient implements AgentClient {
}
}
export function createTestAgentClients(
options: TestAgentClientOptions = {},
): Record<string, AgentClient> {
export function createTestAgentClients(): Record<string, AgentClient> {
return {
claude: new FakeAgentClient("claude", options),
codex: new FakeAgentClient("codex", options),
opencode: new FakeAgentClient("opencode", options),
claude: new FakeAgentClient("claude"),
codex: new FakeAgentClient("codex"),
opencode: new FakeAgentClient("opencode"),
};
}
@@ -454,21 +454,6 @@ function holdNextSessionMessage(session: (typeof sessionMock.instances)[number])
};
}
function holdSessionCleanup(session: (typeof sessionMock.instances)[number]): {
finish: () => void;
} {
let finish = () => {};
session.cleanup.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
return {
finish: () => finish(),
};
}
describe("relay external socket reconnect behavior", () => {
beforeEach(() => {
sessionMock.instances.length = 0;
@@ -532,36 +517,6 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("rejects sockets attached after shutdown begins", async () => {
const server = createServer();
const existingSocket = new MockSocket();
await attachRelayAndHello({
server,
socket: existingSocket,
clientId: "existing-client",
});
const heldCleanup = holdSessionCleanup(sessionMock.instances[0]);
const closePromise = server.close();
const lateSocket = new MockSocket();
try {
await server.attachExternalSocket(lateSocket, { transport: "relay" });
lateSocket.emit("message", JSON.stringify(createHelloMessage("late-client")));
expect({
readyState: lateSocket.readyState,
sessionCount: sessionMock.instances.length,
}).toEqual({
readyState: 3,
sessionCount: 1,
});
} finally {
heldCleanup.finish();
await closePromise;
}
});
test("closes pending connection when hello timeout elapses", async () => {
const server = createServer();
@@ -351,7 +351,6 @@ const HELLO_TIMEOUT_MS = 15_000;
const WS_CLOSE_HELLO_TIMEOUT = 4001;
const WS_CLOSE_INVALID_HELLO = 4002;
const WS_CLOSE_INCOMPATIBLE_PROTOCOL = 4003;
const WS_CLOSE_SERVER_SHUTDOWN = 1001;
const WS_PROTOCOL_VERSION = 1;
const WS_RUNTIME_METRICS_FLUSH_MS = 30_000;
@@ -464,7 +463,6 @@ export class VoiceAssistantWebSocketServer {
private unsubscribeTerminalActivity: (() => void) | null = null;
private readonly browserToolsBroker: BrowserToolsBroker | null;
private readonly browserToolsRegistrations = new Map<string, BrowserToolsRegistration>();
private acceptingConnections = true;
constructor(
server: HTTPServer,
@@ -710,11 +708,6 @@ export class VoiceAssistantWebSocketServer {
hostnames: HostnamesConfig | undefined,
callback: (res: boolean, code?: number, message?: string) => void,
): void {
if (!this.acceptingConnections) {
callback(false, 503, "Server shutting down");
return;
}
const requestMetadata = extractSocketRequestMetadata(req);
const origin = requestMetadata.origin;
const requestHost = requestMetadata.host ?? null;
@@ -806,12 +799,7 @@ export class VoiceAssistantWebSocketServer {
await this.attachSocket(ws, undefined, metadata);
}
public prepareForShutdown(): void {
this.acceptingConnections = false;
}
public async close(): Promise<void> {
this.prepareForShutdown();
this.unsubscribeSpeechReadiness?.();
this.unsubscribeSpeechReadiness = null;
this.unsubscribeDaemonConfigChange?.();
@@ -933,15 +921,6 @@ export class VoiceAssistantWebSocketServer {
request?: unknown,
metadata?: ExternalSocketMetadata,
): Promise<void> {
if (!this.acceptingConnections) {
try {
ws.close(WS_CLOSE_SERVER_SHUTDOWN, "Server shutting down");
} catch {
// ignore close errors
}
return;
}
const requestMetadata = extractSocketRequestMetadata(request);
const identity = createWebSocketConnectionIdentity(requestMetadata, metadata);
this.socketIdentities.set(ws, identity);
@@ -1630,10 +1609,6 @@ export class VoiceAssistantWebSocketServer {
ws: WebSocketLike,
data: Buffer | ArrayBuffer | Buffer[] | string,
): void {
if (!this.acceptingConnections) {
return;
}
const activeConnection = this.sessions.get(ws);
const pendingConnection = this.pendingConnections.get(ws);
const log =
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.106",
"version": "0.1.105",
"private": true,
"type": "module",
"scripts": {