mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 14:43:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d121ac93a2 | ||
|
|
16621b4845 | ||
|
|
8f9465730d | ||
|
|
f92cce9fd2 | ||
|
|
8029f8d5ea | ||
|
|
4992b8d3da |
@@ -406,7 +406,10 @@
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/app/_layout.tsx",
|
||||
"packages/app/src/app/pair-scan.tsx",
|
||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||
"packages/app/src/components/add-host-method-modal.tsx",
|
||||
"packages/app/src/components/add-host-modal.tsx",
|
||||
"packages/app/src/components/agent-list.tsx",
|
||||
"packages/app/src/components/agent-status-dot.tsx",
|
||||
"packages/app/src/components/attachment-lightbox.tsx",
|
||||
@@ -425,6 +428,7 @@
|
||||
"packages/app/src/components/hosts/host-picker.tsx",
|
||||
"packages/app/src/components/icons/paseo-logo.tsx",
|
||||
"packages/app/src/components/left-sidebar.tsx",
|
||||
"packages/app/src/components/pair-link-modal.tsx",
|
||||
"packages/app/src/components/plan-card.tsx",
|
||||
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/question-form-card.tsx",
|
||||
@@ -539,10 +543,14 @@
|
||||
"packages/app/src/agent-skills/selection-sheet.tsx",
|
||||
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
|
||||
"packages/app/src/agent-stream/view.tsx",
|
||||
"packages/app/src/app/pair-scan.tsx",
|
||||
"packages/app/src/changelog/internal/changelog-sheet.tsx",
|
||||
"packages/app/src/command-center/command-center.tsx",
|
||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||
"packages/app/src/components/add-host-method-modal.tsx",
|
||||
"packages/app/src/components/add-host-modal.tsx",
|
||||
"packages/app/src/components/add-project-flow.tsx",
|
||||
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
|
||||
"packages/app/src/components/agent-list.tsx",
|
||||
"packages/app/src/components/app-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/attachment-lightbox.tsx",
|
||||
|
||||
+4
-4
@@ -35,7 +35,7 @@ The authored interface ramp uses a 14px base. New native installs default to 15p
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
|
||||
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx:19-23`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
||||
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
||||
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
|
||||
|
||||
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
|
||||
@@ -102,7 +102,7 @@ Five primitives. The pick is determined by option count, the need to search, and
|
||||
|
||||
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
||||
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
||||
|
||||
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
|
||||
|
||||
@@ -114,7 +114,7 @@ Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value
|
||||
|
||||
## 7. Density and rhythm
|
||||
|
||||
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`).
|
||||
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
|
||||
|
||||
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
|
||||
|
||||
@@ -278,7 +278,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
||||
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
||||
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
||||
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
|
||||
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
|
||||
|
||||
@@ -58,12 +58,6 @@ restore the same workspace and could repeat the crash.
|
||||
|
||||
## App-Wide Route Hops
|
||||
|
||||
Keep Add host in `hosts/add-host`, opened through `useAddHost()`. It is an in-place
|
||||
flow; callers choose what happens after success. Settings opts into the new host’s
|
||||
Overview. Do not send callers to Settings with an `addHost` query parameter: a
|
||||
retained route replays that request when appearance changes remount its content.
|
||||
The flow provider and sheet host stay outside appearance keys so open drafts survive.
|
||||
|
||||
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
|
||||
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
|
||||
branch on its current route.
|
||||
|
||||
+3
-3
@@ -374,10 +374,10 @@ changes both `usePaseo()` and `useRpc()` to that host's installation. An offline
|
||||
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
||||
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
|
||||
|
||||
Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
|
||||
Host handles and host-targeted clients are owned by `packages/app/src/plugins/hosts`. Its `projectHost` is the secret boundary; host target input uses the shared `parseHostTarget` grammar. Per-installation
|
||||
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
|
||||
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
|
||||
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#hosts).
|
||||
|
||||
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
|
||||
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
|
||||
@@ -396,7 +396,7 @@ in-memory bundles usable, and a later catalog retries persistence.
|
||||
|
||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#hosts).
|
||||
|
||||
Workspace panels, Command Center items, and client slash commands are client contributions. The
|
||||
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from "node:path";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import {
|
||||
addProjectFlow,
|
||||
chooseAddHostFromProjectFlow,
|
||||
addProjectFlowBack,
|
||||
addProjectFlowHost,
|
||||
addProjectFlowInput,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
} from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
disconnectAllHostsAndReload,
|
||||
addConnectedHostAndReload,
|
||||
addConnectedHostsAndReload,
|
||||
addOfflineHostAndReload,
|
||||
@@ -71,19 +69,6 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
||||
test.describe("Add Project command-center flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("Add host replaces the project dialog when no hosts are connected", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await disconnectAllHostsAndReload(page);
|
||||
await openAddProjectHostSelection(page);
|
||||
const originalUrl = page.url();
|
||||
await chooseAddHostFromProjectFlow(page);
|
||||
await expect(addProjectFlow(page)).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Direct connection", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
});
|
||||
|
||||
test("method selection shows the daemon's available project sources without search", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -63,24 +63,6 @@ function htmlPreview(page: Page) {
|
||||
};
|
||||
}
|
||||
|
||||
async function followHtmlPreviewLink(page: Page, name: string): Promise<void> {
|
||||
await htmlPreview(page).document.getByRole("link", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectPageHandlesFragment(
|
||||
page: Page,
|
||||
handler: "Document" | "Window",
|
||||
): Promise<void> {
|
||||
const preview = htmlPreview(page);
|
||||
await followHtmlPreviewLink(page, `${handler} handled jump`);
|
||||
await expect(preview.document.getByRole("status")).toHaveText(`${handler} handled click`);
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).not.toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
|
||||
}
|
||||
|
||||
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
|
||||
const option = page.getByTestId("file-panel-bar").getByRole("button", {
|
||||
name: view,
|
||||
@@ -844,192 +826,6 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps HTML fragment links in the preview and scrolls to the target", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragments-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "fragments.html"),
|
||||
`<!doctype html><html><body>
|
||||
<a href="#section">Jump to section</a>
|
||||
<div style="height: 1200px"></div>
|
||||
<h2 id="section">Target section</h2>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "fragments.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.document.getByRole("link", { name: "Jump to section" })).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("fragment-before-click.png") });
|
||||
await preview.document.getByRole("link", { name: "Jump to section" }).click();
|
||||
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("#section");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
await page.screenshot({ path: test.info().outputPath("fragment-after-click.png") });
|
||||
});
|
||||
|
||||
test("lets an HTML page prevent fragment navigation", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-handler-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "handled.html"),
|
||||
`<!doctype html><html><body>
|
||||
<a href="#section" onclick="event.preventDefault(); document.querySelector('output').textContent = 'Page handled click'">Handled jump</a>
|
||||
<output>Waiting for click</output>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "handled.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await preview.document.getByRole("link", { name: "Handled jump" }).click();
|
||||
await expect(preview.document.locator("output")).toHaveText("Page handled click");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).not.toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
|
||||
});
|
||||
|
||||
test("respects delegated HTML fragment handlers and normal URL parsing", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-defaults-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "defaults.html"),
|
||||
`<!doctype html><html><body>
|
||||
<nav style="position: fixed; top: 0; background: white">
|
||||
<a href="#section" id="document-handler">Document handled jump</a>
|
||||
<a href="#section" id="window-handler">Window handled jump</a>
|
||||
<a href=" \t#section\n">Whitespace jump</a>
|
||||
<a href="#100%">Percent jump</a>
|
||||
<output>Waiting for click</output>
|
||||
</nav>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
<div style="height: 1200px"></div><h2 id="100%">Percent target</h2>
|
||||
<div style="height: 1200px"></div>
|
||||
<script>
|
||||
document.addEventListener('click', function(event) {
|
||||
if (event.target.id === 'document-handler') {
|
||||
event.preventDefault(); document.querySelector('output').textContent = 'Document handled click';
|
||||
}
|
||||
});
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target.id === 'window-handler') {
|
||||
event.preventDefault(); document.querySelector('output').textContent = 'Window handled click';
|
||||
}
|
||||
});
|
||||
</script></body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "defaults.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expectPageHandlesFragment(page, "Document");
|
||||
await expectPageHandlesFragment(page, "Window");
|
||||
|
||||
await followHtmlPreviewLink(page, "Whitespace jump");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
await followHtmlPreviewLink(page, "Percent jump");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Percent target" }),
|
||||
).toBeInViewport();
|
||||
});
|
||||
|
||||
test("handles HTML fragment targets, empty and missing fragments, and modified clicks", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-targets-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "targets.html"),
|
||||
`<!doctype html><html><head><style>:target { color: rgb(255, 0, 0); }</style></head><body>
|
||||
<nav style="position: fixed; top: 0; background: white">
|
||||
<a href="#section"><span>Nested jump</span></a>
|
||||
<a href="#legacy">Named jump</a>
|
||||
<a href="#encoded%20section">Encoded jump</a>
|
||||
<a href="#missing">Missing jump</a>
|
||||
<a href="#">Empty jump</a>
|
||||
<output>Waiting for hashchange</output><output id="modified">Waiting for modified click</output>
|
||||
</nav>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
<div style="height: 1200px"></div><a name="legacy">Named target</a>
|
||||
<div style="height: 1200px"></div><h2 id="encoded section">Encoded target</h2>
|
||||
<div style="height: 1200px"></div>
|
||||
<script>
|
||||
window.addEventListener('hashchange', function () { document.querySelector('output').textContent = location.hash || '(empty)'; });
|
||||
function modifiedClick(event) {
|
||||
if (event.button === 1 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||
document.getElementById('modified').textContent = String(event.defaultPrevented);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
window.addEventListener('click', modifiedClick);
|
||||
window.addEventListener('auxclick', modifiedClick);
|
||||
window.addEventListener('contextmenu', modifiedClick);
|
||||
</script></body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "targets.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
const body = preview.document.locator("body");
|
||||
const nested = preview.document.getByText("Nested jump", { exact: true });
|
||||
await nested.click();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#section");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
await expect(preview.document.locator("#section")).toHaveCSS("color", "rgb(255, 0, 0)");
|
||||
|
||||
await body.evaluate(() => window.scrollTo(0, 0));
|
||||
await nested.click();
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
|
||||
await preview.document.getByRole("link", { name: "Named jump" }).click();
|
||||
await expect(preview.document.getByText("Named target", { exact: true })).toBeInViewport();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#legacy");
|
||||
|
||||
await preview.document.getByRole("link", { name: "Encoded jump" }).click();
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Encoded target" }),
|
||||
).toBeInViewport();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#encoded%20section");
|
||||
const scrollY = await body.evaluate(() => window.scrollY);
|
||||
await preview.document.getByRole("link", { name: "Missing jump" }).click();
|
||||
expect(await body.evaluate(() => location.hash)).toBe("#missing");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(scrollY);
|
||||
|
||||
await preview.document.getByRole("link", { name: "Empty jump" }).click();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("(empty)");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(0);
|
||||
|
||||
for (const modifier of ["Control", "Meta", "Shift", "Alt"] as const) {
|
||||
await nested.click({ modifiers: [modifier] });
|
||||
await expect(preview.document.locator("#modified")).toHaveText("false");
|
||||
}
|
||||
await nested.click({ button: "middle" });
|
||||
await expect(preview.document.locator("#modified")).toHaveText("false");
|
||||
expect(await body.evaluate(() => location.hash)).toBe("");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(0);
|
||||
});
|
||||
|
||||
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
|
||||
|
||||
@@ -294,17 +294,10 @@ test("creates, renames, copies, and deletes entries through the file explorer",
|
||||
|
||||
await entry("renamed.txt").click({ button: "right" });
|
||||
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const labelColor = await deleteAction
|
||||
.getByText("Delete", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
const iconColor = await deleteAction
|
||||
.locator("svg")
|
||||
.evaluate((element) => getComputedStyle(element).stroke);
|
||||
return labelColor !== "" && iconColor === labelColor;
|
||||
})
|
||||
.toBe(true);
|
||||
const deleteLabelColor = await deleteAction
|
||||
.getByText("Delete", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
|
||||
const cancelledConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
|
||||
@@ -122,14 +122,10 @@ test.describe("New workspace entry points", () => {
|
||||
});
|
||||
const hostTrigger = page.getByTestId("host-picker-trigger");
|
||||
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
|
||||
const originalUrl = page.url();
|
||||
await hostTrigger.click();
|
||||
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
|
||||
await page.getByTestId("new-workspace-host-picker-add-host").click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Direct connection", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
fillNewWorkspaceDraft,
|
||||
expectNewWorkspaceDraft,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { readFile, mkdir, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -278,71 +273,3 @@ test("restart recovery host controls persist the opt-out and custom prompt", asy
|
||||
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
|
||||
await editRestartRecoveryPrompt(page, "Resume working.");
|
||||
});
|
||||
|
||||
test("sidebar Add host preserves its caller and stays closed after theme changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).toHaveURL(/\/open-project$/);
|
||||
const originalUrl = page.url();
|
||||
await openSidebarAddHost(page);
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await closeAddHostAndChangeTheme(page);
|
||||
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
});
|
||||
|
||||
async function openSidebarAddHost(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Hosts", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Add host", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeAddHostAndChangeTheme(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
|
||||
await page.keyboard.press("Control+Alt+t");
|
||||
}
|
||||
|
||||
test("sidebar Add host completes without navigating", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page).toHaveURL(/\/open-project$/);
|
||||
const originalUrl = page.url();
|
||||
await openSidebarAddHost(page);
|
||||
await connectToTestHost(page);
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await expect(page.getByRole("button", { name: "Hosts", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Settings Add host opens the saved host Overview", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: Number(getE2EDaemonPort()) });
|
||||
await expectSettingsHeader(page, "Overview");
|
||||
await expect(page).toHaveURL(new RegExp(`/settings/hosts/${getServerId()}/host$`));
|
||||
});
|
||||
|
||||
test("New workspace Add host preserves the prompt after connecting", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await fillNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
|
||||
const originalUrl = page.url();
|
||||
await openNewWorkspaceAddHost(page);
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await connectToTestHost(page);
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
await expectNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
|
||||
});
|
||||
|
||||
async function openNewWorkspaceAddHost(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page.getByRole("button", { name: "Add host", exact: true }).click();
|
||||
}
|
||||
|
||||
async function connectToTestHost(page: import("@playwright/test").Page) {
|
||||
await page.getByRole("button", { name: "Direct connection", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Host", exact: true }).fill("127.0.0.1");
|
||||
await page.getByRole("textbox", { name: "Port", exact: true }).fill(String(getE2EDaemonPort()));
|
||||
await page.getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await expect(page.getByTestId("add-host-modal")).toBeHidden({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
@@ -113,7 +113,3 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
|
||||
}
|
||||
|
||||
@@ -273,25 +273,3 @@ export async function reloadPreservingHostRegistry(page: Page): Promise<void> {
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
export async function disconnectAllHostsAndReload(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ registryKey, nonceKey, disableSeedKey }) => {
|
||||
const nonce = localStorage.getItem(nonceKey);
|
||||
if (!nonce) throw new Error("Expected the e2e seed nonce");
|
||||
const hosts = JSON.parse(localStorage.getItem(registryKey) ?? "[]");
|
||||
for (const host of hosts) {
|
||||
host.connections = [{ id: "offline", type: "directTcp", endpoint: "127.0.0.1:59999" }];
|
||||
host.preferredConnectionId = "offline";
|
||||
}
|
||||
localStorage.setItem(registryKey, JSON.stringify(hosts));
|
||||
localStorage.setItem(disableSeedKey, nonce);
|
||||
},
|
||||
{
|
||||
registryKey: REGISTRY_KEY,
|
||||
nonceKey: SEED_NONCE_KEY,
|
||||
disableSeedKey: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
},
|
||||
);
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
|
||||
@@ -466,7 +465,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const surface = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
<AddHostFlowHost />
|
||||
<AppearanceStyleBoundary>
|
||||
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
|
||||
<WindowChromeRegion corners="top-left">
|
||||
@@ -807,6 +805,7 @@ function RootStack() {
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="schedules" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
@@ -891,13 +890,11 @@ function RootAppTree() {
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
|
||||
<AddHostProvider>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</AddHostProvider>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</LucideProvider>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
+59
-24
@@ -1,14 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { openPairScan } from "./pair-scan-model";
|
||||
import { openPairScan } from "@/hosts/pair-scan-model";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -110,26 +113,50 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export function PairScanContent({
|
||||
onConnected,
|
||||
onClose,
|
||||
}: {
|
||||
onConnected: (serverId: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
export default function PairScanScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
source?: string;
|
||||
}>();
|
||||
const source = typeof params.source === "string" ? params.source : "settings";
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
|
||||
const navigateToPairedHost = useCallback(
|
||||
(serverId: string) => {
|
||||
if (source === "onboarding") {
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
return;
|
||||
}
|
||||
router.replace(buildSettingsHostRoute(serverId));
|
||||
},
|
||||
[router, source],
|
||||
);
|
||||
|
||||
const [scan] = useState(() =>
|
||||
openPairScan({
|
||||
importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"),
|
||||
onConnected,
|
||||
importConnectionLink: (link) =>
|
||||
getHostRuntimeStore().importConnectionLink(
|
||||
link,
|
||||
source === "onboarding" ? "hostRoot" : "hostSettings",
|
||||
),
|
||||
onConnected: navigateToPairedHost,
|
||||
}),
|
||||
);
|
||||
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
|
||||
useEffect(() => () => scan.close(), [scan]);
|
||||
|
||||
const closeToSource = useCallback(() => {
|
||||
try {
|
||||
router.back();
|
||||
} catch {
|
||||
router.replace("/" as Href);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb) return;
|
||||
if (permission && permission.granted) return;
|
||||
@@ -142,29 +169,35 @@ export function PairScanContent({
|
||||
[scan],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
const savePasswordPairing = useCallback(
|
||||
({ serverId }: { serverId: string }) => onConnected(serverId),
|
||||
[onConnected],
|
||||
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
||||
[navigateToPairedHost],
|
||||
);
|
||||
const handleRequestPermission = useCallback(() => {
|
||||
void requestPermission();
|
||||
}, [requestPermission]);
|
||||
|
||||
const bodyStyle = useMemo(
|
||||
() => [styles.body, { paddingBottom: insets.bottom + 24 }],
|
||||
[insets.bottom],
|
||||
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
|
||||
[insets.bottom, theme.spacing],
|
||||
);
|
||||
const helperTextStyle = useMemo(
|
||||
() => [styles.helperText, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
|
||||
if (isWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
|
||||
<View style={bodyStyle}>
|
||||
<View style={styles.permissionCard}>
|
||||
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
|
||||
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
|
||||
<Button style={styles.permissionButton} onPress={onClose}>
|
||||
<Text style={styles.permissionButtonText}>{t("common.actions.back")}</Text>
|
||||
</Button>
|
||||
<Pressable style={styles.permissionButton} onPress={closeToSource}>
|
||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -175,14 +208,16 @@ export function PairScanContent({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
|
||||
|
||||
<View style={bodyStyle}>
|
||||
{!granted ? (
|
||||
<View style={styles.permissionCard}>
|
||||
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
|
||||
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
|
||||
<Button style={styles.permissionButton} onPress={handleRequestPermission}>
|
||||
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
|
||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
|
||||
</Button>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.cameraWrap}>
|
||||
@@ -200,10 +235,10 @@ export function PairScanContent({
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
{scanState.status === "pairing" ? (
|
||||
<Text style={styles.helperText}>{t("pairing.scan.pairing")}</Text>
|
||||
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" && scanState.error ? (
|
||||
<Text style={styles.helperText}>{scanState.error}</Text>
|
||||
<Text style={helperTextStyle}>{scanState.error}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" ? (
|
||||
<Button
|
||||
@@ -35,9 +35,10 @@ function SettingsDaemonRedirect() {
|
||||
}
|
||||
|
||||
export default function SettingsSectionRoute() {
|
||||
const params = useLocalSearchParams<{ section?: string }>();
|
||||
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
||||
const rawSection = typeof params.section === "string" ? params.section : "";
|
||||
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
|
||||
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
|
||||
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
|
||||
|
||||
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
|
||||
@@ -49,5 +50,5 @@ export default function SettingsSectionRoute() {
|
||||
);
|
||||
}
|
||||
|
||||
return <SettingsScreen view={view} />;
|
||||
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
||||
}
|
||||
|
||||
+10
-11
@@ -1,10 +1,9 @@
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { isFdroidBuild } from "@/constants/build-profile";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
@@ -85,7 +84,7 @@ export function AddHostMethodModal({
|
||||
onClose={onClose}
|
||||
testID="add-host-method-modal"
|
||||
>
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleDirect}
|
||||
accessibilityRole="button"
|
||||
@@ -99,10 +98,10 @@ export function AddHostMethodModal({
|
||||
{t("pairing.connectionMethods.direct.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
|
||||
{isElectronRuntime() ? (
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleRemoteSsh}
|
||||
accessibilityRole="button"
|
||||
@@ -116,11 +115,11 @@ export function AddHostMethodModal({
|
||||
{t("pairing.connectionMethods.remoteSsh.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{isNative && !isFdroidBuild ? (
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleScan}
|
||||
accessibilityRole="button"
|
||||
@@ -133,10 +132,10 @@ export function AddHostMethodModal({
|
||||
{t("pairing.connectionMethods.scanQr.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handlePaste}
|
||||
accessibilityRole="button"
|
||||
@@ -150,7 +149,7 @@ export function AddHostMethodModal({
|
||||
{t("pairing.connectionMethods.pasteLink.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
+30
-47
@@ -1,22 +1,17 @@
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
|
||||
import { buildConnectionFailureCopy } from "@/components/connection-failure-copy";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PairingTargetTracker } from "@/components/pair-link-credentials";
|
||||
import { buildDirectConnectionUri } from "@/components/direct-connection-host";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
import { buildDirectConnectionUri } from "./direct-connection-host";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
|
||||
@@ -193,6 +188,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
}
|
||||
|
||||
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
|
||||
@@ -211,7 +207,10 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
const advancedTarget = useRef(new PairingTargetTracker("", true));
|
||||
|
||||
const connectIcon = useMemo(() => <ThemedLink2 size={16} />, []);
|
||||
const connectIcon = useMemo(
|
||||
() => <Link2 size={16} color={theme.colors.accentForeground} />,
|
||||
[theme.colors.accentForeground],
|
||||
);
|
||||
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
|
||||
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
|
||||
const checkboxStyle = useMemo(
|
||||
@@ -416,8 +415,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsAdvancedOpen(false);
|
||||
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
|
||||
|
||||
const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight;
|
||||
const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye;
|
||||
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
|
||||
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
@@ -431,7 +430,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
<View style={styles.portRow}>
|
||||
<View style={hostFieldStyle}>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
|
||||
<ThemedAdaptiveTextInput
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-input"
|
||||
nativeID="direct-host-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.host")}
|
||||
@@ -439,6 +438,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
resetKey={`direct-host-${inputResetKey}`}
|
||||
onChangeText={setHost}
|
||||
placeholder="localhost"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -449,7 +449,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
</View>
|
||||
<View style={portFieldStyle}>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
|
||||
<ThemedAdaptiveTextInput
|
||||
<AdaptiveTextInput
|
||||
testID="direct-port-input"
|
||||
nativeID="direct-port-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.port")}
|
||||
@@ -457,6 +457,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
resetKey={`direct-port-${inputResetKey}`}
|
||||
onChangeText={setPort}
|
||||
placeholder="6767"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -468,7 +469,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.checkboxRow}
|
||||
onPress={handleToggleUseTls}
|
||||
disabled={isSaving}
|
||||
@@ -481,17 +482,17 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
<View style={checkboxStyle}>
|
||||
{useTls ? (
|
||||
<View testID="direct-ssl-toggle-checked">
|
||||
<ThemedCheck size={14} />
|
||||
<Check size={14} color={theme.colors.accentForeground} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
|
||||
<View style={styles.passwordRow}>
|
||||
<ThemedAdaptiveTextInput
|
||||
<AdaptiveTextInput
|
||||
testID="direct-password-input"
|
||||
nativeID="direct-password-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.password")}
|
||||
@@ -499,6 +500,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
resetKey={`direct-password-${inputResetKey}`}
|
||||
onChangeText={setPassword}
|
||||
placeholder={t("pairing.direct.fields.optional")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={passwordInputStyle}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -507,7 +509,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
/>
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={handleTogglePasswordVisibility}
|
||||
disabled={isSaving}
|
||||
@@ -519,13 +521,13 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
}
|
||||
testID="direct-password-visibility-toggle"
|
||||
>
|
||||
<PasswordIcon size={18} />
|
||||
</PressHighlight>
|
||||
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={styles.advancedToggle}
|
||||
onPress={handleToggleAdvanced}
|
||||
disabled={isSaving}
|
||||
@@ -535,11 +537,11 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
}
|
||||
testID="direct-host-advanced-toggle"
|
||||
>
|
||||
<AdvancedIcon size={16} />
|
||||
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
{isAdvancedOpen ? (
|
||||
<ThemedAdaptiveTextInput
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
@@ -547,6 +549,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
onChangeText={handleChangeAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -582,23 +585,3 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedLink2 = withUnistyles(Link2, (theme) => ({ color: theme.colors.accentForeground }));
|
||||
|
||||
const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.accentForeground }));
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
|
||||
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
|
||||
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
@@ -93,7 +92,7 @@ import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||
import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes";
|
||||
|
||||
interface AddProjectFlowProps {
|
||||
request: AddProjectFlowRequest;
|
||||
@@ -326,7 +325,6 @@ function setPageStatus(
|
||||
// The product flow is intentionally one cohesive page-stack state machine.
|
||||
// eslint-disable-next-line complexity
|
||||
export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
const addHost = useAddHost();
|
||||
const hosts = useHosts();
|
||||
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
|
||||
@@ -618,9 +616,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
icon: Plus,
|
||||
testID: "add-project-flow-add-host",
|
||||
select: () => {
|
||||
// The native project Modal must release its window before the root sheet opens.
|
||||
onClose();
|
||||
addHost();
|
||||
usePanelStore.getState().showMobileAgent();
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -719,7 +717,6 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
directoryPaths,
|
||||
githubQuery.data,
|
||||
host,
|
||||
addHost,
|
||||
onClose,
|
||||
openAddedProject,
|
||||
page,
|
||||
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
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 "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const ThemedTerminal = withUnistyles(Terminal);
|
||||
@@ -174,11 +174,11 @@ export function AddRemoteSshHostModal({
|
||||
}
|
||||
|
||||
clear();
|
||||
onClose();
|
||||
onSaved?.({
|
||||
...result,
|
||||
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
|
||||
});
|
||||
onClose();
|
||||
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
|
||||
const handleTargetChange = useCallback((value: string) => {
|
||||
targetRef.current = value;
|
||||
@@ -260,7 +260,7 @@ export function AddRemoteSshHostModal({
|
||||
onSubmitEditing={handleSubmit}
|
||||
/>
|
||||
</View>
|
||||
<PressHighlight
|
||||
<Pressable
|
||||
style={iconButtonStyle}
|
||||
onPress={handleTogglePasswordVisibility}
|
||||
disabled={isSaving}
|
||||
@@ -273,7 +273,7 @@ export function AddRemoteSshHostModal({
|
||||
testID="remote-ssh-password-visibility-toggle"
|
||||
>
|
||||
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
|
||||
</PressHighlight>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Field>
|
||||
<View style={styles.actions}>
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { expect, it } from "vitest";
|
||||
import { HostPickerOption } from "./host-picker";
|
||||
|
||||
const actions: string[] = [];
|
||||
function select() {
|
||||
actions.push("select");
|
||||
}
|
||||
function openSettings() {
|
||||
actions.push("settings");
|
||||
}
|
||||
|
||||
it("keeps host selection and settings as separate accessible buttons", () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
actions.length = 0;
|
||||
act(() =>
|
||||
root.render(
|
||||
<HostPickerOption
|
||||
serverId="host-a"
|
||||
label="Host A"
|
||||
showActiveConnection={false}
|
||||
active={false}
|
||||
onPress={select}
|
||||
onOpenHostSettings={openSettings}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const host = container.querySelector('button[aria-label="Host A"]');
|
||||
const settings = container.querySelector('button[aria-label="Open Host A settings"]');
|
||||
expect(host).not.toBeNull();
|
||||
expect(settings).not.toBeNull();
|
||||
expect(host?.contains(settings!)).toBe(false);
|
||||
act(() => (settings as HTMLElement).click());
|
||||
expect(actions).toEqual(["settings"]);
|
||||
act(() => (host as HTMLElement).click());
|
||||
expect(actions).toEqual(["settings", "select"]);
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/comb
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import { useHostRuntimeSnapshot, type ActiveConnection } from "@/runtime/host-runtime";
|
||||
import { orderHostsLocalFirst } from "@/types/host-connection";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
ADD_HOST_OPTION_ID,
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
@@ -89,35 +90,33 @@ export function HostPickerOption({
|
||||
);
|
||||
const trailingSlot = useMemo(() => {
|
||||
if (!onOpenHostSettings) return undefined;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handleSettingsPress}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${label} settings`}
|
||||
>
|
||||
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
}, [
|
||||
handleSettingsPress,
|
||||
label,
|
||||
onOpenHostSettings,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.sm,
|
||||
]);
|
||||
return <View style={styles.settingsSlot} />;
|
||||
}, [onOpenHostSettings]);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={label}
|
||||
description={connectionLabel}
|
||||
leadingSlot={leadingSlot}
|
||||
trailingSlot={trailingSlot}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
/>
|
||||
<View style={styles.optionContainer}>
|
||||
<ComboboxItem
|
||||
label={label}
|
||||
description={connectionLabel}
|
||||
leadingSlot={leadingSlot}
|
||||
trailingSlot={trailingSlot}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
/>
|
||||
{onOpenHostSettings ? (
|
||||
<Pressable
|
||||
style={styles.settingsAction}
|
||||
onPress={handleSettingsPress}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${label} settings`}
|
||||
>
|
||||
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -319,6 +318,15 @@ export function HostPicker({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
optionContainer: { position: "relative" },
|
||||
settingsSlot: { width: theme.iconSize.sm, height: theme.iconSize.sm },
|
||||
settingsAction: {
|
||||
position: "absolute",
|
||||
right: theme.spacing[3] + (isWeb ? 0 : theme.spacing[1]),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: "center",
|
||||
},
|
||||
statusDotSlot: {
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
CircleGauge,
|
||||
@@ -63,7 +62,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
@@ -178,8 +177,14 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
router.push(buildSettingsRoute());
|
||||
}, []);
|
||||
|
||||
const handleAddHostMobile = useAddHost();
|
||||
const handleAddHostDesktop = handleAddHostMobile;
|
||||
const handleAddHostMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
}, [showMobileAgent]);
|
||||
|
||||
const handleAddHostDesktop = useCallback(() => {
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
}, []);
|
||||
|
||||
const handleOpenHostSettingsMobile = useCallback(
|
||||
(serverId: string) => {
|
||||
|
||||
@@ -130,186 +130,6 @@ it("preserves empty table cells while escaping spoilers in cell text", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a table directly after a paragraph through the library parser", async () => {
|
||||
const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
|
||||
const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
|
||||
const flags = { hardSoftBreaks: true, latexMath: false };
|
||||
const expected = await parseMarkdown(separated, flags);
|
||||
expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
|
||||
expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a quote",
|
||||
"> Notes\n> | Item |\n> | --- |\n> | A |",
|
||||
"> Notes\n>\n> | Item |\n> | --- |\n> | A |",
|
||||
["Document", ["Blockquote", "Paragraph", "Table"]],
|
||||
],
|
||||
[
|
||||
"a list item",
|
||||
"- Notes\n | Item |\n | --- |\n | A |\n- Next",
|
||||
"- Notes\n\n | Item |\n | --- |\n | A |\n- Next",
|
||||
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
|
||||
],
|
||||
[
|
||||
"a quoted list item",
|
||||
"> - Notes\n> | Item |\n> | --- |\n> | A |",
|
||||
"> - Notes\n>\n> | Item |\n> | --- |\n> | A |",
|
||||
["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]],
|
||||
],
|
||||
[
|
||||
"a quote inside a list item",
|
||||
"- > Notes\n > | Item |\n > | --- |\n > | A |",
|
||||
"- > Notes\n >\n > | Item |\n > | --- |\n > | A |",
|
||||
["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]],
|
||||
],
|
||||
[
|
||||
"aligned columns without outer pipes",
|
||||
"Notes\nItem | Detail\n:--- | ---:\nA | one",
|
||||
"Notes\n\nItem | Detail\n:--- | ---:\nA | one",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"an entity at the start of a header without outer pipes",
|
||||
"Notes\nAT&T | Detail\n--- | ---\nA | one",
|
||||
"Notes\n\nAT\\&T | Detail\n--- | ---\nA | one",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"a local image at the start of a header without outer pipes",
|
||||
"Notes\n | Detail\n--- | ---\nA | one",
|
||||
"Notes\n\nLocal | Detail\n--- | ---\nA | one",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"CRLF lines",
|
||||
"Notes\r\n| Item |\r\n| --- |\r\n| A |",
|
||||
"Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"CR lines",
|
||||
"Notes\r| Item |\r| --- |\r| A |",
|
||||
"Notes\r\r| Item |\r| --- |\r| A |",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"a quote with CRLF lines",
|
||||
"> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |",
|
||||
"> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |",
|
||||
["Document", ["Blockquote", "Paragraph", "Table"]],
|
||||
],
|
||||
[
|
||||
"a list item with CRLF lines",
|
||||
"- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
|
||||
"- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
|
||||
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
|
||||
],
|
||||
[
|
||||
"a quote with CR lines",
|
||||
"> Notes\r> | Item |\r> | --- |\r> | A |",
|
||||
"> Notes\r>\r> | Item |\r> | --- |\r> | A |",
|
||||
["Document", ["Blockquote", "Paragraph", "Table"]],
|
||||
],
|
||||
[
|
||||
"a list item with CR lines",
|
||||
"- Notes\r | Item |\r | --- |\r | A |\r- Next",
|
||||
"- Notes\r\r | Item |\r | --- |\r | A |\r- Next",
|
||||
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
|
||||
],
|
||||
[
|
||||
"a non-interrupting ordered marker with tildes",
|
||||
"Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |",
|
||||
"Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"a non-interrupting ordered marker with backticks",
|
||||
"Notes\n2. ```\n | Item |\n | --- |\n | A |",
|
||||
"Notes\n2. ```\n\n | Item |\n | --- |\n | A |",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => {
|
||||
const rendered = prepareMarkdownSource(source).render();
|
||||
expect(rendered).toEqual(separated);
|
||||
const flags = { hardSoftBreaks: true, latexMath: false };
|
||||
function blocks(node: ASTNode): unknown {
|
||||
if (node.type === "Paragraph" || node.type === "Table") return node.type;
|
||||
return [node.type, ...(node.children ?? []).map(blocks)];
|
||||
}
|
||||
expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a literal backslash",
|
||||
"Notes\\\n| Item |\n| --- |\n| A |",
|
||||
"Notes\\\n\n| Item |\n| --- |\n| A |",
|
||||
"Notes\\",
|
||||
],
|
||||
[
|
||||
"an entity and literal backslash with LF lines",
|
||||
"AT&T\\\n| Item |\n| --- |\n| A |",
|
||||
"AT\\&T\\\n\n| Item |\n| --- |\n| A |",
|
||||
"AT&T\\",
|
||||
],
|
||||
[
|
||||
"an entity and literal backslash with CRLF lines",
|
||||
"AT&T\\\r\n| Item |\r\n| --- |\r\n| A |",
|
||||
"AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |",
|
||||
"AT&T\\",
|
||||
],
|
||||
[
|
||||
"an entity and literal backslash with CR lines",
|
||||
"AT&T\\\r| Item |\r| --- |\r| A |",
|
||||
"AT\\&T\\\r\r| Item |\r| --- |\r| A |",
|
||||
"AT&T\\",
|
||||
],
|
||||
])(
|
||||
"preserves paragraph text before an interrupting table in %s",
|
||||
async (_name, source, separated, text) => {
|
||||
const rendered = prepareMarkdownSource(source).render();
|
||||
expect(rendered).toEqual(separated);
|
||||
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
|
||||
expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
|
||||
expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"```yaml\n---\nkey: value\n---\n```\n",
|
||||
"~~~md\n| Item |\n| --- |\n| A |\n~~~\n",
|
||||
"````md\n```\n| Item |\n| --- |\n```\n`````\n",
|
||||
"> ```md\n> | Item |\n> | --- |\n> ```\n",
|
||||
"> ```md\n> Code\n",
|
||||
"- ```md\n Code\n",
|
||||
" ```\n\n",
|
||||
"```not`a-fence\n",
|
||||
])("renders a table after a fence without changing its code: %s", async (prefix) => {
|
||||
const table = "| Item |\n| --- |\n| A |";
|
||||
const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render();
|
||||
expect(rendered).toEqual(`${prefix}Notes\n\n${table}`);
|
||||
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
|
||||
expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
|
||||
"Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
|
||||
"Notes\n\n| Item |\n| --- |\n| A |",
|
||||
"| Item |\n| --- |\n| A |",
|
||||
"```md\nNotes\n| Item |\n| --- |\n| A |\n```",
|
||||
"```yaml\n---\nkey: value\n---\n```",
|
||||
"```yaml\n---\nkey: value",
|
||||
"~~~md\n| Item |\n| --- |\n| A |\n~~~",
|
||||
"````md\n```\n| Item |\n| --- |\n```\n````",
|
||||
" Notes\n | Item |\n | --- |\n | A |",
|
||||
"Notes\n| Item | Detail |\n| --- |\n| A |",
|
||||
])("leaves separated tables and table-like text unchanged: %s", (source) => {
|
||||
expect(prepareMarkdownSource(source).render()).toEqual(source);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"src/components/message.tsx",
|
||||
"C:\\repo\\file.ts",
|
||||
|
||||
@@ -138,10 +138,6 @@ export function prepareMarkdownSource(
|
||||
!source.includes("![") &&
|
||||
!source.includes("||") &&
|
||||
!source.includes("&") &&
|
||||
// Match content, header, and delimiter lines; quote-only lines count as blank.
|
||||
!/^[\t >]*[^\t >\r\n][^\r\n]*(?:\r\n|\r|\n)[^\r\n]+(?:\r\n|\r|\n)(?=[\t >:|-]*\|)[\t >|]*:?-[-\t :|]*$/m.test(
|
||||
source,
|
||||
) &&
|
||||
!(workspaceRoot && source.includes("]("))
|
||||
) {
|
||||
return { images, ...metadata, render: () => source };
|
||||
@@ -216,25 +212,11 @@ export function prepareMarkdownSource(
|
||||
if (lineEnd < end) split(lineEnd);
|
||||
}
|
||||
}
|
||||
function visit(node: Nodes, previous?: Nodes) {
|
||||
const position = node.position;
|
||||
if (position?.start.offset !== undefined && position.end.offset !== undefined) {
|
||||
const start = position.start.offset;
|
||||
const end = position.end.offset;
|
||||
function visit(node: Nodes) {
|
||||
const start = node.position?.start.offset;
|
||||
const end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) {
|
||||
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
|
||||
if (
|
||||
node.type === "table" &&
|
||||
previous?.type === "paragraph" &&
|
||||
previous.position?.end.line === position.start.line - 1
|
||||
) {
|
||||
// MD4C only recognizes a table header on the first line of a paragraph.
|
||||
const lineStart =
|
||||
Math.max(source.lastIndexOf("\n", start - 1), source.lastIndexOf("\r", start - 1)) + 1;
|
||||
const newline = source.slice(lineStart - 2, lineStart).replace(/[^\r\n]/g, "");
|
||||
const blank = source.slice(lineStart, start).replace(/[^>]/g, " ").trimEnd();
|
||||
const boundary = lineStart - newline.length;
|
||||
edits.push({ start: boundary, end: lineStart, text: `${newline}${blank}${newline}` });
|
||||
}
|
||||
if (node.type === "link") {
|
||||
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
|
||||
if (title !== null)
|
||||
@@ -262,8 +244,7 @@ export function prepareMarkdownSource(
|
||||
if (text !== source.slice(start, end)) edits.push({ start, end, text });
|
||||
}
|
||||
}
|
||||
if ("children" in node)
|
||||
node.children.forEach((child, index) => visit(child, node.children[index - 1]));
|
||||
if ("children" in node) node.children.forEach(visit);
|
||||
}
|
||||
visit(getTree());
|
||||
return {
|
||||
|
||||
+11
-16
@@ -1,20 +1,16 @@
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Link } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
|
||||
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
|
||||
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
|
||||
import { PairingTargetTracker } from "@/components/pair-link-credentials";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
|
||||
@@ -104,6 +100,7 @@ function PairLinkModalContent({
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: PairLinkModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { beginLinkPairing } = useHostMutations();
|
||||
@@ -129,7 +126,10 @@ function PairLinkModalContent({
|
||||
resetPasswordInput();
|
||||
}, []);
|
||||
|
||||
const pairIcon = useMemo(() => <ThemedLink size={16} />, []);
|
||||
const pairIcon = useMemo(
|
||||
() => <Link size={16} color={theme.colors.accentForeground} />,
|
||||
[theme.colors.accentForeground],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
@@ -212,7 +212,7 @@ function PairLinkModalContent({
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
||||
<ThemedAdaptiveTextInput
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
initialValue={initialUrl}
|
||||
testID="pair-link-input"
|
||||
@@ -220,6 +220,7 @@ function PairLinkModalContent({
|
||||
accessibilityLabel={t("pairing.link.label")}
|
||||
onChangeText={handleChangeOfferUrl}
|
||||
placeholder="https://app.paseo.sh/#offer=..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
@@ -234,7 +235,7 @@ function PairLinkModalContent({
|
||||
<Text style={styles.label}>
|
||||
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
|
||||
</Text>
|
||||
<ThemedAdaptiveTextInput
|
||||
<AdaptiveTextInput
|
||||
testID="pair-link-password-input"
|
||||
resetKey={`pair-link-password-${passwordResetKey}`}
|
||||
accessibilityLabel={t("pairing.hostPassword.label")}
|
||||
@@ -273,9 +274,3 @@ function PairLinkModalContent({
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground }));
|
||||
|
||||
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -13,7 +12,11 @@ import {
|
||||
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";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
@@ -159,13 +162,20 @@ function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomeScreen() {
|
||||
export interface WelcomeScreenProps {
|
||||
onHostAdded?: (profile: HostProfile) => void;
|
||||
}
|
||||
|
||||
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
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));
|
||||
|
||||
@@ -186,10 +196,23 @@ export function WelcomeScreen() {
|
||||
router.push("/settings");
|
||||
}, [router]);
|
||||
|
||||
const handleOpenDirect = useAddHost({ method: "direct", onAdded: finishOnboarding });
|
||||
const handleOpenRemoteSsh = useAddHost({ method: "ssh", onAdded: finishOnboarding });
|
||||
const handleOpenPasteLink = useAddHost({ method: "link", onAdded: finishOnboarding });
|
||||
const handleScanQr = useAddHost({ method: "scan", onAdded: finishOnboarding });
|
||||
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(() => {
|
||||
router.push("/pair-scan?source=onboarding");
|
||||
}, [router]);
|
||||
|
||||
const handleHostSaved = useCallback(
|
||||
({ profile }: { profile: HostProfile; serverId: string }) => {
|
||||
onHostAdded?.(profile);
|
||||
finishOnboarding();
|
||||
},
|
||||
[onHostAdded, finishOnboarding],
|
||||
);
|
||||
|
||||
const actions: WelcomeAction[] =
|
||||
isWeb || isFdroidBuild
|
||||
@@ -293,6 +316,24 @@ export function WelcomeScreen() {
|
||||
</Button>
|
||||
</View>
|
||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||
|
||||
<AddHostModal
|
||||
visible={isDirectOpen}
|
||||
onClose={handleCloseDirect}
|
||||
onSaved={handleHostSaved}
|
||||
/>
|
||||
|
||||
<AddRemoteSshHostModal
|
||||
visible={isRemoteSshOpen}
|
||||
onClose={handleCloseRemoteSsh}
|
||||
onSaved={handleHostSaved}
|
||||
/>
|
||||
|
||||
<PairLinkModal
|
||||
visible={isPasteLinkOpen}
|
||||
onClose={handleClosePasteLink}
|
||||
onSaved={handleHostSaved}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { executeDaemonManagementToggle } from "./daemon-management-toggle";
|
||||
import type { DesktopDaemonStatus } from "./desktop-daemon";
|
||||
import { resolveDesktopDaemonServerId, type DesktopDaemonStatus } from "./desktop-daemon";
|
||||
|
||||
const runningManagedStatus: DesktopDaemonStatus = {
|
||||
serverId: "desktop",
|
||||
@@ -54,6 +54,29 @@ function makeDeps(overrides?: {
|
||||
};
|
||||
}
|
||||
|
||||
it("retains the desktop identity when the daemon is stopped or status fails", async () => {
|
||||
expect(
|
||||
await resolveDesktopDaemonServerId({
|
||||
getStatus: async () => ({ ...stoppedStatus, serverId: "" }),
|
||||
getSavedId: async () => "desktop",
|
||||
}),
|
||||
).toBe("desktop");
|
||||
expect(
|
||||
await resolveDesktopDaemonServerId({
|
||||
getStatus: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
getSavedId: async () => "desktop",
|
||||
}),
|
||||
).toBe("desktop");
|
||||
expect(
|
||||
await resolveDesktopDaemonServerId({
|
||||
getStatus: async () => runningManagedStatus,
|
||||
getSavedId: async () => "old",
|
||||
}),
|
||||
).toBe("desktop");
|
||||
});
|
||||
|
||||
describe("executeDaemonManagementToggle", () => {
|
||||
describe("enable path (currentlyManaging: false)", () => {
|
||||
it("persists the new setting then starts the daemon", async () => {
|
||||
|
||||
@@ -186,6 +186,28 @@ export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status"));
|
||||
}
|
||||
|
||||
/** Resolve the identity even when the daemon has stopped. */
|
||||
export async function resolveDesktopDaemonServerId(
|
||||
deps: {
|
||||
getStatus(): Promise<DesktopDaemonStatus>;
|
||||
getSavedId(): Promise<string | null>;
|
||||
} = {
|
||||
getStatus: getDesktopDaemonStatus,
|
||||
getSavedId: () => invokeDesktopCommand<string | null>("desktop_daemon_identity"),
|
||||
},
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const id = (await deps.getStatus()).serverId.trim();
|
||||
if (id) return id;
|
||||
} catch {
|
||||
/* The persisted identity remains available if the status request fails. */
|
||||
}
|
||||
const id = await deps.getSavedId();
|
||||
if (id === null) return null;
|
||||
if (typeof id !== "string") throw new Error("Invalid desktop daemon identity");
|
||||
return id.trim() || null;
|
||||
}
|
||||
|
||||
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon"));
|
||||
}
|
||||
|
||||
@@ -25,17 +25,12 @@ const iframeStyle = {
|
||||
|
||||
export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) {
|
||||
const { t } = useTranslation();
|
||||
// A document URL lets the browser resolve fragment links within the preview.
|
||||
// srcDoc would inherit the app URL and navigate the frame back into Paseo.
|
||||
const source = useMemo(
|
||||
() => `data:text/html;charset=utf-8,${encodeURIComponent(withPreviewCsp(html))}`,
|
||||
[html],
|
||||
);
|
||||
const document = useMemo(() => withPreviewCsp(html), [html]);
|
||||
return (
|
||||
<iframe
|
||||
data-testid={testID}
|
||||
title={t("panels.file.filePanels.builtin.html")}
|
||||
src={source}
|
||||
srcDoc={document}
|
||||
sandbox={SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
style={iframeStyle}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { createContext, useCallback, useContext, useState, useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useStore } from "zustand";
|
||||
import { AddHostMethodModal } from "./internal/add-host-method-modal";
|
||||
import { AddHostModal } from "./internal/add-host-modal";
|
||||
import { AddRemoteSshHostModal } from "./internal/add-remote-ssh-host-modal";
|
||||
import { PairLinkModal } from "./internal/pair-link-modal";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { PairScanContent } from "./internal/scan";
|
||||
import { createAddHostFlow, type AddHostOptions, type AddHostSession } from "./internal/model";
|
||||
|
||||
const Context = createContext<ReturnType<typeof createAddHostFlow> | null>(null);
|
||||
|
||||
/** Owns the flow outside screen and appearance lifetimes. Callers own success navigation. */
|
||||
export function AddHostProvider({ children }: { children: ReactNode }) {
|
||||
const [flow] = useState(createAddHostFlow);
|
||||
return <Context.Provider value={flow}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
export function AddHostFlowHost() {
|
||||
const flow = useContext(Context);
|
||||
if (!flow) throw new Error("AddHostFlowHost requires AddHostProvider");
|
||||
const session = useStore(flow.store, (state) => state.session);
|
||||
return session ? <AddHostSessionView key={session.id} session={session} /> : null;
|
||||
}
|
||||
|
||||
export function useAddHost({ method, onAdded }: AddHostOptions = {}) {
|
||||
const flow = useContext(Context);
|
||||
if (!flow) throw new Error("useAddHost requires AddHostProvider");
|
||||
return useCallback(() => flow.open({ method, onAdded }), [flow, method, onAdded]);
|
||||
}
|
||||
|
||||
function AddHostSessionView({ session }: { session: AddHostSession }) {
|
||||
const { t } = useTranslation();
|
||||
const method = useStore(session.step, (state) => state.method);
|
||||
const back = useCallback(() => session.choose(null), [session]);
|
||||
const direct = useCallback(() => session.choose("direct"), [session]);
|
||||
const ssh = useCallback(() => session.choose("ssh"), [session]);
|
||||
const link = useCallback(() => session.choose("link"), [session]);
|
||||
const scan = useCallback(() => session.choose("scan"), [session]);
|
||||
const scanned = useCallback((serverId: string) => session.complete({ serverId }), [session]);
|
||||
const header = useMemo(
|
||||
() => ({ title: t("pairing.scan.title"), back: { onPress: back } }),
|
||||
[t, back],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<AddHostMethodModal
|
||||
visible={method === null}
|
||||
onClose={session.close}
|
||||
onDirectConnection={direct}
|
||||
onRemoteSsh={ssh}
|
||||
onPasteLink={link}
|
||||
onScanQr={scan}
|
||||
/>
|
||||
<AddHostModal
|
||||
visible={method === "direct"}
|
||||
onClose={session.close}
|
||||
onCancel={back}
|
||||
onSaved={session.complete}
|
||||
/>
|
||||
<AddRemoteSshHostModal
|
||||
visible={method === "ssh"}
|
||||
onClose={session.close}
|
||||
onCancel={back}
|
||||
onSaved={session.complete}
|
||||
/>
|
||||
<PairLinkModal
|
||||
visible={method === "link"}
|
||||
onClose={session.close}
|
||||
onCancel={back}
|
||||
onSaved={session.complete}
|
||||
/>
|
||||
{method === "scan" ? (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible
|
||||
onClose={session.close}
|
||||
scrollable={false}
|
||||
snapPoints={SCAN_SNAP_POINTS}
|
||||
desktopHeight="85%"
|
||||
>
|
||||
<PairScanContent onConnected={scanned} onClose={back} />
|
||||
</AdaptiveModalSheet>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const SCAN_SNAP_POINTS = ["90%"];
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAddHostFlow } from "./model";
|
||||
|
||||
describe("add host flow", () => {
|
||||
it("closes before notifying the consumer and ignores duplicate completion", () => {
|
||||
const flow = createAddHostFlow();
|
||||
const added: string[] = [];
|
||||
flow.open({
|
||||
onAdded: (serverId) => {
|
||||
expect(flow.store.getState().session).toBeNull();
|
||||
added.push(serverId);
|
||||
},
|
||||
});
|
||||
const session = flow.store.getState().session!;
|
||||
session.choose("direct");
|
||||
session.complete({ serverId: "new-host" });
|
||||
session.complete({ serverId: "new-host" });
|
||||
expect(added).toEqual(["new-host"]);
|
||||
});
|
||||
|
||||
it("cancels without a success action and accepts a fresh request", () => {
|
||||
const flow = createAddHostFlow();
|
||||
const added: string[] = [];
|
||||
flow.open({ onAdded: (id) => added.push(id) });
|
||||
const cancelled = flow.store.getState().session!;
|
||||
cancelled.choose("scan");
|
||||
cancelled.close();
|
||||
expect(flow.store.getState().session).toBeNull();
|
||||
flow.open({ method: "link" });
|
||||
const current = flow.store.getState().session!;
|
||||
cancelled.complete({ serverId: "late-result" });
|
||||
cancelled.close();
|
||||
expect(flow.store.getState().session).toBe(current);
|
||||
expect(current.step.getState()).toEqual({ method: "link" });
|
||||
expect(added).toEqual([]);
|
||||
current.complete({ serverId: "new-host" });
|
||||
expect(flow.store.getState().session).toBeNull();
|
||||
});
|
||||
|
||||
it("returns to method selection within the same request", () => {
|
||||
const flow = createAddHostFlow();
|
||||
flow.open();
|
||||
const session = flow.store.getState().session!;
|
||||
session.choose("ssh");
|
||||
session.choose(null);
|
||||
expect(flow.store.getState().session).toBe(session);
|
||||
expect(session.step.getState()).toEqual({ method: null });
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||
|
||||
export type AddHostMethod = "direct" | "ssh" | "link" | "scan";
|
||||
export interface AddHostOptions {
|
||||
method?: AddHostMethod;
|
||||
onAdded?: (serverId: string) => void;
|
||||
}
|
||||
|
||||
export interface AddHostSession {
|
||||
id: number;
|
||||
step: StoreApi<{ method: AddHostMethod | null }>;
|
||||
choose(method: AddHostMethod | null): void;
|
||||
close(): void;
|
||||
complete(result: { serverId: string }): void;
|
||||
}
|
||||
|
||||
export function createAddHostFlow() {
|
||||
const store = createStore<{ session: AddHostSession | null }>(() => ({ session: null }));
|
||||
let nextId = 0;
|
||||
function createSession(options: AddHostOptions) {
|
||||
const step = createStore<{ method: AddHostMethod | null }>(() => ({
|
||||
method: options.method ?? null,
|
||||
}));
|
||||
const session = {
|
||||
id: ++nextId,
|
||||
step,
|
||||
choose(method: AddHostMethod | null) {
|
||||
step.setState({ method });
|
||||
},
|
||||
close() {
|
||||
if (store.getState().session === session) store.setState({ session: null });
|
||||
},
|
||||
complete({ serverId }: { serverId: string }) {
|
||||
if (store.getState().session !== session) return;
|
||||
session.close();
|
||||
options.onAdded?.(serverId);
|
||||
},
|
||||
};
|
||||
return session;
|
||||
}
|
||||
return {
|
||||
store,
|
||||
open(options: AddHostOptions = {}) {
|
||||
store.setState({ session: createSession(options) });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
} from "@/components/ui/text-input";
|
||||
import { router } from "expo-router";
|
||||
import { Server } from "lucide-react-native";
|
||||
import { create } from "zustand";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "@/lib/overlay-root";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
|
||||
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
|
||||
|
||||
type HostFilter = (host: HostProfile) => boolean;
|
||||
type HostChoiceHandler = (serverId: string) => void | Promise<void>;
|
||||
@@ -63,7 +65,6 @@ function matchesHostQuery(host: HostProfile, query: string): boolean {
|
||||
}
|
||||
|
||||
export function useHostChooser() {
|
||||
const addHost = useAddHost();
|
||||
const hosts = useHosts();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const open = useHostChooserStore((state) => state.open);
|
||||
@@ -75,7 +76,13 @@ export function useHostChooser() {
|
||||
);
|
||||
|
||||
if (availableHosts.length === 0) {
|
||||
(input.onNoHosts ?? addHost)();
|
||||
(
|
||||
input.onNoHosts ??
|
||||
(() => {
|
||||
usePanelStore.getState().showMobileAgent();
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
})
|
||||
)();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -91,7 +98,7 @@ export function useHostChooser() {
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[hosts, localServerId, open, addHost],
|
||||
[hosts, localServerId, open],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -132,12 +132,19 @@ function harness() {
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
addHost: async () => ({ status: "cancelled" }),
|
||||
getHosts: () => [],
|
||||
getSnapshot: () => null,
|
||||
subscribeAll: () => () => {},
|
||||
subscribeHostList: () => () => {},
|
||||
renameHost: async () => {},
|
||||
setHostColor: async () => {},
|
||||
setHostBadgeDisplay: async () => {},
|
||||
removeHost: async () => {},
|
||||
removeConnection: async () => {},
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
{ openSettings() {}, isManagedLocalHost: async () => false },
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createPlayAudio, type AudioEngine } from "@/audio";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import {
|
||||
resolveDesktopDaemonServerId,
|
||||
shouldUseDesktopDaemon,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
|
||||
import {
|
||||
@@ -26,7 +30,14 @@ export function createPluginClientRuntime(
|
||||
...capabilities,
|
||||
i18n: installation.i18n,
|
||||
playAudio: createPlayAudio(audio, installation.lifetime.signal),
|
||||
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
|
||||
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal, {
|
||||
openSettings(serverId) {
|
||||
createPluginNavigation({ serverId, workspaceId: null }).openHostSettings(serverId);
|
||||
},
|
||||
async isManagedLocalHost(serverId) {
|
||||
return shouldUseDesktopDaemon() && (await resolveDesktopDaemonServerId()) === serverId;
|
||||
},
|
||||
}),
|
||||
addComposerPill(contribution) {
|
||||
return pluginButtonStore.addComposerPill(installation, contribution);
|
||||
},
|
||||
|
||||
@@ -819,6 +819,14 @@ it("binds imported getters to each originating installation across delayed callb
|
||||
hosts: {
|
||||
getSnapshot: () => [],
|
||||
subscribe: () => () => {},
|
||||
observeHosts: () => {
|
||||
calls.push(`${installation}/observe`);
|
||||
return () => {};
|
||||
},
|
||||
addHost: async (input) => {
|
||||
calls.push(`${installation}/add:${input.connection}`);
|
||||
return { status: "cancelled" };
|
||||
},
|
||||
getPaseoClient(serverId) {
|
||||
calls.push(`${installation}/${serverId}`);
|
||||
return runtime.paseo;
|
||||
@@ -826,11 +834,12 @@ it("binds imported getters to each originating installation across delayed callb
|
||||
},
|
||||
});
|
||||
const source = bundle(`
|
||||
const { getPaseoClient } = require("@getpaseo/plugin/client");
|
||||
const { getPaseoClient, observeHosts, addHost } = require("@getpaseo/plugin/client");
|
||||
getPaseoClient("entry-host");
|
||||
observeHosts(() => {});
|
||||
plugin.addCommandCenterItem({
|
||||
id: "read", title: "Read", icon: "Server", context: "global",
|
||||
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); },
|
||||
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); await addHost({ connection: "example:6767" }); },
|
||||
});
|
||||
`);
|
||||
const first = runPluginClientBundle("same-id", source, hostRuntime("first"));
|
||||
@@ -840,9 +849,13 @@ it("binds imported getters to each originating installation across delayed callb
|
||||
await second.commandCenterItems[0].onSelect({} as never);
|
||||
expect(calls).toEqual([
|
||||
"first/entry-host",
|
||||
"first/observe",
|
||||
"second/entry-host",
|
||||
"second/observe",
|
||||
"first/target-host",
|
||||
"first/add:example:6767",
|
||||
"second/target-host",
|
||||
"second/add:example:6767",
|
||||
]);
|
||||
await first.cleanup();
|
||||
await second.cleanup();
|
||||
|
||||
@@ -520,6 +520,10 @@ export function runPluginClientBundle(
|
||||
useSettings,
|
||||
openExternalUrl,
|
||||
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
|
||||
addHost: (input: Parameters<typeof runtime.hosts.addHost>[0]) =>
|
||||
runtime.hosts.addHost(input),
|
||||
observeHosts: (listener: Parameters<typeof runtime.hosts.observeHosts>[0]) =>
|
||||
runtime.hosts.observeHosts(listener),
|
||||
useHosts: () =>
|
||||
React.useSyncExternalStore(
|
||||
runtime.hosts.subscribe,
|
||||
|
||||
@@ -1,17 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { createPluginHosts, type PluginHostsSource } from "./index";
|
||||
import { resolveDesktopDaemonServerId } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
function registry() {
|
||||
const hosts = [
|
||||
{ serverId: "a", label: "Alpha", password: "secret" },
|
||||
{ serverId: "b", label: "Beta", password: "secret" },
|
||||
{
|
||||
serverId: "a",
|
||||
label: "Alpha",
|
||||
password: "secret",
|
||||
appearance: { color: "none" as const, badgeDisplay: null },
|
||||
connections: [
|
||||
{ id: "one", type: "directTcp" as const, endpoint: "localhost:6767", useTls: false },
|
||||
{ id: "two", type: "directSocket" as const, path: "/tmp/test" },
|
||||
],
|
||||
lifecycle: {},
|
||||
preferredConnectionId: null,
|
||||
createdAt: "today",
|
||||
updatedAt: "today",
|
||||
},
|
||||
{
|
||||
serverId: "b",
|
||||
label: "Beta",
|
||||
password: "secret",
|
||||
appearance: { color: "none" as const, badgeDisplay: null },
|
||||
connections: [],
|
||||
lifecycle: {},
|
||||
preferredConnectionId: null,
|
||||
createdAt: "today",
|
||||
updatedAt: "today",
|
||||
},
|
||||
];
|
||||
const snapshots = new Map<string, NonNullable<ReturnType<PluginHostsSource["getSnapshot"]>>>();
|
||||
const snapshots = new Map<
|
||||
string,
|
||||
{ connectionStatus: "online" | "offline" | "connecting"; client: DaemonClient | null }
|
||||
>();
|
||||
const listeners = new Set<() => void>();
|
||||
const source: PluginHostsSource = {
|
||||
addHost: async () => ({ status: "cancelled" }),
|
||||
getHosts: () => hosts,
|
||||
getSnapshot: (id) => snapshots.get(id) ?? null,
|
||||
getSnapshot: (id) => {
|
||||
const value = snapshots.get(id);
|
||||
return value
|
||||
? { ...value, activeConnectionId: null, lastError: null, lastOnlineAt: null }
|
||||
: null;
|
||||
},
|
||||
renameHost: async (id, label) => {
|
||||
hosts.find((candidate) => candidate.serverId === id)!.label = label;
|
||||
},
|
||||
setHostColor: async () => {},
|
||||
setHostBadgeDisplay: async () => {},
|
||||
removeHost: async (id) => {
|
||||
const index = hosts.findIndex((host) => host.serverId === id);
|
||||
if (index >= 0) hosts.splice(index, 1);
|
||||
for (const l of listeners) l();
|
||||
},
|
||||
removeConnection: async (id, connectionId) => {
|
||||
const host = hosts.find((candidate) => candidate.serverId === id)!;
|
||||
host.connections = host.connections.filter((c) => c.id !== connectionId);
|
||||
if (!host.connections.length) await source.removeHost(id);
|
||||
else for (const l of listeners) l();
|
||||
},
|
||||
subscribeAll(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
@@ -26,7 +75,10 @@ function registry() {
|
||||
},
|
||||
};
|
||||
const lifetime = new AbortController();
|
||||
const runtime = createPluginHosts(source, lifetime.signal);
|
||||
const runtime = createPluginHosts(source, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
return {
|
||||
hosts,
|
||||
snapshots,
|
||||
@@ -48,7 +100,7 @@ function connection(id: string) {
|
||||
describe("plugin host access", () => {
|
||||
it("publishes credential-free configured hosts and only changes snapshots when summaries change", () => {
|
||||
const h = registry();
|
||||
expect(h.runtime.getSnapshot()).toEqual([
|
||||
expect(h.runtime.getSnapshot()).toMatchObject([
|
||||
{ serverId: "a", label: "Alpha", status: "offline" },
|
||||
{ serverId: "b", label: "Beta", status: "offline" },
|
||||
]);
|
||||
@@ -68,6 +120,171 @@ describe("plugin host access", () => {
|
||||
expect(h.listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps handles stable, protects credentials, and stops observations on unload", async () => {
|
||||
const h = registry();
|
||||
const seen: unknown[] = [];
|
||||
const stop = h.runtime.observeHosts((hosts) => seen.push(hosts));
|
||||
const first = h.runtime.getSnapshot()[0];
|
||||
function walk(value: unknown): void {
|
||||
if (typeof value === "string") expect(value).not.toContain("secret");
|
||||
if (!value || typeof value !== "object") return;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
expect(key.toLowerCase()).not.toBe("password");
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
walk(h.runtime.getSnapshot());
|
||||
h.hosts[0].label = "New name";
|
||||
h.publish();
|
||||
expect(h.runtime.getSnapshot()[0]).toBe(first);
|
||||
expect(first.label).toBe("New name");
|
||||
expect(seen).toHaveLength(2);
|
||||
h.lifetime.abort();
|
||||
h.publish();
|
||||
expect(seen).toHaveLength(2);
|
||||
await expect(first.remove()).rejects.toThrow("Plugin has stopped");
|
||||
stop();
|
||||
});
|
||||
|
||||
it("redacts saved passwords from mutation errors", async () => {
|
||||
const h = registry();
|
||||
h.source.renameHost = async () => {
|
||||
throw new Error("Failed for secret");
|
||||
};
|
||||
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow("secret");
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("isolates a throwing observer from other observers and registry mutations", async () => {
|
||||
const h = registry();
|
||||
let calls = 0;
|
||||
h.runtime.observeHosts(() => {
|
||||
if (calls++ > 0) throw new Error("plugin listener failed");
|
||||
});
|
||||
let delivered = 0;
|
||||
h.runtime.observeHosts(() => {
|
||||
delivered++;
|
||||
});
|
||||
await expect(h.runtime.getSnapshot()[0].removeConnection("one")).resolves.toBeUndefined();
|
||||
expect(delivered).toBe(2);
|
||||
expect(h.runtime.getSnapshot()[0].connections).toHaveLength(1);
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("refuses to remove the last connection of the desktop daemon", async () => {
|
||||
const h = registry();
|
||||
const lifetime = new AbortController();
|
||||
const runtime = createPluginHosts(h.source, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async (id) => id === "a",
|
||||
});
|
||||
const host = runtime.getSnapshot()[0];
|
||||
await host.removeConnection("one");
|
||||
await expect(host.removeConnection("two")).rejects.toThrow("desktop daemon");
|
||||
expect(host.connections).toHaveLength(1);
|
||||
lifetime.abort();
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("returns the projected handle after adding a host", async () => {
|
||||
const h = registry();
|
||||
h.source.addHost = async () => ({ status: "saved", serverId: "a" });
|
||||
const result = await h.runtime.addHost({ connection: "tcp://example:6767?password=secret" });
|
||||
expect(result.status).toBe("saved");
|
||||
if (result.status !== "saved") throw new Error("expected saved host");
|
||||
expect(result.host).toBe(h.runtime.getSnapshot()[0]);
|
||||
expect(JSON.stringify(result)).not.toContain("secret");
|
||||
h.lifetime.abort();
|
||||
await expect(h.runtime.addHost({ connection: "example:6767" })).rejects.toThrow(
|
||||
"Plugin has stopped",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes cancellation and authentication results without exposing credentials", async () => {
|
||||
const h = registry();
|
||||
for (const result of [
|
||||
{ status: "cancelled" as const },
|
||||
{ status: "password_required" as const, reason: "incorrect_password" as const },
|
||||
]) {
|
||||
h.source.addHost = async () => result;
|
||||
const returned = await h.runtime.addHost({
|
||||
connection: "tcp://example:6767?password=secret",
|
||||
});
|
||||
expect(returned).toEqual(result);
|
||||
for (const [key, value] of Object.entries(returned)) {
|
||||
expect(key).not.toBe("password");
|
||||
expect(value).not.toBe("secret");
|
||||
}
|
||||
}
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("redacts encoded saved passwords in mutation errors", async () => {
|
||||
const h = registry();
|
||||
h.hosts[0].password = "two words";
|
||||
h.source.renameHost = async () => {
|
||||
throw new Error("failed for two%20words");
|
||||
};
|
||||
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow(
|
||||
"two%20words",
|
||||
);
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("never exposes supplied passwords in add errors", async () => {
|
||||
const h = registry();
|
||||
h.source.addHost = async () => {
|
||||
throw new Error("secret was rejected");
|
||||
};
|
||||
await expect(
|
||||
h.runtime.addHost({ connection: "tcp://example:6767?password=secret" }),
|
||||
).rejects.not.toThrow("secret");
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("removes a connection and then the last host connection", async () => {
|
||||
const h = registry();
|
||||
const host = h.runtime.getSnapshot()[0];
|
||||
await host.removeConnection("one");
|
||||
expect(host.connections.map((item) => item.id)).toEqual(["two"]);
|
||||
await host.removeConnection("two");
|
||||
expect(h.runtime.getSnapshot().map((entry) => entry.serverId)).toEqual(["b"]);
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("rejects removal of the managed desktop daemon", async () => {
|
||||
const h = registry();
|
||||
const lifetime = new AbortController();
|
||||
const runtime = createPluginHosts(h.source, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async (id) => id === "a",
|
||||
});
|
||||
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
|
||||
expect(runtime.getSnapshot()).toHaveLength(2);
|
||||
lifetime.abort();
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("protects the desktop host after its daemon has stopped", async () => {
|
||||
const h = registry();
|
||||
const lifetime = new AbortController();
|
||||
const runtime = createPluginHosts(h.source, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async (id) =>
|
||||
id ===
|
||||
(await resolveDesktopDaemonServerId({
|
||||
getStatus: async () => {
|
||||
throw new Error("daemon stopped");
|
||||
},
|
||||
getSavedId: async () => "a",
|
||||
})),
|
||||
});
|
||||
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
|
||||
expect(runtime.getSnapshot()).toHaveLength(2);
|
||||
lifetime.abort();
|
||||
h.lifetime.abort();
|
||||
});
|
||||
|
||||
it("rejects unknown and disconnected targets without falling through to another host", () => {
|
||||
const h = registry();
|
||||
h.snapshots.set("a", { connectionStatus: "online", client: connection("a") });
|
||||
@@ -82,7 +299,10 @@ describe("plugin host access", () => {
|
||||
h.snapshots.set("b", { connectionStatus: "online", client });
|
||||
h.publish();
|
||||
const otherLifetime = new AbortController();
|
||||
const other = createPluginHosts(h.source, otherLifetime.signal);
|
||||
const other = createPluginHosts(h.source, otherLifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const api = h.runtime.getPaseoClient("b");
|
||||
expect(api).toBe(h.runtime.getPaseoClient("b"));
|
||||
expect(api).not.toBe(other.getPaseoClient("b"));
|
||||
|
||||
@@ -1,31 +1,288 @@
|
||||
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginHostSummary } from "@getpaseo/plugin/client";
|
||||
import type {
|
||||
PluginHost,
|
||||
PluginHostConnection,
|
||||
PluginHostPatch,
|
||||
PluginHostAddInput,
|
||||
PluginHostAddResult,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import {
|
||||
parseHostPort,
|
||||
serializeConnectionUri,
|
||||
serializeRelayConnectionUri,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "@getpaseo/protocol/daemon-endpoints";
|
||||
import { parseHostTarget, serializeSocketHostTarget } from "@getpaseo/protocol/host-target";
|
||||
import {
|
||||
DEFAULT_SSH_DAEMON_PORT,
|
||||
serializeSshTransportUri,
|
||||
} from "@getpaseo/protocol/ssh-transport";
|
||||
|
||||
type HostSnapshot = Pick<
|
||||
HostRuntimeSnapshot,
|
||||
| "connectionStatus"
|
||||
| "client"
|
||||
| "activeConnectionId"
|
||||
| "lastError"
|
||||
| "authFailureReason"
|
||||
| "lastOnlineAt"
|
||||
>;
|
||||
export interface PluginHostsSource {
|
||||
getHosts(): readonly { serverId: string; label: string }[];
|
||||
getSnapshot(serverId: string): {
|
||||
connectionStatus: PluginHostSummary["status"];
|
||||
client: DaemonClient | null;
|
||||
} | null;
|
||||
addHost(
|
||||
input: PluginHostAddInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
| { status: "saved"; serverId: string }
|
||||
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
|
||||
| { status: "cancelled" }
|
||||
>;
|
||||
getHosts(): readonly HostProfile[];
|
||||
getSnapshot(serverId: string): HostSnapshot | null;
|
||||
subscribeAll(listener: () => void): () => void;
|
||||
subscribeHostList(listener: () => void): () => void;
|
||||
renameHost(serverId: string, label: string): Promise<void>;
|
||||
setHostColor(serverId: string, color: PluginHost["appearance"]["color"]): Promise<void>;
|
||||
setHostBadgeDisplay(
|
||||
serverId: string,
|
||||
badge: PluginHost["appearance"]["badgeDisplay"],
|
||||
): Promise<void>;
|
||||
removeHost(serverId: string): Promise<void>;
|
||||
removeConnection(serverId: string, connectionId: string): Promise<void>;
|
||||
}
|
||||
function redactSecret(message: string, secret: string | undefined): string {
|
||||
if (!secret) return message;
|
||||
for (const value of [
|
||||
secret,
|
||||
encodeURIComponent(secret),
|
||||
new URLSearchParams({ password: secret }).toString().slice(9),
|
||||
]) {
|
||||
message = message.split(value).join("[redacted]");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
type ProjectedHost = Omit<PluginHost, "update" | "remove" | "removeConnection" | "openSettings">;
|
||||
function projectHost(profile: HostProfile, snapshot: HostSnapshot | null): ProjectedHost {
|
||||
const connections: PluginHostConnection[] = profile.connections.map((connection) => {
|
||||
switch (connection.type) {
|
||||
case "directTcp":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
endpoint: connection.endpoint,
|
||||
useTls: connection.useTls ?? false,
|
||||
uri: serializeConnectionUri({
|
||||
...parseHostPort(connection.endpoint),
|
||||
useTls: connection.useTls ?? false,
|
||||
}),
|
||||
};
|
||||
case "directSocket":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
path: connection.path,
|
||||
uri: serializeSocketHostTarget("unix", connection.path),
|
||||
};
|
||||
case "directPipe":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
path: connection.path,
|
||||
uri: serializeSocketHostTarget("pipe", connection.path),
|
||||
};
|
||||
case "remoteSsh":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
host: connection.host,
|
||||
...(connection.sshPort === undefined ? {} : { sshPort: connection.sshPort }),
|
||||
...(connection.daemonPort === undefined ? {} : { daemonPort: connection.daemonPort }),
|
||||
uri: serializeSshTransportUri({
|
||||
host: connection.host,
|
||||
sshPort: connection.sshPort,
|
||||
daemonPort: connection.daemonPort ?? DEFAULT_SSH_DAEMON_PORT,
|
||||
}),
|
||||
};
|
||||
case "relay": {
|
||||
const useTls =
|
||||
connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint);
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
useTls,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
...(connection.minimumSecurityMode
|
||||
? { minimumSecurityMode: connection.minimumSecurityMode }
|
||||
: {}),
|
||||
uri: serializeRelayConnectionUri({
|
||||
offer: {
|
||||
v: 2,
|
||||
serverId: profile.serverId,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
relay: { endpoint: connection.relayEndpoint, useTls },
|
||||
...(connection.minimumSecurityMode
|
||||
? { minimumSecurityMode: connection.minimumSecurityMode }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "routed":
|
||||
return {
|
||||
id: connection.id,
|
||||
type: connection.type,
|
||||
originServerId: connection.originServerId,
|
||||
uri: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
const error = snapshot?.lastError ?? null;
|
||||
return {
|
||||
serverId: profile.serverId,
|
||||
label: profile.label,
|
||||
status: snapshot?.connectionStatus ?? "offline",
|
||||
appearance: { color: profile.appearance.color, badgeDisplay: profile.appearance.badgeDisplay },
|
||||
connections,
|
||||
activeConnectionId: snapshot?.activeConnectionId ?? null,
|
||||
error: error ? redactSecret(error, profile.password) : null,
|
||||
authFailureReason: snapshot?.authFailureReason ?? null,
|
||||
lastOnlineAt: snapshot?.lastOnlineAt ?? null,
|
||||
createdAt: profile.createdAt,
|
||||
updatedAt: profile.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Each evaluated installation owns its borrowed APIs and registry subscriptions. */
|
||||
export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal) {
|
||||
export function createPluginHosts(
|
||||
source: PluginHostsSource,
|
||||
signal: AbortSignal,
|
||||
options: {
|
||||
openSettings(serverId: string): void;
|
||||
isManagedLocalHost(serverId: string): Promise<boolean>;
|
||||
},
|
||||
) {
|
||||
const clients = new Map<
|
||||
string,
|
||||
{ client: DaemonClient; api: PaseoApi; lifetime: AbortController }
|
||||
>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: readonly PluginHostSummary[] = [];
|
||||
function readHosts(): readonly PluginHostSummary[] {
|
||||
return source.getHosts().map(({ serverId, label }) => ({
|
||||
serverId,
|
||||
label,
|
||||
status: source.getSnapshot(serverId)?.connectionStatus ?? "offline",
|
||||
}));
|
||||
let snapshot: readonly PluginHost[] = [];
|
||||
let serialized = "";
|
||||
const entries = new Map<string, { data: ProjectedHost; handle: PluginHost }>();
|
||||
function assertHost(id: string) {
|
||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||
if (!source.getHosts().some((host) => host.serverId === id))
|
||||
throw new Error(`Unknown Paseo host: ${id}`);
|
||||
}
|
||||
async function perform(id: string, action: () => Promise<void>): Promise<void> {
|
||||
const secret = source.getHosts().find((host) => host.serverId === id)?.password;
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// A cause would expose the original error, including any password.
|
||||
// oxlint-disable-next-line preserve-caught-error
|
||||
throw new Error(redactSecret(message, secret));
|
||||
}
|
||||
}
|
||||
function readHosts(): { hosts: PluginHost[]; key: string } {
|
||||
const projected = source
|
||||
.getHosts()
|
||||
.map((profile) => projectHost(profile, source.getSnapshot(profile.serverId)));
|
||||
const ids = new Set(projected.map((host) => host.serverId));
|
||||
for (const id of entries.keys()) if (!ids.has(id)) entries.delete(id);
|
||||
const hosts = projected.map((data) => {
|
||||
let entry = entries.get(data.serverId);
|
||||
if (!entry) {
|
||||
const id = data.serverId;
|
||||
const read = () => entry!.data;
|
||||
const handle: PluginHost = {
|
||||
get serverId() {
|
||||
return read().serverId;
|
||||
},
|
||||
get label() {
|
||||
return read().label;
|
||||
},
|
||||
get status() {
|
||||
return read().status;
|
||||
},
|
||||
get appearance() {
|
||||
return read().appearance;
|
||||
},
|
||||
get connections() {
|
||||
return read().connections;
|
||||
},
|
||||
get activeConnectionId() {
|
||||
return read().activeConnectionId;
|
||||
},
|
||||
get error() {
|
||||
return read().error;
|
||||
},
|
||||
get authFailureReason() {
|
||||
return read().authFailureReason;
|
||||
},
|
||||
get lastOnlineAt() {
|
||||
return read().lastOnlineAt;
|
||||
},
|
||||
get createdAt() {
|
||||
return read().createdAt;
|
||||
},
|
||||
get updatedAt() {
|
||||
return read().updatedAt;
|
||||
},
|
||||
async update(patch: PluginHostPatch) {
|
||||
await perform(id, async () => {
|
||||
assertHost(id);
|
||||
if (patch.label !== undefined) await source.renameHost(id, patch.label);
|
||||
if (patch.appearance?.color !== undefined) {
|
||||
assertHost(id);
|
||||
await source.setHostColor(id, patch.appearance.color);
|
||||
}
|
||||
if (patch.appearance?.badgeDisplay !== undefined) {
|
||||
assertHost(id);
|
||||
await source.setHostBadgeDisplay(id, patch.appearance.badgeDisplay);
|
||||
}
|
||||
});
|
||||
},
|
||||
async remove() {
|
||||
await perform(id, async () => {
|
||||
assertHost(id);
|
||||
if (await options.isManagedLocalHost(id))
|
||||
throw new Error("Cannot remove the desktop daemon host");
|
||||
assertHost(id);
|
||||
await source.removeHost(id);
|
||||
});
|
||||
},
|
||||
async removeConnection(connectionId) {
|
||||
await perform(id, async () => {
|
||||
assertHost(id);
|
||||
const isManaged = await options.isManagedLocalHost(id);
|
||||
assertHost(id);
|
||||
const host = source.getHosts().find((candidate) => candidate.serverId === id)!;
|
||||
if (
|
||||
isManaged &&
|
||||
host.connections.length === 1 &&
|
||||
host.connections[0].id === connectionId
|
||||
) {
|
||||
throw new Error("Cannot remove the desktop daemon host");
|
||||
}
|
||||
await source.removeConnection(id, connectionId);
|
||||
});
|
||||
},
|
||||
openSettings() {
|
||||
assertHost(id);
|
||||
options.openSettings(id);
|
||||
},
|
||||
};
|
||||
entry = { data, handle };
|
||||
entries.set(id, entry);
|
||||
} else entry.data = data;
|
||||
return entry.handle;
|
||||
});
|
||||
return { hosts, key: JSON.stringify(projected) };
|
||||
}
|
||||
function resolve(serverId: string): DaemonClient {
|
||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||
@@ -49,11 +306,16 @@ export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal
|
||||
}
|
||||
}
|
||||
const next = readHosts();
|
||||
if (JSON.stringify(snapshot) === JSON.stringify(next)) return;
|
||||
snapshot = next;
|
||||
if (serialized === next.key) return;
|
||||
serialized = next.key;
|
||||
snapshot = next.hosts;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
snapshot = readHosts();
|
||||
{
|
||||
const initial = readHosts();
|
||||
snapshot = initial.hosts;
|
||||
serialized = initial.key;
|
||||
}
|
||||
const unsubscribeRuntime = source.subscribeAll(refresh);
|
||||
const unsubscribeHosts = source.subscribeHostList(refresh);
|
||||
function stop() {
|
||||
@@ -67,6 +329,46 @@ export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal
|
||||
else signal.addEventListener("abort", stop, { once: true });
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
async addHost(input: PluginHostAddInput): Promise<PluginHostAddResult> {
|
||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||
let result: Awaited<ReturnType<PluginHostsSource["addHost"]>>;
|
||||
try {
|
||||
result = await source.addHost(input, signal);
|
||||
} catch (error) {
|
||||
let secret: string | undefined;
|
||||
try {
|
||||
secret = parseHostTarget(input.connection).password;
|
||||
} catch {
|
||||
/* Invalid input has no parsed password. */
|
||||
}
|
||||
const message = redactSecret(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
secret,
|
||||
);
|
||||
// The original error can contain the supplied credential.
|
||||
// oxlint-disable-next-line preserve-caught-error
|
||||
throw new Error(message);
|
||||
}
|
||||
if (result.status !== "saved") return result;
|
||||
const host = snapshot.find((item) => item.serverId === result.serverId);
|
||||
if (!host) throw new Error(`Unknown Paseo host: ${result.serverId}`);
|
||||
return { status: "saved", host };
|
||||
},
|
||||
observeHosts(listener: (hosts: readonly PluginHost[]) => void) {
|
||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||
const notify = () => {
|
||||
try {
|
||||
listener(snapshot);
|
||||
} catch (error) {
|
||||
console.error("[Plugins] Host observer failed", error);
|
||||
}
|
||||
};
|
||||
listeners.add(notify);
|
||||
notify();
|
||||
return () => {
|
||||
listeners.delete(notify);
|
||||
};
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
if (signal.aborted) return () => {};
|
||||
listeners.add(listener);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { buildPluginSettingsRoute } from "./settings/routes";
|
||||
import { router } from "expo-router";
|
||||
import type { PluginPanelLocation } from "@getpaseo/plugin/client";
|
||||
@@ -9,7 +10,7 @@ import type { PluginNavigation } from "./actions";
|
||||
export function createPluginNavigation(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | null;
|
||||
}): PluginNavigation {
|
||||
}): PluginNavigation & { openHostSettings(serverId: string): void } {
|
||||
const { serverId, workspaceId } = input;
|
||||
function placement(location: PluginPanelLocation) {
|
||||
if (location !== "explorer") return undefined;
|
||||
@@ -20,6 +21,9 @@ export function createPluginNavigation(input: {
|
||||
return { mode: "pane" as const, paneId };
|
||||
}
|
||||
return {
|
||||
openHostSettings(hostId) {
|
||||
router.push(buildSettingsHostRoute(hostId));
|
||||
},
|
||||
openSettings(pluginId, screenId) {
|
||||
router.push(buildPluginSettingsRoute(serverId, pluginId, screenId));
|
||||
},
|
||||
|
||||
@@ -43,12 +43,19 @@ function registry(
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
addHost: async () => ({ status: "cancelled" }),
|
||||
getHosts: () => [],
|
||||
getSnapshot: () => null,
|
||||
subscribeAll: () => () => {},
|
||||
subscribeHostList: () => () => {},
|
||||
renameHost: async () => {},
|
||||
setHostColor: async () => {},
|
||||
setHostBadgeDisplay: async () => {},
|
||||
removeHost: async () => {},
|
||||
removeConnection: async () => {},
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
{ openSettings() {}, isManagedLocalHost: async () => false },
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
|
||||
@@ -18,12 +18,19 @@ function registry(version: string) {
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
addHost: async () => ({ status: "cancelled" }),
|
||||
getHosts: () => [],
|
||||
getSnapshot: () => null,
|
||||
subscribeAll: () => () => {},
|
||||
subscribeHostList: () => () => {},
|
||||
renameHost: async () => {},
|
||||
setHostColor: async () => {},
|
||||
setHostBadgeDisplay: async () => {},
|
||||
removeHost: async () => {},
|
||||
removeConnection: async () => {},
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
{ openSettings() {}, isManagedLocalHost: async () => false },
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
|
||||
@@ -14,45 +14,6 @@ function thought(text: string, status: "loading" | "ready" = "loading"): StreamI
|
||||
}
|
||||
|
||||
describe("plugin timeline projection", () => {
|
||||
it.each(["message", "finished", "errored", "permission-required", "closed"] as const)(
|
||||
"preserves immutable agent-message provenance for %s cards",
|
||||
(event) => {
|
||||
const agentMessage = {
|
||||
event,
|
||||
sender: { id: "remote::reviewer", title: "QA question-timing #7014/#7000/#6809" },
|
||||
text: "The original message, including **Markdown**.",
|
||||
};
|
||||
const source: StreamItem = {
|
||||
kind: "tool_call",
|
||||
id: "message-1",
|
||||
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "codex",
|
||||
callId: "delivery-1",
|
||||
name: "agent_message",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text: agentMessage.text, icon: "bot" },
|
||||
agentMessage,
|
||||
},
|
||||
},
|
||||
};
|
||||
const inspect: TimelineItemTransform = ({ item }) => {
|
||||
expect(item.type).toBe("tool_call");
|
||||
if (item.type !== "tool_call") throw new Error("Expected a tool call");
|
||||
expect(item.agentMessage).toEqual(agentMessage);
|
||||
expect(item.agentMessage).not.toBe(agentMessage);
|
||||
expect(Object.isFrozen(item.agentMessage)).toBe(true);
|
||||
expect(Object.isFrozen(item.agentMessage?.sender)).toBe(true);
|
||||
return undefined;
|
||||
};
|
||||
expect(projectPluginTimelineItems([source], inspect)).toEqual([source]);
|
||||
expect(Object.isFrozen(agentMessage.sender)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not expose mutable tool detail from stream state", () => {
|
||||
const detail = { type: "read" as const, filePath: "/repo/original.ts" };
|
||||
const source: StreamItem = {
|
||||
|
||||
@@ -22,14 +22,13 @@ function cloneAndFreeze<T>(value: T): T {
|
||||
}
|
||||
|
||||
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
|
||||
const { callId, name, status, error, detail, metadata, agentMessage } = data;
|
||||
const { callId, name, status, error, detail, metadata } = data;
|
||||
const base = {
|
||||
type: "tool_call" as const,
|
||||
callId,
|
||||
name,
|
||||
detail,
|
||||
...(metadata ? { metadata } : {}),
|
||||
...(agentMessage ? { agentMessage } : {}),
|
||||
};
|
||||
switch (status) {
|
||||
case "running":
|
||||
|
||||
@@ -55,24 +55,38 @@ export class HostConfirmations {
|
||||
}
|
||||
|
||||
/** Resolves true without asking when the link matches a saved host exactly. */
|
||||
confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
|
||||
confirmLink(
|
||||
offer: ConnectionOffer,
|
||||
savedHosts: HostProfile[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
|
||||
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
|
||||
return this.ask({
|
||||
id: this.nextRequestId++,
|
||||
serverId: offer.serverId,
|
||||
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
kind: savedHost ? "changedConnection" : "newHost",
|
||||
});
|
||||
if (signal?.aborted) return Promise.resolve(false);
|
||||
return this.ask(
|
||||
{
|
||||
id: this.nextRequestId++,
|
||||
serverId: offer.serverId,
|
||||
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
kind: savedHost ? "changedConnection" : "newHost",
|
||||
},
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
private ask(request: HostConfirmationRequest): Promise<boolean> {
|
||||
private ask(request: HostConfirmationRequest, signal?: AbortSignal): Promise<boolean> {
|
||||
// One question at a time: a newer one cancels the unanswered one.
|
||||
this.pending?.resolve(false);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
this.pending = { request, resolve };
|
||||
const cancel = () => this.answer(request.id, false);
|
||||
const finish = (approved: boolean) => {
|
||||
signal?.removeEventListener("abort", cancel);
|
||||
resolve(approved);
|
||||
};
|
||||
this.pending = { request, resolve: finish };
|
||||
signal?.addEventListener("abort", cancel, { once: true });
|
||||
this.emit();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
import { defaultHostAppearance, selectHostBadges } from "@/hosts/appearance";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
||||
@@ -33,6 +33,7 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
||||
import { subscriptionFixture } from "./subscription-fixture";
|
||||
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
||||
import type { HostConfirmationRequest } from "./host-confirmation";
|
||||
import { createPluginHosts } from "@/plugins/hosts";
|
||||
|
||||
it("requests the managed connection credential through desktop main without a web hint", async () => {
|
||||
const requests: string[] = [];
|
||||
@@ -4778,3 +4779,295 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding hosts from plugin connection targets", () => {
|
||||
it("does not save an unauthenticated target and retries with the password", async () => {
|
||||
const store = createPairingStore({ password: "secret", serverIdForHost: () => "srv_direct" });
|
||||
const requests = answerHostConfirmations(store, true);
|
||||
expect(await store.addHost({ connection: "example.com:6767" })).toEqual({
|
||||
status: "password_required",
|
||||
reason: "password_required",
|
||||
});
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
expect(
|
||||
await store.addHost({ connection: "tcp://example.com:6767?password=secret", label: "Build" }),
|
||||
).toEqual({ status: "saved", serverId: "srv_direct" });
|
||||
expect(store.getHosts()[0]).toMatchObject({
|
||||
label: "Build",
|
||||
password: "secret",
|
||||
connections: [{ type: "directTcp", endpoint: "example.com:6767" }],
|
||||
});
|
||||
expect(requests).toHaveLength(0);
|
||||
await waitForHostOnline(store, "srv_direct");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("persists the supplied password and reconnects after the app restarts", async () => {
|
||||
const storage = createMemoryHostRuntimeStorage({ "@paseo:e2e": "1" });
|
||||
const first = createPairingStore({
|
||||
storage,
|
||||
password: "secret",
|
||||
serverIdForHost: () => "srv_direct",
|
||||
});
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(first, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const result = await plugin.addHost({ connection: "tcp://example.com:6767?password=secret" });
|
||||
expect(result.status).toBe("saved");
|
||||
expect(JSON.stringify(result)).not.toContain("secret");
|
||||
const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]");
|
||||
expect(persisted[0].password).toBe("secret");
|
||||
lifetime.abort();
|
||||
first.syncHosts([]);
|
||||
|
||||
const attempts: string[] = [];
|
||||
const restarted = createPairingStore({
|
||||
storage,
|
||||
password: "secret",
|
||||
connectAttempts: attempts,
|
||||
serverIdForHost: () => "srv_direct",
|
||||
});
|
||||
await restarted.boot();
|
||||
await waitForHostOnline(restarted, "srv_direct");
|
||||
expect(attempts).toContain("direct:example.com:6767");
|
||||
expect(restarted.getHosts()[0].password).toBe("secret");
|
||||
const restartedLifetime = new AbortController();
|
||||
const restartedPlugin = createPluginHosts(restarted, restartedLifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
expect(JSON.stringify(restartedPlugin.getSnapshot())).not.toContain("secret");
|
||||
restartedLifetime.abort();
|
||||
restarted.syncHosts([]);
|
||||
});
|
||||
|
||||
it("asks before saving a relay target and respects rejection", async () => {
|
||||
const uri = "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true";
|
||||
const rejected = createPairingStore();
|
||||
const requests = answerHostConfirmations(rejected, false);
|
||||
expect(await rejected.addHost({ connection: uri })).toEqual({ status: "cancelled" });
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(rejected.getHosts()).toHaveLength(0);
|
||||
const accepted = createPairingStore();
|
||||
const acceptedRequests = answerHostConfirmations(accepted, true);
|
||||
expect(await accepted.addHost({ connection: uri, label: "Relay" })).toEqual({
|
||||
status: "saved",
|
||||
serverId: "srv_offer",
|
||||
});
|
||||
expect(acceptedRequests).toHaveLength(1);
|
||||
expect(accepted.getHosts()[0]).toMatchObject({
|
||||
label: "Relay",
|
||||
connections: [{ type: "relay" }],
|
||||
});
|
||||
accepted.syncHosts([]);
|
||||
});
|
||||
it.each([
|
||||
["ssh://deploy@build:2222?daemonPort=7777", "remoteSsh"],
|
||||
["unix:///tmp/paseo.sock", "directSocket"],
|
||||
["pipe://mypipe", "directPipe"],
|
||||
["wss://example.com/ws", "directTcp"],
|
||||
])("probes and saves %s", async (connection, type) => {
|
||||
const store = createPairingStore({ serverIdForHost: () => "srv_target" });
|
||||
expect(await store.addHost({ connection })).toEqual({
|
||||
status: "saved",
|
||||
serverId: "srv_target",
|
||||
});
|
||||
expect(store.getHosts()[0].connections[0].type).toBe(type);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("shares host changes between plugin handles, host settings, and sidebar badges", async () => {
|
||||
const store = createPairingStore({
|
||||
serverIdForHost: (host) => {
|
||||
const connection = host.connections[0];
|
||||
return connection?.type === "directTcp" ? connection.endpoint : "";
|
||||
},
|
||||
});
|
||||
await store.addHost({ connection: "first:6767", label: "First" });
|
||||
await store.addHost({ connection: "second:6767", label: "Second" });
|
||||
await waitForHostOnline(store, "first:6767");
|
||||
await waitForHostOnline(store, "second:6767");
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
expect(
|
||||
plugin.getSnapshot().map((host) => [host.label, host.status, host.connections[0].uri]),
|
||||
).toEqual([
|
||||
["First", "online", "tcp://first:6767"],
|
||||
["Second", "online", "tcp://second:6767"],
|
||||
]);
|
||||
await plugin.getSnapshot()[0].update({ label: "Build", appearance: { color: "sky" } });
|
||||
expect(store.getHosts()[0]).toMatchObject({ label: "Build", appearance: { color: "sky" } });
|
||||
expect(
|
||||
selectHostBadges({ hosts: store.getHosts(), localServerId: null, enabled: true }).get(
|
||||
"first:6767",
|
||||
),
|
||||
).toMatchObject({ label: "Build", color: "sky" });
|
||||
lifetime.abort();
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"tcp://example.com:6767?ssl=true",
|
||||
"ssh://deploy@build:2222?daemonPort=7777",
|
||||
"unix:///tmp/paseo.sock",
|
||||
"relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
|
||||
])("recreates the projected connection on another device: %s", async (connection) => {
|
||||
const first = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
|
||||
answerHostConfirmations(first, true);
|
||||
const result = await first.addHost({ connection });
|
||||
if (result.status !== "saved") throw new Error("expected saved host");
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(first, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const uri = plugin.getSnapshot().find((host) => host.serverId === result.serverId)
|
||||
?.connections[0].uri;
|
||||
if (!uri) throw new Error("expected connection URI");
|
||||
const second = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
|
||||
answerHostConfirmations(second, true);
|
||||
expect(await second.addHost({ connection: uri })).toEqual({
|
||||
status: "saved",
|
||||
serverId: result.serverId,
|
||||
});
|
||||
expect(second.getHosts()[0].connections).toEqual(first.getHosts()[0].connections);
|
||||
lifetime.abort();
|
||||
first.syncHosts([]);
|
||||
second.syncHosts([]);
|
||||
});
|
||||
|
||||
it("does not save an in-flight addition after its plugin stops", async () => {
|
||||
const started = createDeferred<void>();
|
||||
const probe = createDeferred<{
|
||||
client: DaemonClient;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
}>();
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
started.resolve(undefined);
|
||||
return probe.promise;
|
||||
},
|
||||
getClientId: async () => "test",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
});
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const adding = plugin.addHost({ connection: "example.com:6767" });
|
||||
await started.promise;
|
||||
lifetime.abort();
|
||||
probe.resolve({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: "srv",
|
||||
hostname: null,
|
||||
});
|
||||
await expect(adding).rejects.toThrow("Plugin has stopped");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("reports a stopped plugin when an aborted probe rejects authentication", async () => {
|
||||
const started = createDeferred<void>();
|
||||
const probe = createDeferred<{
|
||||
client: DaemonClient;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
}>();
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
started.resolve(undefined);
|
||||
return probe.promise;
|
||||
},
|
||||
getClientId: async () => "test",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
});
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const adding = plugin.addHost({ connection: "example.com:6767" });
|
||||
await started.promise;
|
||||
lifetime.abort();
|
||||
probe.reject(new DaemonAuthenticationError("password_required"));
|
||||
await expect(adding).rejects.toThrow("Plugin has stopped");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("dismisses an unanswered confirmation when the plugin stops", async () => {
|
||||
const store = createPairingStore();
|
||||
const lifetime = new AbortController();
|
||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
||||
openSettings() {},
|
||||
isManagedLocalHost: async () => false,
|
||||
});
|
||||
const pending = createDeferred<void>();
|
||||
const unsubscribe = store.subscribeHostConfirmation(() => {
|
||||
if (store.getPendingHostConfirmation()) pending.resolve(undefined);
|
||||
});
|
||||
const adding = plugin.addHost({
|
||||
connection: "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
|
||||
});
|
||||
await pending.promise;
|
||||
lifetime.abort();
|
||||
await expect(adding).rejects.toThrow("Plugin has stopped");
|
||||
expect(store.getPendingHostConfirmation()).toBeNull();
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
unsubscribe();
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("returns incorrect_password without saving and redacts failed probe errors", async () => {
|
||||
let failure: "auth" | "network" = "auth";
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async () => {
|
||||
if (failure === "auth") throw new DaemonAuthenticationError("incorrect_password");
|
||||
throw new Error("connection failed for secret");
|
||||
},
|
||||
getClientId: async () => "test",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
});
|
||||
expect(await store.addHost({ connection: "tcp://example.com:6767?password=secret" })).toEqual({
|
||||
status: "password_required",
|
||||
reason: "incorrect_password",
|
||||
});
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
failure = "network";
|
||||
await expect(
|
||||
store.addHost({ connection: "tcp://example.com:6767?password=secret" }),
|
||||
).rejects.not.toThrow("secret");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("rejects websocket paths and query options the app cannot represent", async () => {
|
||||
const store = createPairingStore();
|
||||
await expect(store.addHost({ connection: "wss://example.com/custom" })).rejects.toThrow(
|
||||
"unsupported",
|
||||
);
|
||||
await expect(
|
||||
store.addHost({ connection: "wss://example.com/ws?token=secret" }),
|
||||
).rejects.toThrow("unsupported");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { watchdog } from "@/watchdog";
|
||||
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
|
||||
import { useSyncExternalStore, useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
@@ -1612,6 +1613,48 @@ interface AgentDirectoryRefreshInput {
|
||||
page?: FetchAgentsOptions["page"];
|
||||
}
|
||||
|
||||
function directHostTarget(target: HostTarget, input: string): HostConnection {
|
||||
let connection: HostConnection;
|
||||
if (target.kind === "ssh") {
|
||||
connection = createRemoteSshHostConnection(target.ssh);
|
||||
} else {
|
||||
if (/^wss?:\/\//.test(input.trim())) {
|
||||
const url = new URL(target.url);
|
||||
if (url.pathname !== "/ws" || url.search)
|
||||
throw new Error("WebSocket path or query is unsupported by the app");
|
||||
}
|
||||
switch (target.connection.type) {
|
||||
case "directTcp": {
|
||||
const endpoint = normalizeHostPort(target.connection.endpoint);
|
||||
connection = {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp",
|
||||
endpoint,
|
||||
useTls: target.connection.useTls,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "directSocket":
|
||||
connection = {
|
||||
id: `socket:${target.connection.path}`,
|
||||
type: "directSocket",
|
||||
path: target.connection.path,
|
||||
};
|
||||
break;
|
||||
case "directPipe":
|
||||
connection = {
|
||||
id: `pipe:${target.connection.path}`,
|
||||
type: "directPipe",
|
||||
path: target.connection.path,
|
||||
};
|
||||
break;
|
||||
case "relay":
|
||||
throw new Error("Unexpected relay target");
|
||||
}
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export class HostRuntimeStore {
|
||||
private controllers = new Map<string, HostRuntimeController>();
|
||||
private hostObservations = new Map<
|
||||
@@ -1964,6 +2007,66 @@ export class HostRuntimeStore {
|
||||
);
|
||||
}
|
||||
|
||||
/** Add a saved host using the shared connection grammar. */
|
||||
async addHost(
|
||||
input: {
|
||||
connection: string;
|
||||
label?: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
| { status: "saved"; serverId: string }
|
||||
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
|
||||
| { status: "cancelled" }
|
||||
> {
|
||||
const target = parseHostTarget(input.connection);
|
||||
await this.loadRegistry();
|
||||
try {
|
||||
if (signal?.aborted) throw new Error("Plugin has stopped");
|
||||
if (target.kind === "socket" && target.connection.type === "relay") {
|
||||
const approved = await this.confirmLink(target.connection.offer, signal);
|
||||
if (signal?.aborted) throw new Error("Plugin has stopped");
|
||||
if (!approved) return { status: "cancelled" };
|
||||
const result = await this.probeAndUpsertOffer(
|
||||
target.connection.offer,
|
||||
target.password,
|
||||
input.label,
|
||||
signal,
|
||||
);
|
||||
return { status: "saved", serverId: result.serverId };
|
||||
}
|
||||
const connection = directHostTarget(target, input.connection);
|
||||
const result = await this.probeAndUpsertConnection({
|
||||
connection,
|
||||
password: target.password,
|
||||
label: input.label,
|
||||
signal,
|
||||
});
|
||||
return { status: "saved", serverId: result.serverId };
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
// The failed probe can include a credential; do not attach it as a cause.
|
||||
// oxlint-disable-next-line preserve-caught-error
|
||||
throw new Error("Plugin has stopped");
|
||||
}
|
||||
const reason = getConnectionAuthFailureReason(error);
|
||||
if (reason) return { status: "password_required", reason };
|
||||
let message = error instanceof Error ? error.message : String(error);
|
||||
if (target.password) {
|
||||
for (const value of [
|
||||
target.password,
|
||||
encodeURIComponent(target.password),
|
||||
new URLSearchParams({ password: target.password }).toString().slice(9),
|
||||
]) {
|
||||
if (value) message = message.split(value).join("[redacted]");
|
||||
}
|
||||
}
|
||||
// The original error can contain a credential; do not expose it as a cause.
|
||||
// oxlint-disable-next-line preserve-caught-error
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async upsertDirectConnection(input: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
@@ -1993,6 +2096,7 @@ export class HostRuntimeStore {
|
||||
password?: string;
|
||||
label?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
if (input.connection.type === "relay") {
|
||||
throw new Error("Cannot probe a relay connection without a server id.");
|
||||
@@ -2013,6 +2117,10 @@ export class HostRuntimeStore {
|
||||
connection: input.connection,
|
||||
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
||||
});
|
||||
if (input.signal?.aborted) {
|
||||
await client.close().catch(() => undefined);
|
||||
throw new Error("Plugin has stopped");
|
||||
}
|
||||
const profile = await this.upsertHostConnection({
|
||||
serverId,
|
||||
label: input.label ?? hostname ?? undefined,
|
||||
@@ -2062,9 +2170,9 @@ export class HostRuntimeStore {
|
||||
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
|
||||
// confirm when it adds a new host or changes a saved host's key, relay, or
|
||||
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
|
||||
private async confirmLink(offer: ConnectionOffer): Promise<boolean> {
|
||||
private async confirmLink(offer: ConnectionOffer, signal?: AbortSignal): Promise<boolean> {
|
||||
await this.loadRegistry();
|
||||
return this.hostConfirmations.confirmLink(offer, this.hosts);
|
||||
return this.hostConfirmations.confirmLink(offer, this.hosts, signal);
|
||||
}
|
||||
|
||||
getPendingHostConfirmation(): HostConfirmationRequest | null {
|
||||
@@ -2144,6 +2252,8 @@ export class HostRuntimeStore {
|
||||
private async probeAndUpsertOffer(
|
||||
offer: ConnectionOffer,
|
||||
password: string | undefined,
|
||||
label?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
const probeHost: HostProfile = {
|
||||
@@ -2159,7 +2269,12 @@ export class HostRuntimeStore {
|
||||
};
|
||||
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
|
||||
await client.close().catch(() => undefined);
|
||||
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password);
|
||||
if (signal?.aborted) throw new Error("Plugin has stopped");
|
||||
const profile = await this.upsertConnectionFromOffer(
|
||||
offer,
|
||||
label ?? hostname ?? undefined,
|
||||
password,
|
||||
);
|
||||
return { profile, serverId: offer.serverId, hostname };
|
||||
}
|
||||
|
||||
@@ -2221,7 +2336,10 @@ export class HostRuntimeStore {
|
||||
}));
|
||||
}
|
||||
|
||||
async setHostBadgeDisplay(serverId: string, badgeDisplay: HostBadgeDisplay): Promise<void> {
|
||||
async setHostBadgeDisplay(
|
||||
serverId: string,
|
||||
badgeDisplay: HostBadgeDisplay | null,
|
||||
): Promise<void> {
|
||||
await this.updateHostAppearance(serverId, (host) => ({
|
||||
...host,
|
||||
appearance: { ...host.appearance, badgeDisplay },
|
||||
@@ -3034,7 +3152,7 @@ export interface HostMutations {
|
||||
beginLinkPairing: () => LinkPairing;
|
||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
|
||||
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
|
||||
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay | null) => Promise<void>;
|
||||
removeHost: (serverId: string) => Promise<void>;
|
||||
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import type {
|
||||
CreateAgentRequestOptions,
|
||||
CreateWorkspaceRequestOptions,
|
||||
@@ -9,6 +8,7 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { router } from "expo-router";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
@@ -62,6 +62,7 @@ import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
getHostProjectId,
|
||||
getWorktreeSupportForHostProject,
|
||||
@@ -1165,7 +1166,9 @@ export function NewWorkspaceScreen({
|
||||
openAddProjectPicker(selectedServerId);
|
||||
}, [openAddProjectPicker, selectedServerId]);
|
||||
|
||||
const handleAddHost = useAddHost();
|
||||
const handleAddHost = useCallback(() => {
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
}, []);
|
||||
|
||||
const openPicker = useCallback(() => {
|
||||
setPickerOpen(true);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -75,6 +74,10 @@ import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { PageLayout } from "@/components/page-layout";
|
||||
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";
|
||||
import { FilesSection } from "@/screens/settings/files-section";
|
||||
@@ -1086,15 +1089,21 @@ function SettingsSidebar({
|
||||
|
||||
export interface SettingsScreenProps {
|
||||
view: SettingsView;
|
||||
openAddHostIntent?: string | null;
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
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);
|
||||
const lastOpenedAddHostIntentRef = useRef<string | null>(null);
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const appVersion = resolveAppVersion();
|
||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||
@@ -1155,10 +1164,50 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
}
|
||||
}, [isPlaybackTestRunning, t, voiceAudioEngine]);
|
||||
|
||||
const closeAddConnectionFlow = useCallback(() => {
|
||||
setIsAddHostMethodVisible(false);
|
||||
setIsDirectHostVisible(false);
|
||||
setIsRemoteSshVisible(false);
|
||||
setIsPasteLinkVisible(false);
|
||||
}, []);
|
||||
|
||||
const goBackToAddConnectionMethods = useCallback(() => {
|
||||
setIsDirectHostVisible(false);
|
||||
setIsRemoteSshVisible(false);
|
||||
setIsPasteLinkVisible(false);
|
||||
setIsAddHostMethodVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleAddHost = useCallback(() => {
|
||||
setIsAddHostMethodVisible(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openAddHostIntent || lastOpenedAddHostIntentRef.current === openAddHostIntent) {
|
||||
return;
|
||||
}
|
||||
lastOpenedAddHostIntentRef.current = openAddHostIntent;
|
||||
handleAddHost();
|
||||
}, [handleAddHost, openAddHostIntent]);
|
||||
|
||||
const handleSelectDirectConnection = useCallback(() => {
|
||||
setIsAddHostMethodVisible(false);
|
||||
setIsDirectHostVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleSelectRemoteSsh = useCallback(() => {
|
||||
setIsAddHostMethodVisible(false);
|
||||
setIsRemoteSshVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleSelectPasteLink = useCallback(() => {
|
||||
setIsAddHostMethodVisible(false);
|
||||
setIsPasteLinkVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleHostAdded = useCallback(
|
||||
(serverId: string) => {
|
||||
selectSettingsHost(serverId);
|
||||
const target = buildSettingsHostSectionRoute(serverId, "host");
|
||||
({ serverId }: { serverId: string }) => {
|
||||
const target = buildSettingsHostSectionRoute(serverId, "connections");
|
||||
if (isCompactLayout) {
|
||||
router.push(target);
|
||||
} else {
|
||||
@@ -1168,8 +1217,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
[isCompactLayout, router],
|
||||
);
|
||||
|
||||
const handleAddHost = useAddHost({ onAdded: handleHostAdded });
|
||||
|
||||
const handleSelectSection = useCallback(
|
||||
(section: SettingsSectionSlug) => {
|
||||
const target = buildSettingsSectionRoute(section);
|
||||
@@ -1225,6 +1272,14 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
[activeHostServerId, handleAddHost, isCompactLayout, router],
|
||||
);
|
||||
|
||||
const handleScanQr = useCallback(() => {
|
||||
closeAddConnectionFlow();
|
||||
router.push({
|
||||
pathname: "/pair-scan",
|
||||
params: { source: "settings" },
|
||||
});
|
||||
}, [closeAddConnectionFlow, router]);
|
||||
|
||||
const handleHostRemoved = useCallback(() => {
|
||||
const fallback = buildSettingsSectionRoute("general");
|
||||
if (isCompactLayout) {
|
||||
@@ -1333,6 +1388,37 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const addHostModals = (
|
||||
<>
|
||||
<AddHostMethodModal
|
||||
visible={isAddHostMethodVisible}
|
||||
onClose={closeAddConnectionFlow}
|
||||
onDirectConnection={handleSelectDirectConnection}
|
||||
onRemoteSsh={handleSelectRemoteSsh}
|
||||
onPasteLink={handleSelectPasteLink}
|
||||
onScanQr={handleScanQr}
|
||||
/>
|
||||
<AddHostModal
|
||||
visible={isDirectHostVisible}
|
||||
onClose={closeAddConnectionFlow}
|
||||
onCancel={goBackToAddConnectionMethods}
|
||||
onSaved={handleHostAdded}
|
||||
/>
|
||||
<AddRemoteSshHostModal
|
||||
visible={isRemoteSshVisible}
|
||||
onClose={closeAddConnectionFlow}
|
||||
onCancel={goBackToAddConnectionMethods}
|
||||
onSaved={handleHostAdded}
|
||||
/>
|
||||
<PairLinkModal
|
||||
visible={isPasteLinkVisible}
|
||||
onClose={closeAddConnectionFlow}
|
||||
onCancel={goBackToAddConnectionMethods}
|
||||
onSaved={handleHostAdded}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
// Mobile root: full-screen sidebar-as-list.
|
||||
if (isCompactLayout && view.kind === "root") {
|
||||
return (
|
||||
@@ -1350,6 +1436,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
layout="mobile"
|
||||
/>
|
||||
</ScrollView>
|
||||
{addHostModals}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1360,6 +1447,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
|
||||
{content}
|
||||
</PageLayout>
|
||||
{addHostModals}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1394,6 +1482,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
</View>
|
||||
</WindowChromeRegion>
|
||||
</View>
|
||||
{addHostModals}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildOpenProjectRoute,
|
||||
resolveKnownHostRoute,
|
||||
buildSessionsRoute,
|
||||
buildSettingsAddHostRoute,
|
||||
buildProjectSettingsRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
decodeFilePathFromPathSegment,
|
||||
@@ -184,6 +185,14 @@ describe("workspace route parsing", () => {
|
||||
});
|
||||
|
||||
describe("projects settings routes", () => {
|
||||
it("buildSettingsAddHostRoute opens settings with the add-host flag", () => {
|
||||
expect(buildSettingsAddHostRoute()).toBe("/settings/general?addHost=1");
|
||||
});
|
||||
|
||||
it("buildSettingsAddHostRoute accepts a repeatable intent id", () => {
|
||||
expect(buildSettingsAddHostRoute("retry 1")).toBe("/settings/general?addHost=retry%201");
|
||||
});
|
||||
|
||||
it("buildProjectsSettingsRoute scopes the list to a host", () => {
|
||||
expect(buildProjectsSettingsRoute("host a")).toBe("/settings/hosts/host%20a/projects");
|
||||
});
|
||||
|
||||
@@ -553,6 +553,10 @@ export function buildSettingsSectionRoute(section: SettingsSectionSlug) {
|
||||
return `/settings/${section}` as const;
|
||||
}
|
||||
|
||||
export function buildSettingsAddHostRoute(intentId: string | number = "1") {
|
||||
return `/settings/general?addHost=${encodeURIComponent(String(intentId))}` as const;
|
||||
}
|
||||
|
||||
export function buildSettingsHostRoute(serverId: string) {
|
||||
const normalized = trimNonEmpty(serverId);
|
||||
if (!normalized) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { fileURLToPath } from "node:url";
|
||||
import { experimental_createMCPClient } from "ai";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { chromium } from "playwright";
|
||||
import { expect } from "@playwright/test";
|
||||
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
||||
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
||||
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
|
||||
@@ -199,20 +198,6 @@ async function waitForDesktopStatus(page) {
|
||||
);
|
||||
}
|
||||
|
||||
async function settleDesktopStartup(page) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Desktop browser workspace 1", exact: true }),
|
||||
).toBeVisible({ timeout: timeoutMs });
|
||||
// The dev launcher supplies its package directory as an open-project request.
|
||||
// Before the local host connects, that request opens the connection prompt.
|
||||
// Finish startup before exercising the unrelated Settings/browser workflows.
|
||||
const connectionPrompt = page.getByTestId("add-host-method-modal");
|
||||
if (await connectionPrompt.isVisible()) {
|
||||
await connectionPrompt.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(connectionPrompt).toBeHidden();
|
||||
}
|
||||
}
|
||||
|
||||
async function startTargetPage() {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
@@ -1223,7 +1208,6 @@ async function main() {
|
||||
const page = await waitForAppPage(browser, expoPort);
|
||||
await hideDeveloperToasts(page);
|
||||
const status = await waitForDesktopStatus(page);
|
||||
await settleDesktopStartup(page);
|
||||
|
||||
const checkPluginLinks = () =>
|
||||
runPluginLinksRegression({
|
||||
|
||||
@@ -152,6 +152,12 @@ describe("daemon-manager commands", () => {
|
||||
expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the desktop daemon identity after it has stopped", async () => {
|
||||
mkdirSync(mocks.paseoHome);
|
||||
writeFileSync(path.join(mocks.paseoHome, "server-id"), "srv_existing\n");
|
||||
expect(await createDaemonCommandHandlers().desktop_daemon_identity()).toBe("srv_existing");
|
||||
});
|
||||
|
||||
it("reports an errored daemon when the local daemon state cannot be read", async () => {
|
||||
mkdirSync(mocks.paseoHome);
|
||||
writeFileSync(path.join(mocks.paseoHome, "paseo.pid"), "garbage");
|
||||
|
||||
@@ -476,6 +476,14 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
runningUnderARM64Translation: isRunningUnderARM64Translation(),
|
||||
}),
|
||||
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
|
||||
desktop_daemon_identity: () => {
|
||||
try {
|
||||
return readFileSync(path.join(getPaseoHome(), "server-id"), "utf8").trim() || null;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
desktop_local_credential: async (args) => {
|
||||
const instance = await readDaemonInstance(getPaseoHome());
|
||||
if (!instance?.desktopManaged || typeof args?.listen !== "string") return null;
|
||||
|
||||
@@ -92,6 +92,18 @@ function typeErrors(code: string): string[] {
|
||||
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
|
||||
}
|
||||
describe("plugin SDK import boundaries", () => {
|
||||
it("exposes host handles and object-shaped additions from the client entry", () => {
|
||||
expect(
|
||||
typeErrors(`
|
||||
import { addHost, observeHosts, useHosts, type PluginHost } from "@getpaseo/plugin/client";
|
||||
const hosts: readonly PluginHost[] = useHosts();
|
||||
const stop = observeHosts((value) => { void value[0]?.openSettings(); });
|
||||
void addHost({ connection: "localhost:6767", label: "Local" });
|
||||
void hosts; stop();
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes process capabilities without their Windows implementation helpers", () => {
|
||||
expect(Object.keys(server).sort()).toEqual([
|
||||
"defineAgentTool",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { PluginHostSummary } from "./index.js";
|
||||
|
||||
export type PluginHostStatus = PluginHostSummary["status"];
|
||||
export type PluginHostColor =
|
||||
| "none"
|
||||
| "violet"
|
||||
| "sky"
|
||||
| "emerald"
|
||||
| "orange"
|
||||
| "pink"
|
||||
| "indigo"
|
||||
| "teal"
|
||||
| "red"
|
||||
| "amber"
|
||||
| "blue";
|
||||
export type PluginHostBadgeDisplay = "name" | "icon" | "hidden";
|
||||
export interface PluginHostAppearance {
|
||||
readonly color: PluginHostColor;
|
||||
/** Null uses the automatic local or remote default. */
|
||||
readonly badgeDisplay: PluginHostBadgeDisplay | null;
|
||||
}
|
||||
/** Saved connection settings without credentials. Routed connections have no URI. */
|
||||
export type PluginHostConnection =
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "directTcp";
|
||||
readonly uri: string;
|
||||
readonly endpoint: string;
|
||||
readonly useTls: boolean;
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "directSocket";
|
||||
readonly uri: string;
|
||||
readonly path: string;
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "directPipe";
|
||||
readonly uri: string;
|
||||
readonly path: string;
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "remoteSsh";
|
||||
readonly uri: string;
|
||||
readonly host: string;
|
||||
readonly sshPort?: number;
|
||||
readonly daemonPort?: number;
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "relay";
|
||||
readonly uri: string;
|
||||
readonly relayEndpoint: string;
|
||||
readonly useTls: boolean;
|
||||
readonly daemonPublicKeyB64: string;
|
||||
readonly minimumSecurityMode?: "protected";
|
||||
}
|
||||
| {
|
||||
readonly id: string;
|
||||
readonly type: "routed";
|
||||
readonly uri: null;
|
||||
readonly originServerId: string;
|
||||
};
|
||||
export interface PluginHostPatch {
|
||||
label?: string;
|
||||
appearance?: Partial<PluginHostAppearance>;
|
||||
}
|
||||
export interface PluginHost extends PluginHostSummary {
|
||||
readonly appearance: PluginHostAppearance;
|
||||
readonly connections: readonly PluginHostConnection[];
|
||||
readonly activeConnectionId: string | null;
|
||||
readonly error: string | null;
|
||||
readonly authFailureReason: "password_required" | "incorrect_password" | null;
|
||||
readonly lastOnlineAt: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
update(patch: PluginHostPatch): Promise<void>;
|
||||
/** Removes the host; throws for the desktop daemon host. */
|
||||
remove(): Promise<void>;
|
||||
/** Removing the last connection removes the host. */
|
||||
removeConnection(connectionId: string): Promise<void>;
|
||||
openSettings(): void;
|
||||
}
|
||||
export interface PluginHostAddInput {
|
||||
/** Shared host connection grammar; may contain a password for the probe. */
|
||||
connection: string;
|
||||
label?: string;
|
||||
}
|
||||
export type PluginHostAddResult =
|
||||
| { status: "saved"; host: PluginHost }
|
||||
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
|
||||
| { status: "cancelled" };
|
||||
export declare function useHosts(): readonly PluginHost[];
|
||||
export declare function observeHosts(listener: (hosts: readonly PluginHost[]) => void): () => void;
|
||||
|
||||
export declare function addHost(input: PluginHostAddInput): Promise<PluginHostAddResult>;
|
||||
@@ -86,7 +86,20 @@ export interface PluginHostSummary {
|
||||
readonly status: "idle" | "connecting" | "online" | "offline" | "error";
|
||||
}
|
||||
/** Live configured hosts. Supplied by the app's client bundle loader. */
|
||||
export declare function useHosts(): readonly PluginHostSummary[];
|
||||
export declare const useHosts: typeof import("./hosts.js").useHosts;
|
||||
export declare const observeHosts: typeof import("./hosts.js").observeHosts;
|
||||
export declare const addHost: typeof import("./hosts.js").addHost;
|
||||
export type {
|
||||
PluginHost,
|
||||
PluginHostStatus,
|
||||
PluginHostColor,
|
||||
PluginHostBadgeDisplay,
|
||||
PluginHostAppearance,
|
||||
PluginHostConnection,
|
||||
PluginHostPatch,
|
||||
PluginHostAddInput,
|
||||
PluginHostAddResult,
|
||||
} from "./hosts.js";
|
||||
/** Borrow an online host's API under this installation's lifetime. */
|
||||
export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi;
|
||||
/** Open an absolute HTTP(S) URL using the client platform’s external opener. */
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { parseHostTarget } from "./host-target.js";
|
||||
import { serializeSshTransportUri } from "./ssh-transport.js";
|
||||
import { serializeSocketHostTarget } from "./host-target.js";
|
||||
import {
|
||||
parseConnectionUri,
|
||||
serializeConnectionUri,
|
||||
serializeRelayConnectionUri,
|
||||
} from "./daemon-endpoints.js";
|
||||
|
||||
test("SSH and local socket targets round trip through their serializers", () => {
|
||||
const ssh = parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777");
|
||||
if (ssh.kind !== "ssh") throw new Error("expected SSH");
|
||||
expect(parseHostTarget(serializeSshTransportUri(ssh.ssh))).toEqual(ssh);
|
||||
for (const [type, path] of [
|
||||
["unix", "/tmp/paseo.sock"],
|
||||
["pipe", "\\\\.\\pipe\\paseo"],
|
||||
] as const) {
|
||||
const target = parseHostTarget(`${type}://${path}`);
|
||||
expect(parseHostTarget(serializeSocketHostTarget(type, path))).toEqual(target);
|
||||
}
|
||||
});
|
||||
|
||||
test("TCP and relay targets round trip through credential-free serializers", () => {
|
||||
const tcp = parseConnectionUri("tcp://example.com:6767?ssl=true&password=secret");
|
||||
const tcpUri = serializeConnectionUri(tcp);
|
||||
expect(tcpUri).not.toContain("secret");
|
||||
expect(parseHostTarget(tcpUri)).toMatchObject({
|
||||
connection: { type: "directTcp", endpoint: "example.com:6767", useTls: true },
|
||||
});
|
||||
const relay = parseHostTarget(
|
||||
"relay://relay.example:443/server?key=public&ssl=true&password=secret",
|
||||
);
|
||||
if (relay.kind !== "socket" || relay.connection.type !== "relay")
|
||||
throw new Error("expected relay");
|
||||
const relayUri = serializeRelayConnectionUri({ offer: relay.connection.offer });
|
||||
expect(relayUri).not.toContain("secret");
|
||||
expect(parseHostTarget(relayUri)).toMatchObject({ connection: relay.connection });
|
||||
});
|
||||
|
||||
test.each([
|
||||
["example.com:6767", { type: "directTcp", endpoint: "example.com:6767", useTls: false }],
|
||||
[
|
||||
"tcp://example.com:6767?ssl=true",
|
||||
{ type: "directTcp", endpoint: "example.com:6767", useTls: true },
|
||||
],
|
||||
["wss://example.com/ws", { type: "directTcp", endpoint: "example.com:443", useTls: true }],
|
||||
["unix:///tmp/paseo.sock", { type: "directSocket", path: "/tmp/paseo.sock" }],
|
||||
["pipe://mypipe", { type: "directPipe", path: "mypipe" }],
|
||||
])("%s exposes its structured connection", (input, connection) => {
|
||||
expect(parseHostTarget(input)).toMatchObject({ kind: "socket", connection });
|
||||
});
|
||||
|
||||
test.each([
|
||||
["127.0.0.1:6767", "tcp://127.0.0.1:6767"],
|
||||
|
||||
@@ -14,12 +14,23 @@ export type HostTarget =
|
||||
| {
|
||||
kind: "socket";
|
||||
url: string;
|
||||
connection:
|
||||
| { type: "directTcp"; endpoint: string; useTls: boolean }
|
||||
| { type: "directSocket"; path: string }
|
||||
| { type: "directPipe"; path: string }
|
||||
| { type: "relay"; offer: ConnectionOffer };
|
||||
socketPath?: string;
|
||||
password?: string;
|
||||
offer?: ConnectionOffer;
|
||||
description: string;
|
||||
};
|
||||
|
||||
/** Serialize a local socket target in the shared host grammar. */
|
||||
export function serializeSocketHostTarget(type: "unix" | "pipe", path: string): string {
|
||||
if (!path || path === "~") throw new Error("Invalid socket path");
|
||||
return `${type}://${path}`;
|
||||
}
|
||||
|
||||
/** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */
|
||||
export function parseHostTarget(input: string): HostTarget {
|
||||
try {
|
||||
@@ -57,6 +68,9 @@ function parse(input: string): HostTarget {
|
||||
const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\");
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: pipe
|
||||
? { type: "directPipe", path: socketPath }
|
||||
: { type: "directSocket", path: socketPath },
|
||||
socketPath,
|
||||
url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`,
|
||||
description: `${pipe ? "pipe" : "unix"}://${socketPath}`,
|
||||
@@ -67,6 +81,7 @@ function parse(input: string): HostTarget {
|
||||
const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: { type: "directTcp", endpoint, useTls: false },
|
||||
url: buildDaemonWebSocketUrl(endpoint, { useTls: false }),
|
||||
description: `tcp://${endpoint}`,
|
||||
};
|
||||
@@ -75,6 +90,7 @@ function relayTarget(offer: ConnectionOffer, password?: string): HostTarget {
|
||||
const endpoint = validatedEndpoint(offer.relay.endpoint);
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: { type: "relay", offer },
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint,
|
||||
serverId: offer.serverId,
|
||||
@@ -97,6 +113,7 @@ function tcpTarget(input: string): HostTarget {
|
||||
);
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: { type: "directTcp", endpoint, useTls: parts.useTls },
|
||||
url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }),
|
||||
password: parts.password,
|
||||
description: `tcp://${endpoint}`,
|
||||
@@ -109,7 +126,15 @@ function websocketTarget(input: string): HostTarget {
|
||||
// Unknown query parameters may themselves contain credentials; discovery shows only the origin.
|
||||
if (url.pathname === "/") url.pathname = "/ws";
|
||||
url.searchParams.delete("password");
|
||||
return { kind: "socket", url: url.toString(), password, description: url.origin };
|
||||
const useTls = url.protocol === "wss:";
|
||||
const endpoint = `${url.hostname}:${url.port || (useTls ? "443" : "80")}`;
|
||||
return {
|
||||
kind: "socket",
|
||||
connection: { type: "directTcp", endpoint, useTls },
|
||||
url: url.toString(),
|
||||
password,
|
||||
description: url.origin,
|
||||
};
|
||||
}
|
||||
|
||||
function sshTarget(input: string): HostTarget {
|
||||
|
||||
@@ -60,6 +60,19 @@ export function parseSshTransportUri(value: string): SshTransportTarget {
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize an SSH target without daemon credentials. */
|
||||
export function serializeSshTransportUri(target: SshTransportTarget): string {
|
||||
const host = validateSshHost(target.host);
|
||||
const separator = host.lastIndexOf("@");
|
||||
const username = separator < 0 ? "" : `${encodeURIComponent(host.slice(0, separator))}@`;
|
||||
const hostname = host.slice(separator + 1);
|
||||
const authority = hostname.includes(":") ? `[${hostname}]` : hostname;
|
||||
const port = target.sshPort === undefined ? "" : `:${validatePort(target.sshPort, "SSH port")}`;
|
||||
const daemonPort = validatePort(target.daemonPort, "Daemon port");
|
||||
const query = daemonPort === DEFAULT_SSH_DAEMON_PORT ? "" : `?daemonPort=${daemonPort}`;
|
||||
return `ssh://${username}${authority}${port}${query}`;
|
||||
}
|
||||
|
||||
export function buildSshTunnelArgs(target: SshTransportTarget): string[] {
|
||||
const host = validateSshHost(target.host);
|
||||
const daemonPort = validatePort(target.daemonPort, "Daemon port");
|
||||
|
||||
@@ -1623,26 +1623,13 @@ it.each([1, 4])(
|
||||
async (version) => {
|
||||
const paseoHome = createTemporaryHome();
|
||||
const old = await leaveWorkerWithTerminal(paseoHome, version);
|
||||
const [record] = await workerRecords(paseoHome);
|
||||
const program = record!.terminals.find((entry) => entry.id === old.terminalId)!;
|
||||
const inspector = createTerminalWorkerProcessInspector();
|
||||
|
||||
manager = await startRealWorkerManager({ paseoHome });
|
||||
|
||||
expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]);
|
||||
// Startup launches a replacement worker after cleanup. Either old PID may already
|
||||
// belong to another process, so verify that the recorded birth identities are gone.
|
||||
for (const original of [{ pid: record!.pid, ...record!.identity }, program]) {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
!isProcessAlive(original.pid) ||
|
||||
classifyTerminalProgramProcess(original, await inspector.inspect(original.pid))
|
||||
.status === "unrelated",
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
await waitForCondition(
|
||||
() => !isProcessAlive(old.workerPid) && !isProcessAlive(old.programPid),
|
||||
10000,
|
||||
);
|
||||
expect(manager.getTerminal(old.terminalId)).toBeUndefined();
|
||||
const workers = await workerRecords(paseoHome);
|
||||
expect(workers).toHaveLength(1);
|
||||
|
||||
@@ -6,7 +6,7 @@ const desktopName =
|
||||
async function openHomepage(page: Page) {
|
||||
await page.setViewportSize({ width: 1512, height: 930 });
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("button", { name: "Parallel agents", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Build", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function viewPhones(page: Page) {
|
||||
@@ -59,42 +59,3 @@ test("desktop mockup corners shrink in proportion on a phone", async ({ page })
|
||||
await resizeToPhone(page);
|
||||
await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3);
|
||||
});
|
||||
|
||||
/** Opacity as rendered: the element's own times every ancestor's. */
|
||||
async function renderedOpacity(locator: Locator) {
|
||||
return locator.evaluate((element) => {
|
||||
let opacity = 1;
|
||||
for (let node: Element | null = element; node; node = node.parentElement) {
|
||||
opacity *= Number.parseFloat(getComputedStyle(node).opacity);
|
||||
}
|
||||
return opacity;
|
||||
});
|
||||
}
|
||||
|
||||
test("the plugin demo plays through to the panel the agent built", async ({ page }) => {
|
||||
await openHomepage(page);
|
||||
const builtPanelRow = page.getByText("Add Gruvbox theme", { exact: true });
|
||||
expect(await renderedOpacity(builtPanelRow)).toBe(0);
|
||||
|
||||
// Hydration can reset the scroll position, so keep the demo in view until it finishes.
|
||||
const heading = page.getByRole("heading", { name: "Ask Paseo to change Paseo" });
|
||||
await expect(async () => {
|
||||
await heading.scrollIntoViewIfNeeded();
|
||||
expect(await renderedOpacity(builtPanelRow)).toBe(1);
|
||||
}).toPass({ timeout: 15_000 });
|
||||
expect(await renderedOpacity(page.getByText("Review queue is in your sidebar."))).toBe(1);
|
||||
});
|
||||
|
||||
test("the plugin demo shows its finished state when motion is reduced", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await openHomepage(page);
|
||||
const builtPanelRow = page.getByText("Add Gruvbox theme", { exact: true });
|
||||
const panel = builtPanelRow.locator("xpath=ancestor::div[contains(@class, 'md:border-l')]");
|
||||
const heading = page.getByRole("heading", { name: "Ask Paseo to change Paseo" });
|
||||
|
||||
await expect(async () => {
|
||||
await heading.scrollIntoViewIfNeeded();
|
||||
expect(await renderedOpacity(builtPanelRow)).toBe(1);
|
||||
}).toPass({ timeout: 2_000 });
|
||||
expect(await panel.evaluate((element) => getComputedStyle(element).transform)).toBe("none");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { PLUGINS_LINKED } from "~/plugins/launch";
|
||||
import { categoryHref } from "~/plugins/links";
|
||||
import type { CategorySlug } from "~/plugins/categories";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -12,7 +10,7 @@ import {
|
||||
ExternalLink,
|
||||
Gem,
|
||||
GitFork,
|
||||
GitPullRequest,
|
||||
LayoutPanelLeft,
|
||||
Laptop,
|
||||
Lock,
|
||||
Merge,
|
||||
@@ -21,17 +19,16 @@ import {
|
||||
Smartphone,
|
||||
Terminal,
|
||||
Users,
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
useInView,
|
||||
useReducedMotion,
|
||||
useScroll,
|
||||
useTransform,
|
||||
type Transition,
|
||||
type Variants,
|
||||
} from "framer-motion";
|
||||
|
||||
// Shared motion presets — hoisted so every JSX site receives the same object
|
||||
@@ -89,14 +86,7 @@ import {
|
||||
PiIcon,
|
||||
} from "~/components/agent-icons";
|
||||
import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
|
||||
import {
|
||||
MobileChat,
|
||||
MobileDiff,
|
||||
MobileSidebar,
|
||||
PhoneFrame,
|
||||
type ToolCall,
|
||||
ToolCallRow,
|
||||
} from "~/components/mockup";
|
||||
import { MobileChat, MobileDiff, MobileSidebar, PhoneFrame } from "~/components/mockup";
|
||||
import { FAQItem } from "~/components/faq-item";
|
||||
import { SiteFooter } from "~/components/site-footer";
|
||||
import { SponsorSection, SponsorsSection } from "~/components/sponsorship";
|
||||
@@ -142,8 +132,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
<SocialProofWall />
|
||||
<MultiProviderSection />
|
||||
<TurnkeySection />
|
||||
<ExtensibleSection />
|
||||
<AutomationSection />
|
||||
<ExtensibleSection />
|
||||
<SponsorsSection placement="page" />
|
||||
<SponsorSection />
|
||||
<PhilosophySection />
|
||||
@@ -713,50 +703,41 @@ function AutomationDetail({ kind }: { kind: AutomationKind }) {
|
||||
);
|
||||
}
|
||||
|
||||
const MCP_AGENT_CALLS: ToolCall[] = [
|
||||
{ kind: "paseo", label: "Create agent", detail: "Fix #412: retry flaky relay handshake" },
|
||||
{ kind: "paseo", label: "Create agent", detail: "Fix #417: group schedules by project" },
|
||||
];
|
||||
|
||||
function McpAutomationTranscript() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<AgentTurn request={t("landing.automation.transcriptRequest")}>
|
||||
<AgentText>{t("landing.automation.transcriptPlan")}</AgentText>
|
||||
<ToolCalls calls={MCP_AGENT_CALLS} />
|
||||
<AgentText>{t("landing.automation.transcriptDone")}</AgentText>
|
||||
</AgentTurn>
|
||||
);
|
||||
}
|
||||
|
||||
/** A user message and the agent's reply, laid out like the app's timeline. */
|
||||
function AgentTurn({ request, children }: { request: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<UserMessage>{request}</UserMessage>
|
||||
<div className="space-y-3">{children}</div>
|
||||
<div className="ml-auto w-fit max-w-xl rounded-xl rounded-tr-none bg-white/[0.07] px-4 py-3">
|
||||
<p className="text-sm leading-relaxed text-white/75">
|
||||
{t("landing.automation.transcriptRequest")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Bot className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.5} />
|
||||
<div className="min-w-0 flex-1 space-y-4">
|
||||
<p className="text-sm leading-relaxed text-white/55">
|
||||
{t("landing.automation.transcriptPlan")}
|
||||
</p>
|
||||
<div className="space-y-2 font-mono text-[11px]">
|
||||
<McpAgentCall issue="#412" provider="claude/opus-4.6" />
|
||||
<McpAgentCall issue="#417" provider="codex/gpt-5.6-sol" />
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-white/55">
|
||||
{t("landing.automation.transcriptDone")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMessage({ children }: { children: React.ReactNode }) {
|
||||
function McpAgentCall({ issue, provider }: { issue: string; provider: string }) {
|
||||
return (
|
||||
<div className="ml-auto w-fit max-w-md rounded-xl bg-white/[0.07] px-4 py-3">
|
||||
<p className="text-sm leading-relaxed text-white/80">{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentText({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-sm leading-relaxed text-white/80">{children}</p>;
|
||||
}
|
||||
|
||||
function ToolCalls({ calls }: { calls: ReadonlyArray<ToolCall> }) {
|
||||
return (
|
||||
<div>
|
||||
{calls.map((call) => (
|
||||
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border border-white/[0.07] bg-white/[0.025] px-3 py-2">
|
||||
<span className="text-sky-300/80">create_agent</span>
|
||||
<span className="text-white/25">{issue}</span>
|
||||
<span className="text-white/35">{provider}</span>
|
||||
<span className="text-white/25">worktree</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -861,16 +842,17 @@ function ExtensibleSection() {
|
||||
title={t("landing.plugins.title")}
|
||||
description={t("landing.plugins.description")}
|
||||
>
|
||||
<div className="space-y-12">
|
||||
<SelfModifyDemo />
|
||||
<div className="space-y-6">
|
||||
<ProductLead
|
||||
icon={Puzzle}
|
||||
title={t("landing.plugins.libraryTitle")}
|
||||
description={t("landing.plugins.libraryDescription")}
|
||||
actions={pluginActions}
|
||||
/>
|
||||
<PluginCategoryLinks />
|
||||
<div className="space-y-8">
|
||||
<ProductLead icon={Puzzle} title={t("landing.plugins.lead")} actions={pluginActions} />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{PLUGIN_CAPABILITIES.map((capability) => (
|
||||
<FeatureCard
|
||||
key={capability.key}
|
||||
icon={capability.icon}
|
||||
title={t(`landing.plugins.${capability.key}Title`)}
|
||||
description={t(`landing.plugins.${capability.key}Description`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-white/45">{t("landing.plugins.license")}</p>
|
||||
@@ -890,156 +872,6 @@ function ExtensibleSection() {
|
||||
);
|
||||
}
|
||||
|
||||
const PLUGIN_BUILD_CALLS: ToolCall[] = [
|
||||
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
|
||||
{ kind: "shell", detail: "paseo plugin init review-queue" },
|
||||
{ kind: "write", detail: "review-queue/index.ts" },
|
||||
{ kind: "write", detail: "review-queue/review-queue.client.tsx" },
|
||||
{ kind: "shell", detail: "paseo plugin install ./review-queue" },
|
||||
];
|
||||
|
||||
// The demo plays once when it scrolls into view: the prompt, then each timeline
|
||||
// item in turn, then the panel the agent built slides in and fills.
|
||||
const DEMO_STEP_SECONDS = 0.45;
|
||||
const DEMO_STEPS = {
|
||||
prompt: 0,
|
||||
plan: 1.4,
|
||||
firstCall: 2.2,
|
||||
} as const;
|
||||
const DEMO_DONE_STEP = DEMO_STEPS.firstCall + PLUGIN_BUILD_CALLS.length;
|
||||
const DEMO_PANEL_STEP = DEMO_DONE_STEP + 1;
|
||||
|
||||
const DEMO_ITEM_VARIANTS: Variants = {
|
||||
hidden: { opacity: 0, y: 6 },
|
||||
shown: (step: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { delay: step * DEMO_STEP_SECONDS, duration: 0.35, ease: "easeOut" },
|
||||
}),
|
||||
};
|
||||
const DEMO_PANEL_VARIANTS: Variants = {
|
||||
hidden: { opacity: 0, x: "100%" },
|
||||
shown: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
delay: DEMO_PANEL_STEP * DEMO_STEP_SECONDS,
|
||||
duration: 0.6,
|
||||
ease: [0.22, 0.61, 0.36, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** One piece of the demo that fades in at its step. */
|
||||
function DemoItem({ step, children }: { step: number; children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div variants={DEMO_ITEM_VARIANTS} custom={step}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The ask on the left, the panel it produced on the right. */
|
||||
function SelfModifyDemo() {
|
||||
const { t } = useTranslation();
|
||||
// Visitors who ask for reduced motion get the finished demo, with no slide or staging.
|
||||
const reduceMotion = useReducedMotion();
|
||||
return (
|
||||
<motion.div
|
||||
initial={reduceMotion ? "shown" : "hidden"}
|
||||
whileInView="shown"
|
||||
viewport={VIEWPORT_60}
|
||||
className="grid min-h-[22rem] grid-cols-[minmax(0,1fr)] overflow-hidden rounded-xl border border-white/10 bg-black/20 md:grid-cols-[minmax(0,1fr)_17rem]"
|
||||
>
|
||||
<div className="space-y-5 p-5 md:p-6">
|
||||
<DemoItem step={DEMO_STEPS.prompt}>
|
||||
<UserMessage>{t("landing.plugins.request")}</UserMessage>
|
||||
</DemoItem>
|
||||
<div className="space-y-3">
|
||||
<DemoItem step={DEMO_STEPS.plan}>
|
||||
<AgentText>{t("landing.plugins.plan")}</AgentText>
|
||||
</DemoItem>
|
||||
<div>
|
||||
{PLUGIN_BUILD_CALLS.map((call, index) => (
|
||||
<DemoItem key={`${call.kind}:${call.detail}`} step={DEMO_STEPS.firstCall + index}>
|
||||
<ToolCallRow call={call} />
|
||||
</DemoItem>
|
||||
))}
|
||||
</div>
|
||||
<DemoItem step={DEMO_DONE_STEP}>
|
||||
<AgentText>{t("landing.plugins.done")}</AgentText>
|
||||
</DemoItem>
|
||||
</div>
|
||||
</div>
|
||||
<ReviewQueuePreview />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const REVIEW_QUEUE = [
|
||||
{ number: "#418", title: "Retry flaky relay handshake", author: "ana", age: "12m" },
|
||||
{ number: "#415", title: "Group schedules by project", author: "kenji", age: "1h" },
|
||||
{ number: "#409", title: "Add Gruvbox theme", author: "lea", age: "3h" },
|
||||
] as const;
|
||||
|
||||
/** The panel the agent just built, docked like a real sidebar panel. */
|
||||
function ReviewQueuePreview() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<motion.div
|
||||
variants={DEMO_PANEL_VARIANTS}
|
||||
className="border-t border-white/10 bg-white/[0.025] p-4 md:border-t-0 md:border-l"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<GitPullRequest className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<span className="text-sm font-medium text-white/85">{t("landing.plugins.panelTitle")}</span>
|
||||
<span className="ml-auto rounded-full bg-white/[0.07] px-2 py-0.5 text-[10px] text-white/55">
|
||||
{REVIEW_QUEUE.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{REVIEW_QUEUE.map((pr, index) => (
|
||||
<DemoItem key={pr.number} step={DEMO_PANEL_STEP + 1.2 + index * 0.4}>
|
||||
<div className="rounded-md px-2 py-2 hover:bg-white/[0.04]">
|
||||
<p className="truncate text-xs text-white/80">{pr.title}</p>
|
||||
<p className="mt-0.5 font-mono text-[10px] text-white/35">
|
||||
{pr.number} · {pr.author} · {pr.age}
|
||||
</p>
|
||||
</div>
|
||||
</DemoItem>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const SHOWCASE_CATEGORIES = [
|
||||
"themes",
|
||||
"sidebar",
|
||||
"workspaces",
|
||||
"providers",
|
||||
"orchestration",
|
||||
"git",
|
||||
] as const satisfies ReadonlyArray<CategorySlug>;
|
||||
|
||||
function PluginCategoryLinks() {
|
||||
const { t } = useTranslation();
|
||||
const localize = useLocalizePath();
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SHOWCASE_CATEGORIES.map((category) => (
|
||||
<a
|
||||
key={category}
|
||||
href={localize(categoryHref(category))}
|
||||
className="rounded-full border border-white/10 bg-white/[0.025] px-3 py-1.5 text-xs text-white/70 transition-colors hover:border-white/15 hover:bg-white/[0.06] hover:text-white"
|
||||
>
|
||||
{t(`plugins.categories.${category}.label`)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PLUGINS_BROWSE = PLUGINS_LINKED
|
||||
? { href: "/plugins", external: false }
|
||||
: { href: "https://paseo.cafe", external: true };
|
||||
@@ -1067,6 +899,12 @@ function PluginActions() {
|
||||
);
|
||||
}
|
||||
|
||||
const PLUGIN_CAPABILITIES = [
|
||||
{ icon: LayoutPanelLeft, key: "ui" },
|
||||
{ icon: Workflow, key: "lifecycle" },
|
||||
{ icon: Bot, key: "providers" },
|
||||
] as const;
|
||||
|
||||
const PRINCIPLES = [
|
||||
{ icon: Lock, key: "private" },
|
||||
{ icon: Compass, key: "independent" },
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
AudioLines,
|
||||
BookOpen,
|
||||
Brain,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Copy,
|
||||
Eye,
|
||||
FilePenLine,
|
||||
LoaderCircle,
|
||||
ListTodo,
|
||||
Mic,
|
||||
Pencil,
|
||||
Plus,
|
||||
ShieldOff,
|
||||
Split,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DiffStat } from "./atoms";
|
||||
import { CodexIcon, PaseoMark } from "./icons";
|
||||
import { CodexIcon } from "./icons";
|
||||
|
||||
// Icons passed as props are hoisted so they are not rebuilt on every render.
|
||||
const MODEL_ICON = <CodexIcon size={13} />;
|
||||
@@ -94,62 +94,70 @@ export function AgentTranscript() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsed tool call as the app's timeline shows it. Built-in tools carry a
|
||||
* fixed label; Paseo MCP tools name their action, such as "Create agent".
|
||||
*/
|
||||
export type ToolCall =
|
||||
| { kind: "read" | "write" | "shell"; detail: string }
|
||||
| { kind: "paseo"; label: string; detail: string };
|
||||
|
||||
const BUILT_IN_LABELS = { read: "Read", write: "Write", shell: "Shell" } as const;
|
||||
|
||||
function toolCallLabel(call: ToolCall): string {
|
||||
return call.kind === "paseo" ? call.label : BUILT_IN_LABELS[call.kind];
|
||||
interface PluginToolCall {
|
||||
kind: "read" | "shell" | "write";
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const PLUGIN_TOOL_CALLS: ToolCall[] = [
|
||||
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
|
||||
{ kind: "shell", detail: "paseo plugin init ~/dev/release-radar" },
|
||||
{ kind: "write", detail: "~/dev/release-radar/index.ts" },
|
||||
{ kind: "write", detail: "~/dev/release-radar/release-radar.client.tsx" },
|
||||
{ kind: "shell", detail: "npm install && npm run typecheck" },
|
||||
{ kind: "shell", detail: "paseo plugin install ~/dev/release-radar" },
|
||||
const PLUGIN_TOOL_CALLS: PluginToolCall[] = [
|
||||
{ kind: "read", label: "Read", detail: "paseo-plugin/SKILL.md" },
|
||||
{
|
||||
kind: "shell",
|
||||
label: "Shell",
|
||||
detail: "paseo plugin init ~/dev/release-radar",
|
||||
},
|
||||
{
|
||||
kind: "write",
|
||||
label: "Write",
|
||||
detail: "~/dev/release-radar/index.ts",
|
||||
},
|
||||
{
|
||||
kind: "write",
|
||||
label: "Write",
|
||||
detail: "~/dev/release-radar/release-radar.client.tsx",
|
||||
},
|
||||
{
|
||||
kind: "shell",
|
||||
label: "Shell",
|
||||
detail: "npm install && npm run typecheck",
|
||||
},
|
||||
{
|
||||
kind: "shell",
|
||||
label: "Shell",
|
||||
detail: "paseo plugin install ~/dev/release-radar",
|
||||
},
|
||||
];
|
||||
|
||||
const PLUGIN_DISCOVERY_CALLS = PLUGIN_TOOL_CALLS.slice(0, 1);
|
||||
const PLUGIN_BUILD_CALLS = PLUGIN_TOOL_CALLS.slice(1, 4);
|
||||
const PLUGIN_INSTALL_CALLS = PLUGIN_TOOL_CALLS.slice(4);
|
||||
|
||||
function ToolCallIcon({ kind }: { kind: ToolCall["kind"] }) {
|
||||
if (kind === "read") return <Eye size={14} />;
|
||||
if (kind === "write") return <Pencil size={14} />;
|
||||
if (kind === "paseo") return <PaseoMark size={14} />;
|
||||
function PluginToolIcon({ kind }: { kind: PluginToolCall["kind"] }) {
|
||||
if (kind === "read") return <BookOpen size={14} />;
|
||||
if (kind === "write") return <FilePenLine size={14} />;
|
||||
return <SquareTerminal size={14} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tool call row, drawn like the app's collapsed timeline item
|
||||
* (`packages/app/src/components/message.tsx`): icon, label, then the summary,
|
||||
* all muted and in the UI font.
|
||||
*/
|
||||
export function ToolCallRow({ call }: { call: ToolCall }) {
|
||||
function PluginToolRow({ call }: { call: PluginToolCall }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[8px] py-[5px] text-[12.5px] leading-[18px] text-mock-fg-muted">
|
||||
<span className="shrink-0">
|
||||
<ToolCallIcon kind={call.kind} />
|
||||
<div className="flex items-center gap-[9px] py-[5px] text-[12.5px] leading-[18px]">
|
||||
<span className="text-mock-fg-muted">
|
||||
<PluginToolIcon kind={call.kind} />
|
||||
</span>
|
||||
<span className="shrink-0 text-mock-fg">{call.label}</span>
|
||||
<span className="min-w-0 truncate font-mono text-[11.5px] text-mock-fg-muted">
|
||||
{call.detail}
|
||||
</span>
|
||||
<span className="shrink-0">{toolCallLabel(call)}</span>
|
||||
<span className="min-w-0 truncate">{call.detail}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginToolGroup({ calls }: { calls: ToolCall[] }) {
|
||||
function PluginToolGroup({ calls }: { calls: PluginToolCall[] }) {
|
||||
return (
|
||||
<div>
|
||||
{calls.map((call) => (
|
||||
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
|
||||
<PluginToolRow key={`${call.label}:${call.detail}`} call={call} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
|
||||
export { type ToolCall, ToolCallRow } from "./chat";
|
||||
export { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry";
|
||||
export { PhoneFrame } from "./mobile/frame";
|
||||
export { MobileChat } from "./mobile/chat";
|
||||
|
||||
@@ -375,18 +375,19 @@
|
||||
"otherHostComment": "# Target another host"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Ask Paseo to change Paseo",
|
||||
"description": "Agents in Paseo can change the whole UI without a restart, even on mobile.",
|
||||
"request": "Add a sidebar panel with the PRs waiting on my review.",
|
||||
"plan": "I'll build it as a local plugin and install it.",
|
||||
"done": "Review queue is in your sidebar.",
|
||||
"panelTitle": "Review queue",
|
||||
"libraryTitle": "400+ community plugins",
|
||||
"libraryDescription": "Built by developers on the same plugin API your agents use",
|
||||
"title": "Extend it with plugins",
|
||||
"description": "Add new panels and functionality that work in desktop, web and mobile",
|
||||
"lead": "Plugins",
|
||||
"browse": "Browse plugins",
|
||||
"docs": "Plugin docs",
|
||||
"license": "Paseo is Apache 2.0.",
|
||||
"fork": "Fork the repo"
|
||||
"fork": "Fork the repo",
|
||||
"uiTitle": "UI",
|
||||
"uiDescription": "Screens, workspace panels, sidebar items, slash commands, and themes",
|
||||
"lifecycleTitle": "Agent lifecycle",
|
||||
"lifecycleDescription": "Change config and MCP servers, answer permissions, and follow up",
|
||||
"providersTitle": "Providers",
|
||||
"providersDescription": "Add a coding agent as a provider, used like the built-in ones"
|
||||
},
|
||||
"philosophy": {
|
||||
"title": "Philosophy",
|
||||
@@ -446,10 +447,10 @@
|
||||
},
|
||||
"mockup": {
|
||||
"alt": "Paseo desktop app with coding agents, a conversation, and a code diff open side by side",
|
||||
"build": "Parallel agents",
|
||||
"review": "Code review",
|
||||
"ship": "Pull requests",
|
||||
"extend": "Plugins",
|
||||
"build": "Build",
|
||||
"review": "Review",
|
||||
"ship": "Ship",
|
||||
"extend": "Extend",
|
||||
"app": {
|
||||
"newWorkspace": "New workspace",
|
||||
"history": "History",
|
||||
|
||||
@@ -375,18 +375,19 @@
|
||||
"otherHostComment": "# 指定另一台主机"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "让 Paseo 修改 Paseo",
|
||||
"description": "Paseo 里的 Agent 可以修改整个界面,无需重启,在移动端也一样。",
|
||||
"request": "在侧边栏加一个面板,列出等待我审查的 PR。",
|
||||
"plan": "我会把它做成本地插件并安装。",
|
||||
"done": "审查队列已出现在你的侧边栏。",
|
||||
"panelTitle": "审查队列",
|
||||
"libraryTitle": "400 多个社区插件",
|
||||
"libraryDescription": "开发者用的是和你的 Agent 相同的插件 API",
|
||||
"title": "用插件扩展",
|
||||
"description": "添加新的面板和功能,在桌面端、网页端和移动端都能使用",
|
||||
"lead": "插件",
|
||||
"browse": "浏览插件",
|
||||
"docs": "插件文档",
|
||||
"license": "Paseo 采用 Apache 2.0 许可证。",
|
||||
"fork": "Fork 仓库"
|
||||
"fork": "Fork 仓库",
|
||||
"uiTitle": "界面",
|
||||
"uiDescription": "页面、工作区面板、侧边栏项目、斜杠命令和主题",
|
||||
"lifecycleTitle": "Agent 生命周期",
|
||||
"lifecycleDescription": "修改配置和 MCP 服务器、响应权限请求、发送后续消息",
|
||||
"providersTitle": "Provider",
|
||||
"providersDescription": "把编程 Agent 添加为 Provider,用法与内置 Provider 相同"
|
||||
},
|
||||
"philosophy": {
|
||||
"title": "理念",
|
||||
@@ -446,10 +447,10 @@
|
||||
},
|
||||
"mockup": {
|
||||
"alt": "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
|
||||
"build": "并行 Agent",
|
||||
"review": "代码审查",
|
||||
"ship": "Pull Request",
|
||||
"extend": "插件",
|
||||
"build": "构建",
|
||||
"review": "审查",
|
||||
"ship": "发布",
|
||||
"extend": "扩展",
|
||||
"app": {
|
||||
"newWorkspace": "新建工作区",
|
||||
"history": "历史",
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import {
|
||||
addHost,
|
||||
getPaseoClient,
|
||||
useHosts,
|
||||
usePaseo,
|
||||
type PluginSurfaceProps,
|
||||
type PluginHost,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
|
||||
export function Hosts({ theme }: PluginSurfaceProps) {
|
||||
const hosts = useHosts();
|
||||
const selected = usePaseo();
|
||||
const [result, setResult] = useState("");
|
||||
const [connection, setConnection] = useState("");
|
||||
const add = useCallback(async () => {
|
||||
try {
|
||||
const outcome = await addHost({ connection });
|
||||
if (outcome.status === "saved") setResult(`Added ${outcome.host.label}`);
|
||||
else if (outcome.status === "cancelled") setResult("Host addition cancelled");
|
||||
else setResult("Add ?password= and retry");
|
||||
} catch (error) {
|
||||
setResult(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [connection]);
|
||||
const addPressed = useCallback(() => {
|
||||
void add();
|
||||
}, [add]);
|
||||
const list = useCallback(
|
||||
async (serverId?: string) => {
|
||||
try {
|
||||
@@ -33,27 +49,63 @@ export function Hosts({ theme }: PluginSurfaceProps) {
|
||||
const listSelected = useCallback(() => {
|
||||
void list();
|
||||
}, [list]);
|
||||
const rows = hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
label: host.label,
|
||||
status: host.status,
|
||||
onPress() {
|
||||
void list(host.serverId);
|
||||
},
|
||||
}));
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<TextInput
|
||||
value={connection}
|
||||
onChangeText={setConnection}
|
||||
placeholder="host:port, ssh://user@host, or a pairing link"
|
||||
/>
|
||||
<Pressable accessibilityRole="button" onPress={addPressed}>
|
||||
<Text style={styles.text}>Add host</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={listSelected}>
|
||||
<Text style={styles.text}>List selected host agents</Text>
|
||||
</Pressable>
|
||||
{rows.map((host) => (
|
||||
<Pressable key={host.serverId} accessibilityRole="button" onPress={host.onPress}>
|
||||
<Text style={styles.text}>
|
||||
{host.label}: {host.status}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{hosts.map((host) => (
|
||||
<HostRow key={host.serverId} host={host} color={theme.colors.foreground} list={list} />
|
||||
))}
|
||||
<Text style={styles.text}>{result}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HostRow({
|
||||
host,
|
||||
color,
|
||||
list,
|
||||
}: {
|
||||
host: PluginHost;
|
||||
color: string;
|
||||
list: (serverId: string) => Promise<void>;
|
||||
}) {
|
||||
const style = useMemo(() => ({ color }), [color]);
|
||||
const rowStyle = useMemo(() => ({ gap: 4 }), []);
|
||||
const listAgents = useCallback(() => void list(host.serverId), [host, list]);
|
||||
const update = useCallback(
|
||||
() => void host.update({ label: `${host.label} (build)`, appearance: { color: "sky" } }),
|
||||
[host],
|
||||
);
|
||||
const open = useCallback(() => host.openSettings(), [host]);
|
||||
const remove = useCallback(() => void host.remove(), [host]);
|
||||
return (
|
||||
<View style={rowStyle}>
|
||||
<Text style={style}>
|
||||
{host.label}: {host.status} ·{" "}
|
||||
{host.connections.map((connection) => connection.uri ?? "via another host").join(", ")}
|
||||
</Text>
|
||||
<Pressable accessibilityRole="button" onPress={listAgents}>
|
||||
<Text style={style}>List agents</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={update}>
|
||||
<Text style={style}>Rename and color</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={open}>
|
||||
<Text style={style}>Host settings</Text>
|
||||
</Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={remove}>
|
||||
<Text style={style}>Remove host</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
||||
import { observeHosts } from "@getpaseo/plugin/client";
|
||||
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
||||
import { Hosts } from "./client/hosts";
|
||||
|
||||
@@ -11,5 +12,8 @@ function HostsItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addScreen({ id: "main", title: "Host agents", Component: Hosts });
|
||||
client.addSidebarHeaderItem({ id: "hosts", title: "Host agents", Component: HostsItem });
|
||||
return () => {};
|
||||
return observeHosts((hosts) => {
|
||||
for (const host of hosts)
|
||||
if (host.authFailureReason) console.log(`${host.label} requires attention`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "id": "host-agents", "requirements": { "paseo": ">=0.8.0" } }
|
||||
{ "id": "host-agents", "requirements": { "paseo": ">=0.15.0" } }
|
||||
|
||||
@@ -2078,7 +2078,7 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
|
||||
|
||||
The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection.
|
||||
|
||||
### Discover hosts and target another host
|
||||
### Hosts
|
||||
|
||||
Use `useHosts()` to display configured hosts and `getPaseoClient(serverId)` in an action callback
|
||||
to run SDK operations on one of them:
|
||||
@@ -2124,14 +2124,36 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
|
||||
}
|
||||
```
|
||||
|
||||
`useHosts(): readonly PluginHostSummary[]` includes offline hosts and updates when hosts,
|
||||
labels, or statuses change.
|
||||
`useHosts(): readonly PluginHost[]` includes offline hosts and updates when projected fields change.
|
||||
`observeHosts(listener)` immediately delivers the same list and returns an unsubscribe function.
|
||||
Set `requirements.paseo` in the manifest to the release that ships these host capabilities.
|
||||
|
||||
| Summary field | Type or values | Meaning |
|
||||
| ------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||
| `serverId` | `string` | ID to pass to `getPaseoClient`. |
|
||||
| `label` | `string` | Host's display name. |
|
||||
| `status` | `"idle"`, `"connecting"`, `"online"`, `"offline"`, `"error"` | Current app connection status. SDK calls require `"online"`. |
|
||||
| Host field | Meaning |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `serverId` | ID to pass to `getPaseoClient`. |
|
||||
| `label` | Display name. |
|
||||
| `status` | `idle`, `connecting`, `online`, `offline`, or `error`. SDK calls require `online`. |
|
||||
| `appearance` | `color` and `badgeDisplay`; `null` badge display uses the local or remote default. |
|
||||
| `connections` | Saved connections with an ID, type, credential-free `uri`, and type-specific settings. Routed connections have a null URI. |
|
||||
| `activeConnectionId` | ID of the active connection, or null. |
|
||||
| `error` | Current connection error, or null. |
|
||||
| `authFailureReason` | `password_required`, `incorrect_password`, or null. |
|
||||
| `lastOnlineAt`, `createdAt`, `updatedAt` | ISO timestamps; `lastOnlineAt` can be null. |
|
||||
|
||||
Call `host.update({ label, appearance })`, `host.removeConnection(id)`, `host.remove()`,
|
||||
or `host.openSettings()` to manage a host. Updating `appearance.badgeDisplay` to null restores
|
||||
the automatic default. Removing the last connection removes the host. The desktop daemon host
|
||||
cannot be removed by a plugin.
|
||||
|
||||
Use `addHost({ connection, label? })` with the [shared connection grammar](../cli.md#connected-hosts).
|
||||
A connection may include `?password=`. The password is used for the probe and saved locally; plugins
|
||||
cannot read it back. Relay targets and pairing links ask the user to confirm.
|
||||
|
||||
| Result | Meaning |
|
||||
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `{ status: "saved", host }` | The host or connection was saved. |
|
||||
| `{ status: "password_required", reason }` | Nothing was saved; supply a password and retry. The reason is `password_required` or `incorrect_password`. |
|
||||
| `{ status: "cancelled" }` | The user declined the confirmation. |
|
||||
|
||||
`getPaseoClient(serverId: string): PaseoApi` borrows the host's authenticated app connection.
|
||||
Call it in client entry code or callbacks; it opens no socket and does not require the plugin
|
||||
@@ -2145,13 +2167,13 @@ on the target daemon. Acquire the API when performing an action to use the curre
|
||||
| Connection settings change or the app switches connections, including automatic failover | The old API is released. Call `getPaseoClient(serverId)` again and recreate subscriptions. |
|
||||
| Host is removed | Its API is released; the removed ID is unknown. |
|
||||
| `client.dispose()` | Releases that API and its observations. A later getter call returns a fresh API over the app connection. Disposing the old API again leaves the new API usable. |
|
||||
| Originating plugin unloads | All its borrowed APIs and observations are released, including those targeting other hosts. Retained handles cannot outlive the installation. |
|
||||
| Originating plugin unloads | Borrowed APIs and host observations are released. Later host handle calls throw `Plugin has stopped`. |
|
||||
| Surface host selection changes | `usePaseo()` follows the selected host. An explicitly acquired API keeps its original target. |
|
||||
|
||||
You can also release individual subscriptions through the normal SDK API.
|
||||
|
||||
Plugins are trusted app code; cross-host access is intentional. Summaries contain no connection
|
||||
URLs or credentials, and borrowed APIs provide no connection lifecycle controls. See the
|
||||
Plugins see connection targets and daemon identities but cannot read saved passwords. Borrowed APIs
|
||||
provide no connection lifecycle controls. See the
|
||||
[host agents example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts).
|
||||
|
||||
## Add plugin-specific backend behavior
|
||||
|
||||
@@ -1779,7 +1779,7 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
|
||||
|
||||
返回的 API 涵盖项目、工作区、Agent、终端、Provider 和 daemon 配置。其方法请参阅 [SDK API 参考](/docs/sdk/reference)。这里刻意没有连接生命周期方法,因为连接由 Paseo 负责。
|
||||
|
||||
### 发现主机并指定另一台主机 {#discover-hosts-and-target-another-host}
|
||||
### 主机 {#hosts}
|
||||
|
||||
使用 `useHosts()` 显示已配置的主机,并在操作回调中使用 `getPaseoClient(serverId)` 对其中一台主机
|
||||
执行 SDK 操作:
|
||||
@@ -1825,13 +1825,33 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
|
||||
}
|
||||
```
|
||||
|
||||
`useHosts(): readonly PluginHostSummary[]` 包括离线的主机,并在主机、标签或状态变化时更新。
|
||||
`useHosts(): readonly PluginHost[]` 包括离线主机,并在可见字段变化时更新。`observeHosts(listener)`
|
||||
立即提供相同的列表,并返回取消订阅函数。请将清单中的 `requirements.paseo` 设为提供这些主机能力的发布版本。
|
||||
|
||||
| 摘要字段 | 类型或取值 | 含义 |
|
||||
| ---------- | ------------------------------------------------------------ | --------------------------------------------- |
|
||||
| `serverId` | `string` | 传给 `getPaseoClient` 的 ID。 |
|
||||
| `label` | `string` | 主机的显示名称。 |
|
||||
| `status` | `"idle"`、`"connecting"`、`"online"`、`"offline"`、`"error"` | 当前应用的连接状态。SDK 调用需要 `"online"`。 |
|
||||
| 主机字段 | 含义 |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `serverId` | 传给 `getPaseoClient` 的 ID。 |
|
||||
| `label` | 显示名称。 |
|
||||
| `status` | `idle`、`connecting`、`online`、`offline` 或 `error`。SDK 调用需要 `online`。 |
|
||||
| `appearance` | `color` 和 `badgeDisplay`;`null` 使用本地主机或远程主机的默认显示方式。 |
|
||||
| `connections` | 已保存的连接,包括 ID、类型、不含凭据的 `uri` 和类型专属设置。路由连接的 URI 为 null。 |
|
||||
| `activeConnectionId` | 活动连接的 ID,或 null。 |
|
||||
| `error` | 当前连接错误,或 null。 |
|
||||
| `authFailureReason` | `password_required`、`incorrect_password` 或 null。 |
|
||||
| `lastOnlineAt`、`createdAt`、`updatedAt` | ISO 时间戳;`lastOnlineAt` 可以为 null。 |
|
||||
|
||||
调用 `host.update({ label, appearance })`、`host.removeConnection(id)`、`host.remove()` 或
|
||||
`host.openSettings()` 管理主机。将 `appearance.badgeDisplay` 更新为 null 可恢复自动默认值。
|
||||
移除最后一个连接会移除主机。插件不能移除桌面 daemon 主机。
|
||||
|
||||
使用 `addHost({ connection, label? })` 和[共享连接语法](../cli.md#connected-hosts)。连接字符串可以包含
|
||||
`?password=`。密码用于探测并保存在本地,插件无法读回密码。中继目标和配对链接需要用户确认。
|
||||
|
||||
| 结果 | 含义 |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `{ status: "saved", host }` | 主机或连接已保存。 |
|
||||
| `{ status: "password_required", reason }` | 未保存任何内容;提供密码后重试。原因是 `password_required` 或 `incorrect_password`。 |
|
||||
| `{ status: "cancelled" }` | 用户拒绝了确认。 |
|
||||
|
||||
`getPaseoClient(serverId: string): PaseoApi` 借用该主机已认证的应用连接。请在客户端入口代码或回调中
|
||||
调用它;它不会打开 socket,也不要求目标 daemon 上安装了该插件。在执行操作时再获取 API,以便使用当前的
|
||||
@@ -1849,8 +1869,7 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
|
||||
|
||||
你也可以通过普通的 SDK API 释放单个订阅。
|
||||
|
||||
插件是受信任的应用代码;跨主机访问是有意设计的。摘要中不包含连接 URL 或凭据,借用的 API 也不提供连接
|
||||
生命周期控制。请参阅[主机 Agent 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts)。
|
||||
插件可以查看连接目标和 daemon 身份,但不能读取已保存的密码。借用的 API 不提供连接生命周期控制。请参阅[主机 Agent 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts)。
|
||||
|
||||
## 添加插件特有的后端行为 {#add-plugin-specific-backend-behavior}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user