mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e60c2880b | ||
|
|
510074f555 | ||
|
|
ed628ff82e | ||
|
|
667664847d | ||
|
|
7ea4288117 | ||
|
|
42b6d99d60 | ||
|
|
23debf3af1 | ||
|
|
25eb322ef7 | ||
|
|
deb846eed6 | ||
|
|
a1ed5a8bad | ||
|
|
3d69b0f458 | ||
|
|
51a435d3cf | ||
|
|
fb67d2cdc8 | ||
|
|
fe803f6e70 | ||
|
|
a501e1e051 | ||
|
|
1fc5250544 |
@@ -1,5 +1,29 @@
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
# 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"
|
||||
```
|
||||
|
||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||
|
||||
@@ -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
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
+5
-2
@@ -556,12 +556,15 @@ npm run cli -- daemon status # Check daemon status
|
||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||
```
|
||||
|
||||
Use `--host <host:port>` to point the CLI at a different daemon:
|
||||
Use `--host` to point the CLI at a different daemon:
|
||||
|
||||
```bash
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
npm run cli -- ls -a --host localhost:7777
|
||||
npm run cli -- ls -a --host ssh://user@host
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -218,3 +218,8 @@ are stale, run `npm run build:server`.
|
||||
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
|
||||
allowed-failure job carries the `warning` trait, and `canceling` remains active
|
||||
until GitLab reports a terminal status.
|
||||
- GitHub PR polling owns one account-wide GraphQL budget. Coordinate retained
|
||||
targets per host, batch their reads, and stop until GitHub's reset time when
|
||||
the reserve is exhausted. Never add a per-target GitHub request to the poll
|
||||
path. Resolve fork PRs through their parent repository without abandoning the
|
||||
shared batch.
|
||||
|
||||
+54
-8
@@ -1,7 +1,7 @@
|
||||
# Plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||
Paseo does not sandbox it.
|
||||
|
||||
@@ -121,17 +121,20 @@ code lives behind filename boundaries:
|
||||
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
|
||||
|
||||
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks and the
|
||||
host-provided `Icon` component from `@getpaseo/plugin`. `Icon` resolves a Lucide name using the
|
||||
client's installed icon set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
`<Modal.Content>`.
|
||||
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
|
||||
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||
See `public-docs/plugins/reference.md`.
|
||||
|
||||
| Module | Use it for |
|
||||
| ------------------------- | -------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | host UI components, hooks, and UI types |
|
||||
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||
| Module | Use it for |
|
||||
| ------------------------------- | -------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | contribution contracts and client data hooks |
|
||||
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
||||
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||
|
||||
The compiler removes client registrations and imports from the server entry point, and server
|
||||
registrations and imports from the client entry point. Importing a `*.server` module from a client
|
||||
@@ -202,6 +205,49 @@ They use typed plugin RPC only for plugin-specific backend work. Navigation is l
|
||||
plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
|
||||
workspace-layout store access.
|
||||
|
||||
## Contribute composer pills
|
||||
|
||||
Register a headless client entrypoint, then add and remove targeted pills from that client
|
||||
lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs
|
||||
in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary
|
||||
client state without mounting a panel or surface.
|
||||
|
||||
```tsx
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
const pills = new Map<string, () => void>();
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
|
||||
const { id: agentId, workspaceId } = update.agent;
|
||||
pills.get(agentId)?.();
|
||||
pills.set(
|
||||
agentId,
|
||||
client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open review",
|
||||
workspaceId,
|
||||
agentId,
|
||||
Component: ReviewPill,
|
||||
async onPress() {
|
||||
await client.rpc(refreshReview, { agentId });
|
||||
client.openPanel("review", { workspaceId, agentId });
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
for (const remove of pills.values()) remove();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only
|
||||
on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the
|
||||
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
|
||||
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
|
||||
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
|
||||
the host, or unloading the app tears down the contribution.
|
||||
|
||||
## Contribute timeline items
|
||||
|
||||
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-og2UgicET2crFwEazRlBA4Av/IPWR7wtyP8CIqTLCcE=
|
||||
sha256-zn0UJm7RPR5mlCvKTddcq0bIugrp6VcRBPdwJX8kJIg=
|
||||
|
||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36298,7 +36298,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"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.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/server": "0.7.0-beta.1",
|
||||
"@getpaseo/client": "0.7.0-beta.2",
|
||||
"@getpaseo/protocol": "0.7.0-beta.2",
|
||||
"@getpaseo/server": "0.7.0-beta.2",
|
||||
"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.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/relay": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.2",
|
||||
"@getpaseo/relay": "0.7.0-beta.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37629,7 +37629,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"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.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38768,7 +38768,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"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.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.7.0-beta.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/client": "0.7.0-beta.2",
|
||||
"@getpaseo/protocol": "0.7.0-beta.2",
|
||||
"@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.1",
|
||||
"@getpaseo/protocol": "0.7.0-beta.1",
|
||||
"@getpaseo/client": "0.7.0-beta.2",
|
||||
"@getpaseo/protocol": "0.7.0-beta.2",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38833,7 +38833,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39051,17 +39051,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"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.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",
|
||||
"@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",
|
||||
"@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.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { waitForPermissionPrompt } from "../support/helpers/permissions";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// The plan card has to pass its own markdown parser. Drop that prop and
|
||||
// react-native-markdown-display silently supplies one with `typographer: true`
|
||||
// (node_modules/react-native-markdown-display/src/index.js:139-141), which
|
||||
// turns (c) into ©, curls straight quotes, and rewrites --- as an em dash.
|
||||
// No unit test can catch that, because the defect lives in the JSX.
|
||||
//
|
||||
// Nothing here is provider-specific: the mock provider drives it, and the
|
||||
// behavior is the same for every agent.
|
||||
test.describe("Plan card markdown", () => {
|
||||
test("renders plan text verbatim", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "plan-card-markdown-",
|
||||
title: "Plan card markdown e2e",
|
||||
initialPrompt: "Emit synthetic plan approval.",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await waitForPermissionPrompt(page, 120_000);
|
||||
|
||||
const planCard = page.getByTestId("permission-plan-card");
|
||||
await expect(planCard).toContainText("(c)");
|
||||
await expect(planCard).toContainText('--name="my repo"');
|
||||
await expect(planCard).toContainText("---buzz");
|
||||
await expect(planCard).not.toContainText("©");
|
||||
await expect(planCard).not.toContainText("—buzz");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "plugin-host-ui-e2e";
|
||||
|
||||
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
|
||||
function ModalBody({ onSaved }) {
|
||||
usePaseo();
|
||||
useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
function save() {
|
||||
toast.show("Issue saved", { variant: "success" });
|
||||
onSaved();
|
||||
}
|
||||
|
||||
return <View>
|
||||
<Text>Plugin modal contexts ready</Text>
|
||||
<Pressable accessibilityRole="button" onPress={save}>
|
||||
<Text>Save issue</Text>
|
||||
</Pressable>
|
||||
</View>;
|
||||
}
|
||||
|
||||
function Surface() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return <View>
|
||||
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<Icon name="Pencil" size={18} />
|
||||
<Text>Open plugin modal</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Modal
|
||||
title="Edit plugin issue"
|
||||
icon={<Icon name="Pencil" size={18} />}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Modal.Content>
|
||||
<ModalBody onSaved={() => setOpen(false)} />
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</View>;
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Host UI",
|
||||
icon: "PanelsTopLeft",
|
||||
surface: "main",
|
||||
});
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
async function openHostUiPlugin(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Host UI", exact: true }).click();
|
||||
}
|
||||
|
||||
async function useNonCompactLayout(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 1100, height: 800 });
|
||||
}
|
||||
|
||||
async function useCompactLayout(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
}
|
||||
|
||||
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await page.getByRole("button", { name: "Host UI", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openPluginModal(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectCenteredPluginDialog(page: Page): Promise<void> {
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeCenteredPluginDialog(page: Page): Promise<void> {
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function expectCompactPluginSheet(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function savePluginIssue(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save issue", exact: true }).click();
|
||||
await expect(page.getByText("Issue saved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).not.toBeVisible();
|
||||
}
|
||||
|
||||
test("plugin modal adapts its presentation and preserves host contexts", async ({ page }) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await useNonCompactLayout(page);
|
||||
await openHostUiPlugin(page);
|
||||
|
||||
await test.step("non-compact layouts use a centered dialog", async () => {
|
||||
await openPluginModal(page);
|
||||
await expectCenteredPluginDialog(page);
|
||||
await closeCenteredPluginDialog(page);
|
||||
});
|
||||
|
||||
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
|
||||
await useCompactLayout(page);
|
||||
await reopenHostUiPluginFromCompactSidebar(page);
|
||||
await openPluginModal(page);
|
||||
await expectCompactPluginSheet(page);
|
||||
await savePluginIssue(page);
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { buildAgentRoute } from "../support/helpers/mock-agent";
|
||||
@@ -31,7 +32,15 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
||||
function pluginSource(): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
import { defineRpc } from "@getpaseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const recordComposerOpen = defineRpc({
|
||||
name: "composer.open",
|
||||
input: z.object({ workspaceId: z.string() }),
|
||||
output: z.object({ opened: z.boolean() }),
|
||||
});
|
||||
|
||||
function WorkspacePanel({ workspaceId, host, layout }) {
|
||||
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
|
||||
@@ -54,7 +63,56 @@ function SidebarCollisionSurface() {
|
||||
return <View><Text>Sidebar collision surface</Text></View>;
|
||||
}
|
||||
|
||||
function ComposerPill({ theme, workspaceId, agentId }) {
|
||||
const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title }));
|
||||
const agent = useAgent(agentId, (value) => ({ title: value.title }));
|
||||
return <><Icon name="Scan" size={14} color={theme.colors.foregroundMuted} /><Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>Review {workspace?.title}:{agent?.title}</Text></>;
|
||||
}
|
||||
|
||||
function contributeClient(client) {
|
||||
const pills = new Map();
|
||||
const remove = (agentId) => {
|
||||
pills.get(agentId)?.();
|
||||
pills.delete(agentId);
|
||||
};
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind === "remove") {
|
||||
remove(update.agentId);
|
||||
return;
|
||||
}
|
||||
const agent = update.agent;
|
||||
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
|
||||
remove(agent.id);
|
||||
let removePill = () => {};
|
||||
removePill = client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open composer review",
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
Component: ComposerPill,
|
||||
async onPress() {
|
||||
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
|
||||
removePill();
|
||||
client.openPanel("agent", {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
pills.set(agent.id, removePill);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
for (const removePill of pills.values()) removePill();
|
||||
pills.clear();
|
||||
};
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
|
||||
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
|
||||
return { opened: true };
|
||||
});
|
||||
plugin.addSurface("collision", DirectCollisionSurface);
|
||||
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
|
||||
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
|
||||
@@ -64,6 +122,7 @@ export default function contribute(plugin) {
|
||||
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
|
||||
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
|
||||
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
|
||||
plugin.addClientSide(contributeClient);
|
||||
return () => {};
|
||||
}`;
|
||||
}
|
||||
@@ -207,7 +266,29 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
});
|
||||
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
|
||||
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
||||
await submitMessage(page, "emit 1 agent stream updates");
|
||||
await expect(page.getByRole("button", { name: "1/1 tasks" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const composerPill = page.getByRole("button", { name: "Open composer review" });
|
||||
await expect(composerPill).toContainText(
|
||||
"Review Unrelated title update:Plugin panel context agent",
|
||||
);
|
||||
await capture(page, testInfo, "plugin-composer-pill-wide");
|
||||
await page.setViewportSize(COMPACT_VIEWPORT);
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
|
||||
await capture(page, testInfo, "plugin-composer-pill-compact");
|
||||
await page.getByRole("button", { name: "Open composer review" }).click();
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveText(
|
||||
"Opened from composer pill",
|
||||
);
|
||||
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
|
||||
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
|
||||
await capture(page, testInfo, "plugin-agent-panel-compact");
|
||||
|
||||
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
|
||||
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
|
||||
await openCompactSidebar(page);
|
||||
await runCommand(page, "Open plugin agent");
|
||||
await closeMobileAgentSidebar(page);
|
||||
@@ -218,7 +299,6 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
|
||||
await capture(page, testInfo, "plugin-agent-panel-compact");
|
||||
});
|
||||
|
||||
await test.step("removing the active plugin renders the panel unavailable", async () => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForWorkspaceInReplicaCache } from "../support/helpers/replica-cache-storage";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectWorkspaceAbsentFromSidebar,
|
||||
selectWorkspaceInSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
async function archiveWorkspaceOutsideTheApp(workspace: SeededWorkspace): Promise<void> {
|
||||
const result = await workspace.client.archiveWorkspace(workspace.workspaceId);
|
||||
expect(result.error).toBeNull();
|
||||
}
|
||||
|
||||
test.describe("Workspace archive cache coherence", () => {
|
||||
test("an archived selected workspace cannot return from the durable cache", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "archive-cache-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
await waitForWorkspaceInReplicaCache(page, workspace.workspaceId);
|
||||
|
||||
await archiveWorkspaceOutsideTheApp(workspace);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
|
||||
await expect(page.getByText("Workspace unavailable", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
const DATABASE_NAME = "paseo-replica-row-store";
|
||||
const STORE_NAME = "rows";
|
||||
@@ -116,6 +116,23 @@ export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord |
|
||||
return rows.length > 0 ? assembleCache(rows) : null;
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceInReplicaCache(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
return cache?.hosts?.some((host) =>
|
||||
host.workspaces.some((workspace) => workspace.id === workspaceId),
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
|
||||
const rows: ReplicaRowRecord[] = [];
|
||||
for (const host of value.hosts ?? []) {
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface SeedDaemonClient {
|
||||
workspace: SeedWorkspaceDescriptor | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
archiveWorkspace(workspaceId: string): Promise<{ error: string | null }>;
|
||||
/**
|
||||
* Force the daemon to recompute its git snapshot and diff for a checkout,
|
||||
* mirroring the UI's manual refresh. Tests use this to make an out-of-band
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.7.0-beta.1",
|
||||
"version": "0.7.0-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -39,6 +39,7 @@ 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";
|
||||
@@ -595,6 +596,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<CommandCenterWorkspaceActions />
|
||||
<PluginCommandCenterActions />
|
||||
<WorkspacePinShortcutHandler />
|
||||
<WorkspaceRenameHost />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
|
||||
@@ -56,6 +56,7 @@ import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { useCommandCenterContributions } from "./provider";
|
||||
import { filterAndRankWorkspaces } from "./workspace-search";
|
||||
import {
|
||||
buildContributionSections,
|
||||
filterAndRankBuiltInResults,
|
||||
@@ -121,10 +122,6 @@ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearc
|
||||
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
|
||||
}
|
||||
|
||||
function workspaceSearchFields(result: CommandCenterWorkspaceResult): CommandCenterSearchFields {
|
||||
return { visible: [result.title, result.subtitle], hidden: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this
|
||||
* work depends on what was typed, and rebuilding it per keystroke means re-running an Intl
|
||||
@@ -158,6 +155,7 @@ function useBuiltInRows(open: boolean): {
|
||||
project.projectName,
|
||||
workspace.currentBranch,
|
||||
]),
|
||||
changeRequestNumber: workspace.changeRequestNumber,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
|
||||
@@ -208,12 +206,7 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
|
||||
band: PINNED_SECTION_BAND,
|
||||
rank: 2,
|
||||
title: t("shell.commandCenter.workspaces"),
|
||||
results: filterAndRankBuiltInResults(
|
||||
rows.workspaces,
|
||||
query,
|
||||
workspaceSearchFields,
|
||||
compareWorkspacesByTitle,
|
||||
),
|
||||
results: filterAndRankWorkspaces(rows.workspaces, query, compareWorkspacesByTitle),
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
|
||||
@@ -102,7 +102,14 @@ function autoModeChoice(): CommandCenterContribution {
|
||||
}
|
||||
|
||||
function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult {
|
||||
return { kind: "workspace", id, title, subtitle, run: () => undefined };
|
||||
return {
|
||||
kind: "workspace",
|
||||
id,
|
||||
title,
|
||||
subtitle,
|
||||
changeRequestNumber: null,
|
||||
run: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function sectionResultIds(sections: ReturnType<typeof buildContributionSections>): string[] {
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface CommandCenterWorkspaceResult {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
/** PR/MR number backing this workspace, so a number query can match it exactly. */
|
||||
changeRequestNumber: number | null;
|
||||
run(): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,17 +9,19 @@ import {
|
||||
History,
|
||||
Home,
|
||||
Keyboard,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
Settings,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } 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 {
|
||||
@@ -54,6 +56,9 @@ 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} />;
|
||||
@@ -91,6 +96,10 @@ 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();
|
||||
@@ -106,6 +115,10 @@ 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() }),
|
||||
[],
|
||||
@@ -227,6 +240,33 @@ 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) {
|
||||
@@ -278,6 +318,7 @@ export function CommandCenterRootActions() {
|
||||
shortcutPlatform,
|
||||
shortcutsAvailable,
|
||||
t,
|
||||
toggleAgentList,
|
||||
]);
|
||||
|
||||
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
|
||||
|
||||
@@ -23,9 +23,14 @@ 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,
|
||||
@@ -62,19 +67,51 @@ function source(gitActions: GitActions): {
|
||||
moveTabDown: "Move tab down",
|
||||
closePane: "Close pane",
|
||||
toggleFocusMode: "Toggle focus mode",
|
||||
toggleExplorerSidebar: "Toggle side panel",
|
||||
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",
|
||||
},
|
||||
icons: {},
|
||||
shortcuts: {},
|
||||
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
|
||||
capabilities: {
|
||||
canSplitPanes: true,
|
||||
canOpenBrowserTabs: true,
|
||||
isGit: false,
|
||||
canPin: false,
|
||||
canShowSetup: 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +184,8 @@ describe("workspace command center contributions", () => {
|
||||
canSplitPanes: false,
|
||||
canOpenBrowserTabs: false,
|
||||
isGit: false,
|
||||
canPin: false,
|
||||
canShowSetup: false,
|
||||
};
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
@@ -155,6 +194,8 @@ 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", () => {
|
||||
@@ -164,7 +205,14 @@ describe("workspace command center contributions", () => {
|
||||
for (const contribution of contributions) contribution.run();
|
||||
|
||||
expect(fixture.dispatched.length).toBeGreaterThan(5);
|
||||
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
|
||||
// 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);
|
||||
});
|
||||
|
||||
it("keeps workspace creation commands available outside Git", () => {
|
||||
@@ -179,4 +227,164 @@ 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,6 +8,13 @@ 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;
|
||||
@@ -42,6 +49,14 @@ 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 {
|
||||
@@ -63,6 +78,14 @@ 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;
|
||||
}
|
||||
|
||||
@@ -78,6 +101,7 @@ export interface WorkspaceCommandCenterShortcuts {
|
||||
closePane?: ShortcutKey[][];
|
||||
toggleFocusMode?: ShortcutKey[][];
|
||||
toggleExplorerSidebar?: ShortcutKey[][];
|
||||
pinWorkspace?: ShortcutKey[][];
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandCenterSource {
|
||||
@@ -89,12 +113,27 @@ 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(
|
||||
@@ -459,6 +498,35 @@ 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[] {
|
||||
@@ -511,20 +579,150 @@ 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 { useMemo, type ReactElement } from "react";
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
@@ -8,12 +8,16 @@ import {
|
||||
Copy,
|
||||
Files,
|
||||
Focus,
|
||||
GitBranch,
|
||||
GitCompareArrows,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
ListChecks,
|
||||
Move,
|
||||
PanelRight,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
RotateCw,
|
||||
Rows2,
|
||||
SquarePen,
|
||||
@@ -25,23 +29,35 @@ 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 } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
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";
|
||||
@@ -65,6 +81,13 @@ 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 = {
|
||||
@@ -86,6 +109,58 @@ 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();
|
||||
@@ -104,8 +179,29 @@ 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 ?? "",
|
||||
@@ -113,6 +209,27 @@ 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(
|
||||
() =>
|
||||
@@ -152,6 +269,14 @@ 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,
|
||||
@@ -162,27 +287,43 @@ 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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CommandCenterWorkspaceResult } from "./results";
|
||||
import { filterAndRankWorkspaces } from "./workspace-search";
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
changeRequestNumber?: number | null;
|
||||
}): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
id: input.id,
|
||||
title: input.title ?? input.id,
|
||||
subtitle: input.subtitle ?? "",
|
||||
changeRequestNumber: input.changeRequestNumber ?? null,
|
||||
run: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function ids(rows: readonly CommandCenterWorkspaceResult[], query: string): string[] {
|
||||
return filterAndRankWorkspaces(rows, query, (left, right) => left.id.localeCompare(right.id)).map(
|
||||
(row) => row.id,
|
||||
);
|
||||
}
|
||||
|
||||
describe("filterAndRankWorkspaces", () => {
|
||||
const subject = workspace({
|
||||
id: "payments",
|
||||
title: "Payments refactor",
|
||||
subtitle: "Primary host · feature/checkout",
|
||||
changeRequestNumber: 42,
|
||||
});
|
||||
|
||||
it("matches everything when the query is empty", () => {
|
||||
expect(ids([subject], " ")).toEqual(["payments"]);
|
||||
});
|
||||
|
||||
it("matches its own change-request number in every spelling", () => {
|
||||
const spellings = ["42", " 42 ", "#42", "!42", "pr 42", "mr 42", "PR #42", "mr!42", "pr42"];
|
||||
for (const query of spellings) {
|
||||
expect(ids([subject], query)).toEqual(["payments"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not take the number path for text that merely contains digits", () => {
|
||||
const numbered = workspace({ id: "unrelated workspace", changeRequestNumber: 42 });
|
||||
for (const query of ["fix-42-retries", "42x", "v4.2", "pr"]) {
|
||||
expect(ids([numbered], query)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not match a different change-request number that shares digits", () => {
|
||||
const other = workspace({ id: "unrelated", changeRequestNumber: 142 });
|
||||
expect(ids([other], "42")).toEqual([]);
|
||||
});
|
||||
|
||||
it("still matches on title and branch text", () => {
|
||||
expect(ids([subject], "checkout")).toEqual(["payments"]);
|
||||
expect(ids([subject], "payments")).toEqual(["payments"]);
|
||||
});
|
||||
|
||||
it("leaves a workspace without a change request unaffected", () => {
|
||||
const noPr = workspace({ id: "docs workspace" });
|
||||
expect(ids([noPr], "42")).toEqual([]);
|
||||
expect(ids([noPr], "docs")).toEqual(["docs workspace"]);
|
||||
});
|
||||
|
||||
it("falls back to text matching when the number does not match", () => {
|
||||
const textual = workspace({
|
||||
id: "text",
|
||||
subtitle: "branch fix-42-retries",
|
||||
changeRequestNumber: 7,
|
||||
});
|
||||
expect(ids([textual], "42")).toEqual(["text"]);
|
||||
});
|
||||
|
||||
it("ranks an exact change request above an ordinary text match", () => {
|
||||
const text = workspace({ id: "a-text", title: "Migration 42" });
|
||||
expect(ids([text, subject], "42")).toEqual(["payments", "a-text"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
compareCommandCenterScores,
|
||||
filterAndRankBuiltInResults,
|
||||
scoreSearchFields,
|
||||
type CommandCenterScore,
|
||||
type CommandCenterWorkspaceResult,
|
||||
} from "./results";
|
||||
|
||||
/** A change-request query: an optional `pr`/`mr` word, an optional `#`/`!`, a number. */
|
||||
const CHANGE_REQUEST_QUERY = /^(?:(?:pr|mr)\s*)?[#!]?(\d+)$/i;
|
||||
|
||||
/**
|
||||
* Parse a change-request-shaped query into its number.
|
||||
*
|
||||
* Accepts `42`, `#42`, `!42`, `pr 42`, `pr42`, `PR #42`, `mr!42`, and so on.
|
||||
* Returns null for anything else, including text queries that merely contain
|
||||
* digits (`fix-42-retries`), so those fall through to normal text matching.
|
||||
*/
|
||||
function parseChangeRequestQuery(query: string): number | null {
|
||||
const match = CHANGE_REQUEST_QUERY.exec(query.trim());
|
||||
if (!match) return null;
|
||||
const number = Number.parseInt(match[1], 10);
|
||||
return Number.isSafeInteger(number) ? number : null;
|
||||
}
|
||||
|
||||
interface ScoredWorkspace {
|
||||
workspace: CommandCenterWorkspaceResult;
|
||||
score: CommandCenterScore | null;
|
||||
changeRequestHit: boolean;
|
||||
}
|
||||
|
||||
function searchFields(workspace: CommandCenterWorkspaceResult) {
|
||||
return { visible: [workspace.title, workspace.subtitle], hidden: [] };
|
||||
}
|
||||
|
||||
function compareScoredWorkspaces(
|
||||
left: ScoredWorkspace,
|
||||
right: ScoredWorkspace,
|
||||
tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number,
|
||||
): number {
|
||||
if (left.changeRequestHit !== right.changeRequestHit) {
|
||||
return left.changeRequestHit ? -1 : 1;
|
||||
}
|
||||
if (left.score && right.score) {
|
||||
const scoreDelta = compareCommandCenterScores(left.score, right.score);
|
||||
if (scoreDelta !== 0) return scoreDelta;
|
||||
}
|
||||
return tiebreak(left.workspace, right.workspace);
|
||||
}
|
||||
|
||||
export function filterAndRankWorkspaces(
|
||||
workspaces: readonly CommandCenterWorkspaceResult[],
|
||||
query: string,
|
||||
tiebreak: (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => number,
|
||||
): CommandCenterWorkspaceResult[] {
|
||||
if (!query.trim()) {
|
||||
return filterAndRankBuiltInResults(workspaces, query, searchFields, tiebreak);
|
||||
}
|
||||
|
||||
const changeRequestNumber = parseChangeRequestQuery(query);
|
||||
const matches: ScoredWorkspace[] = [];
|
||||
for (const workspace of workspaces) {
|
||||
const score = scoreSearchFields(query, searchFields(workspace));
|
||||
const changeRequestHit =
|
||||
changeRequestNumber !== null && workspace.changeRequestNumber === changeRequestNumber;
|
||||
if (score || changeRequestHit) matches.push({ workspace, score, changeRequestHit });
|
||||
}
|
||||
matches.sort((left, right) => compareScoredWorkspaces(left, right, tiebreak));
|
||||
return matches.map((match) => match.workspace);
|
||||
}
|
||||
@@ -31,7 +31,10 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
type ContextBridge,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import {
|
||||
@@ -450,6 +451,8 @@ export interface AdaptiveModalSheetProps {
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/** Size compact sheet content to the live snap height instead of its largest snap point. */
|
||||
sizeContentToCurrentSnapPoint?: boolean;
|
||||
/** Re-establishes caller-owned contexts inside the compact bottom-sheet portal. */
|
||||
contextBridge?: ContextBridge | null;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -467,6 +470,7 @@ export function AdaptiveModalSheet({
|
||||
presentation,
|
||||
contentStyle,
|
||||
sizeContentToCurrentSnapPoint = false,
|
||||
contextBridge = null,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -634,7 +638,7 @@ export function AdaptiveModalSheet({
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
contextBridge={contextBridge}
|
||||
snapPoints={resolvedSnapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste, Terminal } 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: {
|
||||
@@ -37,6 +45,7 @@ export interface AddHostMethodModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDirectConnection: () => void;
|
||||
onRemoteSsh: () => void;
|
||||
onScanQr: () => void;
|
||||
onPasteLink: () => void;
|
||||
}
|
||||
@@ -45,10 +54,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]);
|
||||
|
||||
@@ -60,6 +69,10 @@ export function AddHostMethodModal({
|
||||
onScanQr();
|
||||
}, [onScanQr]);
|
||||
|
||||
const handleRemoteSsh = useCallback(() => {
|
||||
onRemoteSsh();
|
||||
}, [onRemoteSsh]);
|
||||
|
||||
const handlePaste = useCallback(() => {
|
||||
onPasteLink();
|
||||
}, [onPasteLink]);
|
||||
@@ -78,7 +91,7 @@ export function AddHostMethodModal({
|
||||
accessibilityLabel={t("pairing.connectionMethods.direct.title")}
|
||||
testID="add-host-method-direct"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.foreground} />
|
||||
<ThemedLink2 size={18} uniProps={foregroundIconMapping} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.direct.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
@@ -87,6 +100,24 @@ 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}
|
||||
@@ -94,7 +125,7 @@ export function AddHostMethodModal({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("pairing.connectionMethods.scanQr.title")}
|
||||
>
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<ThemedQrCode size={18} uniProps={foregroundIconMapping} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.scanQr.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
@@ -111,7 +142,7 @@ export function AddHostMethodModal({
|
||||
accessibilityLabel={t("pairing.connectionMethods.pasteLink.title")}
|
||||
testID="add-host-method-pair-link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<ThemedClipboardPaste size={18} uniProps={foregroundIconMapping} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.pasteLink.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-t
|
||||
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import { createMarkdownParser } from "@/utils/markdown-parser";
|
||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -65,7 +66,9 @@ function compactMarkdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRe
|
||||
return { style: createCompactMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
const defaultMarkdownParser = MarkdownIt({ typographer: true, linkify: true });
|
||||
// Serves PR comment bodies and the markdown file preview; agent chat passes its
|
||||
// own parser. The preview has to show the bytes on disk, so no typographer.
|
||||
const defaultMarkdownParser = createMarkdownParser({ linkify: true });
|
||||
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
export interface MarkdownRendererProps {
|
||||
|
||||
@@ -5,6 +5,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { createMarkdownParser } from "@/utils/markdown-parser";
|
||||
|
||||
// Without this prop react-native-markdown-display builds its own parser with
|
||||
// `typographer: true`, which would render a plan's literal `(c)` as ©. Its
|
||||
// default also leaves linkify off, so this one keeps bare URLs as plain text.
|
||||
const planMarkdownParser = createMarkdownParser({ linkify: false });
|
||||
|
||||
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
@@ -219,7 +225,7 @@ export function PlanCard({
|
||||
<View testID={testID} style={containerStyle}>
|
||||
<Text style={titleStyle}>{resolvedTitle}</Text>
|
||||
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
<Markdown style={markdownStyles} rules={markdownRules} markdownit={planMarkdownParser}>
|
||||
{text}
|
||||
</Markdown>
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -36,7 +34,8 @@ 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 * as Clipboard from "expo-clipboard";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
@@ -135,7 +134,6 @@ 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,
|
||||
@@ -1263,42 +1261,14 @@ function WorkspaceRowWithMenu({
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const clipboard = useWorkspaceClipboardActions();
|
||||
const handleCopyPath = useCallback(() => {
|
||||
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]);
|
||||
clipboard.copyPath(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
clipboard.copyBranchName(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleOpenRename = useCallback(() => {
|
||||
setIsRenameOpen(true);
|
||||
@@ -1308,13 +1278,6 @@ 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);
|
||||
@@ -1373,14 +1336,10 @@ function WorkspaceRowWithMenu({
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
<WorkspaceRenameModal
|
||||
visible={isRenameOpen}
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
workspace={workspace}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -40,14 +40,11 @@ import {
|
||||
CircleX,
|
||||
} from "lucide-react-native";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
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";
|
||||
@@ -626,42 +623,17 @@ function StatusWorkspaceRowWithMenu({
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const clipboard = useWorkspaceClipboardActions();
|
||||
const handleCopyPath = useCallback(() => {
|
||||
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]);
|
||||
clipboard.copyPath(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
clipboard.copyBranchName(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
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);
|
||||
@@ -719,14 +691,10 @@ function StatusWorkspaceRowWithMenu({
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
<WorkspaceRenameModal
|
||||
visible={isRenameOpen}
|
||||
title="Rename workspace"
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel="Rename"
|
||||
workspace={workspace}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -2,23 +2,20 @@ 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 { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { WorkspaceRenameModal } from "@/components/workspace-rename-modal";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
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";
|
||||
@@ -108,42 +105,14 @@ export function SidebarWorkspaceRow({
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const clipboard = useWorkspaceClipboardActions();
|
||||
const handleCopyPath = useCallback(() => {
|
||||
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]);
|
||||
clipboard.copyPath(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
clipboard.copyBranchName(workspace);
|
||||
}, [clipboard, workspace]);
|
||||
|
||||
const handleOpenRename = useCallback(() => {
|
||||
setIsRenameOpen(true);
|
||||
@@ -153,13 +122,6 @@ 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,
|
||||
@@ -206,14 +168,10 @@ export function SidebarWorkspaceRow({
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
<WorkspaceRenameModal
|
||||
visible={isRenameOpen}
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
workspace={workspace}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -3,11 +3,19 @@ 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 } from "lucide-react-native";
|
||||
import {
|
||||
QrCode,
|
||||
Link2,
|
||||
ClipboardPaste,
|
||||
ExternalLink,
|
||||
Settings,
|
||||
Terminal,
|
||||
} 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";
|
||||
@@ -17,9 +25,10 @@ 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" | "paste-pairing-link";
|
||||
key: "scan-qr" | "direct-connection" | "remote-ssh" | "paste-pairing-link";
|
||||
label: string;
|
||||
testID: string;
|
||||
primary: boolean;
|
||||
@@ -165,6 +174,7 @@ 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));
|
||||
@@ -188,6 +198,8 @@ 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(() => {
|
||||
@@ -249,6 +261,17 @@ 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],
|
||||
@@ -300,6 +323,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
onSaved={handleHostSaved}
|
||||
/>
|
||||
|
||||
<AddRemoteSshHostModal
|
||||
visible={isRemoteSshOpen}
|
||||
onClose={handleCloseRemoteSsh}
|
||||
onSaved={handleHostSaved}
|
||||
/>
|
||||
|
||||
<PairLinkModal
|
||||
visible={isPasteLinkOpen}
|
||||
onClose={handleClosePasteLink}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,12 +19,12 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
|
||||
|
||||
/**
|
||||
* The strip of pills where a pane's ambient trackers live — subagents and tasks today.
|
||||
* The strip of pills where a pane's ambient trackers and plugin actions live.
|
||||
*
|
||||
* Everything in it is a pill: a count you can read without opening anything, and a panel behind
|
||||
* it for the detail. Trackers used to be stacked cards, so every one of them pushed the composer
|
||||
* further down the pane. The bar has at most one subagent pill and one task pill, so it remains
|
||||
* one line and has deterministic geometry on every platform.
|
||||
* Trackers expose a count and a detail panel; plugin actions expose their own icon and text.
|
||||
* Trackers used to be stacked cards, so every one of them pushed the composer further down the
|
||||
* pane. Built-in and contributed pills share this one-line rail and its deterministic geometry
|
||||
* on every platform.
|
||||
*
|
||||
* The bar floats over the transcript with no background, so content remains visible underneath.
|
||||
* Its host gives the scroll viewport a small bottom inset only when the bar exists; that keeps
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const value = useContext(ToastContext);
|
||||
if (!value) {
|
||||
throw new Error("useToast must be used within ToastProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function ToastApiProvider({ api, children }: { api: ToastApi; children: ReactNode }) {
|
||||
return <ToastContext.Provider value={api}>{children}</ToastContext.Provider>;
|
||||
}
|
||||
@@ -1,23 +1,16 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { ToastViewport, useToastHost, type ToastApi } from "@/components/toast-host";
|
||||
import type { ReactNode } from "react";
|
||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||
import { ToastApiProvider } from "./toast-api-context";
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const value = useContext(ToastContext);
|
||||
if (!value) {
|
||||
throw new Error("useToast must be used within ToastProvider");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
export { ToastApiProvider, useToast } from "./toast-api-context";
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const { api, toast, dismiss } = useToastHost();
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
<ToastApiProvider api={api}>
|
||||
{children}
|
||||
<ToastViewport toast={toast} onDismiss={dismiss} />
|
||||
</ToastContext.Provider>
|
||||
</ToastApiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDesktopLocalDaemonTransportFactory } from "./desktop-daemon-transport";
|
||||
import {
|
||||
buildDesktopDaemonTransportUrl,
|
||||
createDesktopDaemonTransportFactory,
|
||||
} from "./desktop-daemon-transport";
|
||||
import { createFakeLocalDaemonTransportRpc } from "./test-local-daemon-transport-rpc";
|
||||
|
||||
const LOCAL_URL = "paseo+local://socket?path=%2Ftmp%2Fpaseo.sock";
|
||||
const LOCAL_URL = "paseo+desktop://socket?path=%2Ftmp%2Fpaseo.sock";
|
||||
|
||||
describe("desktop-daemon-transport", () => {
|
||||
it("emits open after the session resolves even if the rust open event raced earlier", async () => {
|
||||
it("uses the main-process event as readiness when it races registration", async () => {
|
||||
const rpc = createFakeLocalDaemonTransportRpc();
|
||||
const transportFactory = createDesktopLocalDaemonTransportFactory(rpc);
|
||||
const cleanup = vi.fn();
|
||||
const transportFactory = createDesktopDaemonTransportFactory(rpc);
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
const transport = transportFactory!({ url: LOCAL_URL });
|
||||
@@ -15,32 +19,93 @@ describe("desktop-daemon-transport", () => {
|
||||
const onOpen = vi.fn();
|
||||
transport.onOpen(onOpen);
|
||||
|
||||
rpc.emitEvent({ sessionId: "local-session-1", kind: "open" });
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
rpc.resolveListen(cleanup);
|
||||
await Promise.resolve();
|
||||
|
||||
rpc.resolveOpen("local-session-1");
|
||||
const sessionId = rpc.openCalls[0]?.sessionId ?? "";
|
||||
expect(sessionId).not.toBe("");
|
||||
rpc.emitEvent({ sessionId, kind: "open" });
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
|
||||
rpc.resolveRegistration();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleans up late async setup after the transport is closed", async () => {
|
||||
it("does not start a session when listener setup finishes after close", async () => {
|
||||
const rpc = createFakeLocalDaemonTransportRpc();
|
||||
const cleanup = vi.fn();
|
||||
|
||||
const transportFactory = createDesktopLocalDaemonTransportFactory(rpc);
|
||||
const transportFactory = createDesktopDaemonTransportFactory(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.closedSessions).toEqual(["local-session-2"]);
|
||||
expect(rpc.openCalls).toHaveLength(0);
|
||||
expect(rpc.closedSessions).toHaveLength(1);
|
||||
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,13 +2,15 @@ import type {
|
||||
DaemonTransport,
|
||||
DaemonTransportFactory,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { LocalTransportTarget } from "./desktop-daemon";
|
||||
import { validatePort, validateSshHost } from "@getpaseo/protocol/ssh-transport";
|
||||
import type { DesktopDaemonTransportTarget } from "./desktop-daemon";
|
||||
import {
|
||||
defaultLocalDaemonTransportRpc,
|
||||
type LocalDaemonTransportEvent,
|
||||
type LocalDaemonTransportRpc,
|
||||
} from "./local-daemon-transport-rpc";
|
||||
|
||||
const LOCAL_TRANSPORT_SCHEME = "paseo+local:";
|
||||
const DESKTOP_TRANSPORT_SCHEME = "paseo+desktop:";
|
||||
|
||||
function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
|
||||
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||
@@ -28,21 +30,32 @@ function decodeBase64ToBytes(base64: string): Uint8Array {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function buildLocalDaemonTransportUrl(target: LocalTransportTarget): string {
|
||||
const url = new URL(`${LOCAL_TRANSPORT_SCHEME}//${target.transportType}`);
|
||||
url.searchParams.set("path", target.transportPath);
|
||||
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);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
|
||||
function parseDesktopDaemonTransportUrl(url: string): DesktopDaemonTransportTarget {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== LOCAL_TRANSPORT_SCHEME) {
|
||||
throw new Error(`Unsupported local transport URL: ${url}`);
|
||||
if (parsed.protocol !== DESKTOP_TRANSPORT_SCHEME) {
|
||||
throw new Error(`Unsupported desktop 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 local transport target: ${url}`);
|
||||
throw new Error(`Invalid desktop transport target: ${url}`);
|
||||
}
|
||||
return {
|
||||
transportType,
|
||||
@@ -50,12 +63,33 @@ function parseLocalDaemonTransportUrl(url: string): LocalTransportTarget {
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopLocalDaemonTransportFactory(
|
||||
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(
|
||||
rpc: LocalDaemonTransportRpc = defaultLocalDaemonTransportRpc,
|
||||
): DaemonTransportFactory | null {
|
||||
return ({ url }) => {
|
||||
const target = parseLocalDaemonTransportUrl(url);
|
||||
let sessionId: string | null = null;
|
||||
const target = parseDesktopDaemonTransportUrl(url);
|
||||
const sessionId = `local-session-${globalThis.crypto.randomUUID()}`;
|
||||
let unlisten: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
let didEmitOpen = false;
|
||||
@@ -80,6 +114,9 @@ export function createDesktopLocalDaemonTransportFactory(
|
||||
}
|
||||
};
|
||||
const emitError = (event?: unknown) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
for (const handler of errorHandlers) {
|
||||
handler(event);
|
||||
}
|
||||
@@ -90,61 +127,49 @@ export function createDesktopLocalDaemonTransportFactory(
|
||||
}
|
||||
};
|
||||
|
||||
void rpc
|
||||
.listenToEvents((payload) => {
|
||||
if (disposed || !sessionId || payload.sessionId !== sessionId) {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (payload.kind === "open") {
|
||||
emitOpen();
|
||||
return;
|
||||
if (payload.binaryBase64) {
|
||||
emitMessage(decodeBase64ToBytes(payload.binaryBase64), true);
|
||||
}
|
||||
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) => {
|
||||
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 (disposed) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
unlisten = cleanup;
|
||||
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) => {
|
||||
await rpc.openSession({ sessionId, target });
|
||||
} catch (error) {
|
||||
emitError(error);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
const transport: DaemonTransport = {
|
||||
send: (data) => {
|
||||
if (!sessionId) {
|
||||
if (!didEmitOpen) {
|
||||
return;
|
||||
}
|
||||
if (typeof data === "string") {
|
||||
@@ -157,12 +182,11 @@ export function createDesktopLocalDaemonTransportFactory(
|
||||
void rpc.sendMessage({ sessionId, binaryBase64 }).catch((error) => emitError(error));
|
||||
},
|
||||
close: () => {
|
||||
disposed = true;
|
||||
const currentSessionId = sessionId;
|
||||
sessionId = null;
|
||||
if (currentSessionId) {
|
||||
void rpc.closeSession(currentSessionId).catch((error) => emitError(error));
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
void rpc.closeSession(sessionId).catch((error) => emitError(error));
|
||||
unlisten?.();
|
||||
unlisten = null;
|
||||
},
|
||||
|
||||
@@ -40,6 +40,22 @@ 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";
|
||||
@@ -179,12 +195,10 @@ export async function listenToLocalTransportEvents(
|
||||
return typeof unlisten === "function" ? unlisten : () => {};
|
||||
}
|
||||
|
||||
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 openLocalTransportSession(
|
||||
input: OpenLocalTransportSessionInput,
|
||||
): Promise<void> {
|
||||
await invokeDesktopCommand("open_local_daemon_transport", input);
|
||||
}
|
||||
|
||||
export async function sendLocalTransportMessage(input: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
listenToLocalTransportEvents,
|
||||
openLocalTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
type LocalTransportTarget,
|
||||
type OpenLocalTransportSessionInput,
|
||||
} from "./desktop-daemon";
|
||||
|
||||
export interface LocalDaemonTransportEvent {
|
||||
@@ -17,7 +17,7 @@ export interface LocalDaemonTransportEvent {
|
||||
}
|
||||
|
||||
export interface LocalDaemonTransportRpc {
|
||||
openSession(target: LocalTransportTarget): Promise<string>;
|
||||
openSession(input: OpenLocalTransportSessionInput): Promise<void>;
|
||||
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 { LocalTransportTarget } from "./desktop-daemon";
|
||||
import type { OpenLocalTransportSessionInput } from "./desktop-daemon";
|
||||
import type {
|
||||
LocalDaemonTransportEvent,
|
||||
LocalDaemonTransportRpc,
|
||||
@@ -11,10 +11,10 @@ export interface RecordedSend {
|
||||
}
|
||||
|
||||
export interface FakeLocalDaemonTransportRpc extends LocalDaemonTransportRpc {
|
||||
readonly openCalls: LocalTransportTarget[];
|
||||
readonly openCalls: OpenLocalTransportSessionInput[];
|
||||
readonly recordedSends: RecordedSend[];
|
||||
readonly closedSessions: string[];
|
||||
resolveOpen(sessionId: string): void;
|
||||
resolveRegistration(): 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: LocalTransportTarget[] = [];
|
||||
const openCalls: OpenLocalTransportSessionInput[] = [];
|
||||
const recordedSends: RecordedSend[] = [];
|
||||
const closedSessions: string[] = [];
|
||||
let eventHandler: ((event: LocalDaemonTransportEvent) => void) | null = null;
|
||||
let resolveOpenSession: ((sessionId: string) => void) | null = null;
|
||||
let rejectOpenSession: ((error: Error) => void) | null = null;
|
||||
let resolveRegistrationPromise: (() => void) | null = null;
|
||||
let rejectRegistrationPromise: ((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(target) {
|
||||
openCalls.push(target);
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
resolveOpenSession = resolve;
|
||||
rejectOpenSession = reject;
|
||||
openSession(input) {
|
||||
openCalls.push(input);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
resolveRegistrationPromise = resolve;
|
||||
rejectRegistrationPromise = reject;
|
||||
});
|
||||
},
|
||||
listenToEvents(handler) {
|
||||
@@ -55,17 +55,17 @@ export function createFakeLocalDaemonTransportRpc(): FakeLocalDaemonTransportRpc
|
||||
async closeSession(sessionId) {
|
||||
closedSessions.push(sessionId);
|
||||
},
|
||||
resolveOpen(sessionId) {
|
||||
if (!resolveOpenSession) {
|
||||
resolveRegistration() {
|
||||
if (!resolveRegistrationPromise) {
|
||||
throw new Error("openSession was not called");
|
||||
}
|
||||
resolveOpenSession(sessionId);
|
||||
resolveRegistrationPromise();
|
||||
},
|
||||
rejectOpen(error) {
|
||||
if (!rejectOpenSession) {
|
||||
if (!rejectRegistrationPromise) {
|
||||
throw new Error("openSession was not called");
|
||||
}
|
||||
rejectOpenSession(error);
|
||||
rejectRegistrationPromise(error);
|
||||
},
|
||||
resolveListen(cleanup) {
|
||||
if (!resolveListenPromise) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1558,6 +1558,10 @@ export const ar: TranslationResources = {
|
||||
title: "اتصال مباشر",
|
||||
description: "الشبكة المحلية أو VPN.",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "SSH عن بُعد",
|
||||
description: "الاتصال عبر عميل SSH لسطح المكتب.",
|
||||
},
|
||||
scanQr: {
|
||||
title: "مسح رمز QR",
|
||||
description: "اتصال التتابع المشفر.",
|
||||
@@ -1610,6 +1614,23 @@ 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: "الصق رابط الاقتران من الخادم الخاص بك.",
|
||||
@@ -2244,6 +2265,7 @@ export const ar: TranslationResources = {
|
||||
badges: {
|
||||
relay: "تتابع",
|
||||
local: "محلي",
|
||||
remoteSsh: "SSH عن بُعد",
|
||||
},
|
||||
connections: {
|
||||
title: "اتصالات",
|
||||
|
||||
@@ -1568,6 +1568,10 @@ 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.",
|
||||
@@ -1619,6 +1623,23 @@ 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.",
|
||||
@@ -2342,6 +2363,7 @@ export const en = {
|
||||
badges: {
|
||||
relay: "Relay",
|
||||
local: "Local",
|
||||
remoteSsh: "Remote SSH",
|
||||
},
|
||||
connections: {
|
||||
title: "Connections",
|
||||
|
||||
@@ -1602,6 +1602,10 @@ 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.",
|
||||
@@ -1654,6 +1658,23 @@ 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.",
|
||||
@@ -2295,6 +2316,7 @@ export const es: TranslationResources = {
|
||||
badges: {
|
||||
relay: "Relé",
|
||||
local: "Local",
|
||||
remoteSsh: "SSH remoto",
|
||||
},
|
||||
connections: {
|
||||
title: "Conexiones",
|
||||
|
||||
@@ -1606,6 +1606,10 @@ 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.",
|
||||
@@ -1658,6 +1662,23 @@ 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.",
|
||||
@@ -2300,6 +2321,7 @@ export const fr: TranslationResources = {
|
||||
badges: {
|
||||
relay: "Relais",
|
||||
local: "Locale",
|
||||
remoteSsh: "SSH distant",
|
||||
},
|
||||
connections: {
|
||||
title: "Relations",
|
||||
|
||||
@@ -1573,6 +1573,10 @@ export const ja: TranslationResources = {
|
||||
title: "直接接続",
|
||||
description: "ローカルネットワークまたはVPN。",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "リモート SSH",
|
||||
description: "デスクトップの SSH クライアント経由で接続します。",
|
||||
},
|
||||
scanQr: {
|
||||
title: "QRコードをスキャン",
|
||||
description: "暗号化されたリレー接続。",
|
||||
@@ -1624,6 +1628,23 @@ 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: "サーバーからのペアリングリンクを貼り付けてください。",
|
||||
@@ -2263,6 +2284,7 @@ export const ja: TranslationResources = {
|
||||
badges: {
|
||||
relay: "リレー",
|
||||
local: "ローカル",
|
||||
remoteSsh: "リモート SSH",
|
||||
},
|
||||
connections: {
|
||||
title: "接続",
|
||||
|
||||
@@ -1568,6 +1568,10 @@ export const ko: TranslationResources = {
|
||||
title: "직접 연결",
|
||||
description: "로컬 네트워크 또는 VPN.",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "원격 SSH",
|
||||
description: "데스크톱 SSH 클라이언트를 통해 연결합니다.",
|
||||
},
|
||||
scanQr: {
|
||||
title: "QR 코드 스캔",
|
||||
description: "암호화된 릴레이 연결.",
|
||||
@@ -1618,6 +1622,23 @@ 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: "서버에서 받은 페어링 링크를 붙여넣으세요.",
|
||||
@@ -2254,6 +2275,7 @@ export const ko: TranslationResources = {
|
||||
badges: {
|
||||
relay: "릴레이",
|
||||
local: "로컬",
|
||||
remoteSsh: "원격 SSH",
|
||||
},
|
||||
connections: {
|
||||
title: "연결",
|
||||
|
||||
@@ -1588,6 +1588,10 @@ 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.",
|
||||
@@ -1640,6 +1644,23 @@ 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.",
|
||||
@@ -2279,6 +2300,7 @@ export const ptBR: TranslationResources = {
|
||||
badges: {
|
||||
relay: "Relay",
|
||||
local: "Local",
|
||||
remoteSsh: "SSH remoto",
|
||||
},
|
||||
connections: {
|
||||
title: "Conexões",
|
||||
|
||||
@@ -1585,6 +1585,10 @@ export const ru: TranslationResources = {
|
||||
title: "Прямое подключение",
|
||||
description: "Локальная сеть или VPN.",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "Удалённый SSH",
|
||||
description: "Подключение через SSH-клиент настольного приложения.",
|
||||
},
|
||||
scanQr: {
|
||||
title: "Сканировать QR-код",
|
||||
description: "Зашифрованное подключение через ретранслятор.",
|
||||
@@ -1637,6 +1641,23 @@ 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: "Вставьте ссылку на сопряжение с вашего сервера.",
|
||||
@@ -2281,6 +2302,7 @@ export const ru: TranslationResources = {
|
||||
badges: {
|
||||
relay: "Ретранслятор",
|
||||
local: "Локальный",
|
||||
remoteSsh: "Удалённый SSH",
|
||||
},
|
||||
connections: {
|
||||
title: "Подключения",
|
||||
|
||||
@@ -1541,6 +1541,10 @@ export const zhCN: TranslationResources = {
|
||||
title: "直接连接",
|
||||
description: "本地网络或 VPN。",
|
||||
},
|
||||
remoteSsh: {
|
||||
title: "远程 SSH",
|
||||
description: "通过桌面 SSH 客户端连接。",
|
||||
},
|
||||
scanQr: {
|
||||
title: "扫描二维码",
|
||||
description: "加密 relay 连接。",
|
||||
@@ -1591,6 +1595,23 @@ 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 的配对链接。",
|
||||
@@ -2218,6 +2239,7 @@ export const zhCN: TranslationResources = {
|
||||
badges: {
|
||||
relay: "Relay",
|
||||
local: "本地",
|
||||
remoteSsh: "远程 SSH",
|
||||
},
|
||||
connections: {
|
||||
title: "连接",
|
||||
|
||||
@@ -51,7 +51,10 @@ export type KeyboardActionId =
|
||||
| "workspace.project.pick"
|
||||
| "worktree.new"
|
||||
| "workspace.archive"
|
||||
| "workspace.pin";
|
||||
| "workspace.pin"
|
||||
// Command-center only: no keybind, so these are absent from route-shortcut.ts.
|
||||
| "workspace.rename"
|
||||
| "workspace.setup.show";
|
||||
|
||||
export type KeyboardActionDefinition =
|
||||
| { id: "agent.interrupt"; scope: KeyboardActionScope }
|
||||
@@ -106,7 +109,9 @@ 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.pin"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.rename"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.setup.show"; scope: KeyboardActionScope };
|
||||
|
||||
export interface KeyboardActionHandler {
|
||||
handlerId: string;
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useHasPluginComposerPills } from "@/plugins";
|
||||
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
|
||||
import {
|
||||
type HostRuntimeConnectionStatus,
|
||||
@@ -1288,11 +1289,13 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
parentAgentId: agentId,
|
||||
rows: subagentRows,
|
||||
});
|
||||
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
|
||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||
const hasVisibleAgentTracks = hasAgentTracks({
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus: archiveFinishedSubagents.status,
|
||||
hasPluginComposerPills,
|
||||
});
|
||||
const rawAgentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
@@ -1379,11 +1382,13 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
<AgentTracks
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
subagentRows={subagentRows}
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
hasPluginComposerPills={hasPluginComposerPills}
|
||||
/>
|
||||
) : null}
|
||||
</ReanimatedAnimated.View>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ComposerTrackBar } from "@/composer/tracks";
|
||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { PluginComposerPills } from "@/plugins";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
type ArchiveFinishedStatus,
|
||||
@@ -30,19 +31,23 @@ import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
export const AgentTracks = memo(function AgentTracks({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
cwd,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
onArchiveFinished,
|
||||
hasPluginComposerPills,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
subagentRows: SubagentRow[];
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
onArchiveFinished: () => void;
|
||||
hasPluginComposerPills: boolean;
|
||||
}): ReactElement | null {
|
||||
const { tabId, openTab } = usePaneContext();
|
||||
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||
@@ -107,7 +112,15 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
});
|
||||
}, [cwd, isCompact, openInSidePane, serverId, workspaceKey]);
|
||||
|
||||
if (!hasWorkspaceDiffStat && !hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
|
||||
if (
|
||||
!hasWorkspaceDiffStat &&
|
||||
!hasAgentTracks({
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -123,6 +136,12 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
archiveFinishedStatus={archiveFinishedStatus}
|
||||
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
|
||||
/>
|
||||
<PluginComposerPills
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
compact={isCompact}
|
||||
/>
|
||||
<WorkspaceDiffStatPill
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
@@ -136,10 +155,17 @@ export function hasAgentTracks({
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills = false,
|
||||
}: {
|
||||
subagentRows: readonly SubagentRow[];
|
||||
tasks: readonly TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
hasPluginComposerPills?: boolean;
|
||||
}): boolean {
|
||||
return subagentRows.length > 0 || Boolean(tasks?.length) || archiveFinishedStatus.kind !== "idle";
|
||||
return (
|
||||
subagentRows.length > 0 ||
|
||||
Boolean(tasks?.length) ||
|
||||
archiveFinishedStatus.kind !== "idle" ||
|
||||
hasPluginComposerPills
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { callPluginRpc } from "@getpaseo/plugin/host";
|
||||
import type {
|
||||
PluginAgentCommandContext,
|
||||
PluginCommandCapabilities,
|
||||
PluginPanelLocation,
|
||||
PluginWorkspaceCommandContext,
|
||||
} from "@getpaseo/plugin";
|
||||
import type { PluginClientStateSource } from "@getpaseo/plugin/host";
|
||||
import { resolvePluginPanelOpenLocation } from "./workspace-panels/locations";
|
||||
import type { PluginSurfaceRuntime } from "./surface-runtime";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
export interface PluginNavigation {
|
||||
openSurface(pluginId: string, surfaceId: string): void;
|
||||
openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void;
|
||||
openAgentPanel(
|
||||
pluginId: string,
|
||||
panelId: string,
|
||||
agentId: string,
|
||||
location: PluginPanelLocation,
|
||||
): void;
|
||||
}
|
||||
|
||||
export function createPluginCapabilities(
|
||||
plugin: InstalledPlugin,
|
||||
runtime: PluginSurfaceRuntime,
|
||||
navigation: PluginNavigation,
|
||||
): PluginCommandCapabilities {
|
||||
return {
|
||||
paseo: runtime.paseo,
|
||||
rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input),
|
||||
openSurface(surfaceId) {
|
||||
if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) {
|
||||
throw new Error(`Plugin surface is unavailable: ${surfaceId}`);
|
||||
}
|
||||
navigation.openSurface(plugin.id, surfaceId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginAgentActionContext(input: {
|
||||
plugin: InstalledPlugin;
|
||||
runtime: PluginSurfaceRuntime;
|
||||
navigation: PluginNavigation;
|
||||
state: PluginClientStateSource;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}): PluginAgentCommandContext | null {
|
||||
const { plugin, runtime, navigation, state, workspaceId, agentId } = input;
|
||||
const workspace = state.getWorkspace(workspaceId);
|
||||
const agent = state.getAgent(agentId);
|
||||
if (!workspace || !agent || agent.workspaceId !== workspace.id) return null;
|
||||
return {
|
||||
context: "agent",
|
||||
...createPluginCapabilities(plugin, runtime, navigation),
|
||||
workspace,
|
||||
agent,
|
||||
openPanel(panelId, options) {
|
||||
const panel = plugin.workspacePanels.find((candidate) => candidate.id === panelId);
|
||||
if (!panel) throw new Error(`Workspace panel is unavailable: ${panelId}`);
|
||||
const location = resolvePluginPanelOpenLocation(panel, options?.location);
|
||||
if (panel.context === "workspace") {
|
||||
navigation.openWorkspacePanel(plugin.id, panelId, location);
|
||||
return;
|
||||
}
|
||||
navigation.openAgentPanel(plugin.id, panelId, agent.id, location);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginWorkspaceActionContext(input: {
|
||||
plugin: InstalledPlugin;
|
||||
runtime: PluginSurfaceRuntime;
|
||||
navigation: PluginNavigation;
|
||||
state: PluginClientStateSource;
|
||||
workspaceId: string;
|
||||
}): PluginWorkspaceCommandContext | null {
|
||||
const { plugin, runtime, navigation, state, workspaceId } = input;
|
||||
const workspace = state.getWorkspace(workspaceId);
|
||||
if (!workspace) return null;
|
||||
return {
|
||||
context: "workspace",
|
||||
...createPluginCapabilities(plugin, runtime, navigation),
|
||||
workspace,
|
||||
openPanel(panelId, options) {
|
||||
const panel = plugin.workspacePanels.find(
|
||||
(candidate) => candidate.id === panelId && candidate.context === "workspace",
|
||||
);
|
||||
if (!panel) throw new Error(`Workspace panel is unavailable: ${panelId}`);
|
||||
const location = resolvePluginPanelOpenLocation(panel, options?.location);
|
||||
navigation.openWorkspacePanel(plugin.id, panelId, location);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
const connection = vi.hoisted(() => ({ connected: true, supported: true }));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeIsConnected: () => connection.connected,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-features", () => ({
|
||||
useHostFeature: () => connection.supported,
|
||||
}));
|
||||
|
||||
import { PluginCatalogSync } from "./catalog-sync";
|
||||
import { pluginRegistry } from "./registry";
|
||||
|
||||
function bundle(): string {
|
||||
return `(function() { return { default: function(plugin) {
|
||||
plugin.addSurface("main", function() { return null; });
|
||||
return function() { globalThis.__catalogSyncCleanups = (globalThis.__catalogSyncCleanups || 0) + 1; };
|
||||
} }; })`;
|
||||
}
|
||||
|
||||
const unsubscribe = vi.fn();
|
||||
const catalogClient = {
|
||||
getPluginCatalog: vi.fn(async () => [{ id: "example", clientBundle: bundle() }]),
|
||||
on: vi.fn(() => unsubscribe),
|
||||
} as unknown as DaemonClient;
|
||||
|
||||
describe("PluginCatalogSync", () => {
|
||||
let container: HTMLElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
connection.connected = true;
|
||||
connection.supported = true;
|
||||
unsubscribe.mockReset();
|
||||
vi.mocked(catalogClient.getPluginCatalog).mockClear();
|
||||
vi.mocked(catalogClient.on).mockClear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
pluginRegistry.removeHost("host-a");
|
||||
container.remove();
|
||||
Reflect.deleteProperty(globalThis, "__catalogSyncCleanups");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderSync(): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(<PluginCatalogSync serverId="host-a" client={catalogClient} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("tears down through disconnect and unmount boundaries exactly once each", async () => {
|
||||
await renderSync();
|
||||
const first = pluginRegistry.getSnapshot()[0];
|
||||
expect(first?.id).toBe("example");
|
||||
first?.queryClient.setQueryData(["owned"], "state");
|
||||
|
||||
connection.connected = false;
|
||||
await renderSync();
|
||||
|
||||
expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(1);
|
||||
expect(first?.queryClient.getQueryCache().getAll()).toEqual([]);
|
||||
expect(pluginRegistry.getSnapshot()).toEqual([]);
|
||||
|
||||
connection.connected = true;
|
||||
await renderSync();
|
||||
expect(pluginRegistry.getSnapshot()).toHaveLength(1);
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
|
||||
expect(Reflect.get(globalThis, "__catalogSyncCleanups")).toBe(2);
|
||||
expect(pluginRegistry.getSnapshot()).toEqual([]);
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ export function PluginCatalogSync({
|
||||
if (!cancelled) {
|
||||
const timelineChanged = pluginRegistry.installCatalog(serverId, catalog, {
|
||||
replacePluginId,
|
||||
client,
|
||||
});
|
||||
if (timelineChanged) {
|
||||
useSessionStore
|
||||
|
||||
@@ -97,6 +97,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
||||
onSelect: onAgentSelect,
|
||||
},
|
||||
],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { callPluginRpc } from "@getpaseo/plugin/host";
|
||||
import type { PluginCommandCapabilities, PluginPanelLocation } from "@getpaseo/plugin";
|
||||
import type { PluginClientStateSource } from "@getpaseo/plugin/host";
|
||||
import type { CommandCenterContribution } from "@/command-center/contributions";
|
||||
import { getCommandCenterIcon } from "@/command-center/icon";
|
||||
@@ -7,17 +5,7 @@ import { resolvePluginIcon } from "../icons";
|
||||
import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations";
|
||||
import type { PluginSurfaceRuntime } from "../surface-runtime";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
|
||||
export interface PluginCommandCenterNavigation {
|
||||
openSurface(pluginId: string, surfaceId: string): void;
|
||||
openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void;
|
||||
openAgentPanel(
|
||||
pluginId: string,
|
||||
panelId: string,
|
||||
agentId: string,
|
||||
location: PluginPanelLocation,
|
||||
): void;
|
||||
}
|
||||
import { createPluginCapabilities, type PluginNavigation } from "../actions";
|
||||
|
||||
export interface PluginCommandCenterSource {
|
||||
plugins: readonly InstalledPlugin[];
|
||||
@@ -25,34 +13,17 @@ export interface PluginCommandCenterSource {
|
||||
state: PluginClientStateSource;
|
||||
workspaceId: string | null;
|
||||
agentId: string | null;
|
||||
navigation: PluginCommandCenterNavigation;
|
||||
navigation: PluginNavigation;
|
||||
reportError(error: unknown): void;
|
||||
}
|
||||
|
||||
function capabilities(
|
||||
plugin: InstalledPlugin,
|
||||
runtime: PluginSurfaceRuntime,
|
||||
navigation: PluginCommandCenterNavigation,
|
||||
): PluginCommandCapabilities {
|
||||
return {
|
||||
paseo: runtime.paseo,
|
||||
rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input),
|
||||
openSurface(surfaceId) {
|
||||
if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) {
|
||||
throw new Error(`Plugin surface is unavailable: ${surfaceId}`);
|
||||
}
|
||||
navigation.openSurface(plugin.id, surfaceId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPluginCommandCenterContributions(
|
||||
source: PluginCommandCenterSource,
|
||||
): CommandCenterContribution[] {
|
||||
const contributions: CommandCenterContribution[] = [];
|
||||
for (const plugin of source.plugins) {
|
||||
const runtime = source.runtime(plugin.id);
|
||||
const common = capabilities(plugin, runtime, source.navigation);
|
||||
const common = createPluginCapabilities(plugin, runtime, source.navigation);
|
||||
for (const [rank, item] of plugin.commandCenterItems.entries()) {
|
||||
if (item.context === "workspace" && !source.workspaceId) continue;
|
||||
if (item.context === "agent" && (!source.workspaceId || !source.agentId)) continue;
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { useCommandCenterActions } from "@/command-center/provider";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import {
|
||||
useActiveWorkspaceSelection,
|
||||
navigateToWorkspace,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { PluginPanelLocation } from "@getpaseo/plugin";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes";
|
||||
import { hostIdFromPathname } from "../routes";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
import { buildPluginCommandCenterContributions } from "./contributions";
|
||||
import { getFocusedAgentId } from "./context";
|
||||
import { createPluginNavigation } from "../navigation";
|
||||
|
||||
export function PluginCommandCenterActions() {
|
||||
const pathname = usePathname();
|
||||
@@ -64,33 +61,7 @@ export function PluginCommandCenterActions() {
|
||||
state: stateSource,
|
||||
workspaceId: workspaceExists ? workspaceId : null,
|
||||
agentId: agentExists ? focusedAgentId : null,
|
||||
navigation: {
|
||||
openSurface(pluginId, surfaceId) {
|
||||
router.push(
|
||||
buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }),
|
||||
);
|
||||
},
|
||||
openWorkspacePanel(pluginId, panelId, location) {
|
||||
if (!workspaceId) throw new Error("No active workspace");
|
||||
const placement = resolvePluginPanelPlacement(workspaceKey, location);
|
||||
navigateToWorkspace({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "plugin", pluginId, panelId, context: "workspace" },
|
||||
placement,
|
||||
});
|
||||
},
|
||||
openAgentPanel(pluginId, panelId, agentId, location) {
|
||||
if (!workspaceId) throw new Error("No active workspace");
|
||||
const placement = resolvePluginPanelPlacement(workspaceKey, location);
|
||||
navigateToWorkspace({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "plugin", pluginId, panelId, context: "agent", agentId },
|
||||
placement,
|
||||
});
|
||||
},
|
||||
},
|
||||
navigation: createPluginNavigation({ serverId, workspaceId }),
|
||||
reportError(error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
@@ -105,7 +76,6 @@ export function PluginCommandCenterActions() {
|
||||
toast,
|
||||
workspaceExists,
|
||||
workspaceId,
|
||||
workspaceKey,
|
||||
]);
|
||||
|
||||
useCommandCenterActions({
|
||||
@@ -115,11 +85,3 @@ export function PluginCommandCenterActions() {
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePluginPanelPlacement(workspaceKey: string | null, location: PluginPanelLocation) {
|
||||
if (location !== "explorer") return undefined;
|
||||
if (!workspaceKey) throw new Error("No active workspace");
|
||||
const paneId = useWorkspaceLayoutStore.getState().showExplorerSidebar(workspaceKey);
|
||||
if (!paneId) throw new Error("Explorer is unavailable");
|
||||
return { mode: "pane" as const, paneId };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { PluginClientStateProvider } from "@getpaseo/plugin/host";
|
||||
import type { PluginComposerPillProps, PluginTheme } from "@getpaseo/plugin";
|
||||
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { Platform, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { composerPillStyles } from "@/composer/pill-styles";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { PluginRuntimeBoundary } from "../runtime-boundary";
|
||||
import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import type { InstalledPlugin, PluginComposerPillContribution } from "../types";
|
||||
import { pluginComposerPillStore } from "./store";
|
||||
|
||||
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
|
||||
function resolvePlatform(): PluginComposerPillProps["layout"]["platform"] {
|
||||
if (Platform.OS === "ios") return "ios";
|
||||
if (Platform.OS === "android") return "android";
|
||||
return "web";
|
||||
}
|
||||
|
||||
function PluginComposerPill({
|
||||
plugin,
|
||||
contribution,
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
compact,
|
||||
hostLabel,
|
||||
theme,
|
||||
}: {
|
||||
plugin: InstalledPlugin;
|
||||
contribution: PluginComposerPillContribution;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
compact: boolean;
|
||||
hostLabel: string;
|
||||
theme: PluginTheme;
|
||||
}) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const toast = useToast();
|
||||
const [pending, setPending] = useState(false);
|
||||
const runtime = useMemo(() => createPluginSurfaceRuntime(client, plugin.id), [client, plugin.id]);
|
||||
const state = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
|
||||
const props = useMemo<PluginComposerPillProps>(
|
||||
() => ({
|
||||
theme,
|
||||
host: { id: serverId, label: hostLabel },
|
||||
layout: { compact, platform: resolvePlatform() },
|
||||
workspaceId,
|
||||
agentId,
|
||||
}),
|
||||
[agentId, compact, hostLabel, serverId, theme, workspaceId],
|
||||
);
|
||||
const press = useCallback(async () => {
|
||||
if (!runtime || pending) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await contribution.onPress();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, [contribution, pending, runtime, toast]);
|
||||
const pillStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
composerPillStyles.body,
|
||||
styles.pill,
|
||||
(hovered || pressed) && composerPillStyles.bodyActive,
|
||||
pending && styles.disabled,
|
||||
],
|
||||
[pending],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ busy: pending, disabled: pending }), [pending]);
|
||||
if (!runtime) return null;
|
||||
const Component = contribution.Component;
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={Component}>
|
||||
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
|
||||
<PluginClientStateProvider source={state}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={contribution.title}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={pending}
|
||||
onPress={press}
|
||||
style={pillStyle}
|
||||
>
|
||||
<Component {...props} />
|
||||
{pending ? <LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> : null}
|
||||
</Pressable>
|
||||
</PluginClientStateProvider>
|
||||
</PluginRuntimeBoundary>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedPluginComposerPill = withUnistyles(PluginComposerPill);
|
||||
|
||||
export function PluginComposerPills({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
compact,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const registrations = useSyncExternalStore(
|
||||
pluginComposerPillStore.subscribe,
|
||||
pluginComposerPillStore.getSnapshot,
|
||||
pluginComposerPillStore.getSnapshot,
|
||||
);
|
||||
const hosts = useHosts();
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
registrations.filter(
|
||||
({ installation, contribution }) =>
|
||||
installation.serverId === serverId &&
|
||||
contribution.workspaceId === workspaceId &&
|
||||
contribution.agentId === agentId,
|
||||
),
|
||||
[agentId, registrations, serverId, workspaceId],
|
||||
);
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
return (
|
||||
<>
|
||||
{visible.map(({ installation, contribution }) => (
|
||||
<ThemedPluginComposerPill
|
||||
key={`${installation.id}/${contribution.id}`}
|
||||
plugin={installation}
|
||||
contribution={contribution}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
compact={compact}
|
||||
hostLabel={hostLabel}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHasPluginComposerPills(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
): boolean {
|
||||
const registrations = useSyncExternalStore(
|
||||
pluginComposerPillStore.subscribe,
|
||||
pluginComposerPillStore.getSnapshot,
|
||||
pluginComposerPillStore.getSnapshot,
|
||||
);
|
||||
return registrations.some(
|
||||
({ installation, contribution }) =>
|
||||
installation.serverId === serverId &&
|
||||
contribution.workspaceId === workspaceId &&
|
||||
contribution.agentId === agentId,
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
pill: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,111 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginClientContext, PluginComposerPillProps } from "@getpaseo/plugin";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { startPluginClientSide } from "./lifecycle";
|
||||
import { pluginComposerPillStore } from "./store";
|
||||
|
||||
function Pill(_props: PluginComposerPillProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function installation(clientSide: NonNullable<InstalledPlugin["clientSide"]>): InstalledPlugin {
|
||||
return {
|
||||
id: "review",
|
||||
serverId: "host-a",
|
||||
clientBundle: "bundle",
|
||||
queryClient: new QueryClient(),
|
||||
cleanup: () => undefined,
|
||||
surfaces: [],
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
timelineRenderers: [],
|
||||
};
|
||||
}
|
||||
|
||||
const daemonClient = {} as DaemonClient;
|
||||
const installations: InstalledPlugin[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const plugin of installations.splice(0)) {
|
||||
pluginComposerPillStore.removeInstallation(plugin);
|
||||
}
|
||||
});
|
||||
|
||||
describe("plugin client-side lifecycle", () => {
|
||||
it("lets client code add and remove a targeted composer pill at runtime", async () => {
|
||||
const captured: { client?: PluginClientContext } = {};
|
||||
let cleanupCount = 0;
|
||||
const plugin = installation((context) => {
|
||||
captured.client = context;
|
||||
return () => {
|
||||
cleanupCount += 1;
|
||||
};
|
||||
});
|
||||
installations.push(plugin);
|
||||
const stop = startPluginClientSide(plugin, daemonClient);
|
||||
const client = captured.client;
|
||||
if (!client) throw new Error("Client lifecycle did not start");
|
||||
|
||||
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
||||
const remove = client.addComposerPill({
|
||||
id: "review-ready",
|
||||
title: "Open review",
|
||||
workspaceId: "workspace-a",
|
||||
agentId: "agent-a",
|
||||
Component: Pill,
|
||||
onPress() {},
|
||||
});
|
||||
|
||||
expect(
|
||||
pluginComposerPillStore.getSnapshot().map(({ contribution }) => ({
|
||||
id: contribution.id,
|
||||
workspaceId: contribution.workspaceId,
|
||||
agentId: contribution.agentId,
|
||||
})),
|
||||
).toEqual([{ id: "review-ready", workspaceId: "workspace-a", agentId: "agent-a" }]);
|
||||
|
||||
remove();
|
||||
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
||||
|
||||
client.addComposerPill({
|
||||
id: "review-ready",
|
||||
title: "Open review",
|
||||
workspaceId: "workspace-a",
|
||||
agentId: "agent-a",
|
||||
Component: Pill,
|
||||
onPress() {},
|
||||
});
|
||||
await stop();
|
||||
|
||||
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
||||
expect(cleanupCount).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects duplicate pills only within the same plugin target", () => {
|
||||
const plugin = installation(() => () => undefined);
|
||||
installations.push(plugin);
|
||||
const contribution = {
|
||||
id: "review-ready",
|
||||
title: "Open review",
|
||||
workspaceId: "workspace-a",
|
||||
agentId: "agent-a",
|
||||
Component: Pill,
|
||||
onPress() {},
|
||||
};
|
||||
|
||||
pluginComposerPillStore.add(plugin, contribution);
|
||||
expect(() => pluginComposerPillStore.add(plugin, contribution)).toThrow(
|
||||
"Duplicate composer pill: review-ready",
|
||||
);
|
||||
expect(() =>
|
||||
pluginComposerPillStore.add(plugin, { ...contribution, agentId: "agent-b" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
PluginCleanup,
|
||||
PluginClientContext,
|
||||
PluginClientOpenPanelOptions,
|
||||
} from "@getpaseo/plugin";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
createPluginAgentActionContext,
|
||||
createPluginCapabilities,
|
||||
createPluginWorkspaceActionContext,
|
||||
} from "../actions";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { createPluginNavigation } from "../navigation";
|
||||
import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { pluginComposerPillStore } from "./store";
|
||||
|
||||
export function startPluginClientSide(
|
||||
installation: InstalledPlugin,
|
||||
daemonClient: DaemonClient,
|
||||
): PluginCleanup {
|
||||
if (!installation.clientSide) return () => undefined;
|
||||
const runtime = createPluginSurfaceRuntime(daemonClient, installation.id);
|
||||
if (!runtime) throw new Error("Plugin host is offline");
|
||||
const state = createPluginClientStateSource(installation.serverId);
|
||||
const capabilities = createPluginCapabilities(
|
||||
installation,
|
||||
runtime,
|
||||
createPluginNavigation({ serverId: installation.serverId, workspaceId: null }),
|
||||
);
|
||||
const context: PluginClientContext = {
|
||||
...capabilities,
|
||||
addComposerPill(contribution) {
|
||||
return pluginComposerPillStore.add(installation, contribution);
|
||||
},
|
||||
openPanel(panelId, options) {
|
||||
openClientPanel({ installation, runtime, state, panelId, options });
|
||||
},
|
||||
};
|
||||
|
||||
let cleanup: PluginCleanup;
|
||||
try {
|
||||
cleanup = installation.clientSide(context);
|
||||
if (typeof cleanup !== "function") {
|
||||
throw new Error("Plugin client-side entrypoint must return a cleanup function");
|
||||
}
|
||||
} catch (error) {
|
||||
pluginComposerPillStore.removeInstallation(installation);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let stopped = false;
|
||||
return async () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
pluginComposerPillStore.removeInstallation(installation);
|
||||
await cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
function openClientPanel(input: {
|
||||
installation: InstalledPlugin;
|
||||
runtime: NonNullable<ReturnType<typeof createPluginSurfaceRuntime>>;
|
||||
state: ReturnType<typeof createPluginClientStateSource>;
|
||||
panelId: string;
|
||||
options: PluginClientOpenPanelOptions;
|
||||
}): void {
|
||||
const { installation, runtime, state, panelId, options } = input;
|
||||
const workspaceId = options.workspaceId.trim();
|
||||
const agentId = options.agentId?.trim();
|
||||
const navigation = createPluginNavigation({ serverId: installation.serverId, workspaceId });
|
||||
const action = agentId
|
||||
? createPluginAgentActionContext({
|
||||
plugin: installation,
|
||||
runtime,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
agentId,
|
||||
})
|
||||
: createPluginWorkspaceActionContext({
|
||||
plugin: installation,
|
||||
runtime,
|
||||
navigation,
|
||||
state,
|
||||
workspaceId,
|
||||
});
|
||||
if (!action) throw new Error("Plugin panel context is unavailable");
|
||||
action.openPanel(panelId, { location: options.location });
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { PluginComposerPillContribution } from "@getpaseo/plugin";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
|
||||
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export interface RegisteredPluginComposerPill {
|
||||
installation: InstalledPlugin;
|
||||
contribution: PluginComposerPillContribution;
|
||||
}
|
||||
|
||||
class PluginComposerPillStore {
|
||||
private entries: RegisteredPluginComposerPill[] = [];
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
getSnapshot = (): readonly RegisteredPluginComposerPill[] => this.entries;
|
||||
|
||||
add(installation: InstalledPlugin, input: PluginComposerPillContribution): () => void {
|
||||
const contribution = validateContribution(input);
|
||||
const duplicate = this.entries.some(
|
||||
(entry) =>
|
||||
entry.installation === installation &&
|
||||
entry.contribution.workspaceId === contribution.workspaceId &&
|
||||
entry.contribution.agentId === contribution.agentId &&
|
||||
entry.contribution.id === contribution.id,
|
||||
);
|
||||
if (duplicate) {
|
||||
throw new Error(`Duplicate composer pill: ${contribution.id}`);
|
||||
}
|
||||
const entry = { installation, contribution };
|
||||
this.entries = [...this.entries, entry];
|
||||
this.publish();
|
||||
let removed = false;
|
||||
return () => {
|
||||
if (removed) return;
|
||||
removed = true;
|
||||
this.entries = this.entries.filter((candidate) => candidate !== entry);
|
||||
this.publish();
|
||||
};
|
||||
}
|
||||
|
||||
removeInstallation(installation: InstalledPlugin): void {
|
||||
const next = this.entries.filter((entry) => entry.installation !== installation);
|
||||
if (next.length === this.entries.length) return;
|
||||
this.entries = next;
|
||||
this.publish();
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function validateContribution(
|
||||
contribution: PluginComposerPillContribution,
|
||||
): PluginComposerPillContribution {
|
||||
const id = contribution.id.trim();
|
||||
const title = contribution.title.trim();
|
||||
const workspaceId = contribution.workspaceId.trim();
|
||||
const agentId = contribution.agentId.trim();
|
||||
if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid composer pill id: ${contribution.id}`);
|
||||
if (!title) throw new Error(`Composer pill ${id} has no title`);
|
||||
if (!workspaceId) throw new Error(`Composer pill ${id} has no workspace`);
|
||||
if (!agentId) throw new Error(`Composer pill ${id} has no agent`);
|
||||
if (typeof contribution.Component !== "function") {
|
||||
throw new Error(`Composer pill ${id} is not a component`);
|
||||
}
|
||||
if (typeof contribution.onPress !== "function") {
|
||||
throw new Error(`Composer pill ${id} has no callback`);
|
||||
}
|
||||
return { ...contribution, id, title, workspaceId, agentId };
|
||||
}
|
||||
|
||||
export const pluginComposerPillStore = new PluginComposerPillStore();
|
||||
@@ -212,6 +212,28 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).toThrow("Duplicate Command Center item: review");
|
||||
});
|
||||
|
||||
it("collects one explicit client-side entrypoint", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"review",
|
||||
bundle(`
|
||||
function contributeClient() { return function() {}; }
|
||||
plugin.addClientSide(contributeClient);
|
||||
`),
|
||||
);
|
||||
expect(plugin.clientSide).toBeTypeOf("function");
|
||||
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
"review",
|
||||
bundle(`
|
||||
function contributeClient() { return function() {}; }
|
||||
plugin.addClientSide(contributeClient);
|
||||
plugin.addClientSide(contributeClient);
|
||||
`),
|
||||
),
|
||||
).toThrow("Plugin has more than one client-side entrypoint");
|
||||
});
|
||||
|
||||
it("rejects duplicate attachment source ids", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
@@ -355,6 +377,26 @@ describe("evaluatePluginClientBundle", () => {
|
||||
expect(element).toMatchObject({ props: { size: 18, color: "#123456" } });
|
||||
});
|
||||
|
||||
it("provides Paseo UI through @getpaseo/plugin/react-native", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
`(function(require) {
|
||||
const { Icon, Modal, useToast } = require("@getpaseo/plugin/react-native");
|
||||
const module = { exports: {} };
|
||||
module.exports.default = function(plugin) {
|
||||
if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function") {
|
||||
throw new Error("React Native plugin UI is incomplete");
|
||||
}
|
||||
plugin.addSurface("main", function Surface() { return null; });
|
||||
return function() {};
|
||||
};
|
||||
return module.exports;
|
||||
})`,
|
||||
);
|
||||
|
||||
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
|
||||
});
|
||||
|
||||
it("resolves @getpaseo/plugin/server for shared RPC contracts", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"example",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
defineRpc,
|
||||
type PluginAttachmentSourceContribution,
|
||||
type PluginCommandCenterItemContribution,
|
||||
type PluginClientContribution,
|
||||
type PluginSidebarContribution,
|
||||
type PluginSurfaceProps,
|
||||
type PluginThemeContribution,
|
||||
@@ -25,6 +26,7 @@ import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo
|
||||
import type { EvaluatedPlugin } from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
import { Icon, resolvePluginIcon } from "./icons";
|
||||
import { pluginReactNativeRuntime } from "./react-native/runtime";
|
||||
import { parsePluginThemeContribution } from "./themes";
|
||||
|
||||
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
|
||||
@@ -71,6 +73,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
@@ -163,6 +166,13 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
|
||||
});
|
||||
},
|
||||
addClientSide(contribution: PluginClientContribution) {
|
||||
if (collector.clientSide) throw new Error("Plugin has more than one client-side entrypoint");
|
||||
if (typeof contribution !== "function") {
|
||||
throw new Error("Plugin client-side entrypoint is not a function");
|
||||
}
|
||||
collector.clientSide = contribution;
|
||||
},
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
|
||||
const normalizedId = requireId(contribution.id, "attachment source id");
|
||||
if (attachmentSourceIds.has(normalizedId)) {
|
||||
@@ -254,6 +264,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
useRpc,
|
||||
};
|
||||
}
|
||||
if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") {
|
||||
return pluginReactNativeRuntime;
|
||||
}
|
||||
if (name === "@getpaseo/plugin/server") {
|
||||
return { defineAttachmentSource, defineRpc };
|
||||
}
|
||||
@@ -299,6 +312,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
sidebarItems: collector.sidebarItems,
|
||||
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
|
||||
commandCenterItems: collector.commandCenterItems,
|
||||
clientSide: collector.clientSide,
|
||||
attachmentSources: collector.attachmentSources,
|
||||
themes: collector.themes,
|
||||
timelineTransformers: collector.timelineTransformers,
|
||||
|
||||
@@ -4,3 +4,4 @@ export { PluginSidebarItems } from "./sidebar-items";
|
||||
export { PluginSurfaceScreen } from "./surface-screen";
|
||||
export { usePluginAttachmentPicker } from "./attachments/picker";
|
||||
export { PluginResourceAttachmentPill } from "./attachments/pill";
|
||||
export { PluginComposerPills, useHasPluginComposerPills } from "./composer-pills";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { router } from "expo-router";
|
||||
import type { PluginPanelLocation } from "@getpaseo/plugin";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildPluginSurfaceRoute } from "./routes";
|
||||
import type { PluginNavigation } from "./actions";
|
||||
|
||||
export function createPluginNavigation(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | null;
|
||||
}): PluginNavigation {
|
||||
const { serverId, workspaceId } = input;
|
||||
function placement(location: PluginPanelLocation) {
|
||||
if (location !== "explorer") return undefined;
|
||||
if (!workspaceId) throw new Error("No active workspace");
|
||||
const workspaceKey = `${serverId}:${workspaceId}`;
|
||||
const paneId = useWorkspaceLayoutStore.getState().showExplorerSidebar(workspaceKey);
|
||||
if (!paneId) throw new Error("Explorer is unavailable");
|
||||
return { mode: "pane" as const, paneId };
|
||||
}
|
||||
return {
|
||||
openSurface(pluginId, surfaceId) {
|
||||
router.push(buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }));
|
||||
},
|
||||
openWorkspacePanel(pluginId, panelId, location) {
|
||||
if (!workspaceId) throw new Error("No active workspace");
|
||||
navigateToWorkspace({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "plugin", pluginId, panelId, context: "workspace" },
|
||||
placement: placement(location),
|
||||
});
|
||||
},
|
||||
openAgentPanel(pluginId, panelId, agentId, location) {
|
||||
if (!workspaceId) throw new Error("No active workspace");
|
||||
navigateToWorkspace({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "plugin", pluginId, panelId, context: "agent", agentId },
|
||||
placement: placement(location),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ModalComponent, ModalContentProps, ModalProps } from "@getpaseo/plugin/react-native";
|
||||
import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/host";
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
|
||||
import { ToastApiProvider, useToast as useAppToast } from "@/contexts/toast-api-context";
|
||||
|
||||
interface ModalContextValue {
|
||||
open: boolean;
|
||||
dismiss(): void;
|
||||
header: {
|
||||
title: string;
|
||||
leading?: ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
const ModalContext = createContext<ModalContextValue | null>(null);
|
||||
|
||||
function ModalRoot({ title, icon, open, onOpenChange, children }: ModalProps) {
|
||||
const onOpenChangeRef = useRef(onOpenChange);
|
||||
onOpenChangeRef.current = onOpenChange;
|
||||
const dismiss = useCallback(() => onOpenChangeRef.current(false), []);
|
||||
const header = useMemo(() => ({ title, leading: icon }), [icon, title]);
|
||||
const value = useMemo(() => ({ open, dismiss, header }), [dismiss, header, open]);
|
||||
return <ModalContext.Provider value={value}>{children}</ModalContext.Provider>;
|
||||
}
|
||||
|
||||
function useModalContext(): ModalContextValue {
|
||||
const context = useContext(ModalContext);
|
||||
if (!context) throw new Error("Modal.Content must be rendered inside Modal");
|
||||
return context;
|
||||
}
|
||||
|
||||
function ModalContent({ children }: ModalContentProps) {
|
||||
const modal = useModalContext();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useAppToast();
|
||||
const bridgePluginRuntime = usePluginRuntimeContextBridge();
|
||||
const contextBridge = useCallback(
|
||||
(content: ReactNode) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastApiProvider api={toast}>{bridgePluginRuntime(content)}</ToastApiProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
[bridgePluginRuntime, queryClient, toast],
|
||||
);
|
||||
const { AdaptiveModalSheet } =
|
||||
require("../../components/adaptive-modal-sheet") as typeof import("../../components/adaptive-modal-sheet");
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={modal.open}
|
||||
onClose={modal.dismiss}
|
||||
header={modal.header}
|
||||
contextBridge={contextBridge}
|
||||
>
|
||||
{children}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
export const Modal: ModalComponent = Object.assign(ModalRoot, { Content: ModalContent });
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Icon } from "../icons";
|
||||
import { Modal } from "./modal";
|
||||
import { useToast } from "./toast";
|
||||
|
||||
export const pluginReactNativeRuntime = { Icon, Modal, useToast };
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ToastApi, ToastOptions } from "@getpaseo/plugin/react-native";
|
||||
import { useToast as useAppToast } from "@/contexts/toast-api-context";
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const toast = useAppToast();
|
||||
return useMemo(
|
||||
() => ({
|
||||
show(message: string, options?: ToastOptions) {
|
||||
toast.show(message, options);
|
||||
},
|
||||
error(message: string) {
|
||||
toast.error(message);
|
||||
},
|
||||
}),
|
||||
[toast],
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { startPluginClientSide } from "./composer-pills/lifecycle";
|
||||
import { evaluatePluginClientBundle } from "./evaluate";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
@@ -29,7 +31,10 @@ class PluginRegistry {
|
||||
installCatalog(
|
||||
serverId: string,
|
||||
catalog: CatalogPlugin[],
|
||||
options: { replacePluginId?: string } = {},
|
||||
options: {
|
||||
replacePluginId?: string;
|
||||
client?: DaemonClient;
|
||||
} = {},
|
||||
): boolean {
|
||||
const previous = this.byHost.get(serverId) ?? [];
|
||||
const previousTimelineBundles = previous
|
||||
@@ -61,16 +66,30 @@ class PluginRegistry {
|
||||
return [existing];
|
||||
}
|
||||
const queryClient = new QueryClient();
|
||||
const evaluated = [
|
||||
{
|
||||
...evaluatePluginClientBundle(entry.id, entry.clientBundle),
|
||||
serverId,
|
||||
clientBundle: entry.clientBundle,
|
||||
queryClient,
|
||||
},
|
||||
];
|
||||
const evaluated = evaluatePluginClientBundle(entry.id, entry.clientBundle);
|
||||
const installation: InstalledPlugin = {
|
||||
...evaluated,
|
||||
serverId,
|
||||
clientBundle: entry.clientBundle,
|
||||
queryClient,
|
||||
};
|
||||
if (installation.clientSide) {
|
||||
if (!options.client) throw new Error("Plugin client runtime is unavailable");
|
||||
let clientCleanup;
|
||||
try {
|
||||
clientCleanup = startPluginClientSide(installation, options.client);
|
||||
} catch (error) {
|
||||
queryClient.clear();
|
||||
void Promise.resolve(evaluated.cleanup()).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
installation.cleanup = async () => {
|
||||
await clientCleanup();
|
||||
await evaluated.cleanup();
|
||||
};
|
||||
}
|
||||
this.evaluationErrors.delete(key);
|
||||
return evaluated;
|
||||
return [installation];
|
||||
} catch (error) {
|
||||
this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error));
|
||||
console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error);
|
||||
|
||||
@@ -21,6 +21,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
|
||||
],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -26,6 +26,7 @@ function installation(
|
||||
})),
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -54,6 +54,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes,
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -17,6 +17,7 @@ function plugin(input: {
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginClientContribution,
|
||||
PluginCleanup,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
PluginThemeContribution,
|
||||
@@ -17,11 +20,12 @@ export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelCont
|
||||
|
||||
export interface EvaluatedPlugin {
|
||||
id: string;
|
||||
cleanup: () => void;
|
||||
cleanup: PluginCleanup;
|
||||
surfaces: PluginSurfaceContribution[];
|
||||
sidebarItems: PluginSidebarContribution[];
|
||||
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||
clientSide: PluginClientContribution | null;
|
||||
attachmentSources: PluginAttachmentSourceContribution[];
|
||||
themes: PluginThemeContribution[];
|
||||
timelineTransformers: PluginTimelineTransformerContribution[];
|
||||
@@ -37,6 +41,7 @@ export interface InstalledPlugin extends EvaluatedPlugin {
|
||||
export type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginCommandCenterItemContribution,
|
||||
PluginComposerPillContribution,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
PluginThemeContribution,
|
||||
|
||||
@@ -23,6 +23,7 @@ function installed(): InstalledPlugin {
|
||||
},
|
||||
],
|
||||
commandCenterItems: [],
|
||||
clientSide: null,
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { WorkspaceDirectoryReplica } from "./workspace-replica";
|
||||
|
||||
function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload {
|
||||
@@ -208,3 +212,21 @@ it("does not invent a null-key project from a workspace update", () => {
|
||||
expect(session?.projects.has("fresh-project")).toBe(false);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
it("does not restore a targeted cached workspace while its archive is pending", () => {
|
||||
const serverId = "cached-workspace-during-archive";
|
||||
const workspaceId = "archived-workspace";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
markWorkspaceArchivePending({ serverId, workspaceId });
|
||||
|
||||
try {
|
||||
replica.commitCachedWorkspace(normalizeWorkspaceDescriptor(workspace(workspaceId)), undefined);
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
|
||||
} finally {
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
store.clearSession(serverId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -96,6 +96,7 @@ export class WorkspaceDirectoryReplica {
|
||||
workspace: WorkspaceDescriptor,
|
||||
project: ProjectDescriptor | undefined,
|
||||
): void {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) return;
|
||||
const snapshot = this.read();
|
||||
snapshot.workspaces.set(workspace.id, workspace);
|
||||
if (project) snapshot.projects.set(project.projectId, project);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
connectionFromListen,
|
||||
createRemoteSshHostConnection,
|
||||
normalizeStoredHostProfile,
|
||||
upsertHostConnectionInProfiles,
|
||||
registryHasConnection,
|
||||
@@ -38,8 +39,8 @@ import {
|
||||
type ConnectionProbeState,
|
||||
} from "@/utils/connection-selection";
|
||||
import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
buildDesktopDaemonTransportUrl,
|
||||
createDesktopDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
@@ -81,6 +82,7 @@ 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 =
|
||||
@@ -211,6 +213,13 @@ 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",
|
||||
@@ -493,7 +502,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
|
||||
return {
|
||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||
const desktopTransportFactory = createDesktopDaemonTransportFactory();
|
||||
const webSocketConfig = { webSocketFactory: createAppWebSocketFactory() };
|
||||
const base = {
|
||||
suppressSendErrors: true,
|
||||
@@ -507,13 +516,28 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
...(localTransportFactory ? { transportFactory: localTransportFactory } : {}),
|
||||
url: buildLocalDaemonTransportUrl({
|
||||
...(desktopTransportFactory ? { transportFactory: desktopTransportFactory } : {}),
|
||||
url: buildDesktopDaemonTransportUrl({
|
||||
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,
|
||||
@@ -1754,6 +1778,18 @@ 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;
|
||||
@@ -2488,6 +2524,12 @@ 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;
|
||||
@@ -2513,6 +2555,7 @@ 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),
|
||||
|
||||
@@ -12,6 +12,14 @@ import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore }
|
||||
|
||||
const SERVER_ID = "cached-host";
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve(): void } {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
class MemoryStorage implements ReplicaRowStore {
|
||||
readonly rows = new Map<string, ReplicaRow>();
|
||||
readonly changes: ReplicaRowChanges[] = [];
|
||||
@@ -23,6 +31,8 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
writes = 0;
|
||||
cleanups = 0;
|
||||
nextWriteFailure: Error | null = null;
|
||||
readGate: Promise<void> | null = null;
|
||||
onRead: (() => void) | null = null;
|
||||
|
||||
private key(row: Pick<ReplicaRow, "serverId" | "kind" | "id">): string {
|
||||
return `${row.serverId}:${row.kind}:${row.id}`;
|
||||
@@ -36,6 +46,8 @@ class MemoryStorage implements ReplicaRowStore {
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) });
|
||||
this.onRead?.();
|
||||
await this.readGate;
|
||||
const acceptedKinds = new Set(kinds);
|
||||
const acceptedIds = ids ? new Set(ids) : null;
|
||||
return [...this.rows.values()].filter(
|
||||
@@ -217,6 +229,72 @@ describe("ReplicaCache", () => {
|
||||
expect(restoredTimeline).toEqual(timeline());
|
||||
});
|
||||
|
||||
it("never reads directory rows older than an accepted deferred deletion", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitDirectory(SERVER_ID, directory());
|
||||
await cache.flush();
|
||||
|
||||
cache.commitDirectory(SERVER_ID, {
|
||||
agents: new Map(),
|
||||
workspaces: new Map(),
|
||||
projects: new Map(),
|
||||
});
|
||||
|
||||
expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined();
|
||||
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
|
||||
expect((await cache.readDirectory(SERVER_ID)).projects.size).toBe(0);
|
||||
});
|
||||
|
||||
it("fails closed when an accepted deletion cannot be persisted before a read", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitDirectory(SERVER_ID, directory());
|
||||
await cache.flush();
|
||||
storage.nextWriteFailure = new Error("disk busy");
|
||||
|
||||
cache.commitDirectory(SERVER_ID, {
|
||||
agents: new Map(),
|
||||
workspaces: new Map(),
|
||||
projects: new Map(),
|
||||
});
|
||||
|
||||
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("discards a durable read when the host changes while it is in flight", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitDirectory(SERVER_ID, directory());
|
||||
await cache.flush();
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
storage.onRead = started.resolve;
|
||||
storage.readGate = release.promise;
|
||||
|
||||
const reading = cache.readAgent(SERVER_ID, "agent-1");
|
||||
await started.promise;
|
||||
cache.commitDirectory(SERVER_ID, {
|
||||
agents: new Map(),
|
||||
workspaces: new Map(),
|
||||
projects: new Map(),
|
||||
});
|
||||
release.resolve();
|
||||
|
||||
expect(await reading).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never reads a timeline older than an accepted deferred replacement", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Old"));
|
||||
await cache.flush();
|
||||
|
||||
cache.commitTimeline(SERVER_ID, "agent-1", timeline("New"));
|
||||
|
||||
expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([timelineItem("New")]);
|
||||
});
|
||||
|
||||
it("round-trips plugin timeline items", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
|
||||
@@ -784,6 +784,7 @@ function directoryEntityForRow(row: ReplicaRow): keyof DirectoryCheckpoint | und
|
||||
|
||||
export class ReplicaCache {
|
||||
private readonly activeServerIds = new Set<string>();
|
||||
private readonly hostRevisions = new Map<string, number>();
|
||||
private readonly storedRows = new Map<string, Map<string, ReplicaRow>>();
|
||||
private readonly hostBytes = new Map<string, number>();
|
||||
private readonly hostWriteOrder = new Map<string, true>();
|
||||
@@ -897,9 +898,13 @@ export class ReplicaCache {
|
||||
ids?: readonly string[],
|
||||
): Promise<ReplicaRow[]> {
|
||||
if (!this.activeServerIds.has(serverId)) return [];
|
||||
const revision = this.hostRevisions.get(serverId) ?? 0;
|
||||
try {
|
||||
await this.prepareStore();
|
||||
return await this.rowStore.read(serverId, kinds, ids);
|
||||
await this.flush();
|
||||
if (!this.canReadHostRevision(serverId, revision)) return [];
|
||||
const rows = await this.rowStore.read(serverId, kinds, ids);
|
||||
return this.canReadHostRevision(serverId, revision) ? rows : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -985,6 +990,7 @@ export class ReplicaCache {
|
||||
},
|
||||
): void {
|
||||
if (!this.activeServerIds.has(serverId)) return;
|
||||
this.advanceHostRevision(serverId);
|
||||
this.clearPendingDirectoryChanges(serverId);
|
||||
const desiredRows: ReplicaRow[] = [];
|
||||
for (const agent of directory.agents.values()) {
|
||||
@@ -1013,6 +1019,7 @@ export class ReplicaCache {
|
||||
commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void {
|
||||
if (!this.activeServerIds.has(serverId)) return;
|
||||
if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload");
|
||||
this.advanceHostRevision(serverId);
|
||||
const stored = serializeTimeline(timeline);
|
||||
if (stored) this.queueEntityUpsert(serverId, "timeline", agentId, stored);
|
||||
else this.queueEntityDelete(serverId, "timeline", agentId);
|
||||
@@ -1022,6 +1029,8 @@ export class ReplicaCache {
|
||||
setHosts(serverIds: Iterable<string>): void {
|
||||
const next = new Set(serverIds);
|
||||
const removed = [...this.activeServerIds].filter((serverId) => !next.has(serverId));
|
||||
const added = [...next].filter((serverId) => !this.activeServerIds.has(serverId));
|
||||
for (const serverId of [...removed, ...added]) this.advanceHostRevision(serverId);
|
||||
this.activeServerIds.clear();
|
||||
for (const serverId of next) this.activeServerIds.add(serverId);
|
||||
for (const serverId of removed) {
|
||||
@@ -1032,6 +1041,8 @@ export class ReplicaCache {
|
||||
}
|
||||
|
||||
reconcileServerId(oldServerId: string, newServerId: string): void {
|
||||
this.advanceHostRevision(oldServerId);
|
||||
this.advanceHostRevision(newServerId);
|
||||
const rows = this.storedRows.get(oldServerId);
|
||||
if (rows) {
|
||||
const newRows = this.storedRows.get(newServerId) ?? new Map<string, ReplicaRow>();
|
||||
@@ -1142,6 +1153,29 @@ export class ReplicaCache {
|
||||
);
|
||||
}
|
||||
|
||||
private canReadHostRevision(serverId: string, revision: number): boolean {
|
||||
return (
|
||||
this.activeServerIds.has(serverId) &&
|
||||
(this.hostRevisions.get(serverId) ?? 0) === revision &&
|
||||
!this.hasPendingHostChanges(serverId)
|
||||
);
|
||||
}
|
||||
|
||||
private hasPendingHostChanges(serverId: string): boolean {
|
||||
if (this.pendingDirectoryReplacements.has(serverId)) return true;
|
||||
for (const row of this.pendingUpserts.values()) {
|
||||
if (row.serverId === serverId) return true;
|
||||
}
|
||||
for (const row of this.pendingDeletes.values()) {
|
||||
if (row.serverId === serverId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private advanceHostRevision(serverId: string): void {
|
||||
this.hostRevisions.set(serverId, (this.hostRevisions.get(serverId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
private drainPendingChanges(): PendingReplicaChanges {
|
||||
const changes = {
|
||||
upserts: [...this.pendingUpserts.values()],
|
||||
|
||||
@@ -217,6 +217,7 @@ function workspaceSummary(overrides: Partial<WorkspaceSummary> = {}): WorkspaceS
|
||||
workspaceKind: "directory",
|
||||
status: "done",
|
||||
currentBranch: "main",
|
||||
changeRequestNumber: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ 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";
|
||||
@@ -1187,6 +1188,7 @@ 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);
|
||||
@@ -1293,11 +1295,13 @@ 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);
|
||||
}, []);
|
||||
@@ -1319,6 +1323,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
setIsDirectHostVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleSelectRemoteSsh = useCallback(() => {
|
||||
setIsAddHostMethodVisible(false);
|
||||
setIsRemoteSshVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleSelectPasteLink = useCallback(() => {
|
||||
setIsAddHostMethodVisible(false);
|
||||
setIsPasteLinkVisible(true);
|
||||
@@ -1532,6 +1541,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
visible={isAddHostMethodVisible}
|
||||
onClose={closeAddConnectionFlow}
|
||||
onDirectConnection={handleSelectDirectConnection}
|
||||
onRemoteSsh={handleSelectRemoteSsh}
|
||||
onPasteLink={handleSelectPasteLink}
|
||||
onScanQr={handleScanQr}
|
||||
/>
|
||||
@@ -1541,6 +1551,12 @@ 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,6 +111,9 @@ 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})`;
|
||||
}
|
||||
|
||||
@@ -132,6 +135,12 @@ 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,
|
||||
@@ -186,7 +195,6 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
|
||||
}
|
||||
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme, t);
|
||||
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
||||
|
||||
const statusDotStyle = useMemo(
|
||||
() => [styles.statusDot, { backgroundColor: statusDotColor }],
|
||||
[statusDotColor],
|
||||
|
||||
@@ -3304,7 +3304,8 @@ function WorkspaceScreenContent({
|
||||
],
|
||||
);
|
||||
|
||||
const workspaceKeyboardActionsEnabled = Boolean(
|
||||
// Shared by every handler below: these actions only exist on a focused workspace route.
|
||||
const workspaceActionsEnabled = Boolean(
|
||||
isRouteFocused && normalizedServerId && normalizedWorkspaceId,
|
||||
);
|
||||
|
||||
@@ -3323,7 +3324,7 @@ function WorkspaceScreenContent({
|
||||
"workspace.browser.new",
|
||||
"workspace.tab.menu.open",
|
||||
] as const,
|
||||
enabled: workspaceKeyboardActionsEnabled,
|
||||
enabled: workspaceActionsEnabled,
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle: handleWorkspaceTabAction,
|
||||
@@ -3341,7 +3342,7 @@ function WorkspaceScreenContent({
|
||||
"workspace.tab.target.changes",
|
||||
"workspace.tab.target.files",
|
||||
] as const,
|
||||
enabled: workspaceKeyboardActionsEnabled,
|
||||
enabled: workspaceActionsEnabled,
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle: handleWorkspaceDirectTargetAction,
|
||||
@@ -3354,7 +3355,7 @@ function WorkspaceScreenContent({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
}),
|
||||
actions: ["workspace.tab.open"] as const,
|
||||
enabled: workspaceKeyboardActionsEnabled,
|
||||
enabled: workspaceActionsEnabled,
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle: handleWorkspacePanelOpenAction,
|
||||
@@ -3373,7 +3374,7 @@ function WorkspaceScreenContent({
|
||||
"workspace.tab.copy-id",
|
||||
"workspace.tab.copy-file-path",
|
||||
] as const,
|
||||
enabled: workspaceKeyboardActionsEnabled,
|
||||
enabled: workspaceActionsEnabled,
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle: handleWorkspaceCurrentTabMetadataAction,
|
||||
@@ -3390,7 +3391,7 @@ function WorkspaceScreenContent({
|
||||
"workspace.tab.close-right",
|
||||
"workspace.tab.close-others",
|
||||
] as const,
|
||||
enabled: workspaceKeyboardActionsEnabled,
|
||||
enabled: workspaceActionsEnabled,
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle: handleWorkspaceCurrentTabCloseAction,
|
||||
@@ -3416,7 +3417,7 @@ function WorkspaceScreenContent({
|
||||
"workspace.pane.close",
|
||||
"workspace.focus.toggle",
|
||||
] as const,
|
||||
enabled: workspaceKeyboardActionsEnabled,
|
||||
enabled: workspaceActionsEnabled,
|
||||
priority: 100,
|
||||
isActive: () => true,
|
||||
handle: handleWorkspacePaneAction,
|
||||
@@ -3429,12 +3430,28 @@ function WorkspaceScreenContent({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
}),
|
||||
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
|
||||
enabled: workspaceKeyboardActionsEnabled,
|
||||
enabled: workspaceActionsEnabled,
|
||||
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,6 +3391,99 @@ 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,9 +1182,15 @@ export function createWorkspaceLayoutStore(
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const currentLayout = getWorkspaceLayout(
|
||||
state.layoutByWorkspace,
|
||||
normalizedWorkspaceKey,
|
||||
const rawLayout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
||||
rawLayout,
|
||||
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||
);
|
||||
const currentLayout = keepWorkspaceFocusOutOfExplorerSidebar(
|
||||
rawLayout,
|
||||
explorerSidebarPaneId,
|
||||
rawLayout.focusedPaneId,
|
||||
);
|
||||
const nextState = reconcileWorkspaceTabs(
|
||||
{
|
||||
@@ -1192,21 +1198,23 @@ export function createWorkspaceLayoutStore(
|
||||
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||
pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||
explorerSidebarPaneId: resolveExplorerSidebarPaneId(
|
||||
currentLayout,
|
||||
state.explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey],
|
||||
),
|
||||
explorerSidebarPaneId,
|
||||
},
|
||||
snapshot,
|
||||
);
|
||||
if (nextState.layout === currentLayout) {
|
||||
const nextLayout = keepWorkspaceFocusOutOfExplorerSidebar(
|
||||
nextState.layout,
|
||||
explorerSidebarPaneId,
|
||||
currentLayout.focusedPaneId,
|
||||
);
|
||||
if (nextLayout === rawLayout) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextState.layout,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -16,7 +16,11 @@ function contentHeadingLineHeight(contentSize: number, tier: keyof typeof FONT_S
|
||||
*
|
||||
* Usage:
|
||||
* const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
* <Markdown style={markdownStyles}>{content}</Markdown>
|
||||
* <Markdown style={markdownStyles} markdownit={parser}>{content}</Markdown>
|
||||
*
|
||||
* Always pass `markdownit` from `@/utils/markdown-parser`. Omit it and
|
||||
* react-native-markdown-display builds its own parser with `typographer: true`,
|
||||
* which rewrites a literal `(c)` as ©.
|
||||
*/
|
||||
export function createMarkdownStyles(theme: Theme) {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
import {
|
||||
createRemoteSshHostConnection,
|
||||
normalizeStoredHostProfile,
|
||||
orderHostsLocalFirst,
|
||||
resolveActiveHostServerId,
|
||||
@@ -141,6 +142,51 @@ 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", () => {
|
||||
|
||||
@@ -6,6 +6,11 @@ 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,
|
||||
@@ -27,6 +32,14 @@ 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";
|
||||
@@ -39,6 +52,7 @@ export type HostConnection =
|
||||
| DirectTcpHostConnection
|
||||
| DirectSocketHostConnection
|
||||
| DirectPipeHostConnection
|
||||
| RemoteSshHostConnection
|
||||
| RelayHostConnection;
|
||||
|
||||
export type HostLifecycle = Record<string, never>;
|
||||
@@ -123,6 +137,9 @@ 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 &&
|
||||
@@ -134,6 +151,17 @@ 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);
|
||||
}
|
||||
@@ -294,6 +322,35 @@ 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(),
|
||||
@@ -312,6 +369,13 @@ 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"),
|
||||
@@ -356,6 +420,17 @@ 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);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAssistantMarkdownParser } from "./assistant-markdown-parser";
|
||||
|
||||
describe("createAssistantMarkdownParser", () => {
|
||||
it("renders agent text verbatim", () => {
|
||||
const parser = createAssistantMarkdownParser();
|
||||
|
||||
// The reported bug, plus the substitutions that share its cause.
|
||||
expect(parser.renderInline("(c) (C) (r) (tm) (p)")).toBe("(c) (C) (r) (tm) (p)");
|
||||
expect(parser.renderInline("wait for it...")).toBe("wait for it...");
|
||||
expect(parser.renderInline("a -- b")).toBe("a -- b");
|
||||
// Smart quotes are off too: a curled quote is not pasteable into a shell.
|
||||
expect(parser.renderInline(`run --name="my repo"`)).toBe("run --name="my repo"");
|
||||
expect(parser.renderInline("it's fine")).toBe("it's fine");
|
||||
});
|
||||
|
||||
it("allows file:// links, unlike every other parser", () => {
|
||||
const parser = createAssistantMarkdownParser();
|
||||
|
||||
expect(parser.render("[open](file:///tmp/a.ts)")).toContain('href="file:///tmp/a.ts"');
|
||||
});
|
||||
|
||||
it("still rejects javascript: links", () => {
|
||||
const parser = createAssistantMarkdownParser();
|
||||
|
||||
expect(parser.render("[x](javascript:alert(1))")).not.toContain("href");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,12 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import { createMarkdownParser } from "@/utils/markdown-parser";
|
||||
|
||||
export function createAssistantMarkdownParser(): MarkdownIt {
|
||||
const parser = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
});
|
||||
const parser = createMarkdownParser({ linkify: true });
|
||||
const defaultValidateLink = parser.validateLink.bind(parser);
|
||||
|
||||
// Assistant messages are the only surface allowed to link into the
|
||||
// filesystem. Every other parser keeps markdown-it's stricter default.
|
||||
parser.validateLink = (url: string) =>
|
||||
url.trim().toLowerCase().startsWith("file://") || defaultValidateLink(url);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user