Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 0d1cea9ac0 test(server): reproduce missed cross-client agent deletion
Retain the failing reproduction for #4870. Deletion reaches the requesting subscription but leaves other connected agent subscriptions stale. No production behavior is changed.
2026-10-18 19:03:06 +02:00
303cebec06 Keep sidebar setup actions inside the empty-state card (#7312)
* fix(app): wrap sidebar empty-state actions so longer labels stay inside the card

The "Add project" / "Import session" row in the sidebar's empty state is a
non-wrapping flex row. With longer translations (e.g. Russian) the second
button overflows the card at the default 320px sidebar width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): cover sidebar empty-state actions staying inside the card

Renders the empty state in a real browser at the default sidebar width and
checks both actions' bounds against the card, for Russian (fails without the
wrap) and English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): keep long sidebar actions inside narrow cards

Wrapping the row still lets a single translated action exceed its available width. Bound each empty-state action to the row and allow its label to shrink and wrap.

Co-authored-by: Vyacheslav Scherbinin <gentslava@mail.ru>

---------

Co-authored-by: Vyacheslav Scherbinin <gentslava@mail.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 17:54:51 +02:00
paseo-bot[bot] fcda2f1cf0 fix(hub): reconnect after silent connection loss (#7315)
A network path can swallow close notifications while the Hub socket remains open locally. Probe it with protocol ping/pong frames and feed stale sockets into the existing reconnect path.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 17:00:13 +02:00
Mohamed Boudra f7a0e2cbea test(server): wait up to ten seconds for the OMP relaunch to register MCP tools (#7304)
The crash-relaunch test waited with vi.waitFor's default one second for a
fresh runtime and MCP child to come up. On the Windows runner with three
workers that took longer once in six runs and failed main. The assertions
are unchanged; only the budget for the relaunch is realistic now.
2026-10-18 21:11:14 +07:00
paseo-ai[bot] 2a9e86b542 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-18 11:09:08 +00:00
paseo-bot[bot]andMichael Whitman 9ed4f1069b fix(app): create project workspaces on the active host (#7308)
Grouped sidebar projects chose the first eligible host even while a workspace on another host was active. Prefer that active host when online and eligible, keeping the existing fallback for other placements.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Michael Whitman <michael.whitman@getsteward.ai>
2026-10-18 12:57:54 +02:00
Mohamed Boudra cc748dfe12 chore(release): cut 0.12.0-beta.3 2026-10-18 12:47:46 +02:00
Mohamed Boudra 38a8e60780 docs(changelog): 0.12.0-beta.3 2026-10-18 12:43:26 +02:00
27 changed files with 554 additions and 78 deletions
+14 -3
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.12.0-beta.2 - 2026-10-17
## 0.12.0-beta.3 - 2026-10-18
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
@@ -27,6 +27,9 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Changed phones to open files and subagents in a closable sheet, with Open as tab for subagents ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958))
- Changed diff comments on phones to open in a sheet instead of an editor inside the code ([#6455](https://github.com/getpaseo/paseo/pull/6455))
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
- Changed Command Center to rank workspaces and agents by match and then recency, and to show timestamps, project icons, and highlighted matches ([#7277](https://github.com/getpaseo/paseo/pull/7277))
- Added each Paseo subagent's model, thinking, mode, and context under its title in the subagents list ([#7290](https://github.com/getpaseo/paseo/pull/7290))
- Changed Paseo tool calls in chats to lead with the action and its context, such as Send message · the first line of the prompt ([#7283](https://github.com/getpaseo/paseo/pull/7283))
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
@@ -41,6 +44,8 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD))
- Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
- Fixed follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri))
- Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso))
### Fixed
@@ -54,7 +59,11 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed the daemon running out of memory when several directory searches overlap on a large folder tree ([#7265](https://github.com/getpaseo/paseo/pull/7265))
- Fixed dragging the Explorer edge stuttering with several panes open ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
- Fixed new agent drafts staying on "Loading…" when the host is disconnected; they now say "Host is not connected" and keep your draft ([#7303](https://github.com/getpaseo/paseo/pull/7303) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed reloading a chat during a streaming reply showing a frozen copy above the live one and dropping the ending ([#7256](https://github.com/getpaseo/paseo/pull/7256))
- Fixed question cards spinning forever when an answer fails to send; they now show the error and keep your answers ([#7098](https://github.com/getpaseo/paseo/pull/7098) by [@xzthemeofsss](https://github.com/xzthemeofsss))
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
@@ -68,7 +77,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
- Fixed changing a running Claude agent's thinking level and then its mode killing its running command ([#7179](https://github.com/getpaseo/paseo/pull/7179))
- Fixed Claude models that need a recent Claude Code being hidden when the provider command is a wrapper script ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1))
- Fixed background Claude subagent cards staying empty while the subagent works ([#6242](https://github.com/getpaseo/paseo/pull/6242))
- Fixed background Claude subagent cards staying empty while the subagent works, including after the parent finishes ([#6242](https://github.com/getpaseo/paseo/pull/6242), [#7266](https://github.com/getpaseo/paseo/pull/7266))
- Fixed Copilot staying in Plan mode and refusing edits after you select Allow All ([#7234](https://github.com/getpaseo/paseo/pull/7234))
- Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr))
- Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan))
@@ -91,6 +100,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed copying Korean text from the terminal on iOS and Android adding spaces between characters or dropping the first one ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman))
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
@@ -102,7 +112,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150))
- Fixed home screen actions being cut off on short phone screens ([#7167](https://github.com/getpaseo/paseo/pull/7167))
- Fixed Android dictation stopping with "Dictation was interrupted by another audio source" when another app plays audio ([#7200](https://github.com/getpaseo/paseo/pull/7200))
- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
- Fixed Page Up and Page Down not scrolling the chat after clicking a message or closing Find ([#7033](https://github.com/getpaseo/paseo/pull/7033), [#7263](https://github.com/getpaseo/paseo/pull/7263) by [@panta82](https://github.com/panta82))
- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
- Fixed letter shortcuts not running with non-Latin keyboard layouts such as Russian ([#6755](https://github.com/getpaseo/paseo/pull/6755))
@@ -147,6 +157,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed Google Play listing Android devices without a camera as incompatible ([#6339](https://github.com/getpaseo/paseo/pull/6339))
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
- Fixed usage cards losing their last values and showing "Updated" when a refresh fails ([#7246](https://github.com/getpaseo/paseo/pull/7246))
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
## 0.11.4 - 2026-10-15
+1 -1
View File
@@ -1 +1 @@
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36349,7 +36349,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37469,12 +37469,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/server": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/server": "0.12.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37721,10 +37721,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37735,7 +37735,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37979,7 +37979,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38875,7 +38875,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38904,14 +38904,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38921,8 +38921,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38944,7 +38944,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38971,7 +38971,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39189,16 +39189,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/highlight": "0.12.0-beta.2",
"@getpaseo/plugin": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/highlight": "0.12.0-beta.3",
"@getpaseo/plugin": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40119,7 +40119,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41727,7 +41727,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -9,6 +9,8 @@ import {
expectProjectWorkspaceCountForHost,
expectSeparateProjects,
openGroupedProjectSettings,
openGroupedProjectWorkspace,
expectWorkspaceHost,
openProjectsForSettingsHost,
openProjectDirectory,
openProjectDirectoryWithHosts,
@@ -418,4 +420,29 @@ test.describe("Sidebar project grouping", () => {
count: 2,
});
});
test("creates grouped project workspaces on the active workspace host", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Secondary workspace");
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await expectWorkspaceHost(page, SECONDARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: SECONDARY_HOST_LABEL,
count: 2,
});
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Primary workspace");
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await expectWorkspaceHost(page, PRIMARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: PRIMARY_HOST_LABEL,
count: 2,
});
});
});
@@ -134,3 +134,19 @@ export async function expectProjectSettingsName(page: Page, projectName: string)
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
export async function openGroupedProjectWorkspace(
page: Page,
projectName: string,
workspaceName: string,
): Promise<void> {
await projectGroup(page, projectName)
.getByRole("button")
.filter({ hasText: workspaceName })
.click();
await expect(page).toHaveURL(/\/workspace\//);
}
export async function expectWorkspaceHost(page: Page, hostName: string): Promise<void> {
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"private": true,
"main": "index.ts",
"scripts": {
@@ -41,7 +41,11 @@ import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
useHosts,
useHostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import {
useSidebarWorkspacePinController,
@@ -1596,14 +1600,18 @@ function ProjectBlock({
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces);
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
const rowModel = useMemo(
() =>
buildSidebarProjectRowModel({
project,
collapsed,
supportsMultiplicityByServerId,
preferredServerId,
}),
[collapsed, project, supportsMultiplicityByServerId],
[collapsed, preferredServerId, project, supportsMultiplicityByServerId],
);
// Collapsed rows hide their workspace rows, so the project row carries the most urgent
@@ -1848,6 +1856,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.useNestable === next.useNestable &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId &&
areProjectBlockSelectionsEqual(previous, next)
);
}
@@ -0,0 +1,85 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAllByRole } from "@testing-library/dom";
import { i18n as testI18n } from "@/i18n/i18next";
import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
import { SidebarProjectEmptyState } from "./empty-states";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
afterEach(async () => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
await testI18n.changeLanguage("en");
});
async function mountEmptyState(language: string, width: number): Promise<HTMLElement> {
await testI18n.changeLanguage(language);
const container = document.createElement("div");
container.style.width = `${width}px`;
// Match the workspace list's horizontal inset around the card.
container.style.paddingInline = "6px";
container.style.boxSizing = "border-box";
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(<SidebarProjectEmptyState />));
mounted.push({ root, container });
const card = container.querySelector('[data-testid="sidebar-project-empty-state"]');
if (!(card instanceof HTMLElement)) {
throw new Error("SidebarProjectEmptyState did not render its card");
}
return card;
}
function expectActionsInsideCard(card: HTMLElement) {
const cardRect = card.getBoundingClientRect();
const buttons = getAllByRole(card, "button");
expect(buttons).toHaveLength(2);
for (const button of buttons) {
const rect = button.getBoundingClientRect();
expect(rect.left).toBeGreaterThanOrEqual(cardRect.left);
expect(rect.right).toBeLessThanOrEqual(cardRect.right);
// Containing the button must also keep the whole label readable, without clipping.
const label = document.createRange();
label.selectNodeContents(button);
for (const fragment of Array.from(label.getClientRects())) {
expect(fragment.left).toBeGreaterThanOrEqual(rect.left);
expect(fragment.right).toBeLessThanOrEqual(rect.right);
expect(fragment.top).toBeGreaterThanOrEqual(rect.top);
expect(fragment.bottom).toBeLessThanOrEqual(rect.bottom);
}
}
}
describe("SidebarProjectEmptyState", () => {
const cases = ["en", "ru", "fr", "ja", "es", "pt-BR", "ar", "ko", "zh-CN"].flatMap((language) =>
[MIN_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH].map((width) => ({ language, width })),
);
it.each(cases)(
"keeps every action inside the card ($language, $width px sidebar)",
async ({ language, width }) => {
expectActionsInsideCard(await mountEmptyState(language, width));
},
);
it("keeps English actions on one row when they fit", async () => {
const card = await mountEmptyState("en", DEFAULT_SIDEBAR_WIDTH);
const [addProject, importSession] = getAllByRole(card, "button");
expect(addProject.getBoundingClientRect().top).toBe(importSession.getBoundingClientRect().top);
});
});
@@ -54,7 +54,13 @@ export function SidebarFilterEmptyState() {
title={t("sidebar.filterEmpty.title")}
description={t("sidebar.filterEmpty.description")}
>
<Button variant="secondary" size="xs" onPress={clearFilters}>
<Button
variant="secondary"
size="xs"
style={styles.action}
textStyle={styles.actionText}
onPress={clearFilters}
>
{t("sidebar.filterEmpty.clear")}
</Button>
</SidebarEmptyStateCard>
@@ -76,10 +82,24 @@ export function SidebarProjectEmptyState({
title={t("sidebar.project.empty.title")}
description={t("sidebar.project.empty.description")}
>
<Button variant="secondary" size="xs" leftIcon={Plus} onPress={onAddProject}>
<Button
variant="secondary"
size="xs"
leftIcon={Plus}
style={styles.action}
textStyle={styles.actionText}
onPress={onAddProject}
>
{t("sidebar.actions.addProject")}
</Button>
<Button variant="outline" size="xs" leftIcon={Import} onPress={onImportSession}>
<Button
variant="outline"
size="xs"
leftIcon={Import}
style={styles.action}
textStyle={styles.actionText}
onPress={onImportSession}
>
{t("importSession.title")}
</Button>
</SidebarEmptyStateCard>
@@ -99,9 +119,16 @@ const styles = StyleSheet.create((theme) => ({
},
actions: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
action: {
maxWidth: "100%",
},
actionText: {
flexShrink: 1,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
@@ -65,6 +65,45 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
}
describe("buildSidebarProjectRowModel", () => {
it("prefers the active host when the grouped project can create there", () => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" },
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" },
],
}),
collapsed: false,
preferredServerId: "host-b",
});
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "host-b", projectId: "project-host-b", iconWorkingDir: "/repo/b" },
});
});
it.each(["host-b", "another-host"])(
"keeps the first eligible placement when preferred host %s cannot create here",
(preferredServerId) => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" },
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "unsupported" },
],
}),
collapsed: false,
preferredServerId,
});
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "host-a", projectId: "project-host-a", iconWorkingDir: "/repo/a" },
});
},
);
it("renders a non-git single-workspace project as an expandable section", () => {
const result = buildSidebarProjectRowModel({
project: project({
@@ -85,7 +85,9 @@ export function resolveSidebarProjectLocalPath(
function resolveNewWorkspaceTarget(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
preferredServerId: string | null | undefined,
): SidebarProjectHostTarget | null {
let fallbackTarget: SidebarProjectHostTarget | null = null;
for (const host of project.hosts) {
if (
host.worktreeSupport === "unsupported" &&
@@ -94,22 +96,30 @@ function resolveNewWorkspaceTarget(
continue;
}
const target = hostTarget(host);
if (target) return target;
if (!target) continue;
if (host.serverId === preferredServerId) return target;
fallbackTarget ??= target;
}
return null;
return fallbackTarget;
}
function projectTrailingAction(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
preferredServerId: string | null | undefined,
): SidebarProjectTrailingAction {
const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId);
const target = resolveNewWorkspaceTarget(
project,
supportsMultiplicityByServerId,
preferredServerId,
);
return target ? { kind: "new_workspace", target } : { kind: "none" };
}
export function buildSidebarProjectRowModel(input: {
project: SidebarProjectEntry;
collapsed: boolean;
preferredServerId?: string | null;
supportsMultiplicityByServerId?: ReadonlyMap<string, boolean>;
}): SidebarProjectRowModel {
return {
@@ -118,6 +128,7 @@ export function buildSidebarProjectRowModel(input: {
trailingAction: projectTrailingAction(
input.project,
input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP,
input.preferredServerId,
),
};
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/server": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/server": "0.12.0-beta.3",
"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.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"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.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -70,8 +70,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -81,8 +81,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0-beta.2",
"@getpaseo/highlight": "0.12.0-beta.2",
"@getpaseo/plugin": "0.12.0-beta.2",
"@getpaseo/protocol": "0.12.0-beta.2",
"@getpaseo/relay": "0.12.0-beta.2",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/highlight": "0.12.0-beta.3",
"@getpaseo/plugin": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -285,10 +285,15 @@ describe("OMP MCP host tools", () => {
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
omp.processExit("crashed");
await omp.startTurn("again");
await vi.waitFor(() => {
expect(omp.runtimeSessions()).toHaveLength(2);
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
});
// The relaunch spawns a new runtime and MCP child; on a loaded Windows
// runner that has taken longer than vi.waitFor's default one second.
await vi.waitFor(
() => {
expect(omp.runtimeSessions()).toHaveLength(2);
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
},
{ timeout: 10_000 },
);
} finally {
await omp.close();
}
@@ -0,0 +1,57 @@
import { expect, test } from "vitest";
import { createDaemonTestContext } from "../test-utils/index.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import type { SessionOutboundMessage } from "../messages.js";
test("deleting an agent removes it from every connected agent subscription", async () => {
const ctx = await createDaemonTestContext();
const observer = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
const updates: SessionOutboundMessage[] = [];
const requestorUpdates: SessionOutboundMessage[] = [];
try {
await observer.connect();
const agent = await ctx.client.createAgent({
provider: "codex",
cwd: ctx.daemon.staticDir,
title: "Cross-client deletion",
});
const snapshot = await observer.fetchAgents({ subscribe: {} });
expect(snapshot.entries.some((entry) => entry.agent.id === agent.id)).toBe(true);
observer.subscribeRawMessages((message) => updates.push(message));
ctx.client.subscribeRawMessages((message) => requestorUpdates.push(message));
await ctx.client.deleteAgent(agent.id);
// The record is gone, and a fresh query sees that. An existing subscriber
// must learn the same fact without reconnecting or fetching another list.
const afterDelete = await ctx.client.fetchAgents();
expect(afterDelete.entries.some((entry) => entry.agent.id === agent.id)).toBe(false);
expect(
requestorUpdates.some(
(message) =>
message.type === "agent_update" &&
message.payload.kind === "remove" &&
message.payload.agentId === agent.id,
),
).toBe(true);
await expect(observer.fetchAgentTimeline(agent.id, { direction: "tail" })).rejects.toThrow(
"Agent not found",
);
await expect
.poll(
() =>
updates.some(
(message) =>
message.type === "agent_update" &&
message.payload.kind === "remove" &&
message.payload.agentId === agent.id,
),
{ timeout: 2_000 },
)
.toBe(true);
} finally {
await observer.close();
await ctx.cleanup();
}
});
@@ -0,0 +1,161 @@
import { createServer as createHttpServer } from "node:http";
import { connect, createServer as createTcpServer, type AddressInfo, type Socket } from "node:net";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { HubRelationshipController } from "./relationship-controller.js";
import { DirectHubRelationshipRemote } from "./relationship-remote.js";
test("a silently lost Hub connection leaves connected state and reconnects when the path recovers", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-hub-liveness-"));
const hub = createHttpServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
const enrollment = JSON.parse(body) as { daemonId: string };
response.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
daemonId: enrollment.daemonId,
permissions: ["hub.execute"],
webSocketUrl: `ws://127.0.0.1:${proxyPort}/api/daemons/socket`,
}),
);
});
const webSockets = new WebSocketServer({ server: hub });
const peers = new Set<WebSocket>();
let connections = 0;
let pongs = 0;
webSockets.on("connection", (peer) => {
connections += 1;
peers.add(peer);
peer.on("close", () => peers.delete(peer));
});
await listen(hub);
const hubPort = (hub.address() as AddressInfo).port;
const streams = new Set<Socket>();
let dropped = false;
const proxy = createTcpServer((client) => {
const upstream = connect(hubPort, "127.0.0.1");
streams.add(client);
streams.add(upstream);
client.on("data", (data) => {
if (!dropped) upstream.write(data);
});
upstream.on("data", (data) => {
if (!dropped) client.write(data);
});
client.on("end", () => {
if (!dropped) upstream.end();
});
upstream.on("end", () => {
if (!dropped) client.end();
});
for (const socket of [client, upstream]) {
socket.on("error", () => socket.destroy());
socket.on("close", () => streams.delete(socket));
}
});
await listen(proxy);
const proxyPort = (proxy.address() as AddressInfo).port;
let retry: (() => void) | undefined;
const controller = new HubRelationshipController({
paseoHome: home,
hostname: "test-daemon",
serverId: "server-1",
daemonPublicKey: "public-key",
logger: pino({ level: "silent" }),
remote: new DirectHubRelationshipRemote(),
clock: {
now: () => new Date(),
schedule: (_delay, task) => {
retry = task;
return { cancel: () => (retry = undefined) };
},
},
attachSocket: async (socket) => {
if (!(socket instanceof WebSocket)) throw new Error("Expected a real WebSocket");
socket.on("pong", () => (pongs += 1));
},
updateAttachedPermissions: () => undefined,
createExecutionAgents: () => ({
create: async () => {
throw new Error("Unexpected agent creation");
},
subscribe: () => () => undefined,
invalidateAuthority: async () => undefined,
}),
});
try {
// Keep TCP and WebSocket I/O real; accelerate only the keepalive interval and its clock.
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] });
await controller.connect({
hubUrl: `http://127.0.0.1:${proxyPort}`,
token: "test-token",
permissions: ["hub.execute"],
});
await expect.poll(() => controller.status().state).toBe("connected");
const connectedAt = controller.status().connectedAt;
expect(connections).toBe(1);
// Model the reported proxy request ending without a close reaching the daemon.
dropped = true;
for (const peer of peers) peer.terminate();
await expect.poll(() => peers.size).toBe(0);
await vi.advanceTimersByTimeAsync(60_000);
await expect.poll(() => controller.status().state).toBe("reconnecting");
expect(controller.status().lastError).toBe("Hub WebSocket heartbeat timed out");
expect(retry).toBeTypeOf("function");
dropped = false;
retry?.();
await expect.poll(() => controller.status().state).toBe("connected");
expect(connections).toBe(2);
expect(controller.status().connectedAt).not.toBe(connectedAt);
expect(controller.status().lastError).toBeNull();
// A healthy idle connection survives multiple liveness windows.
for (let tick = 0; tick < 12; tick += 1) {
const before = pongs;
await vi.advanceTimersByTimeAsync(10_000);
await expect.poll(() => pongs).toBeGreaterThan(before);
}
expect(controller.status().state).toBe("connected");
expect(connections).toBe(2);
await controller.stop();
await expect.poll(() => peers.size).toBe(0);
await expect.poll(() => vi.getTimerCount()).toBe(0);
} finally {
await controller.stop();
vi.useRealTimers();
for (const socket of streams) socket.destroy();
for (const peer of peers) peer.terminate();
webSockets.close();
await Promise.all([close(proxy), close(hub)]);
await rm(home, { recursive: true, force: true });
}
});
async function listen(
server: ReturnType<typeof createTcpServer> | ReturnType<typeof createHttpServer>,
) {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
}
async function close(
server: ReturnType<typeof createTcpServer> | ReturnType<typeof createHttpServer>,
) {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
@@ -2,6 +2,9 @@ import { WebSocket } from "ws";
import { z } from "zod";
import type { WebSocketLike } from "../websocket-server.js";
const SOCKET_PING_INTERVAL_MS = 10_000;
const SOCKET_PONG_TIMEOUT_MS = 45_000;
export interface HubEnrollment {
daemonId: string;
idempotencyKey: string;
@@ -171,13 +174,42 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
},
});
let settled = false;
let heartbeat: ReturnType<typeof setInterval> | undefined;
const stopHeartbeat = () => {
clearInterval(heartbeat);
heartbeat = undefined;
};
const fail = (error: Error) => {
if (settled) return;
settled = true;
stopHeartbeat();
socket.terminate();
events.failed(error);
};
let lastPongAt = 0;
socket.on("pong", () => {
lastPongAt = Date.now();
});
socket.once("upgrade", (response) => {
if (response.headers["x-paseo-session-protocol"] === "1") {
sessionProtocol = "session-v1";
}
});
socket.once("open", () => {
if (!settled) events.connected(socket as WebSocketLike, sessionProtocol);
if (settled) return;
lastPongAt = Date.now();
heartbeat = setInterval(() => {
if (socket.readyState !== WebSocket.OPEN) return;
if (Date.now() - lastPongAt >= SOCKET_PONG_TIMEOUT_MS) {
fail(new Error("Hub WebSocket heartbeat timed out"));
return;
}
socket.ping(undefined, undefined, (error) => {
if (error) fail(error);
});
}, SOCKET_PING_INTERVAL_MS);
heartbeat.unref?.();
events.connected(socket as WebSocketLike, sessionProtocol);
});
socket.once("unexpected-response", (_request, response) => {
if (settled) {
@@ -194,16 +226,12 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
events.closed(1006);
});
socket.once("close", (code) => {
stopHeartbeat();
if (settled) return;
settled = true;
events.closed(code);
});
socket.once("error", (error) => {
if (settled) return;
settled = true;
socket.terminate();
events.failed(error);
});
socket.once("error", fail);
return socket;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0-beta.2",
"version": "0.12.0-beta.3",
"private": true,
"type": "module",
"scripts": {