Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] c3fd36e2e9 fix(pi): preserve content block order while streaming
Pi identifies streaming blocks by contentIndex, but the provider discarded that identity and emitted deltas in arrival order. Buffer later blocks until earlier indexed blocks end so late reasoning stays ahead of assistant text.
2026-08-28 11:43:45 +00:00
76 changed files with 746 additions and 3445 deletions
-24
View File
@@ -1,29 +1,5 @@
# Changelog
## 0.7.0-beta.2 - 2026-08-28
### Added
- Added SSH connections to existing remote daemons from Desktop and CLI ([#3989](https://github.com/getpaseo/paseo/pull/3989) by [@reidlevesque](https://github.com/reidlevesque))
- Added workspace management and layout actions to the Command Center ([#3013](https://github.com/getpaseo/paseo/pull/3013) by [@cleiter](https://github.com/cleiter))
- Added contextual composer-pill contributions for plugins ([#3956](https://github.com/getpaseo/paseo/pull/3956))
- Added host UI primitives for client plugins ([#3967](https://github.com/getpaseo/paseo/pull/3967))
- Added PR and MR number search to the Command Center ([#3008](https://github.com/getpaseo/paseo/pull/3008) by [@cleiter](https://github.com/cleiter))
- Added session commands to agent SDK handles ([#3719](https://github.com/getpaseo/paseo/pull/3719) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
### Improved
- Batched GitHub pull request polling within the reserved API budget ([#3825](https://github.com/getpaseo/paseo/pull/3825) by [@dezchai](https://github.com/dezchai))
### Fixed
- Fixed daemon reconnects stalling while Git processes were queued ([#3945](https://github.com/getpaseo/paseo/pull/3945))
- Fixed restored workspace tabs entering reconciliation loops ([#3987](https://github.com/getpaseo/paseo/pull/3987))
- Fixed immediate dictation submissions omitting the newest speech segment ([#3968](https://github.com/getpaseo/paseo/pull/3968))
- Fixed archived workspaces returning from durable cache ([#3975](https://github.com/getpaseo/paseo/pull/3975))
- Fixed commits and squash merges bypassing configured Git signing ([#3976](https://github.com/getpaseo/paseo/pull/3976))
- Fixed Markdown literal characters being replaced by typographic symbols ([#3253](https://github.com/getpaseo/paseo/pull/3253) by [@cleiter](https://github.com/cleiter))
## 0.7.0-beta.1 - 2026-08-27
### Changed
+2 -2
View File
@@ -111,8 +111,8 @@ paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task
# run on a remote daemon; --cwd is a path on that host
paseo run --host workstation.local:6767 --cwd /workspace "run the full test suite"
# run on a remote daemon
paseo --host workstation.local:6767 run "run the full test suite"
```
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
+1 -1
View File
@@ -103,7 +103,7 @@ host's client; plugin subprocesses use the same facade over a host-owned IPC tra
Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`.
- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
+2 -5
View File
@@ -556,15 +556,12 @@ npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
```
Use `--host` to point the CLI at a different daemon:
Use `--host <host:port>` to point the CLI at a different daemon:
```bash
npm run cli -- ls -a --host localhost:7777
npm run cli -- ls -a --host ssh://user@host
npm run cli -- --host localhost:7777 ls -a
```
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
Desktop integrations can focus an existing agent without creating one or
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
+27 -27
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36298,7 +36298,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37363,12 +37363,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/server": "0.7.0-beta.2",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/server": "0.7.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37615,10 +37615,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/relay": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37629,7 +37629,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37872,7 +37872,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38768,7 +38768,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38797,13 +38797,13 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38812,15 +38812,15 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38833,7 +38833,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39051,17 +39051,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.0-beta.2",
"@getpaseo/highlight": "0.7.0-beta.2",
"@getpaseo/plugin": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/relay": "0.7.0-beta.2",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/highlight": "0.7.0-beta.1",
"@getpaseo/plugin": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39985,7 +39985,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"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.7.0-beta.2",
"version": "0.7.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,270 +0,0 @@
import type { Locator } from "@playwright/test";
import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
// These actions used to be reachable only from the sidebar workspace ⋯ menu, the workspace header
// menu, or an unlisted keybind. Rename was the worst of them: its dialog lived inside the sidebar
// row, so it vanished whenever that row was unmounted — a collapsed section, or focus mode.
function workspaceRow(page: Page, workspaceId: string): Locator {
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
}
function action(panel: Locator, title: string): Locator {
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return panel.getByRole("button", { name: new RegExp(`^${escaped}(?:\\s|$)`) });
}
async function openWorkspace(page: Page, workspaceId: string): Promise<void> {
const row = workspaceRow(page, workspaceId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
// Collapsing the project section unmounts every workspace row under it, which is exactly the state
// that used to make the sidebar-owned rename dialog unreachable.
async function collapseProjectSection(page: Page, project: SeededWorkspace): Promise<void> {
const header = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: project.projectDisplayName });
await expect(header).toHaveCount(1, { timeout: 30_000 });
await header.click();
await expect(workspaceRow(page, project.workspaceId)).toHaveCount(0, { timeout: 10_000 });
}
// The shared helper clicks the sidebar's search button, which is inside the Workspaces section
// header — absent on /settings and once every workspace has moved to Pinned. Cmd-K is the
// surface under test anyway, and it works from every route.
async function openCommandCenter(page: Page): Promise<Locator> {
await page.keyboard.press("ControlOrMeta+K");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
}
async function runCommand(page: Page, query: string, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(query);
const entry = action(panel, title);
await expect(entry).toBeVisible({ timeout: 15_000 });
await entry.click();
}
async function readWorkspaceLabels(workspace: SeededWorkspace): Promise<string[] | undefined> {
const workspaces = await workspace.client.fetchWorkspaces();
for (const entry of workspaces.entries) {
if (entry.id === workspace.workspaceId) return entry.labels?.slice().sort();
}
return undefined;
}
async function rejectNextLabelAssignment(page: Page): Promise<void> {
let rejectNext = true;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const raw = typeof message === "string" ? message : message.toString("utf8");
let request: { type?: string; requestId?: string } | null = null;
try {
const envelope = JSON.parse(raw) as {
type?: string;
message?: { type?: string; requestId?: string };
};
request = envelope.type === "session" ? (envelope.message ?? null) : null;
} catch {
server.send(message);
return;
}
if (
rejectNext &&
request?.type === "workspace.label.assignment.set.request" &&
request.requestId
) {
rejectNext = false;
browser.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: "Injected label mutation failure.",
code: "handler_error",
},
},
}),
);
return;
}
server.send(message);
});
server.onMessage((message) => browser.send(message));
});
}
test.describe("Command center workspace management", () => {
test("renames the active workspace while its sidebar row is unmounted", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-rename-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await collapseProjectSection(page, workspace);
await runCommand(page, "rename", "Rename workspace");
// Focused, not merely visible. A modal that mounts behind the closing palette, or loses the
// focus race with its focus-restore, still renders — you just cannot type into it.
const input = page.getByTestId("workspace-rename-modal-global-input");
await expect(input).toBeFocused({ timeout: 15_000 });
await expect(input).toHaveValue(workspace.workspaceName);
const customTitle = "Renamed From Palette";
await input.fill(customTitle);
await page.getByTestId("workspace-rename-modal-global-submit").click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
// Re-expand the section to read the row back.
const header = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: workspace.projectDisplayName });
await header.click();
await expect(workspaceRow(page, workspace.workspaceId)).toContainText(customTitle, {
timeout: 15_000,
});
} finally {
await workspace.cleanup();
}
});
test("copies the workspace path and confirms with a toast", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-copy-path-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await runCommand(page, "copy path", "Copy workspace path");
// toast.copied wraps its label: "Copied {{label}}" with label "Path copied".
await expect(page.getByText("Copied Path copied", { exact: true })).toBeVisible({
timeout: 15_000,
});
} finally {
await workspace.cleanup();
}
});
test("flips the pin entry to Unpin after pinning from the palette", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-pin-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
await runCommand(page, "pin", "Pin to top");
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible({ timeout: 15_000 });
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("pin");
await expect(action(panel, "Unpin")).toBeVisible({ timeout: 15_000 });
await expect(action(panel, "Pin to top")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
// Regression guard for the registration split. Toggle Explorer sidebar and Toggle focus mode are
// handled only by workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so listing them
// off a workspace route would give the user two entries that close the palette and do nothing.
// Toggle left sidebar calls the panel store directly and works everywhere, so it stays listed.
test("lists only the globally-handled toggle off a workspace route", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-toggle-scope-" });
try {
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
// All three are listed while a workspace route owns the handlers.
const workspacePanel = await openCommandCenter(page);
await workspacePanel.getByTestId("command-center-input").fill("toggle");
await expect(action(workspacePanel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
await expect(action(workspacePanel, "Toggle Explorer sidebar")).toBeVisible();
await expect(action(workspacePanel, "Toggle focus mode")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("command-center-panel")).toHaveCount(0);
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
await expect(page.getByTestId("settings-sidebar")).toBeVisible({ timeout: 30_000 });
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("toggle");
await expect(action(panel, "Toggle left sidebar")).toBeVisible({ timeout: 15_000 });
await expect(action(panel, "Toggle Explorer sidebar")).toHaveCount(0);
await expect(action(panel, "Toggle focus mode")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("toggles a label on the active workspace from the palette", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-label-" });
try {
await workspace.client.setWorkspaceLabel({
workspaceId: workspace.workspaceId,
label: { name: "Urgent", color: "red" },
assigned: true,
});
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("urgent");
await panel.getByTestId("command-center-workspace-label-Urgent").click();
// Unassigning the only label drops the field entirely rather than leaving an empty array.
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(undefined);
const reopened = await openCommandCenter(page);
await reopened.getByTestId("command-center-input").fill("urgent");
await reopened.getByTestId("command-center-workspace-label-Urgent").click();
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
} finally {
await workspace.cleanup();
}
});
test("shows an error toast when assigning a label fails", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "cc-label-failure-" });
try {
await rejectNextLabelAssignment(page);
await workspace.client.setWorkspaceLabel({
workspaceId: workspace.workspaceId,
label: { name: "Urgent", color: "red" },
assigned: true,
});
await gotoAppShell(page);
await openWorkspace(page, workspace.workspaceId);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("urgent");
await panel.getByTestId("command-center-workspace-label-Urgent").click();
await expect(page.getByTestId("app-toast-message")).toContainText(
"Injected label mutation failure.",
);
await expect.poll(() => readWorkspaceLabels(workspace)).toEqual(["Urgent"]);
} finally {
await workspace.cleanup();
}
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
-2
View File
@@ -39,7 +39,6 @@ import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { RootErrorBoundary } from "@/components/root-error-boundary";
@@ -596,7 +595,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<CommandCenterWorkspaceActions />
<PluginCommandCenterActions />
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
@@ -9,19 +9,17 @@ import {
History,
Home,
Keyboard,
PanelLeft,
Plus,
Settings,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { getIsElectronRuntime } from "@/constants/layout";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
@@ -56,9 +54,6 @@ const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.for
const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedPanelLeft = withUnistyles(PanelLeft, (theme) => ({
color: theme.colors.foregroundMuted,
}));
function PlusIcon({ size }: CommandCenterIconProps) {
return <ThemedPlus size={size} strokeWidth={2.4} />;
@@ -96,10 +91,6 @@ function CircleDashedIcon({ size }: CommandCenterIconProps) {
return <ThemedCircleDashed size={size} strokeWidth={2.2} />;
}
function PanelLeftIcon({ size }: CommandCenterIconProps) {
return <ThemedPanelLeft size={size} strokeWidth={2.2} />;
}
export function CommandCenterRootActions() {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
@@ -115,10 +106,6 @@ export function CommandCenterRootActions() {
// each time host filters are reconciled.
const groupMode = useSidebarViewStore((state) => state.groupMode);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const isCompact = useIsCompactFormFactor();
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
const toggleAgentList = isCompact ? toggleMobileAgentList : toggleDesktopAgentList;
const shortcutPlatform = useMemo(
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
[],
@@ -240,33 +227,6 @@ export function CommandCenterRootActions() {
undefined,
},
},
// Toggle left sidebar is global: it calls the panel store directly and works on every route.
// The right sidebar and focus toggles do NOT belong here — their handlers live in
// workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so registering them globally
// would list entries that silently no-op off a workspace route. They live in
// workspace-contributions.ts instead. That is why the three toggles render in two
// non-adjacent sections; don't "tidy" them back together.
{
id: "toggle-left-sidebar",
group: "actions",
groupRank: 0,
rank: 7,
keywords: ["toggle", "sidebar", "left", "panel", "workspaces"],
visibility: "query",
run: () => {
clearCommandCenterFocusRestoreElement();
toggleAgentList();
},
presentation: {
kind: "action",
title: t("settings.shortcuts.help.toggleLeftSidebar"),
sectionTitle: t("shell.commandCenter.actions"),
icon: PanelLeftIcon,
shortcutKeys:
resolveShortcutKeysForAction("toggle-left-sidebar", overrides, shortcutPlatform) ??
undefined,
},
},
];
if (shortcutsAvailable) {
@@ -318,7 +278,6 @@ export function CommandCenterRootActions() {
shortcutPlatform,
shortcutsAvailable,
t,
toggleAgentList,
]);
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
@@ -23,14 +23,9 @@ function source(gitActions: GitActions): {
value: WorkspaceCommandCenterSource;
runGitActions: GitAction[];
dispatched: KeyboardActionDefinition[];
copiedPaths: number;
copiedBranchNames: number;
toggledLabels: Array<{ name: string; assigned: boolean }>;
} {
const runGitActions: GitAction[] = [];
const dispatched: KeyboardActionDefinition[] = [];
const toggledLabels: Array<{ name: string; assigned: boolean }> = [];
const counters = { copiedPaths: 0, copiedBranchNames: 0 };
return {
value: {
gitActions,
@@ -67,51 +62,19 @@ function source(gitActions: GitActions): {
moveTabDown: "Move tab down",
closePane: "Close pane",
toggleFocusMode: "Toggle focus mode",
toggleExplorerSidebar: "Toggle Explorer sidebar",
rename: "Rename workspace",
copyPath: "Copy workspace path",
copyBranchName: "Copy branch name",
pin: "Pin to top",
unpin: "Unpin",
showSetup: "Show setup",
labelsGroup: "Labels",
toggleExplorerSidebar: "Toggle side panel",
},
icons: {},
shortcuts: {},
capabilities: {
canSplitPanes: true,
canOpenBrowserTabs: true,
isGit: false,
canPin: false,
canShowSetup: false,
},
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
activeTabKind: null,
activeTabIndex: -1,
activeTabCount: 0,
currentBranch: null,
isPinned: false,
labelCatalog: null,
dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action),
copyPath: () => {
counters.copiedPaths += 1;
},
copyBranchName: () => {
counters.copiedBranchNames += 1;
},
toggleLabel: (name, assigned) => {
toggledLabels.push({ name, assigned });
},
},
runGitActions,
dispatched,
toggledLabels,
get copiedPaths() {
return counters.copiedPaths;
},
get copiedBranchNames() {
return counters.copiedBranchNames;
},
};
}
@@ -184,8 +147,6 @@ describe("workspace command center contributions", () => {
canSplitPanes: false,
canOpenBrowserTabs: false,
isGit: false,
canPin: false,
canShowSetup: false,
};
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
@@ -194,8 +155,6 @@ describe("workspace command center contributions", () => {
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
expect(contributions.some((item) => item.id === "workspace:rename")).toBe(true);
expect(contributions.some((item) => item.id === "workspace:copy-path")).toBe(true);
});
it("dispatches every tab and pane command to the workspace scope", () => {
@@ -205,14 +164,7 @@ describe("workspace command center contributions", () => {
for (const contribution of contributions) contribution.run();
expect(fixture.dispatched.length).toBeGreaterThan(5);
// workspace:rename dispatches to workspace scope
expect(
fixture.dispatched.some((a) => a.id === "workspace.rename" && a.scope === "workspace"),
).toBe(true);
// workspace:toggle-explorer-sidebar intentionally dispatches to sidebar scope
expect(
fixture.dispatched.some((a) => a.id === "sidebar.toggle.right" && a.scope === "sidebar"),
).toBe(true);
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
});
it("keeps workspace creation commands available outside Git", () => {
@@ -227,164 +179,4 @@ describe("workspace command center contributions", () => {
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
});
it("dispatches rename to the workspace scope", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const rename = contributions.find((item) => item.id === "workspace:rename");
expect(rename?.presentation).toMatchObject({ title: "Rename workspace" });
rename?.run();
expect(fixture.dispatched).toEqual([{ id: "workspace.rename", scope: "workspace" }]);
});
it("copies the workspace path without going through the dispatcher", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
contributions.find((item) => item.id === "workspace:copy-path")?.run();
expect(fixture.copiedPaths).toBe(1);
expect(fixture.dispatched).toEqual([]);
});
it("omits Copy branch name when the workspace has no branch, and copies when it does", () => {
const withoutBranch = source({ primary: null, secondary: [], menu: [] });
expect(
buildWorkspaceCommandCenterContributions(withoutBranch.value).some(
(item) => item.id === "workspace:copy-branch-name",
),
).toBe(false);
const withBranch = source({ primary: null, secondary: [], menu: [] });
withBranch.value.currentBranch = "feature/login";
const contribution = buildWorkspaceCommandCenterContributions(withBranch.value).find(
(item) => item.id === "workspace:copy-branch-name",
);
expect(contribution?.keywords).toContain("feature/login");
contribution?.run();
expect(withBranch.copiedBranchNames).toBe(1);
});
it("flips the pin label on pinned state and omits the entry when the host cannot pin", () => {
const unpinned = source({ primary: null, secondary: [], menu: [] });
unpinned.value.capabilities.canPin = true;
const pinContribution = buildWorkspaceCommandCenterContributions(unpinned.value).find(
(item) => item.id === "workspace:pin",
);
expect(pinContribution?.presentation).toMatchObject({ title: "Pin to top" });
expect(pinContribution?.visibility).toBe("always");
pinContribution?.run();
expect(unpinned.dispatched).toEqual([{ id: "workspace.pin", scope: "sidebar" }]);
const pinned = source({ primary: null, secondary: [], menu: [] });
pinned.value.capabilities.canPin = true;
pinned.value.isPinned = true;
expect(
buildWorkspaceCommandCenterContributions(pinned.value).find(
(item) => item.id === "workspace:pin",
)?.presentation,
).toMatchObject({ title: "Unpin" });
const unsupported = source({ primary: null, secondary: [], menu: [] });
expect(
buildWorkspaceCommandCenterContributions(unsupported.value).some(
(item) => item.id === "workspace:pin",
),
).toBe(false);
});
it("lists Show setup only when the workspace has setup to show", () => {
const withoutSetup = source({ primary: null, secondary: [], menu: [] });
expect(
buildWorkspaceCommandCenterContributions(withoutSetup.value).some(
(item) => item.id === "workspace:show-setup",
),
).toBe(false);
const withSetup = source({ primary: null, secondary: [], menu: [] });
withSetup.value.capabilities.canShowSetup = true;
buildWorkspaceCommandCenterContributions(withSetup.value)
.find((item) => item.id === "workspace:show-setup")
?.run();
expect(withSetup.dispatched).toEqual([{ id: "workspace.setup.show", scope: "workspace" }]);
});
// Regression guard for the registration split: these two are handled only by workspace-screen.tsx
// behind `enabled: isRouteFocused && ...`, so they must be built here rather than in the global
// root set, where they would silently no-op off a workspace route.
it("builds the Explorer sidebar and focus toggles in the workspace set", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities.canSplitPanes = false;
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
contributions.find((item) => item.id === "workspace:toggle-explorer-sidebar")?.run();
contributions.find((item) => item.id === "workspace:toggle-focus-mode")?.run();
expect(fixture.dispatched).toEqual([
{ id: "sidebar.toggle.right", scope: "sidebar" },
{ id: "workspace.focus.toggle", scope: "workspace" },
]);
for (const contribution of contributions) {
expect(contribution.group).toBe("workspace");
}
});
// `buildPaneContributions` dispatches this same action as `pane:focus-mode-toggle` once split
// panes are available, so the standalone entry must step aside there — otherwise the palette
// lists "Toggle focus mode" twice for the one `workspace.focus.toggle` action.
it("omits the standalone focus toggle when the pane set already covers it", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.capabilities.canSplitPanes = true;
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const focusToggles = contributions.filter(
(item) =>
item.presentation.kind === "action" && item.presentation.title === "Toggle focus mode",
);
expect(focusToggles).toHaveLength(1);
expect(focusToggles[0]?.id).toBe("pane:focus-mode-toggle");
});
it("omits the labels group when the catalog hasn't loaded", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.some((item) => item.id.startsWith("workspace:label:"))).toBe(false);
});
it("lists a choice per catalog label, ticked when assigned, and toggles the opposite state on run", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.labelCatalog = [
{ name: "bug", assigned: true },
{ name: "urgent", assigned: false },
];
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
const bug = contributions.find((item) => item.id === "workspace:label:bug");
const urgent = contributions.find((item) => item.id === "workspace:label:urgent");
expect(bug?.presentation).toMatchObject({
kind: "choice",
path: ["Labels", "bug"],
selected: true,
});
expect(urgent?.presentation).toMatchObject({
kind: "choice",
path: ["Labels", "urgent"],
selected: false,
});
bug?.run();
urgent?.run();
expect(fixture.toggledLabels).toEqual([
{ name: "bug", assigned: false },
{ name: "urgent", assigned: true },
]);
});
});
@@ -8,13 +8,6 @@ import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { ShortcutKey } from "@/utils/format-shortcut";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
export interface WorkspaceCommandCenterLabelChoice {
name: string;
/** Whether the current workspace already carries this label. */
assigned: boolean;
icon?: CommandCenterIcon;
}
export interface WorkspaceCommandCenterLabels {
section: string;
newAgent: string;
@@ -49,14 +42,6 @@ export interface WorkspaceCommandCenterLabels {
closePane: string;
toggleFocusMode: string;
toggleExplorerSidebar: string;
// Workspace management actions
rename: string;
copyPath: string;
copyBranchName: string;
pin: string;
unpin: string;
showSetup: string;
labelsGroup: string;
}
export interface WorkspaceCommandCenterIcons {
@@ -78,14 +63,6 @@ export interface WorkspaceCommandCenterIcons {
moveTab?: CommandCenterIcon;
focusMode?: CommandCenterIcon;
explorerSidebar?: CommandCenterIcon;
// Workspace management action icons
copyPath?: CommandCenterIcon;
copyBranchName?: CommandCenterIcon;
pin?: CommandCenterIcon;
unpin?: CommandCenterIcon;
showSetup?: CommandCenterIcon;
toggleFocusMode?: CommandCenterIcon;
label?: CommandCenterIcon;
git?(action: GitAction): CommandCenterIcon | undefined;
}
@@ -101,7 +78,6 @@ export interface WorkspaceCommandCenterShortcuts {
closePane?: ShortcutKey[][];
toggleFocusMode?: ShortcutKey[][];
toggleExplorerSidebar?: ShortcutKey[][];
pinWorkspace?: ShortcutKey[][];
}
export interface WorkspaceCommandCenterSource {
@@ -113,27 +89,12 @@ export interface WorkspaceCommandCenterSource {
canSplitPanes: boolean;
canOpenBrowserTabs: boolean;
isGit: boolean;
/** Host supports the `workspacePinning` feature. */
canPin: boolean;
/** The workspace has setup commands or a setup error — same gate as the header menu. */
canShowSetup: boolean;
};
activeTabKind: WorkspaceTabTarget["kind"] | null;
activeTabIndex: number;
activeTabCount: number;
/** Null on a non-git workspace, or before gitRuntime resolves. Omits Copy branch name. */
currentBranch: string | null;
isPinned: boolean;
/**
* The host's label catalog, each entry told whether the current workspace carries it. Null
* before the catalog has loaded — omits the whole group rather than showing it empty.
*/
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): void;
copyPath(): void;
copyBranchName(): void;
toggleLabel(name: string, assigned: boolean): void | Promise<void>;
}
function buildGitContribution(
@@ -498,35 +459,6 @@ function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCe
return paneActions.map((action) => buildQueryAction(source, action));
}
function buildWorkspaceCallback(input: {
source: WorkspaceCommandCenterSource;
id: string;
rank: number;
title: string;
keywords: readonly string[];
icon?: CommandCenterIcon;
shortcutKeys?: ShortcutKey[][];
run: () => void;
visibility: "always" | "query";
}): CommandCenterContribution {
return {
id: input.id,
group: "workspace",
groupRank: -1,
rank: input.rank,
keywords: input.keywords,
visibility: input.visibility,
run: input.run,
presentation: {
kind: "action",
title: input.title,
sectionTitle: input.source.labels.section,
icon: input.icon,
shortcutKeys: input.shortcutKeys,
},
};
}
function buildCreationContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
@@ -579,150 +511,20 @@ export function buildWorkspaceCommandCenterContributions(
...buildPanelContributions(source),
...buildActiveTabContributions(source),
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
buildQueryAction(source, {
id: "explorer-sidebar:toggle",
rank: 70,
title: source.labels.toggleExplorerSidebar,
keywords: ["explorer", "sidebar", "toggle", "show", "hide"],
icon: source.icons.explorerSidebar,
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
action: { id: "sidebar.toggle.right", scope: "workspace" },
}),
];
const primary = source.gitActions.primary;
for (const [index, action] of source.gitActions.secondary.entries()) {
if (action.id === primary?.id) continue;
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
if (source.capabilities.canPin) {
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:pin",
rank: 20,
title: source.isPinned ? source.labels.unpin : source.labels.pin,
keywords: ["pin", "unpin", "favorite", "sticky"],
icon: source.isPinned ? source.icons.unpin : source.icons.pin,
shortcutKeys: source.shortcuts.pinWorkspace,
action: { id: "workspace.pin", scope: "sidebar" },
visibility: "always",
}),
);
}
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:rename",
rank: 21,
title: source.labels.rename,
keywords: ["rename", "title", "name", "label"],
icon: source.icons.rename,
action: { id: "workspace.rename", scope: "workspace" },
visibility: "query",
}),
buildWorkspaceCallback({
source,
id: "workspace:copy-path",
rank: 22,
title: source.labels.copyPath,
keywords: ["copy", "path", "directory", "folder", "cwd"],
icon: source.icons.copyPath,
run: source.copyPath,
visibility: "query",
}),
);
// Omitted rather than present-and-failing: a non-git workspace has nothing to copy.
if (source.currentBranch) {
contributions.push(
buildWorkspaceCallback({
source,
id: "workspace:copy-branch-name",
rank: 23,
title: source.labels.copyBranchName,
keywords: ["copy", "branch", "git", source.currentBranch],
icon: source.icons.copyBranchName,
run: source.copyBranchName,
visibility: "query",
}),
);
}
if (source.capabilities.canShowSetup) {
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:show-setup",
rank: 24,
title: source.labels.showSetup,
keywords: ["setup", "provision", "install", "bootstrap"],
icon: source.icons.showSetup,
action: { id: "workspace.setup.show", scope: "workspace" },
visibility: "query",
}),
);
}
// Toggle Explorer sidebar and Toggle focus mode belong here, NOT in root-registration.tsx:
// their handlers live in workspace-screen.tsx behind `enabled: isRouteFocused && ...`, so a
// global registration would list two entries that silently no-op on /settings, /sessions,
// /schedules and Home. Toggle sidebar (left) is global and stays in the root set — that is why
// the three toggles render in two non-adjacent sections. Don't "tidy" them back together.
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:toggle-explorer-sidebar",
rank: 25,
title: source.labels.toggleExplorerSidebar,
keywords: ["toggle", "sidebar", "explorer", "files", "changes"],
icon: source.icons.explorerSidebar,
shortcutKeys: source.shortcuts.toggleExplorerSidebar,
action: { id: "sidebar.toggle.right", scope: "sidebar" },
visibility: "query",
}),
);
// `buildPaneContributions` already dispatches this same `workspace.focus.toggle` action as
// `pane:focus-mode-toggle` once split panes are available, so only add the standalone entry
// where that function is skipped — otherwise the palette lists "Toggle focus mode" twice.
if (!source.capabilities.canSplitPanes) {
contributions.push(
buildWorkspaceAction({
source,
id: "workspace:toggle-focus-mode",
rank: 26,
title: source.labels.toggleFocusMode,
keywords: ["toggle", "focus", "zen", "distraction", "fullscreen"],
icon: source.icons.toggleFocusMode,
shortcutKeys: source.shortcuts.toggleFocusMode,
action: { id: "workspace.focus.toggle", scope: "workspace" },
visibility: "query",
}),
);
}
contributions.push(...buildLabelContributions(source));
return contributions;
}
/**
* One choice per catalog label, ticked when the current workspace carries it. Selecting toggles
* assignment rather than gating on `!selected` — unlike the model/mode choice groups this is a
* multi-select, so picking an already-assigned label un-assigns it instead of no-op'ing.
*/
function buildLabelContributions(
source: WorkspaceCommandCenterSource,
): CommandCenterContribution[] {
if (!source.labelCatalog) return [];
return source.labelCatalog.map((choice, index) => ({
id: `workspace:label:${choice.name}`,
group: "workspace",
groupRank: -1,
rank: 30 + index,
keywords: ["label", "tag", choice.name],
visibility: "query" as const,
run: () => source.toggleLabel(choice.name, !choice.assigned),
presentation: {
kind: "choice" as const,
path: [source.labels.labelsGroup, choice.name] as const,
icon: choice.icon,
selected: choice.assigned,
testId: `command-center-workspace-label-${choice.name}`,
},
}));
}
@@ -1,4 +1,4 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDownToLine,
@@ -8,16 +8,12 @@ import {
Copy,
Files,
Focus,
GitBranch,
GitCompareArrows,
GitPullRequest,
Globe,
ListChecks,
Move,
PanelRight,
Pencil,
Pin,
PinOff,
RotateCw,
Rows2,
SquarePen,
@@ -29,35 +25,23 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { useToast } from "@/contexts/toast-context";
import { type ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
import { useHostFeature } from "@/runtime/host-features";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import {
collectAllTabs,
findPaneById,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import {
buildWorkspaceLabelPickerRows,
useWorkspaceLabelProjection,
workspaceLabelErrorMessage,
workspaceLabels,
} from "@/workspace-labels";
import { getLabelCommandCenterIcon } from "@/workspace-labels/command-center-icon";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { getCommandCenterIcon } from "./icon";
import type { CommandCenterIcon } from "./contributions";
import { useCommandCenterActions } from "./provider";
import {
buildWorkspaceCommandCenterContributions,
type WorkspaceCommandCenterLabelChoice,
type WorkspaceCommandCenterShortcuts,
} from "./workspace-contributions";
import { resolveWorkspaceCommandCenterShortcuts } from "./workspace-shortcuts";
@@ -81,13 +65,6 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
moveTab: getCommandCenterIcon(Move),
focusMode: getCommandCenterIcon(ArrowDownToLine),
explorerSidebar: getCommandCenterIcon(PanelRight),
// Workspace management action icons
copyPath: getCommandCenterIcon(Copy),
copyBranchName: getCommandCenterIcon(GitBranch),
pin: getCommandCenterIcon(Pin),
unpin: getCommandCenterIcon(PinOff),
showSetup: getCommandCenterIcon(ListChecks),
toggleFocusMode: getCommandCenterIcon(Focus),
};
const OPEN_PANEL_LABEL_KEYS = {
@@ -109,58 +86,6 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
return resolveWorkspaceCommandCenterShortcuts({ overrides, platform });
}
/**
* The label catalog and its toggle callback, pulled out of `useWorkspaceCommandCenterActions` to
* keep that hook under the complexity limit. Null while the host's catalog hasn't loaded, so the
* group is omitted rather than shown empty — the same rule `WorkspaceLabelPickerPage` follows for
* the sidebar's own picker.
*/
function useWorkspaceLabelCatalog(
serverId: string | null,
fields: { id: string; labels: readonly string[] } | null,
): {
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
toggleLabel: (name: string, assigned: boolean) => void;
} {
const { labels: labelDefinitions, targetHost: labelHost } = useWorkspaceLabelProjection(
serverId ?? undefined,
);
const toast = useToast();
const assignedLabels = fields?.labels;
const labelCatalog = useMemo<readonly WorkspaceCommandCenterLabelChoice[] | null>(
() =>
labelHost?.status === "online" && assignedLabels
? buildWorkspaceLabelPickerRows({ labels: labelDefinitions, assigned: assignedLabels }).map(
(row) => ({
name: row.name,
assigned: row.assigned,
icon: getLabelCommandCenterIcon(row.color),
}),
)
: null,
[assignedLabels, labelDefinitions, labelHost?.status],
);
const toggleLabel = useCallback(
async (name: string, assigned: boolean) => {
if (!serverId || !fields) return;
const definition = labelDefinitions.find((label) => label.name === name);
if (!definition) return;
try {
await workspaceLabels.setAssignment({
serverId,
workspaceId: fields.id,
label: definition,
assigned,
});
} catch (cause) {
toast.error(workspaceLabelErrorMessage(cause));
}
},
[fields, labelDefinitions, serverId, toast],
);
return { labelCatalog, toggleLabel };
}
export function useWorkspaceCommandCenterActions(): void {
const keyboardActionDispatcher = useKeyboardActionDispatcher();
const { t } = useTranslation();
@@ -179,29 +104,8 @@ export function useWorkspaceCommandCenterActions(): void {
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
const activeTabKind =
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
// One narrow projection for the workspace management contribution fields. The registry's snapshot
// dedup is unreachable (registry.ts spreads a fresh object per contribution, then compares by
// reference), so the array-identity guard in registry.replace() is the only thing stopping a
// re-render — a churny subscription here rebuilds the list while the user is looking at it.
const fields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({
id: workspace.id,
workspaceDirectory: workspace.workspaceDirectory ?? null,
currentBranch: workspace.gitRuntime?.currentBranch ?? null,
pinnedAt: workspace.pinnedAt ?? null,
labels: workspace.labels ?? [],
}));
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const currentBranch = fields?.currentBranch ?? null;
const isPinned = fields?.pinnedAt != null;
const isCompact = useIsCompactFormFactor();
const canPin = useHostFeature(serverId, "workspacePinning");
const persistenceKey =
serverId && fields
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id })
: null;
const canShowSetup = useWorkspaceSetupStore((state) =>
shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null),
);
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions, isGit } = useGitActions({
serverId: serverId ?? "",
@@ -209,27 +113,6 @@ export function useWorkspaceCommandCenterActions(): void {
icons: GIT_ACTION_ICONS,
});
const runGitAction = useGitActionRunner();
const clipboard = useWorkspaceClipboardActions();
const copyPath = useCallback(() => {
if (!fields) return;
clipboard.copyPath({
workspaceId: fields.id,
workspaceDirectory: fields.workspaceDirectory,
currentBranch: fields.currentBranch,
});
}, [clipboard, fields]);
const copyBranchName = useCallback(() => {
if (!fields) return;
clipboard.copyBranchName({
workspaceId: fields.id,
workspaceDirectory: fields.workspaceDirectory,
currentBranch: fields.currentBranch,
});
}, [clipboard, fields]);
const { labelCatalog, toggleLabel } = useWorkspaceLabelCatalog(serverId, fields);
const actions = useMemo(
() =>
@@ -269,14 +152,6 @@ export function useWorkspaceCommandCenterActions(): void {
closePane: t("settings.shortcuts.help.closePane"),
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
// Workspace management labels
rename: t("sidebar.workspace.actions.rename"),
copyPath: t("workspace.header.actions.copyPath"),
copyBranchName: t("workspace.header.actions.copyBranchName"),
pin: t("sidebar.workspace.actions.pin"),
unpin: t("sidebar.workspace.actions.unpin"),
showSetup: t("workspace.header.actions.showSetup"),
labelsGroup: t("workspaceLabels.title"),
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
@@ -287,43 +162,27 @@ export function useWorkspaceCommandCenterActions(): void {
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
canOpenBrowserTabs: getIsElectron(),
isGit,
canPin,
canShowSetup,
},
activeTabKind,
activeTabIndex,
activeTabCount: focusedTabs.length,
currentBranch,
isPinned,
labelCatalog,
dispatch: (action) => {
clearCommandCenterFocusRestoreElement();
keyboardActionDispatcher.dispatch(action);
},
runGitAction,
copyPath,
copyBranchName,
toggleLabel,
}),
[
activeTabIndex,
activeTabKind,
canPin,
canShowSetup,
copyBranchName,
copyPath,
currentBranch,
focusedTabs.length,
gitActions,
isCompact,
isGit,
isPinned,
keyboardActionDispatcher,
labelCatalog,
overrides,
runGitAction,
t,
toggleLabel,
],
);
@@ -31,10 +31,7 @@ export function resolveWorkspaceCommandCenterShortcuts({
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
closePane:
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
toggleFocusMode: resolveShortcutKeysForAction("toggle-focus", overrides, platform) ?? undefined,
toggleExplorerSidebar:
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
// Workspace management shortcuts
pinWorkspace: resolveShortcutKeysForAction("pin-workspace", overrides, platform) ?? undefined,
};
}
@@ -1,19 +1,11 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { isFdroidBuild } from "@/constants/build-profile";
import { isNative } from "@/constants/platform";
import { isElectronRuntime } from "@/desktop/host";
import type { Theme } from "@/styles/theme";
const ThemedQrCode = withUnistyles(QrCode);
const ThemedLink2 = withUnistyles(Link2);
const ThemedClipboardPaste = withUnistyles(ClipboardPaste);
const ThemedTerminal = withUnistyles(Terminal);
const foregroundIconMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const styles = StyleSheet.create((theme) => ({
option: {
@@ -45,7 +37,6 @@ export interface AddHostMethodModalProps {
visible: boolean;
onClose: () => void;
onDirectConnection: () => void;
onRemoteSsh: () => void;
onScanQr: () => void;
onPasteLink: () => void;
}
@@ -54,10 +45,10 @@ export function AddHostMethodModal({
visible,
onClose,
onDirectConnection,
onRemoteSsh,
onScanQr,
onPasteLink,
}: AddHostMethodModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.connectionMethods.title") }), [t]);
@@ -69,10 +60,6 @@ export function AddHostMethodModal({
onScanQr();
}, [onScanQr]);
const handleRemoteSsh = useCallback(() => {
onRemoteSsh();
}, [onRemoteSsh]);
const handlePaste = useCallback(() => {
onPasteLink();
}, [onPasteLink]);
@@ -91,7 +78,7 @@ export function AddHostMethodModal({
accessibilityLabel={t("pairing.connectionMethods.direct.title")}
testID="add-host-method-direct"
>
<ThemedLink2 size={18} uniProps={foregroundIconMapping} />
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>{t("pairing.connectionMethods.direct.title")}</Text>
<Text style={styles.optionSubtext}>
@@ -100,24 +87,6 @@ export function AddHostMethodModal({
</View>
</Pressable>
{isElectronRuntime() ? (
<Pressable
style={styles.option}
onPress={handleRemoteSsh}
accessibilityRole="button"
accessibilityLabel={t("pairing.connectionMethods.remoteSsh.title")}
testID="add-host-method-remote-ssh"
>
<ThemedTerminal size={18} uniProps={foregroundIconMapping} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>{t("pairing.connectionMethods.remoteSsh.title")}</Text>
<Text style={styles.optionSubtext}>
{t("pairing.connectionMethods.remoteSsh.description")}
</Text>
</View>
</Pressable>
) : null}
{isNative && !isFdroidBuild ? (
<Pressable
style={styles.option}
@@ -125,7 +94,7 @@ export function AddHostMethodModal({
accessibilityRole="button"
accessibilityLabel={t("pairing.connectionMethods.scanQr.title")}
>
<ThemedQrCode size={18} uniProps={foregroundIconMapping} />
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>{t("pairing.connectionMethods.scanQr.title")}</Text>
<Text style={styles.optionSubtext}>
@@ -142,7 +111,7 @@ export function AddHostMethodModal({
accessibilityLabel={t("pairing.connectionMethods.pasteLink.title")}
testID="add-host-method-pair-link"
>
<ThemedClipboardPaste size={18} uniProps={foregroundIconMapping} />
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>{t("pairing.connectionMethods.pasteLink.title")}</Text>
<Text style={styles.optionSubtext}>
@@ -1,172 +0,0 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Terminal } from "lucide-react-native";
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
import type { HostProfile } from "@/types/host-connection";
import { useHostMutations, useHosts } from "@/runtime/host-runtime";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedTerminal = withUnistyles(Terminal);
const styles = StyleSheet.create((theme) => ({
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[2],
},
}));
export interface AddRemoteSshHostModalProps {
visible: boolean;
onClose: () => void;
onCancel?: () => void;
onSaved?: (result: {
profile: HostProfile;
serverId: string;
hostname: string | null;
isNewHost: boolean;
}) => void;
}
export function AddRemoteSshHostModal({
visible,
onClose,
onCancel,
onSaved,
}: AddRemoteSshHostModalProps) {
const { t } = useTranslation();
const hosts = useHosts();
const isCompact = useIsCompactFormFactor();
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
const targetRef = useRef("");
const inputRef = useRef<EditingTextInputHandle>(null);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.remoteSsh.title") }), [t]);
const clear = useCallback(() => {
targetRef.current = "";
inputRef.current?.replaceText("");
setErrorMessage("");
}, []);
const handleClose = useCallback(() => {
if (isSaving) return;
clear();
onClose();
}, [clear, isSaving, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
clear();
(onCancel ?? onClose)();
}, [clear, isSaving, onCancel, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
const rawTarget = targetRef.current.trim();
if (!rawTarget) {
setErrorMessage(t("pairing.remoteSsh.errors.targetRequired"));
return;
}
let target: ReturnType<typeof parseSshTransportUri>;
try {
target = parseSshTransportUri(rawTarget);
} catch {
setErrorMessage(t("pairing.remoteSsh.errors.invalidTarget"));
return;
}
let result: Awaited<ReturnType<typeof probeAndUpsertRemoteSshConnection>>;
try {
setIsSaving(true);
setErrorMessage("");
result = await probeAndUpsertRemoteSshConnection(target);
} catch (error) {
const message =
error instanceof DaemonConnectionTestError
? t("pairing.remoteSsh.errors.failedToConnect", { detail: error.message })
: t("common.errors.unableToSave");
setErrorMessage(message);
return;
} finally {
setIsSaving(false);
}
clear();
onClose();
onSaved?.({
...result,
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
});
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
const handleTargetChange = useCallback((value: string) => {
targetRef.current = value;
}, []);
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={handleClose}
testID="add-remote-ssh-host-modal"
>
<Text style={styles.helper}>{t("pairing.remoteSsh.helper")}</Text>
<Field
label={t("pairing.remoteSsh.fields.target")}
error={errorMessage}
testID="remote-ssh-target"
>
<FormTextInput
ref={inputRef}
size={isCompact ? "md" : "sm"}
testID="remote-ssh-target-input"
accessibilityLabel={t("pairing.remoteSsh.fields.target")}
initialValue=""
onChangeText={handleTargetChange}
placeholder="ssh://user@host"
autoCapitalize="none"
autoCorrect={false}
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmit}
/>
</Field>
<View style={styles.actions}>
<Button
style={FLEX_ONE_STYLE}
variant="secondary"
onPress={handleCancel}
disabled={isSaving}
>
{t("pairing.remoteSsh.actions.cancel")}
</Button>
<Button
style={FLEX_ONE_STYLE}
onPress={handleSubmit}
disabled={isSaving}
leftIcon={ThemedTerminal}
testID="remote-ssh-submit"
>
{isSaving
? t("pairing.remoteSsh.actions.connecting")
: t("pairing.remoteSsh.actions.connect")}
</Button>
</View>
</AdaptiveModalSheet>
);
}
@@ -9,6 +9,8 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { useMutation } from "@tanstack/react-query";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import {
memo,
useCallback,
@@ -34,8 +36,7 @@ import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import * as Clipboard from "expo-clipboard";
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
@@ -134,6 +135,7 @@ import {
} from "@/utils/sidebar-project-row-model";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { openExternalUrl } from "@/utils/open-external-url";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import {
getCurrentProjectRemoveReadiness,
@@ -1261,14 +1263,42 @@ function WorkspaceRowWithMenu({
archiveController.archive();
}, [archiveController, isArchiving]);
const clipboard = useWorkspaceClipboardActions();
const handleCopyPath = useCallback(() => {
clipboard.copyPath(workspace);
}, [clipboard, workspace]);
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
if (!workspace.currentBranch) {
return;
}
void Clipboard.setStringAsync(workspace.currentBranch);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
}, [t, toast, workspace.currentBranch]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const handleOpenRename = useCallback(() => {
setIsRenameOpen(true);
@@ -1278,6 +1308,13 @@ function WorkspaceRowWithMenu({
setIsRenameOpen(false);
}, []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const isPinned = workspace.pinnedAt != null;
const handleTogglePin = useCallback(() => {
onToggleWorkspacePin(workspace);
@@ -1336,10 +1373,14 @@ function WorkspaceRowWithMenu({
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
/>
<WorkspaceRenameModal
<AdaptiveRenameModal
visible={isRenameOpen}
workspace={workspace}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
@@ -40,11 +40,14 @@ import {
CircleX,
} from "lucide-react-native";
import { useToast } from "@/contexts/toast-context";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -623,17 +626,42 @@ function StatusWorkspaceRowWithMenu({
archiveController.archive();
}, [archiveController, isArchiving]);
const clipboard = useWorkspaceClipboardActions();
const handleCopyPath = useCallback(() => {
clipboard.copyPath(workspace);
}, [clipboard, workspace]);
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied");
}, [toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const isPinned = workspace.pinnedAt != null;
const handleTogglePin = useCallback(() => {
onToggleWorkspacePin(workspace);
@@ -691,10 +719,14 @@ function StatusWorkspaceRowWithMenu({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
<WorkspaceRenameModal
<AdaptiveRenameModal
visible={isRenameOpen}
workspace={workspace}
title="Rename workspace"
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel="Rename"
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
@@ -2,20 +2,23 @@ import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { useToast } from "@/contexts/toast-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
@@ -105,14 +108,42 @@ export function SidebarWorkspaceRow({
archiveController.archive();
}, [archiveController, isArchiving]);
const clipboard = useWorkspaceClipboardActions();
const handleCopyPath = useCallback(() => {
clipboard.copyPath(workspace);
}, [clipboard, workspace]);
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
clipboard.copyBranchName(workspace);
}, [clipboard, workspace]);
if (!workspace.currentBranch) {
return;
}
void Clipboard.setStringAsync(workspace.currentBranch);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
}, [t, toast, workspace.currentBranch]);
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const handleOpenRename = useCallback(() => {
setIsRenameOpen(true);
@@ -122,6 +153,13 @@ export function SidebarWorkspaceRow({
setIsRenameOpen(false);
}, []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(value.trim());
},
[renameMutation],
);
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
@@ -168,10 +206,14 @@ export function SidebarWorkspaceRow({
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<WorkspaceRenameModal
<AdaptiveRenameModal
visible={isRenameOpen}
workspace={workspace}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
+2 -31
View File
@@ -3,19 +3,11 @@ import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
QrCode,
Link2,
ClipboardPaste,
ExternalLink,
Settings,
Terminal,
} from "lucide-react-native";
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { AddHostModal } from "./add-host-modal";
import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version";
@@ -25,10 +17,9 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url";
import { isFdroidBuild } from "@/constants/build-profile";
import { isWeb, isNative } from "@/constants/platform";
import { isElectronRuntime } from "@/desktop/host";
interface WelcomeAction {
key: "scan-qr" | "direct-connection" | "remote-ssh" | "paste-pairing-link";
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
label: string;
testID: string;
primary: boolean;
@@ -174,7 +165,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
@@ -198,8 +188,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []);
const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []);
const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []);
const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []);
const handleScanQr = useCallback(() => {
@@ -261,17 +249,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
];
if (isElectronRuntime()) {
actions.splice(1, 0, {
key: "remote-ssh",
label: t("pairing.connectionMethods.remoteSsh.title"),
testID: "welcome-remote-ssh",
primary: false,
icon: Terminal,
onPress: handleOpenRemoteSsh,
});
}
const scrollContentContainerStyle = useMemo(
() => [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }],
[theme.spacing, insets.bottom],
@@ -323,12 +300,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
onSaved={handleHostSaved}
/>
<AddRemoteSshHostModal
visible={isRemoteSshOpen}
onClose={handleCloseRemoteSsh}
onSaved={handleHostSaved}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={handleClosePasteLink}
@@ -1,93 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
const WORKSPACE_RENAME_ACTIONS: readonly KeyboardActionId[] = ["workspace.rename"];
/**
* The rename dialog used to live only on the sidebar row, so it was unreachable whenever that row
* was not rendered — a collapsed project or status group, a collapsed Pinned section, or focus
* mode. This is the same fix `use-global-workspace-pin-action.ts` applies to pin: one registration
* keyed on the active route selection.
*
* Sidebar rows keep their own modal instances. They rename *their* workspace, not the active one.
*/
export function WorkspaceRenameHost() {
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const routeWorkspaceId = selection?.workspaceId ?? null;
// Narrow projection so the dialog doesn't re-render on every gitRuntime/diffStat tick.
// `id` is projected rather than reusing the route id: the route carries an opaque workspace id
// that is not guaranteed to equal the descriptor id, and setWorkspaceTitle needs the descriptor.
const fields = useWorkspaceFields(serverId, routeWorkspaceId, (workspace) => ({
id: workspace.id,
name: workspace.name,
title: workspace.title ?? null,
}));
const [isOpen, setIsOpen] = useState(false);
const openFrameRef = useRef<number | null>(null);
const cancelPendingOpen = useCallback(() => {
if (openFrameRef.current !== null) {
cancelAnimationFrame(openFrameRef.current);
openFrameRef.current = null;
}
}, []);
useEffect(() => cancelPendingOpen, [cancelPendingOpen]);
// The command center closes and dispatches in the same React batch, so opening synchronously
// would mount this modal while the palette is still unmounting — on Android the portal stacking
// lands the dialog behind it, and on web the palette's teardown steals focus back from the
// input. Wait a frame. (The caller has already cleared the focus-restore element.)
const handle = useCallback(() => {
if (!fields) return false;
cancelPendingOpen();
openFrameRef.current = requestAnimationFrame(() => {
openFrameRef.current = null;
setIsOpen(true);
});
return true;
}, [cancelPendingOpen, fields]);
const handleClose = useCallback(() => {
cancelPendingOpen();
setIsOpen(false);
}, [cancelPendingOpen]);
// Closing on workspace change avoids renaming whatever the user navigated to instead.
useEffect(() => {
cancelPendingOpen();
setIsOpen(false);
}, [cancelPendingOpen, serverId, routeWorkspaceId]);
useKeyboardActionHandler({
handlerId: "workspace-rename-global",
actions: WORKSPACE_RENAME_ACTIONS,
enabled: serverId !== null && fields !== null,
priority: 0,
handle,
});
const workspace = useMemo(
() =>
serverId && fields
? { serverId, workspaceId: fields.id, name: fields.name, title: fields.title }
: null,
[fields, serverId],
);
if (!workspace) return null;
return (
<WorkspaceRenameModal
visible={isOpen}
workspace={workspace}
onClose={handleClose}
testID="workspace-rename-modal-global"
/>
);
}
@@ -1,70 +0,0 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
// The subset of a workspace the rename dialog needs. Narrower than SidebarWorkspaceEntry so the
// command center can build one from the active route selection without a sidebar row.
export interface RenamableWorkspace {
serverId: string;
workspaceId: string;
name: string;
title?: string | null;
}
export interface WorkspaceRenameModalProps {
visible: boolean;
workspace: RenamableWorkspace;
onClose: () => void;
/**
* Prefix for the modal's testIDs. The sidebar callers must keep passing
* `sidebar-workspace-rename-modal-${workspaceKey}` — e2e/browser/sidebar-workspace-rename.spec.ts
* builds its locators from exactly that prefix.
*/
testID?: string;
}
/**
* Owns the setWorkspaceTitle mutation and every rename string, so a caller only tracks its own
* open/closed boolean. Errors surface inline inside AdaptiveRenameModal; the dialog stays open.
*/
export function WorkspaceRenameModal({
visible,
workspace,
onClose,
testID,
}: WorkspaceRenameModalProps) {
const { t } = useTranslation();
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
},
});
const renameAsync = renameMutation.mutateAsync;
const handleSubmit = useCallback(
async (value: string) => {
await renameAsync(value.trim());
},
[renameAsync],
);
return (
<AdaptiveRenameModal
visible={visible}
title={t("sidebar.workspace.rename.title")}
initialValue={workspace.title ?? workspace.name}
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
onClose={onClose}
onSubmit={handleSubmit}
testID={testID}
/>
);
}
@@ -1,17 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import {
buildDesktopDaemonTransportUrl,
createDesktopDaemonTransportFactory,
} from "./desktop-daemon-transport";
import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport";
import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc";
const LOCAL_URL = "paseo+desktop://socket?path=%2Ftmp%2Fpaseo.sock";
const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock";
describe("desktop-daemon-transport", () => {
it("uses the main-process event as readiness when it races registration", async () => {
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
const rpc = createFakeLocalDaemonTransportRpc();
const cleanup = vi.fn();
const transportFactory = createDesktopDaemonTransportFactory(rpc);
const transportFactory = createDesktopLocalDaemonTransportFactory(rpc);
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ url: LOCAL_URL });
@@ -19,93 +15,32 @@ describe("desktop-daemon-transport", () => {
const onOpen = vi.fn();
transport.onOpen(onOpen);
rpc.resolveListen(cleanup);
await Promise.resolve();
rpc.emitEvent({ sessionId: "local-session-1", kind: "open" });
expect(onOpen).not.toHaveBeenCalled();
const sessionId = rpc.openCalls[0]?.sessionId ?? "";
expect(sessionId).not.toBe("");
rpc.emitEvent({ sessionId, kind: "open" });
expect(onOpen).toHaveBeenCalledTimes(1);
rpc.resolveRegistration();
rpc.resolveOpen("local-session-1");
await Promise.resolve();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("does not start a session when listener setup finishes after close", async () => {
it("cleans up late async setup after the transport is closed", async () => {
const rpc = createFakeLocalDaemonTransportRpc();
const cleanup = vi.fn();
const transportFactory = createDesktopDaemonTransportFactory(rpc);
const transportFactory = createDesktopLocalDaemonTransportFactory(rpc);
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ url: LOCAL_URL });
transport.close();
rpc.resolveOpen("local-session-2");
rpc.resolveListen(cleanup);
await Promise.resolve();
await Promise.resolve();
expect(rpc.openCalls).toHaveLength(0);
expect(rpc.closedSessions).toHaveLength(1);
expect(rpc.closedSessions).toEqual(["local-session-2"]);
expect(cleanup).toHaveBeenCalledTimes(1);
});
it("cancels a registered session while readiness is pending", async () => {
const rpc = createFakeLocalDaemonTransportRpc();
const transportFactory = createDesktopDaemonTransportFactory(rpc);
expect(transportFactory).not.toBeNull();
const transport = transportFactory!({ url: LOCAL_URL });
rpc.resolveListen(vi.fn());
await Promise.resolve();
const sessionId = rpc.openCalls[0]?.sessionId ?? "";
expect(sessionId).not.toBe("");
transport.close();
expect(rpc.closedSessions).toEqual([sessionId]);
});
it("passes Remote SSH parameters to the desktop transport bridge", async () => {
const rpc = createFakeLocalDaemonTransportRpc();
const transportFactory = createDesktopDaemonTransportFactory(rpc);
expect(transportFactory).not.toBeNull();
const url = buildDesktopDaemonTransportUrl({
transportType: "ssh",
host: "deploy@example.com",
sshPort: 2222,
daemonPort: 7777,
});
transportFactory!({ url });
rpc.resolveListen(vi.fn());
await Promise.resolve();
expect(rpc.openCalls).toHaveLength(1);
expect(rpc.openCalls[0]?.target).toEqual({
transportType: "ssh",
host: "deploy@example.com",
sshPort: 2222,
daemonPort: 7777,
});
});
it.each([0, 65536])("rejects an out-of-range Remote SSH port (%s)", (sshPort) => {
const transportFactory = createDesktopDaemonTransportFactory(
createFakeLocalDaemonTransportRpc(),
);
expect(transportFactory).not.toBeNull();
const url = buildDesktopDaemonTransportUrl({
transportType: "ssh",
host: "deploy@example.com",
sshPort,
});
expect(() => transportFactory!({ url })).toThrow("Invalid SSH transport target");
});
});
@@ -2,15 +2,13 @@ import type {
DaemonTransport,
DaemonTransportFactory,
} from "@getpaseo/client/internal/daemon-client";
import { validatePort, validateSshHost } from "@getpaseo/protocol/ssh-transport";
import type { DesktopDaemonTransportTarget } from "./desktop-daemon";
import type { LocalTransportTarget } from "./desktop-daemon";
import {
defaultLocalDaemonTransportRpc,
type LocalDaemonTransportEvent,
type LocalDaemonTransportRpc,
} from "./local-daemon-transport-rpc";
const DESKTOP_TRANSPORT_SCHEME = "paseo+desktop:";
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
@@ -30,32 +28,21 @@ function decodeBase64ToBytes(base64: string): Uint8Array {
return bytes;
}
export function buildDesktopDaemonTransportUrl(target: DesktopDaemonTransportTarget): string {
const url = new URL(`${DESKTOP_TRANSPORT_SCHEME}//${target.transportType}`);
if (target.transportType === "ssh") {
url.searchParams.set("host", target.host);
if (target.sshPort !== undefined) {
url.searchParams.set("port", String(target.sshPort));
}
if (target.daemonPort !== undefined) {
url.searchParams.set("daemonPort", String(target.daemonPort));
}
} else {
url.searchParams.set("path", target.transportPath);
}
export function buildLocalDaemonTransportUrl(target: LocalTransportTarget): string {
const url = new URL(`${LOCAL_TRANSPORT_SCHEME}//${target.transportType}`);
url.searchParams.set("path", target.transportPath);
return url.toString();
}
function parseDesktopDaemonTransportUrl(url: string): DesktopDaemonTransportTarget {
function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
const parsed = new URL(url);
if (parsed.protocol !== DESKTOP_TRANSPORT_SCHEME) {
throw new Error(`Unsupported desktop transport URL: ${url}`);
if (parsed.protocol !== LOCAL_TRANSPORT_SCHEME) {
throw new Error(`Unsupported local transport URL: ${url}`);
}
const transportType = parsed.hostname;
if (transportType === "ssh") return parseSshDesktopTransportUrl(parsed, url);
const transportPath = parsed.searchParams.get("path")?.trim() ?? "";
if ((transportType !== "socket" && transportType !== "pipe") || !transportPath) {
throw new Error(`Invalid desktop transport target: ${url}`);
throw new Error(`Invalid local transport target: ${url}`);
}
return {
transportType,
@@ -63,33 +50,12 @@ function parseDesktopDaemonTransportUrl(url: string): DesktopDaemonTransportTarg
};
}
function parseSshDesktopTransportUrl(parsed: URL, rawUrl: string): DesktopDaemonTransportTarget {
try {
const host = validateSshHost(parsed.searchParams.get("host") ?? "");
const sshPort = parseOptionalUrlPort(parsed, "port", "SSH port");
const daemonPort = parseOptionalUrlPort(parsed, "daemonPort", "Daemon port");
return {
transportType: "ssh",
host,
...(sshPort !== undefined ? { sshPort } : {}),
...(daemonPort !== undefined ? { daemonPort } : {}),
};
} catch (error) {
throw new Error(`Invalid SSH transport target: ${rawUrl}`, { cause: error });
}
}
function parseOptionalUrlPort(parsed: URL, key: string, label: string): number | undefined {
const value = parsed.searchParams.get(key);
return value === null ? undefined : validatePort(value, label);
}
export function createDesktopDaemonTransportFactory(
export function createDesktopLocalDaemonTransportFactory(
rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
): DaemonTransportFactory | null {
return ({ url }) => {
const target = parseDesktopDaemonTransportUrl(url);
const sessionId = `local-session-${globalThis.crypto.randomUUID()}`;
const target = parseLocalDaemonTransportUrl(url);
let sessionId: string | null = null;
let unlisten: (() => void) | null = null;
let disposed = false;
let didEmitOpen = false;
@@ -114,9 +80,6 @@ export function createDesktopDaemonTransportFactory(
}
};
const emitError = (event?: unknown) => {
if (disposed) {
return;
}
for (const handler of errorHandlers) {
handler(event);
}
@@ -127,49 +90,61 @@ export function createDesktopDaemonTransportFactory(
}
};
const handleEvent = (payload: LocalDaemonTransportEvent) => {
if (disposed || payload.sessionId !== sessionId) {
return;
}
if (payload.kind === "open") {
emitOpen();
return;
}
if (payload.kind === "message") {
if (payload.text) {
emitMessage(payload.text, false);
void rpc
.listenToEvents((payload) => {
if (disposed || !sessionId || payload.sessionId !== sessionId) {
return;
}
if (payload.binaryBase64) {
emitMessage(decodeBase64ToBytes(payload.binaryBase64), true);
if (payload.kind === "open") {
emitOpen();
return;
}
return;
}
if (payload.kind === "close") {
emitClose(payload);
return;
}
emitError(payload.error ?? "Local daemon transport error");
};
void (async () => {
try {
const cleanup = await rpc.listenToEvents(handleEvent);
if (payload.kind === "message") {
if (payload.text) {
emitMessage(payload.text, false);
return;
}
if (payload.binaryBase64) {
emitMessage(decodeBase64ToBytes(payload.binaryBase64), true);
}
return;
}
if (payload.kind === "close") {
emitClose(payload);
return;
}
emitError(payload.error ?? "Local daemon transport error");
})
.then((cleanup) => {
if (disposed) {
cleanup();
return;
}
unlisten = cleanup;
await rpc.openSession({ sessionId, target });
} catch (error) {
return;
})
.catch((error) => {
emitError(error);
}
})();
});
void rpc
.openSession(target)
.then((id) => {
if (disposed) {
void rpc.closeSession(id).catch((error) => emitError(error));
return;
}
sessionId = id;
emitOpen();
return;
})
.catch((error) => {
emitError(error);
});
const transport: DaemonTransport = {
send: (data) => {
if (!didEmitOpen) {
if (!sessionId) {
return;
}
if (typeof data === "string") {
@@ -182,11 +157,12 @@ export function createDesktopDaemonTransportFactory(
void rpc.sendMessage({ sessionId, binaryBase64 }).catch((error) => emitError(error));
},
close: () => {
if (disposed) {
return;
}
disposed = true;
void rpc.closeSession(sessionId).catch((error) => emitError(error));
const currentSessionId = sessionId;
sessionId = null;
if (currentSessionId) {
void rpc.closeSession(currentSessionId).catch((error) => emitError(error));
}
unlisten?.();
unlisten = null;
},
@@ -40,22 +40,6 @@ export interface LocalTransportTarget {
transportPath: string;
}
export interface RemoteSshTransportTarget {
[key: string]: unknown;
transportType: "ssh";
host: string;
sshPort?: number;
daemonPort?: number;
}
export type DesktopDaemonTransportTarget = LocalTransportTarget | RemoteSshTransportTarget;
export interface OpenLocalTransportSessionInput {
[key: string]: unknown;
sessionId: string;
target: DesktopDaemonTransportTarget;
}
interface LocalTransportEventPayload {
sessionId: string;
kind: "open" | "message" | "close" | "error";
@@ -195,10 +179,12 @@ export async function listenToLocalTransportEvents(
return typeof unlisten === "function" ? unlisten : () => {};
}
export async function openLocalTransportSession(
input: OpenLocalTransportSessionInput,
): Promise<void> {
await invokeDesktopCommand("open_local_daemon_transport", input);
export async function openLocalTransportSession(target: LocalTransportTarget): Promise<string> {
const raw = await invokeDesktopCommand<unknown>("open_local_daemon_transport", target);
if (typeof raw !== "string" || raw.trim().length === 0) {
throw new Error("Unexpected local transport session response.");
}
return raw;
}
export async function sendLocalTransportMessage(input: {
@@ -3,7 +3,7 @@ import {
listenToLocalTransportEvents,
openLocalTransportSession,
sendLocalTransportMessage,
type OpenLocalTransportSessionInput,
type LocalTransportTarget,
} from "./desktop-daemon";
export interface LocalDaemonTransportEvent {
@@ -17,7 +17,7 @@ export interface LocalDaemonTransportEvent {
}
export interface LocalDaemonTransportRpc {
openSession(input: OpenLocalTransportSessionInput): Promise<void>;
openSession(target: LocalTransportTarget): Promise<string>;
listenToEvents(handler: (event: LocalDaemonTransportEvent) => void): Promise<() => void>;
sendMessage(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise<void>;
closeSession(sessionId: string): Promise<void>;
@@ -1,4 +1,4 @@
import type { OpenLocalTransportSessionInput } from "./desktop-daemon";
import type { LocalTransportTarget } from "./desktop-daemon";
import type {
LocalDaemonTransportEvent,
LocalDaemonTransportRpc,
@@ -11,10 +11,10 @@ export interface RecordedSend {
}
export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
readonly openCalls: OpenLocalTransportSessionInput[];
readonly openCalls: LocalTransportTarget[];
readonly recordedSends: RecordedSend[];
readonly closedSessions: string[];
resolveRegistration(): void;
resolveOpen(sessionId: string): void;
rejectOpen(error: Error): void;
resolveListen(cleanup: () => void): void;
rejectListen(error: Error): void;
@@ -22,12 +22,12 @@ export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
}
export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc {
const openCalls: OpenLocalTransportSessionInput[] = [];
const openCalls: LocalTransportTarget[] = [];
const recordedSends: RecordedSend[] = [];
const closedSessions: string[] = [];
let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null;
let resolveRegistrationPromise: (() => void) | null = null;
let rejectRegistrationPromise: ((error: Error) => void) | null = null;
let resolveOpenSession: ((sessionId: string) => void) | null = null;
let rejectOpenSession: ((error: Error) => void) | null = null;
let resolveListenPromise: ((cleanup: () => void) => void) | null = null;
let rejectListenPromise: ((error: Error) => void) | null = null;
@@ -35,11 +35,11 @@ export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc
openCalls,
recordedSends,
closedSessions,
openSession(input) {
openCalls.push(input);
return new Promise<void>((resolve, reject) => {
resolveRegistrationPromise = resolve;
rejectRegistrationPromise = reject;
openSession(target) {
openCalls.push(target);
return new Promise<string>((resolve, reject) => {
resolveOpenSession = resolve;
rejectOpenSession = reject;
});
},
listenToEvents(handler) {
@@ -55,17 +55,17 @@ export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc
async closeSession(sessionId) {
closedSessions.push(sessionId);
},
resolveRegistration() {
if (!resolveRegistrationPromise) {
resolveOpen(sessionId) {
if (!resolveOpenSession) {
throw new Error("openSession was not called");
}
resolveRegistrationPromise();
resolveOpenSession(sessionId);
},
rejectOpen(error) {
if (!rejectRegistrationPromise) {
if (!rejectOpenSession) {
throw new Error("openSession was not called");
}
rejectRegistrationPromise(error);
rejectOpenSession(error);
},
resolveListen(cleanup) {
if (!resolveListenPromise) {
@@ -1,58 +0,0 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import * as Clipboard from "expo-clipboard";
import { useToast } from "@/contexts/toast-context";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
// Everything the copy actions actually need. Kept narrower than SidebarWorkspaceEntry so the
// command center can build one from the active route selection without a sidebar row.
export interface CopyableWorkspace {
workspaceId: string;
workspaceDirectory: string | null | undefined;
currentBranch: string | null | undefined;
}
export interface WorkspaceClipboardActions {
copyPath: (workspace: CopyableWorkspace) => void;
copyBranchName: (workspace: CopyableWorkspace) => void;
}
export function useWorkspaceClipboardActions(): WorkspaceClipboardActions {
const { t } = useTranslation();
const toast = useToast();
const copyPath = useCallback(
(workspace: CopyableWorkspace) => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
},
[t, toast],
);
const copyBranchName = useCallback(
(workspace: CopyableWorkspace) => {
if (!workspace.currentBranch) {
return;
}
void Clipboard.setStringAsync(workspace.currentBranch);
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
},
[t, toast],
);
return useMemo(() => ({ copyPath, copyBranchName }), [copyBranchName, copyPath]);
}
-22
View File
@@ -1558,10 +1558,6 @@ export const ar: TranslationResources = {
title: "اتصال مباشر",
description: "الشبكة المحلية أو VPN.",
},
remoteSsh: {
title: "SSH عن بُعد",
description: "الاتصال عبر عميل SSH لسطح المكتب.",
},
scanQr: {
title: "مسح رمز QR",
description: "اتصال التتابع المشفر.",
@@ -1614,23 +1610,6 @@ export const ar: TranslationResources = {
details: "التفاصيل:{{detail}}",
},
},
remoteSsh: {
title: "SSH عن بُعد",
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
fields: {
target: "مضيف SSH",
},
actions: {
cancel: "إلغاء",
connect: "اتصال",
connecting: "جارٍ الاتصال...",
},
errors: {
targetRequired: "مضيف SSH مطلوب",
invalidTarget: "أدخل مضيف ssh:// صالحًا",
failedToConnect: "تعذر الاتصال عبر SSH. {{detail}}",
},
},
link: {
title: "الصق رابط الاقتران",
helper: "الصق رابط الاقتران من الخادم الخاص بك.",
@@ -2265,7 +2244,6 @@ export const ar: TranslationResources = {
badges: {
relay: "تتابع",
local: "محلي",
remoteSsh: "SSH عن بُعد",
},
connections: {
title: "اتصالات",
-22
View File
@@ -1568,10 +1568,6 @@ export const en = {
title: "Direct connection",
description: "Local network or VPN.",
},
remoteSsh: {
title: "Remote SSH",
description: "Connect through the desktop SSH client.",
},
scanQr: {
title: "Scan QR code",
description: "Encrypted relay connection.",
@@ -1623,23 +1619,6 @@ export const en = {
details: "Details: {{detail}}",
},
},
remoteSsh: {
title: "Remote SSH",
helper: "Connect to a Paseo daemon running on the remote host.",
fields: {
target: "SSH host",
},
actions: {
cancel: "Cancel",
connect: "Connect",
connecting: "Connecting...",
},
errors: {
targetRequired: "SSH host is required",
invalidTarget: "Enter a valid ssh:// host",
failedToConnect: "Unable to connect over SSH. {{detail}}",
},
},
link: {
title: "Paste pairing link",
helper: "Paste the pairing link from your server.",
@@ -2363,7 +2342,6 @@ export const en = {
badges: {
relay: "Relay",
local: "Local",
remoteSsh: "Remote SSH",
},
connections: {
title: "Connections",
-22
View File
@@ -1602,10 +1602,6 @@ export const es: TranslationResources = {
title: "Conexión directa",
description: "Red local o VPN.",
},
remoteSsh: {
title: "SSH remoto",
description: "Conéctate mediante el cliente SSH de escritorio.",
},
scanQr: {
title: "Escanea el códigoQR",
description: "Conexión de retransmisión cifrada.",
@@ -1658,23 +1654,6 @@ export const es: TranslationResources = {
details: "Detalles:{{detail}}",
},
},
remoteSsh: {
title: "SSH remoto",
helper: "Conéctate a un daemon de Paseo en el host remoto.",
fields: {
target: "Host SSH",
},
actions: {
cancel: "Cancelar",
connect: "Conectar",
connecting: "Conectando...",
},
errors: {
targetRequired: "El host SSH es obligatorio",
invalidTarget: "Introduce un host ssh:// válido",
failedToConnect: "No se pudo conectar por SSH. {{detail}}",
},
},
link: {
title: "Pegar enlace de emparejamiento",
helper: "Pegue el enlace de emparejamiento de su servidor.",
@@ -2316,7 +2295,6 @@ export const es: TranslationResources = {
badges: {
relay: "Relé",
local: "Local",
remoteSsh: "SSH remoto",
},
connections: {
title: "Conexiones",
-22
View File
@@ -1606,10 +1606,6 @@ export const fr: TranslationResources = {
title: "Connexion directe",
description: "Réseau local ou VPN.",
},
remoteSsh: {
title: "SSH distant",
description: "Connectez-vous avec le client SSH de bureau.",
},
scanQr: {
title: "Scanner le codeQR",
description: "Connexion relais cryptée.",
@@ -1662,23 +1658,6 @@ export const fr: TranslationResources = {
details: "Détails:{{detail}}",
},
},
remoteSsh: {
title: "SSH distant",
helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.",
fields: {
target: "Hôte SSH",
},
actions: {
cancel: "Annuler",
connect: "Connecter",
connecting: "Connexion...",
},
errors: {
targetRequired: "L’hôte SSH est requis",
invalidTarget: "Saisissez un hôte ssh:// valide",
failedToConnect: "Connexion SSH impossible. {{detail}}",
},
},
link: {
title: "Coller le lien d'association",
helper: "Collez le lien d'appairage depuis votre serveur.",
@@ -2321,7 +2300,6 @@ export const fr: TranslationResources = {
badges: {
relay: "Relais",
local: "Locale",
remoteSsh: "SSH distant",
},
connections: {
title: "Relations",
-22
View File
@@ -1573,10 +1573,6 @@ export const ja: TranslationResources = {
title: "直接接続",
description: "ローカルネットワークまたはVPN。",
},
remoteSsh: {
title: "リモート SSH",
description: "デスクトップの SSH クライアント経由で接続します。",
},
scanQr: {
title: "QRコードをスキャン",
description: "暗号化されたリレー接続。",
@@ -1628,23 +1624,6 @@ export const ja: TranslationResources = {
details: "詳細: {{detail}}",
},
},
remoteSsh: {
title: "リモート SSH",
helper: "リモートホストで動作する Paseo デーモンに接続します。",
fields: {
target: "SSH ホスト",
},
actions: {
cancel: "キャンセル",
connect: "接続",
connecting: "接続中...",
},
errors: {
targetRequired: "SSH ホストは必須です",
invalidTarget: "有効な ssh:// ホストを入力してください",
failedToConnect: "SSH で接続できません。{{detail}}",
},
},
link: {
title: "ペアリングリンクを貼り付け",
helper: "サーバーからのペアリングリンクを貼り付けてください。",
@@ -2284,7 +2263,6 @@ export const ja: TranslationResources = {
badges: {
relay: "リレー",
local: "ローカル",
remoteSsh: "リモート SSH",
},
connections: {
title: "接続",
-22
View File
@@ -1568,10 +1568,6 @@ export const ko: TranslationResources = {
title: "직접 연결",
description: "로컬 네트워크 또는 VPN.",
},
remoteSsh: {
title: "원격 SSH",
description: "데스크톱 SSH 클라이언트를 통해 연결합니다.",
},
scanQr: {
title: "QR 코드 스캔",
description: "암호화된 릴레이 연결.",
@@ -1622,23 +1618,6 @@ export const ko: TranslationResources = {
details: "세부 정보: {{detail}}",
},
},
remoteSsh: {
title: "원격 SSH",
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
fields: {
target: "SSH 호스트",
},
actions: {
cancel: "취소",
connect: "연결",
connecting: "연결 중...",
},
errors: {
targetRequired: "SSH 호스트가 필요합니다",
invalidTarget: "유효한 ssh:// 호스트를 입력하세요",
failedToConnect: "SSH로 연결할 수 없습니다. {{detail}}",
},
},
link: {
title: "페어링 링크 붙여넣기",
helper: "서버에서 받은 페어링 링크를 붙여넣으세요.",
@@ -2275,7 +2254,6 @@ export const ko: TranslationResources = {
badges: {
relay: "릴레이",
local: "로컬",
remoteSsh: "원격 SSH",
},
connections: {
title: "연결",
-22
View File
@@ -1588,10 +1588,6 @@ export const ptBR: TranslationResources = {
title: "Conexão direta",
description: "Rede local ou VPN.",
},
remoteSsh: {
title: "SSH remoto",
description: "Conecte-se pelo cliente SSH do desktop.",
},
scanQr: {
title: "Escanear QR code",
description: "Conexão relay criptografada.",
@@ -1644,23 +1640,6 @@ export const ptBR: TranslationResources = {
details: "Detalhes: {{detail}}",
},
},
remoteSsh: {
title: "SSH remoto",
helper: "Conecte-se a um daemon Paseo no host remoto.",
fields: {
target: "Host SSH",
},
actions: {
cancel: "Cancelar",
connect: "Conectar",
connecting: "Conectando...",
},
errors: {
targetRequired: "O host SSH é obrigatório",
invalidTarget: "Insira um host ssh:// válido",
failedToConnect: "Não foi possível conectar por SSH. {{detail}}",
},
},
link: {
title: "Colar link de pareamento",
helper: "Cole o link de pareamento do seu servidor.",
@@ -2300,7 +2279,6 @@ export const ptBR: TranslationResources = {
badges: {
relay: "Relay",
local: "Local",
remoteSsh: "SSH remoto",
},
connections: {
title: "Conexões",
-22
View File
@@ -1585,10 +1585,6 @@ export const ru: TranslationResources = {
title: "Прямое подключение",
description: "Локальная сеть или VPN.",
},
remoteSsh: {
title: "Удалённый SSH",
description: "Подключение через SSH-клиент настольного приложения.",
},
scanQr: {
title: "Сканировать QR-код",
description: "Зашифрованное подключение через ретранслятор.",
@@ -1641,23 +1637,6 @@ export const ru: TranslationResources = {
details: "Подробности: {{detail}}",
},
},
remoteSsh: {
title: "Удалённый SSH",
helper: "Подключитесь к демону Paseo на удалённом хосте.",
fields: {
target: "Хост SSH",
},
actions: {
cancel: "Отмена",
connect: "Подключить",
connecting: "Подключение...",
},
errors: {
targetRequired: "Укажите хост SSH",
invalidTarget: "Укажите корректный хост ssh://",
failedToConnect: "Не удалось подключиться по SSH. {{detail}}",
},
},
link: {
title: "Вставить ссылку для сопряжения",
helper: "Вставьте ссылку на сопряжение с вашего сервера.",
@@ -2302,7 +2281,6 @@ export const ru: TranslationResources = {
badges: {
relay: "Ретранслятор",
local: "Локальный",
remoteSsh: "Удалённый SSH",
},
connections: {
title: "Подключения",
-22
View File
@@ -1541,10 +1541,6 @@ export const zhCN: TranslationResources = {
title: "直接连接",
description: "本地网络或 VPN。",
},
remoteSsh: {
title: "远程 SSH",
description: "通过桌面 SSH 客户端连接。",
},
scanQr: {
title: "扫描二维码",
description: "加密 relay 连接。",
@@ -1595,23 +1591,6 @@ export const zhCN: TranslationResources = {
details: "详情:{{detail}}",
},
},
remoteSsh: {
title: "远程 SSH",
helper: "连接到远程主机上运行的 Paseo 守护进程。",
fields: {
target: "SSH 主机",
},
actions: {
cancel: "取消",
connect: "连接",
connecting: "正在连接...",
},
errors: {
targetRequired: "SSH 主机为必填项",
invalidTarget: "请输入有效的 ssh:// 主机",
failedToConnect: "无法通过 SSH 连接。{{detail}}",
},
},
link: {
title: "粘贴配对链接",
helper: "粘贴来自 server 的配对链接。",
@@ -2239,7 +2218,6 @@ export const zhCN: TranslationResources = {
badges: {
relay: "Relay",
local: "本地",
remoteSsh: "远程 SSH",
},
connections: {
title: "连接",
@@ -51,10 +51,7 @@ export type KeyboardActionId =
| "workspace.project.pick"
| "worktree.new"
| "workspace.archive"
| "workspace.pin"
// Command-center only: no keybind, so these are absent from route-shortcut.ts.
| "workspace.rename"
| "workspace.setup.show";
| "workspace.pin";
export type KeyboardActionDefinition =
| { id: "agent.interrupt"; scope: KeyboardActionScope }
@@ -109,9 +106,7 @@ export type KeyboardActionDefinition =
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "workspace.archive"; scope: KeyboardActionScope }
| { id: "workspace.pin"; scope: KeyboardActionScope }
| { id: "workspace.rename"; scope: KeyboardActionScope }
| { id: "workspace.setup.show"; scope: KeyboardActionScope };
| { id: "workspace.pin"; scope: KeyboardActionScope };
export interface KeyboardActionHandler {
handlerId: string;
+5 -48
View File
@@ -9,7 +9,6 @@ import {
} from "@getpaseo/client/internal/daemon-client";
import {
connectionFromListen,
createRemoteSshHostConnection,
normalizeStoredHostProfile,
upsertHostConnectionInProfiles,
registryHasConnection,
@@ -39,8 +38,8 @@ import {
type ConnectionProbeState,
} from "@/utils/connection-selection";
import {
buildDesktopDaemonTransportUrl,
createDesktopDaemonTransportFactory,
buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
import { getDesktopHost } from "@/desktop/host";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
@@ -82,7 +81,6 @@ export type ActiveConnection =
| { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
| { type: "directPipe"; endpoint: string; display: "pipe" }
| { type: "remoteSsh"; endpoint: string; display: string }
| { type: "relay"; endpoint: string; display: "relay" };
export type HostRuntimeAgentDirectoryStatus =
@@ -213,13 +211,6 @@ function toActiveConnection(connection: HostConnection): ActiveConnection {
display: "pipe",
};
}
if (connection.type === "remoteSsh") {
return {
type: "remoteSsh",
endpoint: connection.host,
display: connection.host,
};
}
if (connection.type === "directTcp") {
return {
type: "directTcp",
@@ -502,7 +493,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
return {
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
const desktopTransportFactory = createDesktopDaemonTransportFactory();
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
const webSocketConfig = { webSocketFactory: createAppWebSocketFactory() };
const base = {
suppressSendErrors: true,
@@ -516,28 +507,13 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({
...base,
...(desktopTransportFactory ? { transportFactory: desktopTransportFactory } : {}),
url: buildDesktopDaemonTransportUrl({
...(localTransportFactory ? { transportFactory: localTransportFactory } : {}),
url: buildLocalDaemonTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path,
}),
});
}
if (connection.type === "remoteSsh") {
if (!desktopTransportFactory) {
throw new Error("Remote SSH is only available in the desktop app.");
}
return new DaemonClient({
...base,
transportFactory: desktopTransportFactory,
url: buildDesktopDaemonTransportUrl({
transportType: "ssh",
host: connection.host,
...(connection.sshPort !== undefined ? { sshPort: connection.sshPort } : {}),
...(connection.daemonPort !== undefined ? { daemonPort: connection.daemonPort } : {}),
}),
});
}
if (connection.type === "directTcp") {
return new DaemonClient({
...base,
@@ -1778,18 +1754,6 @@ export class HostRuntimeStore {
});
}
async probeAndUpsertRemoteSshConnection(input: {
host: string;
sshPort?: number;
daemonPort?: number;
label?: string;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
return this.probeAndUpsertConnection({
label: input.label,
connection: createRemoteSshHostConnection(input),
});
}
async upsertRelayConnection(input: {
serverId: string;
relayEndpoint: string;
@@ -2524,12 +2488,6 @@ export interface HostMutations {
password?: string;
label?: string;
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
probeAndUpsertRemoteSshConnection: (input: {
host: string;
sshPort?: number;
daemonPort?: number;
label?: string;
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
upsertRelayConnection: (input: {
serverId: string;
relayEndpoint: string;
@@ -2555,7 +2513,6 @@ export function useHostMutations(): HostMutations {
() => ({
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input),
probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input),
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
@@ -74,7 +74,6 @@ import { BackHeader } from "@/components/headers/back-header";
import { ScreenHeader } from "@/components/headers/screen-header";
import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { EditorSection } from "@/screens/settings/editor-section";
@@ -1188,7 +1187,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isRemoteSshVisible, setIsRemoteSshVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
@@ -1295,13 +1293,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
const closeAddConnectionFlow = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
setIsAddHostMethodVisible(true);
}, []);
@@ -1323,11 +1319,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
setIsDirectHostVisible(true);
}, []);
const handleSelectRemoteSsh = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsRemoteSshVisible(true);
}, []);
const handleSelectPasteLink = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsPasteLinkVisible(true);
@@ -1541,7 +1532,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
visible={isAddHostMethodVisible}
onClose={closeAddConnectionFlow}
onDirectConnection={handleSelectDirectConnection}
onRemoteSsh={handleSelectRemoteSsh}
onPasteLink={handleSelectPasteLink}
onScanQr={handleScanQr}
/>
@@ -1551,12 +1541,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<AddRemoteSshHostModal
visible={isRemoteSshVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<PairLinkModal
visible={isPasteLinkVisible}
onClose={closeAddConnectionFlow}
@@ -111,9 +111,6 @@ function formatHostConnectionLabel(connection: HostConnection, t: TFunction): st
if (connection.type === "directSocket" || connection.type === "directPipe") {
return `${t("settings.host.badges.local")} (${connection.path})`;
}
if (connection.type === "remoteSsh") {
return `${t("settings.host.badges.remoteSsh")} (${connection.host})`;
}
return `TCP (${connection.endpoint})`;
}
@@ -135,12 +132,6 @@ function formatActiveConnectionBadge(
text: t("settings.host.badges.local"),
};
}
if (activeConnection.type === "remoteSsh") {
return {
icon: <Globe size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: t("settings.host.badges.remoteSsh"),
};
}
return {
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: activeConnection.display,
@@ -195,6 +186,7 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
}
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme, t);
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const statusDotStyle = useMemo(
() => [styles.statusDot, { backgroundColor: statusDotColor }],
[statusDotColor],
@@ -3304,8 +3304,7 @@ function WorkspaceScreenContent({
],
);
// Shared by every handler below: these actions only exist on a focused workspace route.
const workspaceActionsEnabled = Boolean(
const workspaceKeyboardActionsEnabled = Boolean(
isRouteFocused && normalizedServerId && normalizedWorkspaceId,
);
@@ -3324,7 +3323,7 @@ function WorkspaceScreenContent({
"workspace.browser.new",
"workspace.tab.menu.open",
] as const,
enabled: workspaceActionsEnabled,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceTabAction,
@@ -3342,7 +3341,7 @@ function WorkspaceScreenContent({
"workspace.tab.target.changes",
"workspace.tab.target.files",
] as const,
enabled: workspaceActionsEnabled,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceDirectTargetAction,
@@ -3355,7 +3354,7 @@ function WorkspaceScreenContent({
workspaceId: normalizedWorkspaceId,
}),
actions: ["workspace.tab.open"] as const,
enabled: workspaceActionsEnabled,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspacePanelOpenAction,
@@ -3374,7 +3373,7 @@ function WorkspaceScreenContent({
"workspace.tab.copy-id",
"workspace.tab.copy-file-path",
] as const,
enabled: workspaceActionsEnabled,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceCurrentTabMetadataAction,
@@ -3391,7 +3390,7 @@ function WorkspaceScreenContent({
"workspace.tab.close-right",
"workspace.tab.close-others",
] as const,
enabled: workspaceActionsEnabled,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceCurrentTabCloseAction,
@@ -3417,7 +3416,7 @@ function WorkspaceScreenContent({
"workspace.pane.close",
"workspace.focus.toggle",
] as const,
enabled: workspaceActionsEnabled,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspacePaneAction,
@@ -3430,28 +3429,12 @@ function WorkspaceScreenContent({
workspaceId: normalizedWorkspaceId,
}),
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
enabled: workspaceActionsEnabled,
enabled: workspaceKeyboardActionsEnabled,
priority: 100,
isActive: () => true,
handle: handleWorkspaceSidebarAction,
});
// Gated on the same predicate as the header menu item, so the command center never lists a
// Show setup entry the menu would hide.
// Gated by isActive so the handler is only dispatched when the workspace has visible setup;
// the command center contribution is separately gated by canShowSetup in workspace-registration.
useKeyboardActionHandler({
handlerId: `workspace-setup-show:${normalizedServerId}:${normalizedWorkspaceId}`,
actions: ["workspace.setup.show"] as const,
enabled: workspaceActionsEnabled,
priority: 100,
isActive: () => showWorkspaceSetup,
handle: () => {
handleOpenSetupTab();
return true;
},
});
const activeTabDescriptor = useMemo(() => activeTab?.descriptor ?? null, [activeTab]);
const activeFileFields = getWorkspaceFileLocationFields(activeTabDescriptor);
const activeFilePath = activeFileFields.path;
@@ -3391,99 +3391,6 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(mainTab?.tabId);
});
it("reconcileTabs stays stable when startup removes a duplicate agent kept in Explorer", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
root: {
kind: "group",
group: {
id: "root",
direction: "horizontal",
sizes: [0.78, 0.22],
children: [
createPane({
id: "main",
tabIds: ["new", "draft-origin-agent"],
focusedTabId: "draft-origin-agent",
targetsByTabId: {
new: { kind: "new_tab" },
"draft-origin-agent": { kind: "agent", agentId: "agent-1" },
},
}),
createPane({
id: "explorer",
tabIds: ["files", "changes_tree", "agent_agent-1"],
focusedTabId: "agent_agent-1",
targetsByTabId: {
files: { kind: "files" },
changes_tree: { kind: "changes_tree" },
"agent_agent-1": { kind: "agent", agentId: "agent-1" },
},
}),
],
},
},
focusedPaneId: "main",
},
},
explorerSidebarPaneIdByWorkspace: {
...state.explorerSidebarPaneIdByWorkspace,
[workspaceKey]: "explorer",
},
}));
store.reconcileTabs(workspaceKey, {
agentsHydrated: false,
terminalsHydrated: false,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
});
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].focusedPaneId).toBe(
"main",
);
workspaceLayoutStore.setState((state) => ({
layoutByWorkspace: {
...state.layoutByWorkspace,
[workspaceKey]: {
...state.layoutByWorkspace[workspaceKey],
focusedPaneId: "explorer",
},
},
}));
const emptySnapshot = {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
};
store.reconcileTabs(workspaceKey, emptySnapshot);
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterSecondReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
store.reconcileTabs(workspaceKey, emptySnapshot);
const afterThirdReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
expect(afterThirdReconcile).toBe(afterSecondReconcile);
const mainTabs = collectAllTabs(afterThirdReconcile.root).filter(
(tab) => findPaneContainingTab(afterThirdReconcile.root, tab.tabId)?.id === "main",
);
expect(mainTabs).toEqual([
expect.objectContaining({ target: expect.objectContaining({ kind: "draft" }) }),
]);
});
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
const workspaceKey = createWorkspaceKey();
@@ -1182,15 +1182,9 @@ export function createWorkspaceLayoutStore(
}
set((state) => {
const rawLayout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
rawLayout,
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
);
const currentLayout = keepWorkspaceFocusOutOfExplorerSidebar(
rawLayout,
explorerSidebarPaneId,
rawLayout.focusedPaneId,
const currentLayout = getWorkspaceLayout(
state.layoutByWorkspace,
normalizedWorkspaceKey,
);
const nextState = reconcileWorkspaceTabs(
{
@@ -1198,23 +1192,21 @@ export function createWorkspaceLayoutStore(
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
explorerSidebarPaneId,
explorerSidebarPaneId: resolveExplorerSidebarPaneId(
currentLayout,
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
),
},
snapshot,
);
const nextLayout = keepWorkspaceFocusOutOfExplorerSidebar(
nextState.layout,
explorerSidebarPaneId,
currentLayout.focusedPaneId,
);
if (nextLayout === rawLayout) {
if (nextState.layout === currentLayout) {
return state;
}
return {
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
[normalizedWorkspaceKey]: nextState.layout,
},
};
});
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import { defaultHostAppearance } from "@/hosts/appearance";
import {
createRemoteSshHostConnection,
normalizeStoredHostProfile,
orderHostsLocalFirst,
resolveActiveHostServerId,
@@ -142,51 +141,6 @@ describe("normalizeStoredHostProfile", () => {
expect(profile?.appearance).toEqual({ color: "teal", badgeDisplay: "icon" });
});
it("normalizes stored Remote SSH connection parameters", () => {
const profile = normalizeStoredHostProfile({
serverId: "srv_ssh",
connections: [
{
type: "remoteSsh",
host: " deploy@example.com ",
sshPort: 2222,
daemonPort: 7777,
},
],
});
expect(profile?.connections[0]).toEqual({
id: "ssh:deploy%40example.com:2222:7777",
type: "remoteSsh",
host: "deploy@example.com",
sshPort: 2222,
daemonPort: 7777,
});
});
});
describe("createRemoteSshHostConnection", () => {
it("keeps optional SSH settings absent", () => {
expect(createRemoteSshHostConnection({ host: "build-box" })).toEqual({
id: "ssh:build-box::",
type: "remoteSsh",
host: "build-box",
});
});
it("rejects invalid SSH destinations and ports", () => {
expect(() => createRemoteSshHostConnection({ host: "" })).toThrow("SSH host is required");
expect(() => createRemoteSshHostConnection({ host: "bad host" })).toThrow(
"SSH host is invalid",
);
expect(() => createRemoteSshHostConnection({ host: "build-box", sshPort: 70000 })).toThrow(
"SSH port must be between 1 and 65535",
);
expect(() => createRemoteSshHostConnection({ host: "build-box", daemonPort: 0 })).toThrow(
"Daemon port must be between 1 and 65535",
);
});
});
describe("upsertHostConnectionInProfiles", () => {
-75
View File
@@ -6,11 +6,6 @@ import {
DirectTcpHostConnectionSchema,
type DirectTcpHostConnection,
} from "@getpaseo/protocol/host-connection-schema";
import {
DEFAULT_SSH_DAEMON_PORT,
validatePort,
validateSshHost,
} from "@getpaseo/protocol/ssh-transport";
import {
type HostAppearance,
defaultHostAppearance,
@@ -32,14 +27,6 @@ export interface DirectPipeHostConnection {
path: string;
}
export interface RemoteSshHostConnection {
id: string;
type: "remoteSsh";
host: string;
sshPort?: number;
daemonPort?: number;
}
export interface RelayHostConnection {
id: string;
type: "relay";
@@ -52,7 +39,6 @@ export type HostConnection =
| DirectTcpHostConnection
| DirectSocketHostConnection
| DirectPipeHostConnection
| RemoteSshHostConnection
| RelayHostConnection;
export type HostLifecycle = Record<string, never>;
@@ -137,9 +123,6 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
if (left.type === "directPipe" && right.type === "directPipe") {
return left.path === right.path;
}
if (left.type === "remoteSsh" && right.type === "remoteSsh") {
return remoteSshConnectionEquals(left, right);
}
if (left.type === "relay" && right.type === "relay") {
return (
left.relayEndpoint === right.relayEndpoint &&
@@ -151,17 +134,6 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool
return false;
}
function remoteSshConnectionEquals(
left: RemoteSshHostConnection,
right: RemoteSshHostConnection,
): boolean {
return (
left.host === right.host &&
left.sshPort === right.sshPort &&
left.daemonPort === right.daemonPort
);
}
function hostLifecycleEquals(left: HostLifecycle, right: HostLifecycle): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
@@ -322,35 +294,6 @@ export function connectionFromListen(listen: string): HostConnection | null {
}
}
export function createRemoteSshHostConnection(input: {
host: string;
sshPort?: number;
daemonPort?: number;
}): RemoteSshHostConnection {
const host = validateSshHost(input.host);
const sshPort = input.sshPort === undefined ? undefined : validatePort(input.sshPort, "SSH port");
const daemonPort =
input.daemonPort === undefined || input.daemonPort === DEFAULT_SSH_DAEMON_PORT
? undefined
: validatePort(input.daemonPort, "Daemon port");
const id = [
"ssh",
encodeURIComponent(host),
sshPort === undefined ? "" : String(sshPort),
daemonPort === undefined ? "" : String(daemonPort),
].join(":");
return {
id,
type: "remoteSsh",
host,
...(sshPort !== undefined ? { sshPort } : {}),
...(daemonPort !== undefined ? { daemonPort } : {}),
};
}
const StoredHostConnectionSchema = z.discriminatedUnion("type", [
z.strictObject({
id: z.string().optional(),
@@ -369,13 +312,6 @@ const StoredHostConnectionSchema = z.discriminatedUnion("type", [
type: z.literal("directPipe"),
path: z.string(),
}),
z.strictObject({
id: z.string().optional(),
type: z.literal("remoteSsh"),
host: z.string(),
sshPort: z.number().optional(),
daemonPort: z.number().optional(),
}),
z.strictObject({
id: z.string().optional(),
type: z.literal("relay"),
@@ -420,17 +356,6 @@ function normalizeStoredConnection(connection: StoredHostConnection): HostConnec
const path = connection.path.trim();
return path ? { id: `pipe:${path}`, type: "directPipe", path } : null;
}
if (connection.type === "remoteSsh") {
try {
return createRemoteSshHostConnection({
host: connection.host,
...(connection.sshPort !== undefined ? { sshPort: connection.sshPort } : {}),
...(connection.daemonPort !== undefined ? { daemonPort: connection.daemonPort } : {}),
});
} catch {
return null;
}
}
if (connection.type === "relay") {
try {
const relayEndpoint = normalizeHostPort(connection.relayEndpoint);
@@ -43,13 +43,9 @@ class FakeDaemonProbe {
return "cid_shared_probe_test";
},
resolveAppVersion: () => null,
createDesktopTransportFactory: () => null,
buildDesktopTransportUrl: (target) => {
if (target.transportType === "ssh") {
return `paseo+desktop://ssh?host=${encodeURIComponent(target.host)}`;
}
return `paseo+desktop://${target.transportType}?path=${encodeURIComponent(target.transportPath)}`;
},
createLocalTransportFactory: () => null,
buildLocalTransportUrl: ({ transportType, transportPath }) =>
`paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
createClient: (config) => {
const client = new FakeDaemonClient(this, config);
this.createdClients.push(client);
@@ -141,32 +137,7 @@ describe("test-daemon-connection connectToDaemon", () => {
);
await result.client.close();
expect(probe.createdConfigs()[0]?.url).toBe("paseo+desktop://socket?path=%2Ftmp%2Fpaseo.sock");
});
it("uses the desktop transport for Remote SSH connections", async () => {
const { connectToDaemon } = await import("./test-daemon-connection");
const transportFactory = vi.fn();
const result = await connectToDaemon(
{
id: "ssh:deploy%40example.com:2222:%2Fkeys%2Fpaseo",
type: "remoteSsh",
host: "deploy@example.com",
sshPort: 2222,
daemonPort: 7777,
},
undefined,
{
...probe.deps,
createDesktopTransportFactory: () => transportFactory,
},
);
await result.client.close();
expect(probe.createdConfigs()[0]).toMatchObject({
url: "paseo+desktop://ssh?host=deploy%40example.com",
transportFactory,
});
expect(probe.createdConfigs()[0]?.url).toBe("paseo+local://socket?path=%2Ftmp%2Fpaseo.sock");
});
it("passes direct TCP connection passwords into the client config", async () => {
@@ -9,10 +9,9 @@ import {
shouldUseTlsForDefaultHostedRelay,
} from "./daemon-endpoints";
import {
buildDesktopDaemonTransportUrl,
createDesktopDaemonTransportFactory,
buildLocalDaemonTransportUrl,
createDesktopLocalDaemonTransportFactory,
} from "@/desktop/daemon/desktop-daemon-transport";
import type { DesktopDaemonTransportTarget } from "@/desktop/daemon/desktop-daemon";
export interface DaemonProbeClient {
readonly lastError: string | null;
@@ -21,45 +20,27 @@ export interface DaemonProbeClient {
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
}
interface LocalTransportUrlInput {
transportType: "socket" | "pipe";
transportPath: string;
}
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
getClientId(): Promise<string>;
resolveAppVersion(): string | null;
createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null;
buildDesktopTransportUrl(input: DesktopDaemonTransportTarget): string;
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
createClient(config: DaemonClientConfig): TClient;
}
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
getClientId: getOrCreateClientId,
resolveAppVersion,
createDesktopTransportFactory: createDesktopDaemonTransportFactory,
buildDesktopTransportUrl: buildDesktopDaemonTransportUrl,
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
createClient: (config) => new DaemonClient(config),
};
function buildRemoteSshClientConfig(input: {
connection: Extract<HostConnection, { type: "remoteSsh" }>;
base: Omit<DaemonClientConfig, "url">;
desktopTransportFactory: DaemonClientConfig["transportFactory"] | null;
buildDesktopTransportUrl: (target: DesktopDaemonTransportTarget) => string;
}): DaemonClientConfig {
if (!input.desktopTransportFactory) {
throw new Error("Remote SSH is only available in the desktop app.");
}
return {
...input.base,
transportFactory: input.desktopTransportFactory,
url: input.buildDesktopTransportUrl({
transportType: "ssh",
host: input.connection.host,
...(input.connection.sshPort !== undefined ? { sshPort: input.connection.sshPort } : {}),
...(input.connection.daemonPort !== undefined
? { daemonPort: input.connection.daemonPort }
: {}),
}),
};
}
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
@@ -122,14 +103,11 @@ export async function buildClientConfig(
},
deps: Pick<
DaemonConnectionDependencies<DaemonProbeClient>,
| "getClientId"
| "resolveAppVersion"
| "createDesktopTransportFactory"
| "buildDesktopTransportUrl"
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
> = defaultDaemonConnectionDependencies,
): Promise<DaemonClientConfig> {
const clientId = await deps.getClientId();
const desktopTransportFactory = deps.createDesktopTransportFactory();
const localTransportFactory = deps.createLocalTransportFactory();
const base = {
clientId,
clientType: "mobile" as const,
@@ -139,30 +117,21 @@ export async function buildClientConfig(
...(options?.capabilities ? { capabilities: options.capabilities } : {}),
...(options?.trace ? { trace: options.trace } : {}),
...((connection.type === "directSocket" || connection.type === "directPipe") &&
desktopTransportFactory
? { transportFactory: desktopTransportFactory }
localTransportFactory
? { transportFactory: localTransportFactory }
: {}),
};
if (connection.type === "directSocket" || connection.type === "directPipe") {
return {
...base,
url: deps.buildDesktopTransportUrl({
url: deps.buildLocalTransportUrl({
transportType: connection.type === "directSocket" ? "socket" : "pipe",
transportPath: connection.path,
}),
};
}
if (connection.type === "remoteSsh") {
return buildRemoteSshClientConfig({
connection,
base,
desktopTransportFactory,
buildDesktopTransportUrl: deps.buildDesktopTransportUrl,
});
}
if (connection.type === "directTcp") {
return {
...base,
@@ -264,9 +233,7 @@ interface ProbeOptions {
function resolveTimeout(connection: HostConnection, options?: ProbeOptions): number {
if (options?.timeoutMs) return options.timeoutMs;
if (connection.type === "relay") return 10_000;
if (connection.type === "remoteSsh") return 15_000;
return 6_000;
return connection.type === "relay" ? 10_000 : 6_000;
}
export function connectToDaemon(
@@ -1,19 +0,0 @@
import type { WorkspaceLabelColor } from "@getpaseo/protocol/workspace-labels";
import type { CommandCenterIcon } from "@/command-center/contributions";
import { WorkspaceLabelDot } from "./swatch";
// One stable component per color for the life of the module — `getCommandCenterIcon` in
// `@/command-center/icon` does the same thing keyed by lucide component identity. A label's dot
// has no lucide component to key off, so the cache is keyed by color name instead.
const cache = new Map<WorkspaceLabelColor, CommandCenterIcon>();
/** The color dot a label's row draws everywhere else, adapted to the command center's icon shape. */
export function getLabelCommandCenterIcon(color: WorkspaceLabelColor): CommandCenterIcon {
const cached = cache.get(color);
if (cached) return cached;
function LabelCommandCenterIcon() {
return <WorkspaceLabelDot color={color} />;
}
cache.set(color, LabelCommandCenterIcon);
return LabelCommandCenterIcon;
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"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.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/server": "0.7.0-beta.2",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/server": "0.7.0-beta.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+4 -7
View File
@@ -106,16 +106,13 @@ export type AgentLsResult = ListResult<AgentListItem>;
function daemonConnectionFailure(host: string, cause: unknown): CommandError {
const reason = cause instanceof Error ? cause.message : String(cause);
const isSsh = host.trim().startsWith("ssh://");
return {
code: "DAEMON_NOT_RUNNING",
message: `Cannot reach the daemon at ${host}: ${reason}`,
details: isSsh
? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
: [
"Start a local daemon with: paseo daemon start",
"To use another daemon, pass --host <host:port> or set PASEO_HOST.",
].join("\n"),
details: [
"Start a local daemon with: paseo daemon start",
"To use another daemon, pass --host <host:port> or set PASEO_HOST.",
].join("\n"),
};
}
-90
View File
@@ -1,90 +0,0 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createServer, type Server, type Socket } from "node:net";
import { buildSshTunnelArgs, type SshTransportTarget } from "@getpaseo/protocol/ssh-transport";
const SSH_STDERR_LIMIT = 8192;
export interface SshTunnel {
endpoint: string;
close(): void;
failureDetail(): string | null;
}
function formatSshFailure(
stderr: string,
code: number | null,
signal: NodeJS.Signals | null,
): string {
const detail = stderr.trim();
if (detail) return detail;
if (signal) return `ssh exited with signal ${signal}`;
return `ssh exited with code ${code ?? "unknown"}`;
}
export function resolveSshFailureDetail(failure: string | null, stderr: string): string | null {
return failure ?? (stderr.trim() || null);
}
export function createSshTunnel(target: SshTransportTarget): Promise<SshTunnel> {
let server: Server | null = null;
let socket: Socket | null = null;
let child: ChildProcessWithoutNullStreams | null = null;
let stderr = "";
let failure: string | null = null;
function close(): void {
server?.close();
server = null;
socket?.destroy();
socket = null;
if (child && !child.killed) child.kill();
child = null;
}
return new Promise((resolve, reject) => {
server = createServer((acceptedSocket) => {
socket = acceptedSocket;
server?.close();
server = null;
child = spawn("ssh", buildSshTunnelArgs(target), {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
child.stderr.on("data", (chunk: Buffer | string) => {
stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT);
});
child.on("error", (error) => {
failure = error.message;
acceptedSocket.destroy(error);
});
child.on("exit", (code, signal) => {
if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal);
acceptedSocket.destroy(failure ? new Error(failure) : undefined);
});
acceptedSocket.on("error", () => undefined);
acceptedSocket.on("close", () => {
if (child && !child.killed) child.kill();
});
acceptedSocket.pipe(child.stdin);
child.stdout.pipe(acceptedSocket);
});
server.once("error", (error) => {
close();
reject(error);
});
server.listen(0, "127.0.0.1", () => {
const address = server?.address();
if (!address || typeof address === "string") {
close();
reject(new Error("Failed to allocate the SSH tunnel port"));
return;
}
resolve({
endpoint: `127.0.0.1:${address.port}`,
close,
failureDetail: () => resolveSshFailureDetail(failure, stderr),
});
});
});
}
-63
View File
@@ -1,63 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveSshFailureDetail } from "../ssh/ssh-tunnel.js";
import { connectToDaemon } from "./client.js";
const mocks = vi.hoisted(() => ({
configs: [] as Array<Record<string, unknown>>,
createSshTunnel: vi.fn(),
}));
vi.mock("@getpaseo/client/internal/daemon-client", () => ({
DaemonClient: class {
lastError = null;
constructor(config: Record<string, unknown>) {
mocks.configs.push(config);
}
async connect() {}
async close() {}
},
}));
vi.mock("../ssh/ssh-tunnel.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../ssh/ssh-tunnel.js")>()),
createSshTunnel: mocks.createSshTunnel,
}));
vi.mock("./client-id.js", () => ({ getOrCreateCliClientId: async () => "cli-test-id" }));
describe("CLI SSH transport", () => {
beforeEach(() => {
mocks.configs.length = 0;
mocks.createSshTunnel.mockReset();
mocks.createSshTunnel.mockResolvedValue({
endpoint: "127.0.0.1:4567",
close: vi.fn(),
failureDetail: () => null,
});
});
it("surfaces SSH stderr before the child exit event settles", () => {
expect(resolveSshFailureDetail(null, "Host key verification failed.\n")).toBe(
"Host key verification failed.",
);
expect(resolveSshFailureDetail("ssh exited with code 255", "earlier stderr")).toBe(
"ssh exited with code 255",
);
});
it("routes an SSH host through a local tunnel", async () => {
await connectToDaemon({ host: "ssh://deploy@build-box:2222?daemonPort=7777" });
expect(mocks.createSshTunnel).toHaveBeenCalledWith({
host: "deploy@build-box",
sshPort: 2222,
daemonPort: 7777,
});
expect(mocks.configs[0]).toMatchObject({
url: "ws://127.0.0.1:4567/ws",
clientId: "cli-test-id",
clientType: "cli",
});
});
});
+1 -23
View File
@@ -11,13 +11,11 @@ import {
parseConnectionOfferFromUrl,
type ConnectionOffer,
} from "@getpaseo/protocol/connection-offer";
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client";
import path from "node:path";
import { WebSocket } from "ws";
import { getOrCreateCliClientId } from "./client-id.js";
import { resolveCliVersion } from "../version.js";
import { createSshTunnel } from "../ssh/ssh-tunnel.js";
export interface ConnectOptions {
host?: string;
@@ -61,9 +59,7 @@ export function buildDaemonConnectionCommandError(options: {
return {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: host.trim().startsWith("ssh://")
? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
: "Start the daemon with: paseo daemon start",
details: "Start the daemon with: paseo daemon start",
};
}
@@ -361,24 +357,6 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
const nodeWebSocketFactory = createNodeWebSocketFactory();
const explicitHost = options?.host ?? process.env.PASEO_HOST;
if (explicitHost?.trim().startsWith("ssh://")) {
const target = parseSshTransportUri(explicitHost.trim());
const tunnel = await createSshTunnel(target);
const password = resolveDaemonPassword(explicitHost);
const result = await tryConnectHost(
tunnel.endpoint,
password,
clientId,
timeout,
nodeWebSocketFactory,
);
if ("client" in result) return result.client;
const failure = tunnel.failureDetail();
tunnel.close();
if (failure) throw new Error(`SSH connection failed: ${failure}`, { cause: result.error });
throw result.error;
}
const offer = parseHostOfferOrNull(explicitHost);
if (offer) {
return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory);
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Command } from "commander";
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
const DAEMON_HOST_OPTION_DESCRIPTION =
"Daemon host target: host:port, tcp://host:port, or ssh://user@host (default: local socket/pipe, then localhost:6767)";
"Daemon host target: host:port or tcp://host:port?ssl=true&password=secret (default: local socket/pipe, then localhost:6767)";
export function collectMultiple(value: string, previous: string[]): string[] {
return previous.concat([value]);
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/relay": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -539,7 +539,10 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
read_file_base64: (args) => readManagedFileBase64(args ?? {}),
delete_attachment_file: (args) => deleteManagedAttachmentFile(args ?? {}),
garbage_collect_attachment_files: (args) => garbageCollectManagedAttachmentFiles(args ?? {}),
open_local_daemon_transport: async (args) => await openLocalTransportSession(args),
open_local_daemon_transport: async (args) => {
const target = args as { transportType: "socket" | "pipe"; transportPath: string };
return await openLocalTransportSession(target);
},
send_local_daemon_transport_message: async (args) => {
await sendLocalTransportMessage(
args as { sessionId: string; text?: string; binaryBase64?: string },
@@ -1,204 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
buildSshArgs,
createLocalTransportManager,
LOCAL_TRANSPORT_SETUP_TIMEOUT_MS,
parseTransportTarget,
resolveSshFailureDetail,
type TransportEndpoint,
type TransportEventPayload,
type TransportWebSocket,
} from "./local-transport";
const SESSION_INPUT = {
sessionId: "local-session-test",
target: { transportType: "ssh", host: "build-box" },
} as const;
function createConnectingSocket(): TransportWebSocket & {
close: ReturnType<typeof vi.fn>;
terminate: ReturnType<typeof vi.fn>;
} {
return {
readyState: 0,
once: vi.fn(),
on: vi.fn(),
send: vi.fn(),
close: vi.fn(),
terminate: vi.fn(),
} as unknown as TransportWebSocket & {
close: ReturnType<typeof vi.fn>;
terminate: ReturnType<typeof vi.fn>;
};
}
function createEndpoint(): TransportEndpoint & { close: ReturnType<typeof vi.fn> } {
return {
url: "ws://127.0.0.1:12345/ws",
close: vi.fn(),
failureDetail: () => null,
};
}
interface ScheduledTimeout {
callback: () => void;
delayMs: number;
cancelled: boolean;
}
function createManagerHarness(
resolveEndpoint: () => Promise<TransportEndpoint>,
sockets: TransportWebSocket[],
) {
const events: TransportEventPayload[] = [];
const scheduledTimeouts: ScheduledTimeout[] = [];
const createWebSocket = vi.fn(() => {
const socket = sockets.shift();
if (!socket) {
throw new Error("No fake WebSocket is available");
}
return socket;
});
const manager = createLocalTransportManager({
resolveEndpoint,
createWebSocket,
scheduleTimeout(callback, delayMs) {
const scheduled = { callback, delayMs, cancelled: false };
scheduledTimeouts.push(scheduled);
return () => {
scheduled.cancelled = true;
};
},
emitEvent: (event) => events.push(event),
});
return { createWebSocket, events, manager, scheduledTimeouts };
}
describe("Remote SSH desktop transport", () => {
it("builds a batch-mode SSH stdio tunnel with optional connection settings", () => {
expect(
buildSshArgs({
transportType: "ssh",
host: "deploy@example.com",
sshPort: 2222,
daemonPort: 7777,
}),
).toEqual([
"-T",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
"-o",
"ClearAllForwardings=yes",
"-o",
"ExitOnForwardFailure=yes",
"-p",
"2222",
"-W",
"127.0.0.1:7777",
"deploy@example.com",
]);
});
it("rejects unsafe SSH targets at the IPC boundary", () => {
expect(() =>
parseTransportTarget({ transportType: "ssh", host: "-oProxyCommand=bad" }),
).toThrow("SSH host is invalid");
expect(() =>
parseTransportTarget({ transportType: "ssh", host: "build-box", sshPort: 0 }),
).toThrow("SSH port must be between 1 and 65535");
expect(() =>
parseTransportTarget({ transportType: "ssh", host: "build-box", daemonPort: 65536 }),
).toThrow("Daemon port must be between 1 and 65535");
});
it("surfaces SSH stderr before the child exit event settles", () => {
expect(resolveSshFailureDetail(null, "Permission denied.\n")).toBe("Permission denied.");
expect(resolveSshFailureDetail("ssh exited with code 255", "earlier stderr")).toBe(
"ssh exited with code 255",
);
});
});
describe("local transport session lifecycle", () => {
it("releases an opening transport when its setup deadline expires", async () => {
const firstEndpoint = createEndpoint();
const secondEndpoint = createEndpoint();
const firstSocket = createConnectingSocket();
const secondSocket = createConnectingSocket();
const endpoints = [firstEndpoint, secondEndpoint];
const { events, manager, scheduledTimeouts } = createManagerHarness(
async () => endpoints.shift() ?? createEndpoint(),
[firstSocket, secondSocket],
);
manager.open(SESSION_INPUT);
await Promise.resolve();
expect(scheduledTimeouts[0]?.delayMs).toBe(LOCAL_TRANSPORT_SETUP_TIMEOUT_MS);
scheduledTimeouts[0]?.callback();
expect(firstSocket.terminate).toHaveBeenCalledTimes(1);
expect(firstEndpoint.close).toHaveBeenCalledTimes(1);
expect(events).toEqual([
{
sessionId: SESSION_INPUT.sessionId,
kind: "error",
error: "Connection to Remote SSH host build-box timed out during setup.",
},
]);
manager.close(SESSION_INPUT.sessionId);
expect(firstSocket.terminate).toHaveBeenCalledTimes(1);
expect(firstEndpoint.close).toHaveBeenCalledTimes(1);
expect(() => manager.open(SESSION_INPUT)).not.toThrow();
await Promise.resolve();
manager.close(SESSION_INPUT.sessionId);
expect(secondSocket.terminate).toHaveBeenCalledTimes(1);
expect(secondEndpoint.close).toHaveBeenCalledTimes(1);
});
it("releases a connecting transport when the caller closes it", async () => {
const endpoint = createEndpoint();
const socket = createConnectingSocket();
const { events, manager, scheduledTimeouts } = createManagerHarness(
async () => endpoint,
[socket],
);
manager.open(SESSION_INPUT);
await Promise.resolve();
manager.close(SESSION_INPUT.sessionId);
manager.close(SESSION_INPUT.sessionId);
expect(socket.terminate).toHaveBeenCalledTimes(1);
expect(endpoint.close).toHaveBeenCalledTimes(1);
expect(scheduledTimeouts[0]?.cancelled).toBe(true);
expect(events).toEqual([]);
});
it("closes an endpoint that resolves after the caller cancels setup", async () => {
const endpoint = createEndpoint();
let resolveEndpoint: ((endpoint: TransportEndpoint) => void) | null = null;
const endpointPromise = new Promise<TransportEndpoint>((resolve) => {
resolveEndpoint = resolve;
});
const socket = createConnectingSocket();
const { createWebSocket, events, manager } = createManagerHarness(
() => endpointPromise,
[socket],
);
manager.open(SESSION_INPUT);
manager.close(SESSION_INPUT.sessionId);
resolveEndpoint?.(endpoint);
await Promise.resolve();
await Promise.resolve();
expect(endpoint.close).toHaveBeenCalledTimes(1);
expect(createWebSocket).not.toHaveBeenCalled();
expect(events).toEqual([]);
});
});
+99 -462
View File
@@ -1,29 +1,12 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createServer, type Server, type Socket } from "node:net";
import {
buildSshTunnelArgs,
DEFAULT_SSH_DAEMON_PORT,
validatePort,
validateSshHost,
} from "@getpaseo/protocol/ssh-transport";
import { BrowserWindow } from "electron";
import { WebSocket, type RawData } from "ws";
export interface LocalTransportTarget {
interface TransportTarget {
transportType: "socket" | "pipe";
transportPath: string;
}
export interface SshTransportTarget {
transportType: "ssh";
host: string;
sshPort?: number;
daemonPort?: number;
}
export type TransportTarget = LocalTransportTarget | SshTransportTarget;
export interface TransportEventPayload {
interface TransportEventPayload {
sessionId: string;
kind: "open" | "message" | "close" | "error";
text?: string | null;
@@ -35,52 +18,14 @@ export interface TransportEventPayload {
interface Session {
id: string;
target: TransportTarget;
ws: TransportWebSocket | null;
state: "opening" | "open" | "closed";
closeTarget: (() => void) | null;
cancelSetupDeadline: () => void;
}
interface OpenTransportSessionInput {
sessionId: string;
target: TransportTarget;
}
export interface TransportEndpoint {
url: string;
close: () => void;
failureDetail: () => string | null;
}
export interface TransportWebSocket {
readonly readyState: number;
once(event: "open", listener: () => void): void;
on(event: "message", listener: (data: RawData, isBinary: boolean) => void): void;
on(event: "close", listener: (code: number, reason?: Buffer | string) => void): void;
on(event: "error", listener: (error: Error) => void): void;
send(data: string | Buffer, callback: (error?: Error) => void): void;
close(): void;
terminate(): void;
}
export interface LocalTransportManagerDependencies {
resolveEndpoint(target: TransportTarget): Promise<TransportEndpoint>;
createWebSocket(url: string): TransportWebSocket;
scheduleTimeout(callback: () => void, delayMs: number): () => void;
emitEvent(payload: TransportEventPayload): void;
}
export interface LocalTransportManager {
open(rawInput: unknown): void;
send(input: { sessionId: string; text?: string; binaryBase64?: string }): Promise<void>;
close(sessionId: string): void;
closeAll(): void;
ws: WebSocket;
state: "opening" | "open" | "closing" | "closed";
}
const WS_ENDPOINT_PATH = "/ws";
const SSH_STDERR_LIMIT = 8192;
export const LOCAL_TRANSPORT_SETUP_TIMEOUT_MS = 30_000;
let nextSessionId = 0;
const sessions = new Map<string, Session>();
function emitTransportEvent(payload: TransportEventPayload): void {
for (const win of BrowserWindow.getAllWindows()) {
@@ -98,183 +43,15 @@ function emitTransportEvent(payload: TransportEventPayload): void {
* The part before `:` is the IPC path, the part after is the HTTP request
* path used during the WebSocket upgrade handshake.
*/
function buildLocalWebSocketUrl(target: LocalTransportTarget): string {
function buildLocalWebSocketUrl(target: TransportTarget): string {
const ipcPath = target.transportPath;
return `ws+unix://${ipcPath}:${WS_ENDPOINT_PATH}`;
}
function describeTransportTarget(target: TransportTarget): string {
if (target.transportType === "ssh") {
return `Remote SSH host ${target.host}`;
}
return target.transportType === "pipe" ? "local daemon pipe" : "local daemon socket";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function parseTransportTarget(value: unknown): TransportTarget {
if (!isRecord(value)) {
throw new Error("Desktop transport target must be an object.");
}
if (value.transportType === "socket" || value.transportType === "pipe") {
const transportPath = typeof value.transportPath === "string" ? value.transportPath.trim() : "";
if (!transportPath) {
throw new Error("Local transport path is required.");
}
return { transportType: value.transportType, transportPath };
}
if (value.transportType === "ssh") return parseSshTransportTarget(value);
throw new Error("Unsupported desktop transport type.");
}
function parseSshTransportTarget(value: Record<string, unknown>): SshTransportTarget {
const host = validateSshHost(typeof value.host === "string" ? value.host : "");
const sshPort =
value.sshPort === undefined ? undefined : validatePortValue(value.sshPort, "SSH port");
const daemonPort =
value.daemonPort === undefined ? undefined : validatePortValue(value.daemonPort, "Daemon port");
return {
transportType: "ssh",
host,
...(sshPort !== undefined ? { sshPort } : {}),
...(daemonPort !== undefined ? { daemonPort } : {}),
};
}
function validatePortValue(value: unknown, label: string): number {
if (typeof value !== "number") throw new Error(`${label} must be between 1 and 65535.`);
try {
return validatePort(value, label);
} catch {
throw new Error(`${label} must be between 1 and 65535.`);
}
}
function parseOpenTransportSessionInput(value: unknown): OpenTransportSessionInput {
if (!isRecord(value)) {
throw new Error("Desktop transport open input must be an object.");
}
const sessionId = typeof value.sessionId === "string" ? value.sessionId.trim() : "";
if (!/^[A-Za-z0-9_-]{1,128}$/u.test(sessionId)) {
throw new Error("Desktop transport session ID is invalid.");
}
return {
sessionId,
target: parseTransportTarget(value.target),
};
}
export function buildSshArgs(target: SshTransportTarget): string[] {
return buildSshTunnelArgs({
host: target.host,
...(target.sshPort !== undefined ? { sshPort: target.sshPort } : {}),
daemonPort: target.daemonPort ?? DEFAULT_SSH_DAEMON_PORT,
});
}
function formatSshFailure(
stderr: string,
code: number | null,
signal: NodeJS.Signals | null,
): string {
const detail = stderr.trim();
if (detail) return detail;
if (signal) return `ssh exited with signal ${signal}`;
return `ssh exited with code ${code ?? "unknown"}`;
}
export function resolveSshFailureDetail(failure: string | null, stderr: string): string | null {
return failure ?? (stderr.trim() || null);
}
function createSshProxy(target: SshTransportTarget): Promise<TransportEndpoint> {
let server: Server | null = null;
let socket: Socket | null = null;
let child: ChildProcessWithoutNullStreams | null = null;
let stderr = "";
let failure: string | null = null;
function close(): void {
server?.close();
server = null;
socket?.destroy();
socket = null;
if (child && !child.killed) {
child.kill();
}
child = null;
}
return new Promise((resolve, reject) => {
server = createServer((acceptedSocket) => {
socket = acceptedSocket;
server?.close();
server = null;
child = spawn("ssh", buildSshArgs(target), {
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
child.stderr.on("data", (chunk: Buffer | string) => {
stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT);
});
child.on("error", (error) => {
failure = error.message;
acceptedSocket.destroy(error);
});
child.on("exit", (code, signal) => {
if (code !== 0 || signal) {
failure = formatSshFailure(stderr, code, signal);
}
acceptedSocket.destroy(failure ? new Error(failure) : undefined);
});
acceptedSocket.on("error", () => undefined);
acceptedSocket.on("close", () => {
if (child && !child.killed) {
child.kill();
}
});
acceptedSocket.pipe(child.stdin);
child.stdout.pipe(acceptedSocket);
});
server.once("error", (error) => {
close();
reject(error);
});
server.listen(0, "127.0.0.1", () => {
const address = server?.address();
if (!address || typeof address === "string") {
close();
reject(new Error("Failed to allocate the Remote SSH proxy port."));
return;
}
resolve({
url: `ws://127.0.0.1:${address.port}${WS_ENDPOINT_PATH}`,
close,
failureDetail: () => resolveSshFailureDetail(failure, stderr),
});
});
});
}
async function resolveTransportEndpoint(target: TransportTarget): Promise<TransportEndpoint> {
if (target.transportType === "ssh") {
return createSshProxy(target);
}
return {
url: buildLocalWebSocketUrl(target),
close: () => undefined,
failureDetail: () => null,
};
}
function decodeTransportMessage(input: { text?: string; binaryBase64?: string }): string | Buffer {
if (typeof input.text === "string") {
return input.text;
@@ -287,272 +64,94 @@ function decodeTransportMessage(input: { text?: string; binaryBase64?: string })
throw new Error("Local transport send requires text or binary payload.");
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function openLocalTransportSession(target: TransportTarget): Promise<string> {
const sessionId = `local-session-${++nextSessionId}`;
const url = buildLocalWebSocketUrl(target);
export function createLocalTransportManager(
deps: LocalTransportManagerDependencies,
): LocalTransportManager {
const sessions = new Map<string, Session>();
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
const session: Session = {
id: sessionId,
ws,
state: "opening",
};
sessions.set(sessionId, session);
function isCurrent(session: Session): boolean {
return sessions.get(session.id) === session && session.state !== "closed";
}
let openSettled = false;
function emitEvent(payload: TransportEventPayload): void {
try {
deps.emitEvent(payload);
} catch {
// A renderer may disappear while the main process is broadcasting an event.
}
}
function disposeSession(session: Session): void {
if (session.state === "closed") {
return;
}
session.state = "closed";
try {
session.cancelSetupDeadline();
} catch {
// Continue releasing the socket and endpoint if a runtime adapter fails.
}
session.cancelSetupDeadline = () => undefined;
if (sessions.get(session.id) === session) {
sessions.delete(session.id);
}
const ws = session.ws;
session.ws = null;
if (ws) {
try {
if (ws.readyState === WebSocket.CONNECTING) {
ws.terminate();
} else if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
} catch {
// Closing is best-effort; the endpoint still owns the underlying transport.
}
}
const closeTarget = session.closeTarget;
session.closeTarget = null;
try {
closeTarget?.();
} catch {
// Closing is best-effort and must not prevent the registry from being released.
}
}
function failOpeningSession(session: Session, message: string): void {
if (!isCurrent(session) || session.state !== "opening") {
return;
}
disposeSession(session);
emitEvent({ sessionId: session.id, kind: "error", error: message });
}
async function connectSession(session: Session): Promise<void> {
let endpoint: TransportEndpoint;
try {
endpoint = await deps.resolveEndpoint(session.target);
} catch (error) {
failOpeningSession(
session,
`Failed to connect to ${describeTransportTarget(session.target)}: ${getErrorMessage(error)}`,
);
return;
}
if (!isCurrent(session) || session.state !== "opening") {
try {
endpoint.close();
} catch {
// The cancelled session no longer owns any other resources to release.
}
return;
}
let targetClosed = false;
session.closeTarget = () => {
if (targetClosed) {
const finalizeOpenFailure = (message: string): void => {
if (openSettled) {
return;
}
targetClosed = true;
endpoint.close();
openSettled = true;
session.state = "closed";
sessions.delete(sessionId);
reject(new Error(message));
};
let ws: TransportWebSocket;
try {
ws = deps.createWebSocket(endpoint.url);
} catch (error) {
failOpeningSession(
session,
`Failed to connect to ${describeTransportTarget(session.target)}: ${getErrorMessage(error)}`,
);
return;
}
session.ws = ws;
ws.once("open", () => {
if (!isCurrent(session) || session.state !== "opening") {
return;
}
session.cancelSetupDeadline();
session.cancelSetupDeadline = () => undefined;
openSettled = true;
session.state = "open";
emitEvent({ sessionId: session.id, kind: "open" });
resolve(sessionId);
emitTransportEvent({ sessionId, kind: "open" });
});
ws.on("message", (data: RawData, isBinary: boolean) => {
if (!isCurrent(session) || session.state !== "open") {
return;
}
if (isBinary || data instanceof Buffer) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
emitEvent({
sessionId: session.id,
emitTransportEvent({
sessionId,
kind: "message",
binaryBase64: buf.toString("base64"),
});
return;
}
emitEvent({
sessionId: session.id,
emitTransportEvent({
sessionId,
kind: "message",
text: data.toString(),
});
});
ws.on("close", (code: number, reason?: Buffer | string) => {
if (!isCurrent(session)) {
return;
}
if (session.state === "opening") {
const failureDetail = endpoint.failureDetail();
const detail = failureDetail ? `: ${failureDetail}` : "";
failOpeningSession(
session,
`${describeTransportTarget(session.target)} closed before the session became ready${detail}.`,
const shouldEmitClose = session.state === "open" || session.state === "closing";
session.state = "closed";
sessions.delete(sessionId);
if (!openSettled) {
finalizeOpenFailure(
`${describeTransportTarget(target)} closed before the session became ready.`,
);
return;
}
disposeSession(session);
emitEvent({
sessionId: session.id,
kind: "close",
code,
reason: reason ? String(reason) : "",
});
if (shouldEmitClose) {
emitTransportEvent({
sessionId,
kind: "close",
code,
reason: reason ? String(reason) : "",
});
}
});
ws.on("error", (error: Error) => {
if (!isCurrent(session)) {
return;
}
const failureDetail = endpoint.failureDetail();
const detail = failureDetail ? `${error.message}: ${failureDetail}` : error.message;
if (session.state === "opening") {
failOpeningSession(
session,
`Failed to connect to ${describeTransportTarget(session.target)}: ${detail}`,
ws.on("error", (err: Error) => {
if (!openSettled) {
finalizeOpenFailure(
`Failed to connect to ${describeTransportTarget(target)}: ${err.message}`,
);
return;
}
emitEvent({ sessionId: session.id, kind: "error", error: detail });
});
}
function open(rawInput: unknown): void {
const { sessionId, target } = parseOpenTransportSessionInput(rawInput);
if (sessions.has(sessionId)) {
throw new Error(`Local transport session already exists: ${sessionId}`);
}
const session: Session = {
id: sessionId,
target,
ws: null,
state: "opening",
closeTarget: null,
cancelSetupDeadline: () => undefined,
};
sessions.set(sessionId, session);
session.cancelSetupDeadline = deps.scheduleTimeout(() => {
failOpeningSession(
session,
`Connection to ${describeTransportTarget(target)} timed out during setup.`,
);
}, LOCAL_TRANSPORT_SETUP_TIMEOUT_MS);
void connectSession(session);
}
async function send(input: {
sessionId: string;
text?: string;
binaryBase64?: string;
}): Promise<void> {
const session = sessions.get(input.sessionId);
if (!session) {
throw new Error(`Local transport session not found: ${input.sessionId}`);
}
const ws = session.ws;
if (session.state !== "open" || !ws || ws.readyState !== WebSocket.OPEN) {
throw new Error(
session.state === "opening"
? "Local transport session is not open yet."
: "Local transport session is closed.",
);
}
const payload = decodeTransportMessage(input);
await new Promise<void>((resolve, reject) => {
ws.send(payload, (error) => {
if (error) {
reject(new Error(`Local transport write failed: ${error.message}`));
return;
}
resolve();
emitTransportEvent({
sessionId,
kind: "error",
error: err.message,
});
});
}
function close(sessionId: string): void {
const session = sessions.get(sessionId);
if (session) {
disposeSession(session);
}
}
function closeAll(): void {
for (const session of sessions.values()) {
disposeSession(session);
}
}
return { open, send, close, closeAll };
}
const localTransportManager = createLocalTransportManager({
resolveEndpoint: resolveTransportEndpoint,
createWebSocket: (url) => new WebSocket(url),
scheduleTimeout: (callback, delayMs) => {
const timeout = setTimeout(callback, delayMs);
timeout.unref();
return () => clearTimeout(timeout);
},
emitEvent: emitTransportEvent,
});
export function openLocalTransportSession(rawInput: unknown): void {
localTransportManager.open(rawInput);
});
}
export async function sendLocalTransportMessage(input: {
@@ -560,13 +159,51 @@ export async function sendLocalTransportMessage(input: {
text?: string;
binaryBase64?: string;
}): Promise<void> {
await localTransportManager.send(input);
const session = sessions.get(input.sessionId);
if (!session) {
throw new Error(`Local transport session not found: ${input.sessionId}`);
}
if (session.state !== "open" || session.ws.readyState !== WebSocket.OPEN) {
throw new Error(
session.state === "opening"
? "Local transport session is not open yet."
: "Local transport session is closed.",
);
}
const payload = decodeTransportMessage(input);
await new Promise<void>((resolve, reject) => {
session.ws.send(payload, (error) => {
if (error) {
reject(new Error(`Local transport write failed: ${error.message}`));
return;
}
resolve();
});
});
}
export function closeLocalTransportSession(sessionId: string): void {
localTransportManager.close(sessionId);
const session = sessions.get(sessionId);
if (!session) return;
try {
if (session.ws.readyState === WebSocket.CONNECTING) {
session.state = "closed";
session.ws.terminate();
} else {
session.state = "closing";
session.ws.close();
}
} catch {
// ignore close errors
}
sessions.delete(sessionId);
}
export function closeAllTransportSessions(): void {
localTransportManager.closeAll();
for (const [id] of sessions) {
closeLocalTransportSession(id);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"files": [
"dist",
"!dist/**/*.map"
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -44,8 +44,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -54,8 +54,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"react": "19.1.0",
"zod": "^4.4.3"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -1,66 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildSshTunnelArgs, parseSshTransportUri, validateSshHost } from "./ssh-transport.js";
describe("SSH transport", () => {
it("parses SSH targets with Paseo's default daemon port", () => {
expect(parseSshTransportUri("ssh://deploy@example.com:2222")).toEqual({
host: "deploy@example.com",
sshPort: 2222,
daemonPort: 6767,
});
});
it("accepts an explicit remote daemon port", () => {
expect(parseSshTransportUri("ssh://build-box?daemonPort=7777")).toEqual({
host: "build-box",
daemonPort: 7777,
});
});
it("passes IPv6 hosts to OpenSSH without URI brackets", () => {
expect(parseSshTransportUri("ssh://deploy@[2001:db8::1]:2222")).toEqual({
host: "deploy@2001:db8::1",
sshPort: 2222,
daemonPort: 6767,
});
});
it.each([
"http://build-box",
"ssh://build-box/path",
"ssh://build-box?unknown=true",
"ssh://build-box?daemonPort=0",
"ssh://user:secret@build-box",
])("rejects invalid target %s", (target) => {
expect(() => parseSshTransportUri(target)).toThrow();
});
it("builds a non-interactive stdio tunnel and preserves SSH config", () => {
expect(
buildSshTunnelArgs({
host: "deploy@build-box",
sshPort: 2222,
daemonPort: 7777,
}),
).toEqual([
"-T",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
"-o",
"ClearAllForwardings=yes",
"-o",
"ExitOnForwardFailure=yes",
"-p",
"2222",
"-W",
"127.0.0.1:7777",
"deploy@build-box",
]);
});
it.each(["", "-oProxyCommand=bad", "bad host"])("rejects unsafe SSH host %j", (host) => {
expect(() => validateSshHost(host)).toThrow();
});
});
-82
View File
@@ -1,82 +0,0 @@
export const DEFAULT_SSH_DAEMON_PORT = 6767;
export interface SshTransportTarget {
host: string;
sshPort?: number;
daemonPort: number;
}
export function validatePort(value: string | number, label: string): number {
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${label} must be between 1 and 65535`);
}
return port;
}
export function validateSshHost(host: string): string {
const normalized = host.trim();
if (!normalized) throw new Error("SSH host is required");
if (/\s/u.test(normalized) || normalized.startsWith("-")) {
throw new Error("SSH host is invalid");
}
return normalized;
}
export function parseSshTransportUri(value: string): SshTransportTarget {
let url: URL;
try {
url = new URL(value);
} catch (error) {
throw new Error("Invalid SSH host URI", { cause: error });
}
if (url.protocol !== "ssh:" || url.password || (url.pathname !== "" && url.pathname !== "/")) {
throw new Error("Invalid SSH host URI");
}
if (url.hash) throw new Error("SSH host URI does not support fragments");
for (const key of url.searchParams.keys()) {
if (key !== "daemonPort") throw new Error(`Unsupported SSH host option: ${key}`);
}
const daemonPorts = url.searchParams.getAll("daemonPort");
if (daemonPorts.length > 1) throw new Error("daemonPort may only be specified once");
const urlHostname = url.hostname;
const hostname = validateSshHost(
urlHostname.startsWith("[") && urlHostname.endsWith("]")
? urlHostname.slice(1, -1)
: urlHostname,
);
const username = decodeURIComponent(url.username);
const host = validateSshHost(username ? `${username}@${hostname}` : hostname);
return {
host,
...(url.port ? { sshPort: validatePort(url.port, "SSH port") } : {}),
daemonPort:
daemonPorts[0] === undefined
? DEFAULT_SSH_DAEMON_PORT
: validatePort(daemonPorts[0], "Daemon port"),
};
}
export function buildSshTunnelArgs(target: SshTransportTarget): string[] {
const host = validateSshHost(target.host);
const daemonPort = validatePort(target.daemonPort, "Daemon port");
const args = [
"-T",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
"-o",
"ClearAllForwardings=yes",
"-o",
"ExitOnForwardFailure=yes",
];
if (target.sshPort !== undefined) {
args.push("-p", String(validatePort(target.sshPort, "SSH port")));
}
args.push("-W", `127.0.0.1:${daemonPort}`, host);
return args;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -72,11 +72,11 @@
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.7.0-beta.2",
"@getpaseo/highlight": "0.7.0-beta.2",
"@getpaseo/plugin": "0.7.0-beta.2",
"@getpaseo/protocol": "0.7.0-beta.2",
"@getpaseo/relay": "0.7.0-beta.2",
"@getpaseo/client": "0.7.0-beta.1",
"@getpaseo/highlight": "0.7.0-beta.1",
"@getpaseo/plugin": "0.7.0-beta.1",
"@getpaseo/protocol": "0.7.0-beta.1",
"@getpaseo/relay": "0.7.0-beta.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -604,6 +604,98 @@ describe("PiRpcAgentSession", () => {
]);
});
test("streams Pi content blocks by content index when an earlier thinking delta arrives late", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
await session.startTurn("hello");
fakeSession.emit({
type: "message_start",
message: {
role: "assistant",
responseId: "response-1",
content: [
{ type: "thinking", thinking: "" },
{ type: "text", text: "" },
],
},
});
fakeSession.emit({
type: "message_update",
assistantMessageEvent: { type: "thinking_start", contentIndex: 0 },
});
fakeSession.emit({
type: "message_update",
assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta: "verify" },
});
fakeSession.emit({
type: "message_update",
assistantMessageEvent: { type: "text_start", contentIndex: 1 },
});
fakeSession.emit({
type: "message_update",
assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: "Answer" },
});
fakeSession.emit({
type: "message_update",
assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta: "." },
});
fakeSession.emit({
type: "message_update",
assistantMessageEvent: { type: "thinking_end", contentIndex: 0 },
});
fakeSession.emit({
type: "message_update",
assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: " complete." },
});
fakeSession.emit({
type: "message_update",
assistantMessageEvent: { type: "text_end", contentIndex: 1 },
});
expect(events.timelineItems()).toEqual([
{ type: "reasoning", text: "verify" },
{ type: "reasoning", text: "." },
{ type: "assistant_message", text: "Answer", messageId: "response-1" },
{ type: "assistant_message", text: " complete.", messageId: "response-1" },
]);
});
test("flushes indexed Pi blocks under the previous message id when its end event is missing", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
await session.startTurn("hello");
fakeSession.emit({
type: "message_start",
message: {
role: "assistant",
responseId: "response-1",
content: [
{ type: "thinking", thinking: "" },
{ type: "text", text: "" },
],
},
});
fakeSession.emit({
type: "message_update",
assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta: "think" },
});
fakeSession.emit({
type: "message_update",
assistantMessageEvent: { type: "text_delta", contentIndex: 1, delta: "answer" },
});
fakeSession.emit({
type: "message_start",
message: { role: "assistant", responseId: "response-2", content: [] },
});
expect(events.timelineItems()).toEqual([
{ type: "reasoning", text: "think" },
{ type: "assistant_message", text: "answer", messageId: "response-1" },
]);
});
test("streams Pi task calls as sub-agent cards with lifecycle status", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
@@ -63,6 +63,7 @@ import {
} from "./history-mapper.js";
import { materializeProviderImage } from "../provider-image-output.js";
import { PiCliRuntime } from "./cli-runtime.js";
import { PiMessageContentStream, type PiMessageContentChunk } from "./message-content-stream.js";
import { revertPiConversation } from "./rewind.js";
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js";
@@ -1205,6 +1206,7 @@ export class PiRpcAgentSession implements AgentSession {
private activeTurnId: string | null = null;
private activeClientMessageId: string | null = null;
private activeAssistantMessageId: string | null = null;
private readonly activeAssistantContent = new PiMessageContentStream();
private activeTurnStarted = false;
private activeTurnStartedEmitted = false;
private pendingSettledMessages: PiAgentMessage[] | null = null;
@@ -1300,6 +1302,7 @@ export class PiRpcAgentSession implements AgentSession {
this.lastInterruptedTurnId = null;
this.activeClientMessageId = options?.clientMessageId ?? null;
this.activeAssistantMessageId = null;
this.activeAssistantContent.reset();
this.activeTurnStarted = false;
this.activeTurnStartedEmitted = false;
this.pendingSettledMessages = null;
@@ -2101,7 +2104,7 @@ export class PiRpcAgentSession implements AgentSession {
});
return;
case "message_start":
this.handleMessageStart(event);
this.handleMessageStart(event, turnId);
return;
case "message_end":
this.handleMessageEnd(event, turnId);
@@ -2246,37 +2249,55 @@ export class PiRpcAgentSession implements AgentSession {
if (event.message && event.message.role !== "assistant") {
return;
}
if (event.assistantMessageEvent.type === "text_delta") {
// Pi-compatible runtimes may emit updates without a preceding message_start.
this.activeAssistantMessageId ??= event.message?.responseId || randomUUID();
this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: {
type: "assistant_message",
text: event.assistantMessageEvent.delta ?? "",
messageId: this.activeAssistantMessageId,
},
});
return;
}
if (event.assistantMessageEvent.type === "thinking_delta") {
const message = event.message?.role === "assistant" ? event.message : undefined;
this.emitMessageContentChunks(
this.activeAssistantContent.update(event.assistantMessageEvent, message),
turnId,
message?.responseId,
);
}
private emitMessageContentChunks(
chunks: PiMessageContentChunk[],
turnId: string | undefined,
responseId?: string,
): void {
for (const chunk of chunks) {
if (chunk.type === "text") {
// Pi-compatible runtimes may emit updates without a preceding message_start.
this.activeAssistantMessageId ??= responseId || randomUUID();
this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: {
type: "assistant_message",
text: chunk.text,
messageId: this.activeAssistantMessageId,
},
});
continue;
}
this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: {
type: "reasoning",
text: event.assistantMessageEvent.delta ?? "",
text: chunk.text,
},
});
}
}
private handleMessageStart(event: Extract<PiAgentSessionEvent, { type: "message_start" }>): void {
private handleMessageStart(
event: Extract<PiAgentSessionEvent, { type: "message_start" }>,
turnId: string | undefined,
): void {
if (event.message.role === "assistant") {
this.emitMessageContentChunks(this.activeAssistantContent.finish(), turnId);
this.activeAssistantMessageId = event.message.responseId || null;
this.activeAssistantContent.start(event.message.content);
}
}
@@ -2285,6 +2306,7 @@ export class PiRpcAgentSession implements AgentSession {
turnId: string | undefined,
): void {
if (event.message.role === "assistant") {
this.emitMessageContentChunks(this.activeAssistantContent.finish(), turnId);
this.activeAssistantMessageId = null;
return;
}
@@ -2355,6 +2377,7 @@ export class PiRpcAgentSession implements AgentSession {
this.lastInterruptedTurnId = null;
return;
}
this.emitMessageContentChunks(this.activeAssistantContent.finish(), turnId);
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeAssistantMessageId = null;
@@ -0,0 +1,123 @@
import type { PiAssistantContent, PiAssistantMessageEvent, PiAgentMessage } from "./rpc-types.js";
export interface PiMessageContentChunk {
type: "text" | "thinking";
text: string;
}
interface PendingContentBlock {
chunks: PiMessageContentChunk[];
complete: boolean;
}
// Pi may revisit an earlier contentIndex while a later block is already streaming. Paseo's
// timeline is append-only, so later blocks wait here until every earlier block has ended.
export class PiMessageContentStream {
private readonly blocks = new Map<number, PendingContentBlock>();
private readonly completedIndexes = new Set<number>();
start(content: PiAssistantContent[]): void {
this.reset();
this.seedBlocks(content);
}
update(
event: PiAssistantMessageEvent,
message?: Extract<PiAgentMessage, { role: "assistant" }>,
): PiMessageContentChunk[] {
this.seedBlocks(message?.content ?? []);
const contentIndex = readContentIndex(event);
if (event.type === "text_delta" || event.type === "thinking_delta") {
const chunk = {
type: event.type === "text_delta" ? ("text" as const) : ("thinking" as const),
text: event.delta ?? "",
};
if (contentIndex === null) {
return [chunk];
}
this.ensureBlock(contentIndex).chunks.push(chunk);
return this.flushReadyBlocks();
}
if (contentIndex === null) {
return [];
}
if (isContentStart(event)) {
this.ensureBlock(contentIndex);
return this.flushReadyBlocks();
}
if (isContentEnd(event)) {
this.ensureBlock(contentIndex).complete = true;
return this.flushReadyBlocks();
}
return [];
}
finish(): PiMessageContentChunk[] {
for (const block of this.blocks.values()) {
block.complete = true;
}
const chunks = this.flushReadyBlocks();
this.reset();
return chunks;
}
reset(): void {
this.blocks.clear();
this.completedIndexes.clear();
}
private seedBlocks(content: PiAssistantContent[]): void {
for (let index = 0; index < content.length; index += 1) {
this.ensureBlock(index);
}
}
private ensureBlock(contentIndex: number): PendingContentBlock {
const existing = this.blocks.get(contentIndex);
if (existing) {
return existing;
}
const block = { chunks: [], complete: false };
if (!this.completedIndexes.has(contentIndex)) {
this.blocks.set(contentIndex, block);
}
return block;
}
private flushReadyBlocks(): PiMessageContentChunk[] {
const chunks: PiMessageContentChunk[] = [];
while (this.blocks.size > 0) {
const contentIndex = Math.min(...this.blocks.keys());
const block = this.blocks.get(contentIndex);
if (!block) break;
chunks.push(...block.chunks.splice(0));
if (!block.complete) break;
this.blocks.delete(contentIndex);
this.completedIndexes.add(contentIndex);
}
return chunks;
}
}
function readContentIndex(event: PiAssistantMessageEvent): number | null {
if (!("contentIndex" in event)) return null;
return Number.isInteger(event.contentIndex) && (event.contentIndex ?? -1) >= 0
? (event.contentIndex ?? null)
: null;
}
function isContentStart(event: PiAssistantMessageEvent): boolean {
return (
event.type === "text_start" ||
event.type === "thinking_start" ||
event.type === "toolcall_start"
);
}
function isContentEnd(event: PiAssistantMessageEvent): boolean {
return (
event.type === "text_end" || event.type === "thinking_end" || event.type === "toolcall_end"
);
}
@@ -149,9 +149,11 @@ export interface PiRpcResponse {
}
export type PiAssistantMessageEvent =
| { type: "text_delta"; delta?: string }
| { type: "thinking_delta"; delta?: string }
| { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" };
| { type: "text_delta"; contentIndex?: number; delta?: string }
| { type: "thinking_delta"; contentIndex?: number; delta?: string }
| { type: "text_start" | "text_end" | "thinking_start" | "thinking_end"; contentIndex?: number }
| { type: "toolcall_start" | "toolcall_delta" | "toolcall_end"; contentIndex?: number }
| { type: "start" | "done" };
export type PiAgentSessionEvent =
| { type: "agent_start" }
@@ -298,82 +298,6 @@ describe("DictationStreamManager (provider-agnostic provider)", () => {
}
});
it("waits for an in-flight auto-commit before finalizing", async () => {
const session = new FakeRealtimeSession();
const emitted: Array<{ type: string; payload: unknown }> = [];
const manager = new DictationStreamManager({
logger: pino({ level: "silent" }),
emit: (message) => emitted.push(message),
sessionId: "s1",
stt: new FakeSttProvider(session),
autoCommitSeconds: 1,
});
await manager.handleStart("d-delayed-auto-commit", "audio/pcm;rate=24000;bits=16");
await manager.handleChunk({
dictationId: "d-delayed-auto-commit",
seq: 0,
audioBase64: buildPcmBase64(2000, 24000),
format: "audio/pcm;rate=24000;bits=16",
});
expect(session.commitCalls).toBe(1);
await manager.handleFinish("d-delayed-auto-commit", 0);
await tick();
expect(emitted.find((message) => message.type === "dictation_stream_final")).toBeUndefined();
expect(session.closed).toBe(false);
session.emitCommitted("seg-tail");
session.emitTranscript("seg-tail", "the final words", true);
await tick();
const final = emitted.find((message) => message.type === "dictation_stream_final");
expect((final?.payload as { text?: string } | undefined)?.text).toBe("the final words");
expect(session.closed).toBe(true);
});
it("commits tail audio appended while an auto-commit is in flight", async () => {
const session = new FakeRealtimeSession();
const emitted: Array<{ type: string; payload: unknown }> = [];
const manager = new DictationStreamManager({
logger: pino({ level: "silent" }),
emit: (message) => emitted.push(message),
sessionId: "s1",
stt: new FakeSttProvider(session),
autoCommitSeconds: 1,
});
await manager.handleStart("d-tail-during-commit", "audio/pcm;rate=24000;bits=16");
await manager.handleChunk({
dictationId: "d-tail-during-commit",
seq: 0,
audioBase64: buildPcmBase64(2000, 24000),
format: "audio/pcm;rate=24000;bits=16",
});
await manager.handleChunk({
dictationId: "d-tail-during-commit",
seq: 1,
audioBase64: buildPcmBase64(2000, 2400),
format: "audio/pcm;rate=24000;bits=16",
});
session.emitCommitted("seg-first");
session.emitTranscript("seg-first", "the beginning", true);
await manager.handleFinish("d-tail-during-commit", 1);
expect(session.commitCalls).toBe(2);
session.emitCommitted("seg-tail");
session.emitTranscript("seg-tail", "the final words", true);
await tick();
const final = emitted.find((message) => message.type === "dictation_stream_final");
expect((final?.payload as { text?: string } | undefined)?.text).toBe(
"the beginning the final words",
);
});
it("adapts finish timeout based on pending committed segments", async () => {
const session = new FakeRealtimeSession();
const emitted: Array<{ type: string; payload: unknown }> = [];
@@ -450,14 +374,13 @@ describe("DictationStreamManager (provider-agnostic provider)", () => {
sessionId: "s1",
stt: new FakeSttProvider(session),
finalTimeoutMs: 5000,
autoCommitSeconds: 1,
});
await manager.handleStart("d-clear-tail", "audio/pcm;rate=24000;bits=16");
await manager.handleChunk({
dictationId: "d-clear-tail",
seq: 0,
audioBase64: buildPcmBase64(2000, 24000),
audioBase64: buildPcmBase64(2000, 2400),
format: "audio/pcm;rate=24000;bits=16",
});
@@ -87,7 +87,6 @@ interface DictationStreamState {
committedSegmentIds: string[];
transcriptsBySegmentId: Map<string, string>;
finalTranscriptSegmentIds: Set<string>;
inFlightCommitCount: number;
awaitingFinalCommit: boolean;
finishRequested: boolean;
finishSealed: boolean;
@@ -194,10 +193,9 @@ export class DictationStreamManager {
if (!state) {
return;
}
if (state.inFlightCommitCount > 0) {
state.inFlightCommitCount -= 1;
}
state.committedSegmentIds.push(segmentId);
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
if (state.finishRequested && state.awaitingFinalCommit) {
state.awaitingFinalCommit = false;
@@ -237,9 +235,6 @@ export class DictationStreamManager {
const message = err instanceof Error ? err.message : String(err);
const state = this.streams.get(dictationId);
if (state && state.finishRequested && isBufferTooSmallError(message)) {
if (state.inFlightCommitCount > 0) {
state.inFlightCommitCount -= 1;
}
if (state.awaitingFinalCommit) {
state.awaitingFinalCommit = false;
}
@@ -314,7 +309,6 @@ export class DictationStreamManager {
committedSegmentIds: [],
transcriptsBySegmentId: new Map(),
finalTranscriptSegmentIds: new Set(),
inFlightCommitCount: 0,
awaitingFinalCommit: false,
finishRequested: false,
finishSealed: false,
@@ -589,7 +583,9 @@ export class DictationStreamManager {
return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1;
}, 0);
const pendingSegments =
pendingCommittedSegments + pendingUncommittedTranscriptSegments + state.inFlightCommitCount;
pendingCommittedSegments +
pendingUncommittedTranscriptSegments +
(state.awaitingFinalCommit ? 1 : 0);
const pendingAudioSeconds = Math.ceil(Math.max(0, state.bytesSinceCommit) / bytesPerSecond);
const missingSeqCount =
state.finalSeq === null ? 0 : Math.max(0, state.finalSeq - state.ackSeq);
@@ -626,19 +622,9 @@ export class DictationStreamManager {
return;
}
this.requestDictationCommit(state);
}
private requestDictationCommit(state: DictationStreamState): void {
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
state.inFlightCommitCount += 1;
try {
state.stt.commit();
} catch (error) {
state.inFlightCommitCount -= 1;
throw error;
}
state.stt.commit();
}
private maybeSealDictationStreamFinish(dictationId: string): void {
@@ -683,7 +669,7 @@ export class DictationStreamManager {
} else {
state.awaitingFinalCommit = true;
try {
this.requestDictationCommit(state);
state.stt.commit();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
void this.failAndCleanupDictationStream(dictationId, message, true);
@@ -728,9 +714,6 @@ export class DictationStreamManager {
if (state.awaitingFinalCommit) {
return;
}
if (state.inFlightCommitCount > 0) {
return;
}
const committedSet = new Set(state.committedSegmentIds);
const orderedSegmentIds: string[] = [...state.committedSegmentIds];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.1",
"private": true,
"type": "module",
"scripts": {
+2 -36
View File
@@ -1,6 +1,6 @@
---
title: Connectivity
description: Connect a Paseo client to your daemon through SSH, the relay, or Tailscale.
description: Connect a Paseo client to your daemon through the relay or Tailscale.
nav: Connectivity
order: 4
category: Getting started
@@ -8,45 +8,13 @@ category: Getting started
# Connectivity
Your Paseo app connects to the daemon running on your computer or server. Paseo Desktop and the CLI can tunnel through SSH. Mobile clients can connect through the Paseo relay or directly with Tailscale.
Your Paseo app connects to the daemon running on your computer or server. You can connect through the Paseo relay or directly with Tailscale.
This is client-to-daemon transport. If you are looking for the service that starts agents from GitHub, Slack, and Discord events, that is [Hub](/docs/hub).
- [SSH](#ssh)
- [Paseo relay](#paseo-relay)
- [Tailscale](#tailscale)
## SSH
SSH transport connects to an existing daemon through your local OpenSSH client. It does not install, start, or configure Paseo on the remote host.
Before connecting:
1. Start the Paseo daemon on the remote host.
2. Confirm `ssh user@host` works with a key or SSH agent. Paseo uses non-interactive SSH and follows your OpenSSH config.
The CLI accepts an SSH URI as its host:
```bash
paseo ls -a --host ssh://user@host
```
The daemon is expected at `127.0.0.1:6767` on the remote host. The port in the SSH URL is the SSH server port:
```bash
paseo ls -a --host ssh://user@host:2222
```
Set a different remote daemon port with `daemonPort`:
```bash
paseo ls -a --host 'ssh://user@host?daemonPort=7777'
```
`--host` belongs after the command. `paseo daemon status` checks only the local daemon; use `paseo ls --host ...` to verify a remote connection. `paseo run --host ...` also requires `--cwd` with a path that exists on the remote host.
In Paseo Desktop, open **Settings → Add host → Remote SSH** and enter the same `ssh://` destination.
## Paseo relay
The relay works without Tailscale, port forwarding, or network configuration. Traffic is end-to-end encrypted.
@@ -121,8 +89,6 @@ If the host was already paired through the relay, Paseo adds the direct connecti
## Troubleshooting
- **SSH authentication failed:** Run `ssh user@host` in a terminal and fix the key, agent, host key, or `~/.ssh/config` entry there. Paseo does not prompt for SSH passwords.
- **SSH connects but Paseo is refused:** Run `paseo daemon status` on the remote host. SSH transport does not start the daemon.
- **Connection timed out:** Check that Tailscale is connected on both devices and that you used the daemon machine's Tailscale IP.
- **Connection refused:** Run `paseo daemon status` and confirm the daemon is running on the configured IP and port.
- **Config change has no effect:** Run `paseo reload`. `daemon.listen` is a startup setting, so restart when the command reports it.