Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 0c05f69402 fix(server): cap shell timeline text by UTF-8 bytes 2026-07-12 14:28:33 +00:00
Mohamed Boudra 8fdc41f06c fix(server): cover imported and failed shell output 2026-07-12 16:08:15 +02:00
Mohamed Boudra 928c124356 refactor(server): slice oversized tool output directly 2026-07-12 16:01:11 +02:00
Mohamed Boudra 0a398833d8 fix(server): bound shell tool output in timelines
Provider tool output could enter timeline persistence and live streams without a size limit. Bound canonical shell output before coalescing, storage, history hydration, and dispatch so every provider follows the same 64 KiB budget.
2026-07-12 15:55:13 +02:00
paseo-ai[bot] 71c66823aa fix: update lockfile signatures and Nix hash [skip ci] 2026-07-12 08:47:55 +00:00
Mohamed Boudra a1821d5863 chore(release): cut 0.1.106 2026-07-12 10:42:56 +02:00
Mohamed Boudra 5db070a4d9 docs: promote 0.1.106 release notes 2026-07-12 10:40:19 +02:00
Matt Cowgerandgreptile-apps[bot] 461be47c91 fix(server): await OpenCode event stream shutdown (#2015)
* fix(server): await OpenCode event stream shutdown

* Update opencode-agent.test.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-07-12 16:20:13 +08:00
Mohamed Boudra 92d7066601 fix(app): prevent white flashes between workspace routes
Nested native stacks fell back to the navigation library's light background during cross-stack swaps. Share the active app surface color across every stack without subscribing route layouts to all theme runtime changes.
2026-07-12 00:27:40 +02:00
Mohamed Boudra d28e174b38 ops(relay): require manual deployment 2026-07-12 00:09:15 +02:00
paseo-ai[bot] a257a1c5f6 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-11 18:59:45 +00:00
Mohamed Boudra 144f951a79 ops(relay): merge production bridge to Fly so release deploys preserve it 2026-07-11 20:55:56 +02:00
Mohamed Boudra 51fea4b7e0 docs: clarify mobile beta release note 2026-07-11 20:47:48 +02:00
Mohamed Boudra e98ba1beb1 chore(release): cut 0.1.106-beta.1 2026-07-11 20:45:30 +02:00
Mohamed Boudra 3bbbfbc880 docs: add 0.1.106 beta release notes 2026-07-11 20:43:14 +02:00
Mohamed Boudra f86226a196 chore: update ACP provider catalog 2026-07-11 20:42:47 +02:00
Mohamed Boudra 120842264e fix(app): do not promote archive as primary for regular Git checkouts 2026-07-11 18:58:24 +02:00
Mohamed Boudra a017ddfc29 fix(app): hide header Git actions for non-Git workspaces 2026-07-11 18:34:26 +02:00
Mohamed Boudra 03dcda7c41 Archive any workspace from the app (#2003)
* feat(app): expose archive for every workspace

Workspace actions now archive one workspace by id. The daemon remains responsible for backing-specific cleanup, including removing a managed worktree only after its final workspace is archived.

* refactor(app): clarify archive descriptor name

* fix(app): preserve workspace archive safety

* fix(app): allow archive without upstream

* refactor(app): remove stale archive callback

* test(app): cover workspace archive ownership
2026-07-11 18:22:31 +02:00
Matt Cowger ada0955334 fix(server): preserve Pi MCP config during injection (#1990) 2026-07-11 23:56:42 +08:00
Mohamed Boudra ca5c786ccf Stop reconnects from reviving agents during shutdown (#1977)
* fix(server): stop reconnects reviving agents during shutdown

Shutdown previously took its agent snapshot while reconnects and in-flight registrations could still add or repersist agents. Gate ingress and registration first so closed agents cannot return during teardown.

* fix(server): drain late sessions during shutdown
2026-07-11 23:48:56 +08:00
Mohamed Boudra 5ae53c7e55 ops(relay): bridge production traffic to Fly 2026-07-11 16:49:15 +02:00
82 changed files with 1992 additions and 963 deletions
+2 -5
View File
@@ -1,11 +1,8 @@
name: Deploy Relay
on:
push:
branches: [main]
paths:
- "packages/relay/**"
- ".github/workflows/deploy-relay.yml"
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
# A release or main push must not redeploy the temporary Cloudflare bridge.
workflow_dispatch:
jobs:
+17
View File
@@ -1,5 +1,22 @@
# 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. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
- 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.
- 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,6 +96,18 @@ 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,6 +4,7 @@ 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. 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. 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 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,6 +52,8 @@ 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".
+5
View File
@@ -9,6 +9,11 @@ The invariant is:
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
Tool output is bounded before it enters either delivery path. Canonical shell tool output and failed
shell error text are capped at 64 KiB of UTF-8 data, and the same bounded item is used for durable
timeline rows and live stream events. Provider history hydration applies the same rule so reopening
an agent cannot restore an oversized tool payload.
## Presence is not delivery
Client heartbeat reports presence:
+1 -1
View File
@@ -1 +1 @@
sha256-cU6qRXY9fnr80pllmqJts5w8+OiE6F99SRo2d9G0HDA=
sha256-lYvP0RKgxshO68+bjkkUXX9YJtzgoGIp8kGdGFYOT+o=
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.105",
"version": "0.1.106",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.105",
"version": "0.1.106",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35152,7 +35152,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.105",
"version": "0.1.106",
"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.105",
"version": "0.1.106",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/server": "0.1.105",
"@getpaseo/client": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/server": "0.1.106",
"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.105",
"version": "0.1.106",
"dependencies": {
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36435,7 +36435,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.105",
"version": "0.1.106",
"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.105",
"version": "0.1.106",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37574,7 +37574,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.105",
"version": "0.1.106",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37806,7 +37806,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.105",
"version": "0.1.106",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37819,7 +37819,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.105",
"version": "0.1.106",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38037,15 +38037,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.105",
"version": "0.1.106",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.105",
"@getpaseo/highlight": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"@getpaseo/client": "0.1.106",
"@getpaseo/highlight": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@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.105",
"version": "0.1.106",
"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.105",
"version": "0.1.106",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,4 +1,5 @@
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";
@@ -11,7 +12,7 @@ function workspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
const serverId = getServerId();
const row = page.getByTestId(workspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
@@ -21,10 +22,6 @@ async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promis
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();
@@ -159,17 +156,21 @@ test.describe("Project with no workspaces persists", () => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
await expect(workspaceRow).toBeVisible({
timeout: 30_000,
});
await workspaceRow.click();
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(0);
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
await archiveWorkspaceFromSidebar(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");
+3 -4
View File
@@ -39,10 +39,9 @@ export async function clickArchiveWorkspaceMenuItem(
await archiveItem.click();
}
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.
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.
page.once("dialog", (dialog) => void dialog.accept());
await clickArchiveWorkspaceMenuItem(page, workspaceId);
}
@@ -0,0 +1,26 @@
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("Worktree archive risk warning", () => {
test.describe("Workspace archive risk warning for worktree backing", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -97,7 +97,7 @@ test.describe("Worktree archive risk warning", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
page,
}) => {
const serverId = getServerId();
+44 -6
View File
@@ -8,11 +8,11 @@ import {
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { getServerId } from "./helpers/server-id";
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
test.describe("Worktree archive", () => {
test.describe("Workspace archive with worktree backing", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
@@ -33,9 +33,7 @@ test.describe("Worktree archive", () => {
await tempRepo?.cleanup().catch(() => undefined);
});
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
page,
}) => {
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
const serverId = getServerId();
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
@@ -49,11 +47,51 @@ test.describe("Worktree archive", () => {
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
await archiveWorkspaceFromSidebar(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.105",
"version": "0.1.106",
"private": true,
"main": "index.ts",
"scripts": {
+8 -13
View File
@@ -52,6 +52,7 @@ 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";
@@ -784,21 +785,15 @@ 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 (
<Stack screenOptions={stackScreenOptions}>
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
@@ -815,7 +810,7 @@ function RootStack() {
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
</Stack>
</ThemedStack>
);
}
@@ -2,6 +2,7 @@ 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 = {
@@ -33,14 +34,14 @@ function KnownHostRoute() {
}
const stack = (
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
<ThemedStack 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" />
</Stack>
</ThemedStack>
);
if (!routeServerId) {
@@ -77,7 +77,6 @@ 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";
@@ -112,7 +111,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, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import {
getCurrentProjectRemoveReadiness,
@@ -284,16 +283,6 @@ 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);
@@ -1454,20 +1443,7 @@ function WorkspaceRowWithMenu({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
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 isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
@@ -1479,7 +1455,6 @@ function WorkspaceRowWithMenu({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -1546,7 +1521,7 @@ function WorkspaceRowWithMenu({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -1558,8 +1533,8 @@ function WorkspaceRowWithMenu({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -1584,11 +1559,7 @@ function WorkspaceRowWithMenu({
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={getWorkspaceArchiveStatus(
isWorktree,
worktreeArchiveStatus,
isHidingWorkspace,
)}
archiveStatus={isArchiving ? "pending" : "idle"}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
@@ -23,10 +23,9 @@ 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, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory } 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";
@@ -330,20 +329,7 @@ function StatusWorkspaceRowWithMenu({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
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 isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -358,7 +344,6 @@ function StatusWorkspaceRowWithMenu({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -408,7 +393,7 @@ function StatusWorkspaceRowWithMenu({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -420,8 +405,8 @@ function StatusWorkspaceRowWithMenu({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -430,13 +415,6 @@ function StatusWorkspaceRowWithMenu({
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
computedArchiveStatus = "pending";
}
return (
<>
<StatusWorkspaceRowInner
@@ -448,7 +426,7 @@ function StatusWorkspaceRowWithMenu({
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={computedArchiveStatus}
archiveStatus={isArchiving ? "pending" : "idle"}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
@@ -11,14 +11,13 @@ 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, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { requireWorkspaceDirectory } 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";
@@ -66,20 +65,7 @@ export function SidebarWorkspaceRow({
const toast = useToast();
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
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 isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
@@ -94,7 +80,6 @@ export function SidebarWorkspaceRow({
const archiveController = useWorkspaceArchive({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
@@ -161,7 +146,7 @@ export function SidebarWorkspaceRow({
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
@@ -173,8 +158,8 @@ export function SidebarWorkspaceRow({
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
handlerId: `workspace-archive-${workspace.workspaceKey}`,
actions: ["workspace.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
@@ -183,13 +168,6 @@ export function SidebarWorkspaceRow({
},
});
let archiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
archiveStatus = worktreeArchiveStatus;
} else if (isHidingWorkspace) {
archiveStatus = "pending";
}
return (
<>
<WorkspaceRowBody
@@ -205,7 +183,7 @@ export function SidebarWorkspaceRow({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={archiveStatus}
archiveStatus={isArchiving ? "pending" : "idle"}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
onArchive={handleArchive}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
@@ -1,9 +1,8 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
interface PendingWorkspaceArchive {
workspaceId: string;
workspaceDirectory: string | null;
}
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
@@ -15,7 +14,6 @@ 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);
@@ -26,7 +24,6 @@ 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);
}
@@ -54,7 +51,6 @@ export function clearWorkspaceArchivePending(input: {
export function isWorkspaceArchivePending(input: {
serverId: string;
workspaceId?: string | null;
workspaceDirectory?: string | null;
}): boolean {
const serverId = input.serverId.trim();
if (!serverId) {
@@ -67,21 +63,7 @@ export function isWorkspaceArchivePending(input: {
}
const workspaceId = normalizeWorkspaceOpaqueId(input.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;
return Boolean(workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId })));
}
export function shouldSuppressWorkspaceForLocalArchive(input: {
@@ -91,6 +73,5 @@ 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.118.2",
version: "2.119.2",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.118.2", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.119.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.26",
version: "0.2.27",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.26", "acp"],
command: ["npx", "-y", "dimcode@0.2.27", "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.169.0",
version: "0.170.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.169.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.170.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.41",
version: "1.0.43",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.41", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.43", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.8",
version: "0.19.9",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.8", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.9", "--acp", "--experimental-skills"],
},
{
id: "sigit",
@@ -48,7 +48,7 @@ function GitActionMenuItem({
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
const trailing = useMemo(
() =>
action.id === "archive-worktree" && archiveShortcutKeys ? (
action.id === "archive-workspace" && archiveShortcutKeys ? (
<Shortcut chord={archiveShortcutKeys} />
) : undefined,
[action.id, archiveShortcutKeys],
@@ -58,7 +58,7 @@ function GitActionMenuItem({
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
testID={
action.id === "archive-worktree"
action.id === "archive-workspace"
? "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-worktree");
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const getActionDisplayLabel = useCallback((action: GitAction): string => {
if (action.status === "pending") return action.pendingLabel;
-141
View File
@@ -2,16 +2,10 @@ 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: {
@@ -31,33 +25,13 @@ 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: {} }));
});
@@ -65,7 +39,6 @@ describe("checkout-git-actions-store", () => {
afterEach(() => {
vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests();
clearWorkspaceArchivePending({ serverId, workspaceId });
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
@@ -294,118 +267,4 @@ 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);
});
});
+1 -187
View File
@@ -1,19 +1,7 @@
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";
@@ -36,8 +24,7 @@ export type CheckoutGitAsyncActionId =
| "enable-pr-auto-merge-rebase"
| "disable-pr-auto-merge"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
| "merge-from-base";
type CheckoutKey = string;
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
@@ -89,119 +76,6 @@ 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>>();
@@ -209,16 +83,6 @@ 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>;
@@ -247,12 +111,6 @@ 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({
@@ -490,50 +348,6 @@ 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() {
+40 -8
View File
@@ -122,7 +122,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle",
handler: () => undefined,
},
"archive-worktree": {
"archive-workspace": {
disabled: false,
status: "idle",
handler: () => undefined,
@@ -140,7 +140,13 @@ describe("git-actions-policy", () => {
it("shows only remote sync actions on the base branch", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push", "pull-and-push"]);
expect(actions.primary).toBeNull();
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"archive-workspace",
]);
});
it("prioritizes pull when the branch is behind origin", () => {
@@ -272,6 +278,7 @@ 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"),
@@ -352,12 +359,33 @@ describe("git-actions-policy", () => {
);
});
it("only shows archive worktree for paseo worktrees", () => {
const hidden = buildGitActions(createInput());
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
it("hides Git actions for a non-Git workspace", () => {
const directory = buildGitActions(createInput({ isGit: false }));
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(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" });
});
it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => {
@@ -541,6 +569,7 @@ describe("git-actions-policy", () => {
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
"archive-workspace",
]);
});
@@ -717,6 +746,7 @@ describe("git-actions-policy", () => {
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
"archive-workspace",
]);
});
@@ -757,6 +787,7 @@ describe("git-actions-policy", () => {
"merge-branch",
"pr",
"enable-pr-auto-merge-squash",
"archive-workspace",
]);
expect(
actions.secondary.some((action) =>
@@ -899,6 +930,7 @@ describe("git-actions-policy", () => {
"merge-branch",
"pr",
"merge-pr-merge",
"archive-workspace",
]);
});
@@ -952,7 +984,7 @@ describe("git-actions-policy", () => {
.filter((action) => !action.startsGroup)
.map((action) => action.id);
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-worktree"]);
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-workspace"]);
expect(nonGroupStarters).toEqual([
"pull",
"push",
+20 -17
View File
@@ -23,7 +23,7 @@ export type GitActionId =
| "disable-pr-auto-merge"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
| "archive-workspace";
export interface GitAction {
id: GitActionId;
@@ -282,20 +282,16 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
handler: input.runtime["merge-from-base"].handler,
});
allActions.set("archive-worktree", {
id: "archive-worktree",
allActions.set("archive-workspace", {
id: "archive-workspace",
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-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,
disabled: input.runtime["archive-workspace"].disabled,
status: input.runtime["archive-workspace"].status,
icon: input.runtime["archive-workspace"].icon,
startsGroup: true,
handler: input.runtime["archive-worktree"].handler,
handler: input.runtime["archive-workspace"].handler,
});
const primaryActionId = getPrimaryActionId(input);
@@ -305,20 +301,20 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isOnBaseBranch) {
secondaryIds.push(...getFeatureActionIds(input));
}
if (input.isPaseoOwnedWorktree) {
secondaryIds.push("archive-worktree");
}
secondaryIds.push("archive-workspace");
return {
primary,
secondary: secondaryIds.map((id) => allActions.get(id)!),
secondary: secondaryIds
.filter((id) => id !== "archive-workspace" || primaryActionId !== "archive-workspace")
.map((id) => allActions.get(id)!),
menu: [],
};
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
return "archive-worktree";
if (input.shouldPromoteArchive) {
return "archive-workspace";
}
if (input.hasUncommittedChanges) {
return "commit";
@@ -350,6 +346,13 @@ 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;
}
+84 -39
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 } from "@/stores/session-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
useActiveWorkspaceSelection,
type ActiveWorkspaceSelection,
@@ -22,6 +22,7 @@ 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";
@@ -97,15 +98,12 @@ function extractGitCommitCounts(gitStatus: CheckoutStatusPayload | null): GitCom
}
function computeShouldPromoteArchive(input: {
isPaseoOwnedWorktree: boolean;
hasUncommittedChanges: boolean;
postShipArchiveSuggested: boolean;
isMergedPullRequest: boolean;
}): boolean {
return (
input.isPaseoOwnedWorktree &&
!input.hasUncommittedChanges &&
(input.postShipArchiveSuggested || input.isMergedPullRequest)
!input.hasUncommittedChanges && (input.postShipArchiveSuggested || input.isMergedPullRequest)
);
}
@@ -131,7 +129,6 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
isPaseoOwnedWorktree,
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
shouldPromoteArchive: computeShouldPromoteArchive({
isPaseoOwnedWorktree,
hasUncommittedChanges,
postShipArchiveSuggested,
isMergedPullRequest,
@@ -173,6 +170,52 @@ 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,
@@ -182,28 +225,28 @@ function useWorkspaceScreenArchiveController({
t,
}: UseWorkspaceScreenArchiveControllerInput) {
const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
const archiveWorkspaceRecord = useMemo(() => {
if (!workspaceDirectory) {
return null;
}
for (const candidate of sessionWorkspaces?.values() ?? []) {
if (candidate.workspaceDirectory === workspaceDirectory) {
return candidate;
}
}
return null;
}, [sessionWorkspaces, workspaceDirectory]);
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const workspaceDescriptor = useMemo(
() =>
resolveArchiveWorkspaceDescriptor({
workspaces: sessionWorkspaces,
activeWorkspaceSelection,
workspaceDirectory,
}),
[activeWorkspaceSelection, sessionWorkspaces, workspaceDirectory],
);
const archiveRisk = resolveWorkspaceArchiveRisk(workspaceDescriptor, gitStatus);
return useWorkspaceArchive({
const controller = useWorkspaceArchive({
serverId,
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,
workspaceId: workspaceDescriptor?.id ?? "",
workspaceKind: workspaceDescriptor?.workspaceKind ?? "directory",
name: workspaceDescriptor?.name ?? branchLabel,
isDirty: archiveRisk.isDirty,
aheadOfOrigin: archiveRisk.aheadOfOrigin,
diffStat: workspaceDescriptor?.diffStat ?? null,
warningLabels: getWorktreeArchiveWarningLabels(t),
onSetHiding: setIsHidingWorkspace,
onArchiveStarted: () => {
if (!activeWorkspaceSelection) {
return;
@@ -215,6 +258,12 @@ function useWorkspaceScreenArchiveController({
});
},
});
return {
...controller,
isArchiving: workspaceDescriptor?.archivingAt != null || isHidingWorkspace,
canArchive: canArchiveWorkspace(workspaceDescriptor, archiveRisk),
};
}
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
@@ -342,9 +391,6 @@ 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);
@@ -533,7 +579,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
t,
});
const handleArchiveWorktree = useCallback(() => {
const handleArchiveWorkspace = useCallback(() => {
archiveController.archive();
}, [archiveController]);
@@ -678,11 +724,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
icon: icons.mergeFromBase,
handler: handleMergeFromBase,
},
"archive-worktree": {
disabled: isActionDisabled(actionsDisabled, archiveStatus),
status: archiveStatus,
"archive-workspace": {
disabled: !archiveController.canArchive || archiveController.isArchiving,
status: archiveController.isArchiving ? "pending" : "idle",
icon: icons.archive,
handler: handleArchiveWorktree,
handler: handleArchiveWorkspace,
},
},
}),
@@ -723,7 +769,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
disablePrAutoMergeStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
archiveController.canArchive,
archiveController.isArchiving,
handleCommit,
handlePull,
handlePush,
@@ -734,7 +781,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handleDisablePrAutoMerge,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
handleArchiveWorkspace,
icons,
baseRef,
],
@@ -890,7 +937,7 @@ function getTranslatedGitActionLabels(
pendingLabel: t("workspace.git.actions.mergeFromBase.pending"),
successLabel: t("workspace.git.actions.mergeFromBase.success"),
};
case "archive-worktree":
case "archive-workspace":
return {
label: t("workspace.git.actions.archive.label"),
pendingLabel: t("workspace.git.actions.archive.pending"),
@@ -945,8 +992,6 @@ 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":
@@ -979,7 +1024,7 @@ function getWorktreeArchiveWarningLabels(
t: (key: string, options?: Record<string, unknown>) => string,
): WorktreeArchiveWarningLabels {
return {
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
title: (workspaceName) => t("workspace.git.actions.archiveWarning.title", { workspaceName }),
confirm: t("workspace.git.actions.archiveWarning.confirm"),
cancel: t("workspace.git.actions.archiveWarning.cancel"),
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
+3 -7
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 WorkspaceGitActionsProps {
interface WorkspaceActionsProps {
serverId: string;
cwd: string;
hideLabels?: boolean;
@@ -47,16 +47,12 @@ const ICONS = {
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
};
export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) {
const { gitActions, isGit } = useGitActions({
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
const { gitActions } = 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("worktree archive warning", () => {
describe("workspace archive warning for worktree backing", () => {
it("does not require a confirmation for clean and pushed worktrees", () => {
expect(
buildWorktreeArchiveConfirmationMessage({
worktreeName: "feature",
workspaceName: "feature",
isDirty: false,
aheadOfOrigin: 0,
diffStat: null,
@@ -51,7 +51,7 @@ describe("worktree archive warning", () => {
it("includes every archive risk in the confirmation copy", () => {
expect(
buildWorktreeArchiveConfirmationMessage({
worktreeName: "risky-feature",
workspaceName: "risky-feature",
isDirty: true,
aheadOfOrigin: 1,
diffStat: { additions: 1, deletions: 3 },
@@ -14,11 +14,11 @@ export interface WorktreeArchiveRiskInput {
}
export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
worktreeName: string;
workspaceName: string;
}
export interface WorktreeArchiveWarningLabels {
title: (worktreeName: string) => string;
title: (workspaceName: string) => string;
confirm: string;
cancel: string;
uncommittedChanges: string;
@@ -29,7 +29,7 @@ export interface WorktreeArchiveWarningLabels {
}
export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabels = {
title: (worktreeName) => i18n.t("workspace.git.actions.archiveWarning.title", { worktreeName }),
title: (workspaceName) => i18n.t("workspace.git.actions.archiveWarning.title", { workspaceName }),
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.worktreeName),
title: labels.title(input.workspaceName),
message,
confirmLabel: labels.confirm,
cancelLabel: labels.cancel,
+5 -9
View File
@@ -619,7 +619,7 @@ export const ar: TranslationResources = {
success: "تم التحديث",
},
archive: {
label: "أرشفة شجرة العمل",
label: "أرشفة مساحة العمل",
pending: "أرشفة...",
success: "مؤرشف",
},
@@ -661,8 +661,6 @@ export const ar: TranslationResources = {
updateNoBase: "التحديث غير متاح لأننا لم نتمكن من تحديد الفرع الأساسي",
updateDirty: "التحديث غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
updateCurrent: "التحديث غير متاح لأن هذا الفرع محدث بالفعل باستخدام{{baseRef}}",
archiveNotWorktree:
"الأرشيف غير متاح هنا لأنه لم يتم إنشاء مساحة العمل هذه كشجرة عمل Paseo",
mergePrNoGithub: "دمج PR غير متاح الآن لأن GitHub غير متصل",
mergePrMissing: "دمج PR غير متاح لأنه لا يوجد طلب سحب حتى الآن",
mergePrDraft: "دمج PR غير متاح لأن طلب السحب لا يزال مسودة",
@@ -685,11 +683,9 @@ export const ar: TranslationResources = {
baseRefUnavailable: "المرجع الأساسي غير متاح",
failedMerge: "فشل الدمج",
failedMergeFromBase: "فشل الدمج من القاعدة",
worktreePathUnavailable: "مسار شجرة العمل غير متوفر",
failedArchive: "فشل في أرشفة شجرة العمل",
},
archiveWarning: {
title: 'الأرشيف "{{worktreeName}}"؟',
title: 'الأرشيف "{{workspaceName}}"؟',
confirm: "أرشيف",
cancel: "يلغي",
uncommittedChanges: "تغييرات غير ملتزم بها",
@@ -839,7 +835,7 @@ export const ar: TranslationResources = {
copyBranchName: "انسخ اسم الفرع",
rename: "إعادة تسمية مساحة العمل",
archive: "أرشيف",
archiveWorktree: "أرشفة شجرة العمل",
archiveWorkspace: "أرشفة مساحة العمل",
hideFromSidebar: "إخفاء من الشريط الجانبي",
archiving: "أرشفة...",
hiding: "إخفاء...",
@@ -862,7 +858,7 @@ export const ar: TranslationResources = {
branchNameCopied: "تم نسخ اسم الفرع",
hostDisconnected: "Host غير متصل",
hideFailed: "فشل في إخفاء مساحة العمل",
archiveFailed: "فشل في أرشفة شجرة العمل",
archiveFailed: "فشل في أرشفة مساحة العمل",
},
},
},
@@ -1605,7 +1601,7 @@ export const ar: TranslationResources = {
openProject: "مشروع مفتوح",
newWorkspace: "مساحة عمل جديدة",
newWorktree: "شجرة عمل جديدة",
archiveWorktree: "أرشفة شجرة العمل",
archiveWorkspace: "أرشفة مساحة العمل",
newTab: "علامة تبويب جديدة",
closeCurrentTab: "إغلاق علامة التبويب الحالية",
jumpToWorkspace: "انتقل إلى مساحة العمل",
+5 -9
View File
@@ -618,7 +618,7 @@ export const en = {
success: "Updated",
},
archive: {
label: "Archive worktree",
label: "Archive workspace",
pending: "Archiving...",
success: "Archived",
},
@@ -667,8 +667,6 @@ 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",
@@ -692,11 +690,9 @@ 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 "{{worktreeName}}"?',
title: 'Archive "{{workspaceName}}"?',
confirm: "Archive",
cancel: "Cancel",
uncommittedChanges: "Uncommitted changes",
@@ -846,7 +842,7 @@ export const en = {
copyBranchName: "Copy branch name",
rename: "Rename workspace",
archive: "Archive",
archiveWorktree: "Archive worktree",
archiveWorkspace: "Archive workspace",
hideFromSidebar: "Hide from sidebar",
archiving: "Archiving...",
hiding: "Hiding...",
@@ -869,7 +865,7 @@ export const en = {
branchNameCopied: "Branch name copied",
hostDisconnected: "Host is not connected",
hideFailed: "Failed to hide workspace",
archiveFailed: "Failed to archive worktree",
archiveFailed: "Failed to archive workspace",
},
},
},
@@ -1612,7 +1608,7 @@ export const en = {
openProject: "Open project",
newWorkspace: "New workspace",
newWorktree: "New worktree",
archiveWorktree: "Archive worktree",
archiveWorkspace: "Archive workspace",
newTab: "New tab",
closeCurrentTab: "Close current tab",
jumpToWorkspace: "Jump to workspace",
+5 -9
View File
@@ -625,7 +625,7 @@ export const es: TranslationResources = {
success: "Actualizado",
},
archive: {
label: "Árbol de trabajo de archivo",
label: "Archivar espacio de trabajo",
pending: "Archivando...",
success: "Archivado",
},
@@ -679,8 +679,6 @@ 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:
@@ -712,11 +710,9 @@ 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 "{{worktreeName}}"?',
title: '¿Archivo "{{workspaceName}}"?',
confirm: "Archivo",
cancel: "Cancelar",
uncommittedChanges: "Cambios no confirmados",
@@ -866,7 +862,7 @@ export const es: TranslationResources = {
copyBranchName: "Copiar nombre de sucursal",
rename: "Cambiar nombre del espacio de trabajo",
archive: "Archivo",
archiveWorktree: "Árbol de trabajo de archivo",
archiveWorkspace: "Archivar espacio de trabajo",
hideFromSidebar: "Ocultar de la barra lateral",
archiving: "Archivando...",
hiding: "Ocultación...",
@@ -889,7 +885,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 árbol de trabajo",
archiveFailed: "No se pudo archivar el espacio de trabajo",
},
},
},
@@ -1647,7 +1643,7 @@ export const es: TranslationResources = {
openProject: "Abrir proyecto",
newWorkspace: "Nuevo espacio de trabajo",
newWorktree: "Nuevo árbol de trabajo",
archiveWorktree: "Árbol de trabajo de archivo",
archiveWorkspace: "Archivar espacio de trabajo",
newTab: "Nueva pestaña",
closeCurrentTab: "Cerrar pestaña actual",
jumpToWorkspace: "Saltar al espacio de trabajo",
+5 -9
View File
@@ -625,7 +625,7 @@ export const fr: TranslationResources = {
success: "Mis à jour",
},
archive: {
label: "Arbre de travail d'archivage",
label: "Archiver l’espace de travail",
pending: "Archivage...",
success: "Archivé",
},
@@ -679,8 +679,6 @@ 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:
@@ -711,11 +709,9 @@ 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 «{{worktreeName}}»?",
title: "Archiver «{{workspaceName}}»?",
confirm: "Archive",
cancel: "Annuler",
uncommittedChanges: "Modifications non validées",
@@ -865,7 +861,7 @@ export const fr: TranslationResources = {
copyBranchName: "Copier le nom de la branche",
rename: "Renommer l'espace de travail",
archive: "Archive",
archiveWorktree: "Arbre de travail d'archivage",
archiveWorkspace: "Archiver l’espace de travail",
hideFromSidebar: "Masquer de la barre latérale",
archiving: "Archivage...",
hiding: "Dissimulation...",
@@ -888,7 +884,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'arbre de travail",
archiveFailed: "Échec de l'archivage de l'espace de travail",
},
},
},
@@ -1650,7 +1646,7 @@ export const fr: TranslationResources = {
openProject: "Projet ouvert",
newWorkspace: "Nouvel espace de travail",
newWorktree: "Nouvel arbre de travail",
archiveWorktree: "Arbre de travail d'archivage",
archiveWorkspace: "Archiver l’espace de travail",
newTab: "Nouvel onglet",
closeCurrentTab: "Fermer l'onglet actuel",
jumpToWorkspace: "Accéder à l'espace de travail",
+5 -9
View File
@@ -623,7 +623,7 @@ export const ja: TranslationResources = {
success: "更新しました",
},
archive: {
label: "ワークツリーをアーカイブ",
label: "ワークスペースをアーカイブ",
pending: "アーカイブ中...",
success: "アーカイブしました",
},
@@ -670,8 +670,6 @@ export const ja: TranslationResources = {
updateDirty:
"ローカルに変更があるため更新は利用できません。先にコミットまたはスタッシュしてください",
updateCurrent: "このブランチはすでに{{baseRef}}と最新の状態のため、更新は利用できません",
archiveNotWorktree:
"このワークスペースはPaseoワークツリーとして作成されていないため、アーカイブはここでは利用できません",
mergePrNoGithub: "GitHubが接続されていないため、PRのマージは現在利用できません",
mergePrMissing: "プルリクエストがまだないため、PRのマージは利用できません",
mergePrDraft: "プルリクエストがまだドラフトのため、PRのマージは利用できません",
@@ -697,11 +695,9 @@ export const ja: TranslationResources = {
baseRefUnavailable: "ベースRefが利用できません",
failedMerge: "マージに失敗しました",
failedMergeFromBase: "ベースからのマージに失敗しました",
worktreePathUnavailable: "ワークツリーパスが利用できません",
failedArchive: "ワークツリーのアーカイブに失敗しました",
},
archiveWarning: {
title: '"{{worktreeName}}"をアーカイブしますか?',
title: '"{{workspaceName}}"をアーカイブしますか?',
confirm: "アーカイブ",
cancel: "キャンセル",
uncommittedChanges: "未コミットの変更",
@@ -851,7 +847,7 @@ export const ja: TranslationResources = {
copyBranchName: "ブランチ名をコピー",
rename: "ワークスペースの名前を変更",
archive: "アーカイブ",
archiveWorktree: "ワークツリーをアーカイブ",
archiveWorkspace: "ワークスペースをアーカイブ",
hideFromSidebar: "サイドバーから非表示",
archiving: "アーカイブ中...",
hiding: "非表示にしています...",
@@ -874,7 +870,7 @@ export const ja: TranslationResources = {
branchNameCopied: "ブランチ名をコピーしました",
hostDisconnected: "ホストが接続されていません",
hideFailed: "ワークスペースの非表示に失敗しました",
archiveFailed: "ワークツリーのアーカイブに失敗しました",
archiveFailed: "ワークスペースのアーカイブに失敗しました",
},
},
},
@@ -1622,7 +1618,7 @@ export const ja: TranslationResources = {
openProject: "プロジェクトを開く",
newWorkspace: "新しいワークスペース",
newWorktree: "新しいワークツリー",
archiveWorktree: "ワークツリーをアーカイブ",
archiveWorkspace: "ワークスペースをアーカイブ",
newTab: "新しいタブ",
closeCurrentTab: "現在のタブを閉じる",
jumpToWorkspace: "ワークスペースにジャンプ",
+5 -9
View File
@@ -623,7 +623,7 @@ export const ptBR: TranslationResources = {
success: "Atualizado",
},
archive: {
label: "Arquivar worktree",
label: "Arquivar workspace",
pending: "Arquivando...",
success: "Arquivado",
},
@@ -675,8 +675,6 @@ 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",
@@ -703,11 +701,9 @@ 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 "{{worktreeName}}"?',
title: 'Arquivar "{{workspaceName}}"?',
confirm: "Arquivar",
cancel: "Cancelar",
uncommittedChanges: "Alterações sem commit",
@@ -857,7 +853,7 @@ export const ptBR: TranslationResources = {
copyBranchName: "Copiar nome da branch",
rename: "Renomear workspace",
archive: "Arquivar",
archiveWorktree: "Arquivar worktree",
archiveWorkspace: "Arquivar workspace",
hideFromSidebar: "Ocultar da barra lateral",
archiving: "Arquivando...",
hiding: "Ocultando...",
@@ -880,7 +876,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 worktree",
archiveFailed: "Falha ao arquivar workspace",
},
},
},
@@ -1631,7 +1627,7 @@ export const ptBR: TranslationResources = {
openProject: "Abrir projeto",
newWorkspace: "Novo workspace",
newWorktree: "Novo worktree",
archiveWorktree: "Arquivar worktree",
archiveWorkspace: "Arquivar workspace",
newTab: "Nova aba",
closeCurrentTab: "Fechar aba atual",
jumpToWorkspace: "Ir para workspace",
+5 -9
View File
@@ -624,7 +624,7 @@ export const ru: TranslationResources = {
success: "Обновлено",
},
archive: {
label: "Архив рабочего дерева",
label: "Архивировать рабочее пространство",
pending: "Архивирование...",
success: "В архиве",
},
@@ -675,8 +675,6 @@ export const ru: TranslationResources = {
"Обновление недоступно, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.",
updateCurrent:
"Обновление недоступно, поскольку эта ветка уже обновлена ​​до версии{{baseRef}}.",
archiveNotWorktree:
"Архив здесь недоступен, поскольку это рабочее пространство не было создано как рабочее дерево Paseo.",
mergePrNoGithub: "Объединение PR сейчас недоступно, поскольку GitHub не подключен.",
mergePrMissing: "Объединение PR недоступно, поскольку еще нет запроса на включение",
mergePrDraft:
@@ -704,11 +702,9 @@ export const ru: TranslationResources = {
baseRefUnavailable: "Базовый номер недоступен.",
failedMerge: "Не удалось объединиться",
failedMergeFromBase: "Не удалось объединиться с базой.",
worktreePathUnavailable: "Путь к рабочему дереву недоступен.",
failedArchive: "Не удалось заархивировать рабочее дерево.",
},
archiveWarning: {
title: 'Архив "{{worktreeName}}"?',
title: 'Архив "{{workspaceName}}"?',
confirm: "Архив",
cancel: "Отмена",
uncommittedChanges: "Незафиксированные изменения",
@@ -858,7 +854,7 @@ export const ru: TranslationResources = {
copyBranchName: "Скопировать название ветки",
rename: "Переименовать рабочую область",
archive: "Архив",
archiveWorktree: "Архив рабочего дерева",
archiveWorkspace: "Архивировать рабочее пространство",
hideFromSidebar: "Скрыть с боковой панели",
archiving: "Архивирование...",
hiding: "Скрытие...",
@@ -881,7 +877,7 @@ export const ru: TranslationResources = {
branchNameCopied: "Название филиала скопировано.",
hostDisconnected: "Host не подключен",
hideFailed: "Не удалось скрыть рабочую область.",
archiveFailed: "Не удалось заархивировать рабочее дерево.",
archiveFailed: "Не удалось заархивировать рабочее пространство.",
},
},
},
@@ -1639,7 +1635,7 @@ export const ru: TranslationResources = {
openProject: "Открыть проект",
newWorkspace: "Новое рабочее пространство",
newWorktree: "Новое рабочее дерево",
archiveWorktree: "Архив рабочего дерева",
archiveWorkspace: "Архивировать рабочее пространство",
newTab: "Новая вкладка",
closeCurrentTab: "Закрыть текущую вкладку",
jumpToWorkspace: "Перейти в рабочую область",
+5 -8
View File
@@ -617,7 +617,7 @@ export const zhCN: TranslationResources = {
success: "已更新",
},
archive: {
label: "归档 worktree",
label: "归档工作区",
pending: "正在归档...",
success: "已归档",
},
@@ -656,7 +656,6 @@ 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",
@@ -679,11 +678,9 @@ export const zhCN: TranslationResources = {
baseRefUnavailable: "Base ref 不可用",
failedMerge: "Merge 失败",
failedMergeFromBase: "从 base merge 失败",
worktreePathUnavailable: "Worktree 路径不可用",
failedArchive: "归档 worktree 失败",
},
archiveWarning: {
title: "归档「{{worktreeName}}」?",
title: "归档「{{workspaceName}}」?",
confirm: "归档",
cancel: "取消",
uncommittedChanges: "未 commit 的变更",
@@ -831,7 +828,7 @@ export const zhCN: TranslationResources = {
copyBranchName: "复制分支名称",
rename: "重命名 workspace",
archive: "归档",
archiveWorktree: "归档 worktree",
archiveWorkspace: "归档工作区",
hideFromSidebar: "从侧边栏隐藏",
archiving: "正在归档...",
hiding: "正在隐藏...",
@@ -853,7 +850,7 @@ export const zhCN: TranslationResources = {
branchNameCopied: "分支名称已复制",
hostDisconnected: "Host 未连接",
hideFailed: "隐藏 workspace 失败",
archiveFailed: "归档 worktree 失败",
archiveFailed: "归档工作区失败",
},
},
},
@@ -1586,7 +1583,7 @@ export const zhCN: TranslationResources = {
openProject: "打开项目",
newWorkspace: "新建 workspace",
newWorktree: "新建 worktree",
archiveWorktree: "归档 worktree",
archiveWorkspace: "归档工作区",
newTab: "新建标签",
closeCurrentTab: "关闭当前标签",
jumpToWorkspace: "跳转到 workspace",
+1 -1
View File
@@ -44,7 +44,7 @@ export type KeyboardActionId =
| "workspace.terminal.new"
| "workspace.new"
| "worktree.new"
| "worktree.archive"
| "workspace.archive"
| "view.toggle.focus"
| "theme.cycle"
| "message-input.action";
@@ -29,7 +29,7 @@ export type KeyboardActionId =
| "sidebar.toggle.right"
| "workspace.new"
| "worktree.new"
| "worktree.archive";
| "workspace.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: "worktree.archive"; scope: KeyboardActionScope };
| { id: "workspace.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-worktree": "settings.shortcuts.help.archiveWorktree",
"archive-workspace": "settings.shortcuts.help.archiveWorkspace",
"workspace-tab-new": "settings.shortcuts.help.newTab",
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
"workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace",
@@ -221,28 +221,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- Archive worktree ---
// --- Archive workspace ---
{
// 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: "worktree.archive",
action: "workspace.archive",
combo: "Cmd+Shift+Backspace",
when: { mac: true, commandCenter: false },
help: {
id: "archive-worktree",
id: "archive-workspace",
section: "projects",
label: "Archive worktree",
label: "Archive workspace",
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: "worktree.archive",
action: "workspace.archive",
combo: "Ctrl+Shift+Backspace",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "archive-worktree",
id: "archive-workspace",
section: "projects",
label: "Archive worktree",
label: "Archive workspace",
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" }],
["worktree.archive", { id: "worktree.archive", scope: "sidebar" }],
["workspace.archive", { id: "workspace.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" },
"worktree.archive": { id: "worktree.archive", scope: "sidebar" },
"workspace.archive": { id: "workspace.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" },
@@ -0,0 +1,26 @@
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,11 +77,7 @@ function hasPendingArchiveForProject(input: {
}
}
const workspaceDirectory = getHostProjectSourceDirectory(input.project, input.selectedServerId);
return isWorkspaceArchivePending({
serverId: input.selectedServerId,
workspaceDirectory,
});
return false;
}
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 { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceActions } 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 && isGitCheckout ? (
{!isMobile && workspaceDirectory ? (
<>
{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}
<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}
>
{({ 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>
<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}
</>
) : null}
{!isMobile && !isGitCheckout ? (
@@ -31,7 +31,7 @@ describe("selectProjectWorkspacesToArchive", () => {
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
worktreeName: "feature/risky",
workspaceName: "feature/risky",
isDirty: true,
aheadOfOrigin: 2,
diffStat: { additions: 5, deletions: 1 },
@@ -73,7 +73,7 @@ describe("selectProjectWorkspacesToArchive", () => {
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
worktreeName: "feature/risky",
workspaceName: "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({
worktreeName: workspace.name,
workspaceName: workspace.name,
...toWorktreeArchiveRisk(workspace),
});
if (!shouldArchive) {
@@ -2,20 +2,30 @@ 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";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
const workspaceKey = buildWorkspaceTabPersistenceKey(input);
if (workspaceKey) {
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
}
useWorkspaceTabsStore.getState().purgeWorkspace(input);
}
export interface ArchiveWorkspaceInput {
serverId: string;
workspaceId: string;
workspaceDirectory: string | null | undefined;
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
isDirty?: boolean | null;
@@ -34,7 +44,6 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
const {
serverId,
workspaceId,
workspaceDirectory,
workspaceKind,
name,
isDirty,
@@ -46,37 +55,8 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
} = input;
const { t } = useTranslation();
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
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 archiveWorkspaceRecord = useCallback(async () => {
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
@@ -84,17 +64,18 @@ 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.hideFailed"),
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
);
} finally {
onSetHiding?.(false);
@@ -106,7 +87,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
if (workspaceKind === "worktree") {
const confirmed = await confirmRiskyWorktreeArchive(
{
worktreeName: name,
workspaceName: name,
isDirty,
aheadOfOrigin,
diffStat,
@@ -116,15 +97,12 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
if (!confirmed) {
return;
}
archiveWorktreeRecord();
return;
}
await archiveNonWorktreeRecord();
await archiveWorkspaceRecord();
})();
}, [
aheadOfOrigin,
archiveNonWorktreeRecord,
archiveWorktreeRecord,
archiveWorkspaceRecord,
diffStat,
isDirty,
name,
@@ -144,23 +144,6 @@ 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", () => {
@@ -183,10 +166,7 @@ describe("archiveWorkspacesOptimistically", () => {
const failures = await archiveWorkspacesOptimistically({
getClient: () => client,
workspaces: [
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
],
workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })],
});
expect(failures).toHaveLength(1);
@@ -219,12 +199,10 @@ describe("archiveWorkspacesOptimistically", () => {
target({
serverId: SERVER_ID,
workspaceId: first.id,
workspaceDirectory: first.workspaceDirectory,
}),
target({
serverId: SECOND_SERVER_ID,
workspaceId: second.id,
workspaceDirectory: second.workspaceDirectory,
}),
],
});
@@ -9,7 +9,6 @@ import { i18n } from "@/i18n/i18next";
export interface WorkspaceArchiveTarget {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}
interface WorkspaceArchiveClient {
@@ -50,7 +49,6 @@ function hideWorkspaceOptimistically(
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return { workspace: snapshot };
@@ -83,10 +81,8 @@ 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.105",
"version": "0.1.106",
"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.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/server": "0.1.105",
"@getpaseo/client": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/server": "0.1.106",
"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.105",
"version": "0.1.106",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.105",
"version": "0.1.106",
"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.105",
"version": "0.1.106",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.105",
"version": "0.1.106",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.105",
"version": "0.1.106",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.105",
"version": "0.1.106",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6
View File
@@ -17,6 +17,7 @@
* ```
*/
import { createCutoverProxy } from "./cutover-proxy.js";
import type { ConnectionRole, RelaySessionAttachment } from "./types.js";
type RelayProtocolVersion = "1" | "2";
@@ -97,6 +98,7 @@ function getGlobalWebSocketPair(): (new () => WebSocketPair) | undefined {
interface Env {
RELAY: DurableObjectNamespace;
PASEO_RELAY_UPSTREAM?: string;
}
interface DurableObjectNamespace {
@@ -572,6 +574,10 @@ 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
+88
View File
@@ -0,0 +1,88 @@
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];
interface ReceivedRequest {
method: string;
path: string;
probe: string;
}
class RecordingOrigin {
private readonly requests: ReceivedRequest[] = [];
private server: Server | null = null;
async start(): Promise<string> {
this.server = createServer((request, response) => {
this.requests.push(recordRequest(request));
response.writeHead(202, { "content-type": "application/json", "x-relay-origin": "fly" });
response.end(JSON.stringify({ status: "forwarded" }));
});
await new Promise<void>((resolve) => this.server!.listen(0, "127.0.0.1", resolve));
const address = this.server.address() as AddressInfo;
return `http://127.0.0.1:${address.port}`;
}
received(): ReceivedRequest[] {
return this.requests;
}
async close(): Promise<void> {
const server = this.server;
this.server = null;
if (!server) return;
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
}
function recordRequest(request: IncomingMessage): ReceivedRequest {
return {
method: request.method ?? "",
path: request.url ?? "",
probe: String(request.headers["x-relay-probe"] ?? ""),
};
}
describe("cutover proxy", () => {
const origins: RecordingOrigin[] = [];
afterEach(async () => {
await Promise.all(origins.splice(0).map((origin) => origin.close()));
});
it("forwards the request to the configured origin without changing its route", async () => {
const origin = new RecordingOrigin();
origins.push(origin);
const originUrl = await origin.start();
const response = await relayWorker.fetch(
new Request("https://relay.paseo.sh/ws?serverId=srv_prod&role=server&v=2", {
headers: { "x-relay-probe": "production" },
}),
{ PASEO_RELAY_UPSTREAM: originUrl } as RelayEnv,
);
expect(origin.received()).toEqual([
{
method: "GET",
path: "/ws?serverId=srv_prod&role=server&v=2",
probe: "production",
},
]);
expect(response.status).toBe(202);
await expect(response.json()).resolves.toEqual({ status: "forwarded" });
expect(response.headers.get("x-relay-origin")).toBe("fly");
});
});
+17
View File
@@ -0,0 +1,17 @@
interface CutoverProxy {
fetch(request: Request): Promise<Response>;
}
export function createCutoverProxy(origin: string): CutoverProxy {
const originUrl = new URL(origin);
return {
async fetch(request: Request): Promise<Response> {
const upstreamUrl = new URL(request.url);
upstreamUrl.protocol = originUrl.protocol;
upstreamUrl.host = originUrl.host;
return fetch(new Request(upstreamUrl, request));
},
};
}
+3 -2
View File
@@ -12,7 +12,7 @@ import {
// This live test uses the hosted relay's real TLS endpoint. Self-hosted relay TLS
// opt-in is covered at URL-building/integration level so the local E2E does not
// need to provision trusted certificates.
const RELAY_BASE_URL = "wss://relay.paseo.sh";
const RELAY_BASE_URL = process.env.PASEO_LIVE_RELAY_URL ?? "wss://relay.paseo.sh";
async function withRetry<T>(
fn: () => Promise<T>,
@@ -119,6 +119,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
// === Connect ===
const daemonControlWs = new WebSocket(serverControlUrl);
const clientWs = new WebSocket(clientUrl);
const connected = waitForConnected(daemonControlWs, connectionId);
let daemonWs: WebSocket | null = null;
try {
@@ -127,7 +128,7 @@ describe("Live relay (relay.paseo.sh) E2E", () => {
waitOpen(clientWs, "client"),
]);
await waitForConnected(daemonControlWs, connectionId);
await connected;
daemonWs = new WebSocket(serverDataUrl);
await waitOpen(daemonWs, "server-data");
+3
View File
@@ -8,6 +8,9 @@ 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.105",
"version": "0.1.106",
"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.105",
"@getpaseo/highlight": "0.1.105",
"@getpaseo/protocol": "0.1.105",
"@getpaseo/relay": "0.1.105",
"@getpaseo/client": "0.1.106",
"@getpaseo/highlight": "0.1.106",
"@getpaseo/protocol": "0.1.106",
"@getpaseo/relay": "0.1.106",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -30,6 +30,7 @@ import type {
const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS;
const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0);
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
@@ -377,6 +378,148 @@ afterEach(() => {
});
describe("target coalesced behavior", () => {
test("bounds tool output before persisting and streaming it", async () => {
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
});
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
await waitForSessionEventQueue();
const rows = await harness.manager.getTimelineRows(agentId);
const events = getTimelineStreamEvents(harness.events, agentId);
expect(getTimelineItems(rows)).toEqual([expectedItem]);
expect(
events.map((event) => (event.type === "agent_stream" ? event.event.item : null)),
).toEqual([expectedItem]);
} finally {
harness.cleanup();
}
});
test("bounds tool output by UTF-8 bytes", async () => {
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
const output = "漢".repeat(TOOL_CALL_CONTENT_MAX_BYTES);
const expectedItem = toolCall({
status: "completed",
output: "漢".repeat(Math.floor(TOOL_CALL_CONTENT_MAX_BYTES / 3)),
});
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
await waitForSessionEventQueue();
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
expectedItem,
]);
} finally {
harness.cleanup();
}
});
test("bounds appended tool output before persisting and streaming it", async () => {
const harness = createHarness();
try {
const { agentId } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
});
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
const rows = await harness.manager.getTimelineRows(agentId);
const events = getTimelineStreamEvents(harness.events, agentId);
expect(getTimelineItems(rows)).toEqual([expectedItem]);
expect(
events.map((event) => (event.type === "agent_stream" ? event.event.item : null)),
).toEqual([expectedItem]);
} finally {
harness.cleanup();
}
});
test("bounds tool output while hydrating provider history", async () => {
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
});
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
await harness.manager.hydrateTimelineFromProvider(agentId);
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
expectedItem,
]);
} finally {
harness.cleanup();
}
});
test("bounds tool output emitted only to the live stream", async () => {
const harness = createHarness();
try {
const { agentId } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
});
await harness.manager.emitLiveTimelineItem(
agentId,
toolCall({ status: "completed", output }),
);
const events = getTimelineStreamEvents(harness.events, agentId);
expect(await harness.manager.getTimelineRows(agentId)).toEqual([]);
expect(
events.map((event) => (event.type === "agent_stream" ? event.event.item : null)),
).toEqual([expectedItem]);
} finally {
harness.cleanup();
}
});
test("bounds failed shell output carried in error content and message", async () => {
const harness = createHarness();
try {
const { agentId, session } = await createManagedSession(harness);
const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "failed",
error: {
content: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
message: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
},
});
session.pushEvent(
timelineEvent(toolCall({ status: "failed", error: { content, message: content } })),
);
await waitForSessionEventQueue();
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
expectedItem,
]);
} finally {
harness.cleanup();
}
});
test(`coalesces a same-tick assistant burst after the ${COALESCE_WINDOW_MS}ms window`, async () => {
vi.useFakeTimers();
const harness = createHarness();
@@ -1,6 +1,6 @@
import { expect, test, vi } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
@@ -8,6 +8,7 @@ import { randomUUID } from "node:crypto";
import { createTestLogger } from "../../test-utils/test-logger.js";
import {
AgentManager,
AgentManagerShuttingDownError,
commandMayHaveChangedExternalState,
type AgentManagerEvent,
type ManagedAgent,
@@ -139,6 +140,117 @@ 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[] = [];
@@ -290,6 +402,52 @@ 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;
@@ -449,6 +607,211 @@ 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");
@@ -1951,6 +2314,22 @@ test("importProviderSession imports the selected session without listing and pub
item: { type: "assistant_message" as const, text: "Done" },
timestamp: "2026-01-02T00:00:01.000Z",
},
{
item: {
type: "tool_call" as const,
callId: "large-shell-result",
name: "shell",
status: "completed" as const,
error: null,
detail: {
type: "shell" as const,
command: "print output",
output: "x".repeat(1024 * 1024),
exitCode: 0,
},
},
timestamp: "2026-01-02T00:00:02.000Z",
},
],
};
}
@@ -1980,6 +2359,19 @@ test("importProviderSession imports the selected session without listing and pub
expect(manager.getTimeline(imported.id)).toEqual([
{ type: "user_message", text: "Trace provider imports" },
{ type: "assistant_message", text: "Done" },
{
type: "tool_call",
callId: "large-shell-result",
name: "shell",
status: "completed",
error: null,
detail: {
type: "shell",
command: "print output",
output: "x".repeat(64 * 1024),
exitCode: 0,
},
},
]);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
+256 -92
View File
@@ -58,6 +58,7 @@ import {
AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
AgentStreamCoalescer,
} from "./agent-stream-coalescer.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
@@ -82,6 +83,13 @@ 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;
@@ -486,7 +494,7 @@ function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): A
rows.push({
seq: rows.length + 1,
timestamp: entry.timestamp ?? new Date().toISOString(),
item: entry.item,
item: limitAgentTimelineItemContent(entry.item),
});
}
return rows;
@@ -535,6 +543,7 @@ 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;
@@ -546,6 +555,7 @@ 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());
@@ -619,6 +629,10 @@ export class AgentManager {
this.mcpBaseUrl = url;
}
prepareForShutdown(): void {
this.acceptingAgentRegistrations = false;
}
setPaseoToolsEnabled(enabled: boolean): void {
this.paseoToolsEnabled = enabled;
}
@@ -922,11 +936,20 @@ export class AgentManager {
return this.timelineStore.fetch(id, options);
}
async createAgent(
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);
@@ -954,7 +977,7 @@ export class AgentManager {
// Reconstruct an agent from provider persistence. Callers should explicitly
// hydrate timeline history after resume.
async resumeAgentFromPersistence(
resumeAgentFromPersistence(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
agentId?: string,
@@ -966,6 +989,24 @@ 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",
@@ -994,13 +1035,24 @@ export class AgentManager {
return this.registerSession(session, storedConfig, resolvedAgentId, options);
}
async importProviderSession(input: {
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);
@@ -1025,20 +1077,30 @@ export class AgentManager {
},
{ config: providerLaunchConfig, storedConfig, launchContext },
);
const importedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(imported.config));
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
let handedToRegistration = false;
try {
const importedConfig = await this.normalizeConfig(
stripInternalPaseoMcpServer(imported.config),
);
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
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,
});
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);
}
}
}
// Hot-reload an active agent session with config overrides. By default the
@@ -1047,11 +1109,22 @@ 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.
async reloadAgentSession(
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);
@@ -1078,36 +1151,43 @@ export class AgentManager {
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
: await client.createSession(providerLaunchConfig, launchContext);
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);
let handedToRegistration = false;
try {
this.assertAcceptingAgentRegistrations();
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);
}
const closedExisting = this.prepareAgentForClosure(existing, "agent reloaded");
try {
await this.persistSnapshot(closedExisting);
} finally {
await this.closeReloadedSession(existing.session, 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,
});
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);
}
}
}
private async closeReloadedSession(session: AgentSession, agentId: string): Promise<void> {
@@ -1673,6 +1753,7 @@ export class AgentManager {
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
const agent = this.requireAgent(agentId);
item = limitAgentTimelineItemContent(item);
this.touchUpdatedAt(agent);
const row = this.recordTimeline(agentId, item);
this.dispatchStream(
@@ -2474,49 +2555,84 @@ export class AgentManager {
workspaceId?: string;
},
): Promise<ManagedAgent> {
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,
);
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 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.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.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.emitState(managed, { persist: false });
this.subscribeToSession(managed);
return { ...managed };
} catch (error) {
if (!registered) {
await this.closeUnregisteredSession(session);
}
throw error;
}
}
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 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");
}
}
private async initializeAgentTimelineForRegister(params: {
@@ -2876,17 +2992,22 @@ export class AgentManager {
agent.historyPrimed = true;
for (const event of historyEvents) {
const item = limitAgentTimelineItemContent(event.item);
const row = this.recordTimeline(
agent.id,
event.item,
item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (options?.broadcast) {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
this.dispatchStream(
agent.id,
{ ...event, item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
}
}
this.touchUpdatedAt(agent);
@@ -2943,6 +3064,12 @@ export class AgentManager {
event: AgentStreamEvent,
options?: HandleStreamEventOptions,
): Promise<boolean> {
if (event.type === "timeline") {
event = {
...event,
item: limitAgentTimelineItemContent(event.item),
};
}
const eventTurnId = getAgentStreamEventTurnId(event);
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
@@ -3439,6 +3566,7 @@ export class AgentManager {
item: AgentTimelineItem,
options?: { timestamp?: string },
): AgentTimelineRow {
item = limitAgentTimelineItemContent(item);
const row = this.timelineStore.append(agentId, item, options);
this.enqueueDurableTimelineAppend(agentId, row);
return row;
@@ -3565,15 +3693,45 @@ 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) {
const pending = Array.from(this.backgroundTasks);
while (
this.backgroundTasks.size > 0 ||
(options.includeAgentRegistrations && this.agentRegistrationTasks.size > 0)
) {
const pending = options.includeAgentRegistrations
? [...this.backgroundTasks, ...this.agentRegistrationTasks]
: [...this.backgroundTasks];
await Promise.allSettled(pending);
}
}
@@ -3598,6 +3756,12 @@ export class AgentManager {
event: AgentStreamEvent,
metadata?: { seq?: number; epoch?: string; timestamp?: string },
): void {
if (event.type === "timeline") {
event = {
...event,
item: limitAgentTimelineItemContent(event.item),
};
}
const agent = this.agents.get(agentId);
this.logger.trace(
{
@@ -0,0 +1,67 @@
import { StringDecoder } from "node:string_decoder";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
function limitTextContent(value: string): string {
if (Buffer.byteLength(value, "utf8") <= TOOL_CALL_CONTENT_MAX_BYTES) {
return value;
}
const bytes = Buffer.from(value, "utf8").subarray(0, TOOL_CALL_CONTENT_MAX_BYTES);
return new StringDecoder("utf8").write(bytes);
}
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
if (
item.type !== "tool_call" ||
item.detail.type !== "shell" ||
item.status !== "failed" ||
typeof item.error !== "object" ||
item.error === null
) {
return item;
}
const error: Record<string, unknown> = { ...item.error };
let changed = false;
for (const key of ["content", "message"] as const) {
const value = error[key];
if (typeof value !== "string") {
continue;
}
const limitedValue = limitTextContent(value);
if (limitedValue !== value) {
error[key] = limitedValue;
changed = true;
}
}
if (!changed) {
return item;
}
return {
...item,
error,
};
}
export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem {
item = limitFailedShellError(item);
if (
item.type !== "tool_call" ||
item.detail.type !== "shell" ||
typeof item.detail.output !== "string"
) {
return item;
}
const output = limitTextContent(item.detail.output);
if (output === item.detail.output) {
return item;
}
return {
...item,
detail: {
...item.detail,
output,
},
};
}
@@ -1197,6 +1197,7 @@ 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;
@@ -1222,14 +1223,27 @@ describe("OpenCode adapter startTurn error handling", () => {
},
};
}
return new Promise(() => {});
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 },
);
});
},
};
},
};
const fakeClient = {
global: {
event: vi.fn().mockResolvedValue({ stream: retryStream }),
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => {
eventStreamSignal = signal;
return { stream: retryStream };
}),
},
session: {
abort: vi.fn().mockResolvedValue({ error: null }),
@@ -1319,6 +1333,52 @@ 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: {
@@ -2178,6 +2238,15 @@ 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,6 +2789,7 @@ 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;
@@ -3160,11 +3161,24 @@ class OpenCodeAgentSession implements AgentSession {
const eventStreamReady = createDeferred<void>();
this.eventStreamAbortController = eventStreamAbortController;
this.eventStreamReady = eventStreamReady;
void this.consumeEventStream(eventStreamAbortController, eventStreamReady).finally(() => {
const eventStreamTask = 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;
@@ -3561,9 +3575,19 @@ 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,7 +156,10 @@ export class TestOpenCodeClient {
global: {
event: async (options: unknown) => {
this.calls.globalEvent.push(options);
return { stream: this.eventStream };
const signal = (options as { signal?: AbortSignal }).signal;
return {
stream: signal ? stopEventStreamOnAbort(this.eventStream, signal) : this.eventStream,
};
},
},
mcp: {
@@ -247,6 +250,38 @@ 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, test } from "vitest";
import { describe, expect, onTestFinished, test } from "vitest";
import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
@@ -550,12 +550,14 @@ 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);
@@ -1221,7 +1223,21 @@ describe("PiRpcAgentClient", () => {
expect(pi.latestSession().treeNavigationRequests).toEqual(["entry-1"]);
});
test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => {
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"],
},
},
}),
);
const pi = new FakePi();
pi.queueCommands([
{
@@ -1248,6 +1264,7 @@ describe("PiRpcAgentClient", () => {
},
},
}),
{ env: { PI_CODING_AGENT_DIR: agentDir } },
);
expect(pi.recordedLaunches).toHaveLength(2);
@@ -1276,7 +1293,12 @@ 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,
@@ -1294,6 +1316,27 @@ 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, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { join, resolve as resolvePath } from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
@@ -497,14 +497,63 @@ function toPiMcpConfig(config: McpServerConfig): PiMcpServerConfig {
};
}
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> = {};
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 };
for (const [name, serverConfig] of Object.entries(servers)) {
mcpServers[name] = toPiMcpConfig(serverConfig);
}
writeFileSync(filePath, `${JSON.stringify({ mcpServers }, null, 2)}\n`, "utf8");
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,
});
return {
path: filePath,
cleanup: () => rmSync(dir, { recursive: true, force: true }),
@@ -1911,7 +1960,10 @@ export class PiRpcAgentClient implements AgentClient {
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, {
...this.runtimeSettings?.env,
...launchContext?.env,
});
const paseoExtension = createPiPaseoExtensionFile();
let runtimeSession: PiRuntimeSession;
try {
@@ -1953,7 +2005,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) {
@@ -1963,12 +2015,20 @@ 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);
const mcpConfig = await this.prepareMcpConfig(
resumeConfig.cwd,
resumeConfig.config.mcpServers,
{
...this.runtimeSettings?.env,
...launchContext?.env,
},
);
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,
@@ -2079,18 +2139,19 @@ 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))) {
if (!(await this.detectMcpAdapter(cwd, env))) {
return null;
}
return createPiMcpConfigFile(servers);
return createPiMcpConfigFile(servers, env);
}
private async detectMcpAdapter(cwd: string): Promise<boolean> {
const runtimeSession = await this.runtime.startSession({ cwd }).catch((error) => {
private async detectMcpAdapter(cwd: string, env?: Record<string, string>): Promise<boolean> {
const runtimeSession = await this.runtime.startSession({ cwd, env }).catch((error) => {
this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed to start");
return null;
});
@@ -7,6 +7,7 @@ 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";
@@ -14,6 +15,23 @@ 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();
@@ -206,6 +224,17 @@ 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) => {
@@ -522,3 +551,97 @@ 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 });
});
});
}
+4 -1
View File
@@ -1443,8 +1443,11 @@ 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.flush().catch(() => undefined);
await agentManager.flushForShutdown().catch(() => undefined);
detachAgentStoragePersistence();
await agentStorage.flush().catch(() => undefined);
await providerSnapshotManager.shutdown();
@@ -51,6 +51,18 @@ 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;
@@ -316,16 +328,14 @@ class FakeAgentSession implements AgentSession {
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
constructor(
providerName: string,
config: AgentSessionConfig,
sessionId?: string,
memoryMarker?: string | null,
) {
this.providerName = providerName;
this.config = config;
this.id = sessionId ?? randomUUID();
this.memoryMarker = memoryMarker ?? 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;
this.historyPath = path.join(
tmpdir(),
"paseo-fake-provider-history",
@@ -847,7 +857,9 @@ class FakeAgentSession implements AgentSession {
this.interruptSignal.resolve();
}
async close(): Promise<void> {}
async close(): Promise<void> {
await this.closeSession?.();
}
async listCommands(): Promise<AgentSlashCommand[]> {
if (this.providerName === "codex") {
@@ -1155,13 +1167,20 @@ class FakeAgentSession implements AgentSession {
class FakeAgentClient implements AgentClient {
readonly capabilities = TEST_CAPABILITIES;
constructor(public readonly provider: string) {}
constructor(
public readonly provider: string,
private readonly options: TestAgentClientOptions,
) {}
async createSession(
config: AgentSessionConfig,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
return new FakeAgentSession(this.provider, { ...config });
return new FakeAgentSession({
providerName: this.provider,
config: { ...config },
closeSession: this.options.closeSession,
});
}
async resumeSession(
@@ -1178,12 +1197,13 @@ class FakeAgentClient implements AgentClient {
(handle.metadata as Record<string, unknown> | undefined)?.marker ??
(handle.metadata as Record<string, unknown> | undefined)?.conversationId ??
null;
return new FakeAgentSession(
this.provider,
cfg,
handle.sessionId,
typeof marker === "string" ? marker : null,
);
return new FakeAgentSession({
providerName: this.provider,
config: cfg,
sessionId: handle.sessionId,
memoryMarker: typeof marker === "string" ? marker : null,
closeSession: this.options.closeSession,
});
}
async fetchCatalog(
@@ -1222,10 +1242,12 @@ class FakeAgentClient implements AgentClient {
}
}
export function createTestAgentClients(): Record<string, AgentClient> {
export function createTestAgentClients(
options: TestAgentClientOptions = {},
): Record<string, AgentClient> {
return {
claude: new FakeAgentClient("claude"),
codex: new FakeAgentClient("codex"),
opencode: new FakeAgentClient("opencode"),
claude: new FakeAgentClient("claude", options),
codex: new FakeAgentClient("codex", options),
opencode: new FakeAgentClient("opencode", options),
};
}
@@ -454,6 +454,21 @@ 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;
@@ -517,6 +532,36 @@ 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,6 +351,7 @@ 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;
@@ -463,6 +464,7 @@ 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,
@@ -708,6 +710,11 @@ 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;
@@ -799,7 +806,12 @@ 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?.();
@@ -921,6 +933,15 @@ 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);
@@ -1609,6 +1630,10 @@ 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.105",
"version": "0.1.106",
"private": true,
"type": "module",
"scripts": {