Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 8bd38fb236 chore(release): cut 0.15.0-beta.1 2026-11-01 06:19:33 +01:00
Mohamed Boudra 7e5ad53491 docs(changelog): prepare 0.15.0-beta.1 2026-11-01 06:11:09 +01:00
paseo-ai[bot] a529578cd1 fix: update lockfile signatures and Nix hash [skip ci]
(cherry picked from commit b2666a0aec)
2026-11-01 06:11:09 +01:00
218 changed files with 2620 additions and 11708 deletions
+8
View File
@@ -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",
+1 -4
View File
@@ -74,10 +74,7 @@ registrations remain inspectable; a changed destination requires explicit identi
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
operations; requesting this feature gives update guidance. The registry retries an older destination
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
authentication failures stay in `error` until an update changes the connection, `enabled`, or
`replaceIdentity`, or A restarts.
operations; requesting this feature gives update guidance.
Disposing a routed API releases its observations and session. It does not remove the saved host or
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
+4 -4
View File
@@ -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` |
-6
View File
@@ -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 -21
View File
@@ -357,27 +357,9 @@ keys are stored under a `param.` prefix; plugins never see it and may use any ke
`openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
params.
RPC and subscription contracts validate inputs, outputs, and events in both the app and plugin
subprocess. `useRpc` returns a typed async function. Use the host-provided `@tanstack/react-query`
for request state and caching; Paseo gives each plugin installation its own query client.
A subscription is a session-owned observation (`plugin-rpc` delivery family): the session that
subscribed owns its lifetime, so release, disconnect, plugin stop, and reload need no plugin code.
Each subscriber gets its own handler call in the subprocess; there is no fan-out or replay. The
runtime buffers pushes until the session has sent `plugin.rpc.subscribe.response`, so a client
never sees an event for an unknown subscription. The app installation's lifetime aborts every
subscription its surfaces left open, and a reload replaces the installation, which resubscribes
mounted hooks.
The byte rule (a top-level `Uint8Array` is binary, a nested one is a validation error) is checked
once per hop by `assertPluginWireValue`. Bytes ride plugin binary frames, opcodes `0x20`-`0x2f`
(`packages/protocol/src/binary-frames/plugin.ts`); nothing on the path copies or re-encodes them,
and `plugin-subscriptions.perf.e2e.test.ts` locks that. Keep it that way: never stringify a plugin
payload for a log, and the client's bootstrap buffer holds bytes only until the first observer
attaches. Binary outputs reach only clients that send `pluginBinaryPayloads`. A binary input
frame (`0x22`) becomes a synthetic `plugin.rpc.invoke.request` and runs through the normal message
path, so it is authorized like any other invoke. The author contract, including the native upload
limit, is in [stream events from the daemon](../public-docs/plugins/reference.md#subscriptions).
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects,
workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
+1 -1
View File
@@ -127,7 +127,7 @@ buildNpmPackage {
# The Nix output is not a distributable DMG, so leave it unsigned and
# disable the hardened runtime that requires a matching signature.
CSC_IDENTITY_AUTO_DISCOVERY=false \
npm exec --offline -- electron-builder \
../../node_modules/.bin/electron-builder \
--config electron-builder.yml \
--dir \
--mac \
+1 -1
View File
@@ -1 +1 @@
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
+1295 -1018
View File
File diff suppressed because it is too large Load Diff
@@ -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,
}) => {
@@ -3,7 +3,7 @@ import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -12,14 +12,8 @@ function userMessages(page: Page) {
return page.getByTestId("user-message");
}
async function sendDraftWhenReady(page: Page) {
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await pressInComposer(page, "Enter");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await fillComposerDraft(page, text);
await sendDraftWhenReady(page);
await submitMessage(page, text);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, "");
}
@@ -55,7 +49,7 @@ test.describe("Composer recall", () => {
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await sendDraftWhenReady(page);
await pressInComposer(page, "Enter");
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
@@ -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-" });
@@ -1,6 +1,4 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { test } from "../support/fixtures";
import {
collapseFolder,
expandFolder,
@@ -10,7 +8,6 @@ import {
openFileExplorer,
openFileFromExplorer,
} from "../support/helpers/file-explorer";
import { delayDirectoryListingResponse } from "../support/helpers/directory-listing-gate";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
@@ -52,66 +49,3 @@ test.describe("File explorer collapse", () => {
await expectExplorerEntryVisible(page, "guide.md");
});
});
test("Files Refresh reveals new files when a cached collapsed folder is reopened", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "guide.md");
await collapseFolder(page, "docs");
await expectExplorerEntryHidden(page, "guide.md");
await createFileOutsidePaseo("docs/NEW.md");
await refreshFiles(page);
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "guide.md");
await expectExplorerEntryVisible(page, "NEW.md");
await createFileOutsidePaseo("docs/NEXT.md");
await refreshFiles(page);
await expectExplorerEntryVisible(page, "NEXT.md");
});
async function refreshFiles(page: import("@playwright/test").Page): Promise<void> {
const refresh = page.getByRole("button", { name: "Refresh files", exact: true });
await refresh.click();
await expect(refresh).toBeEnabled();
}
async function createFileOutsidePaseo(filePath: string): Promise<void> {
await writeFile(path.join(workspace.repoPath, filePath), "# New output\n");
}
test("Files Refresh ignores an old reply for a folder collapsed while loading", async ({
page,
}) => {
const reply = await delayDirectoryListingResponse(page, "docs");
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expandFolder(page, "docs");
await reply.waitUntilHeld();
await collapseFolder(page, "docs");
await createFileOutsidePaseo("docs/DELAYED.md");
await refreshFiles(page);
await reply.release();
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "DELAYED.md");
});
test("Files Refresh preserves expanded folders while their saved view is restoring", async ({
page,
}) => {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expandFolder(page, "docs");
await expectExplorerEntryVisible(page, "guide.md");
const reply = await delayDirectoryListingResponse(page, "docs");
await page.reload();
await openFileExplorer(page);
await reply.waitUntilHeld();
await refreshFiles(page);
await expectExplorerEntryVisible(page, "guide.md");
await reply.release();
await expectExplorerEntryVisible(page, "guide.md");
});
@@ -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,79 +0,0 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
/** Installs a plugin example, opens a seeded workspace, and runs `exercise` against them. */
async function withExample(
page: Page,
example: { name: string; id: string },
exercise: (workspace: SeededWorkspace) => Promise<void>,
): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const copy = await copyPluginExample(example.name);
const workspace = await seedWorkspace({ repoPrefix: `${example.name}-` });
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(copy.directory);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
await waitForWorkspaceInSidebar(page, target);
await switchWorkspaceViaSidebar({ page, ...target });
await exercise(workspace);
} finally {
try {
await client.removePlugin(example.id);
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await workspace.cleanup();
await client.close();
await copy.cleanup();
}
}
}
test.describe("plugin subscriptions", () => {
test.describe.configure({ timeout: 180_000 });
test("a panel follows events the daemon pushes as files change", async ({ page }) => {
await withExample(page, { name: "diff-watch", id: "diff-watch" }, async (workspace) => {
await runCommand(page, "Open live changes");
await expect(page.getByText("No changes", { exact: true })).toBeVisible();
await writeFile(path.join(workspace.repoPath, "pushed.txt"), "from the test\n");
await expect(page.getByText("1 file changed", { exact: true })).toBeVisible();
await expect(page.getByText("pushed.txt", { exact: true })).toBeVisible();
});
});
test("a panel plays streamed bytes and sends bytes back", async ({ page }) => {
await withExample(page, { name: "media-stream", id: "media-stream" }, async () => {
await runCommand(page, "Open media stream");
await expect(page.getByText("live", { exact: true })).toBeVisible();
await expect(page.getByText(/^[1-9]\d* video frames/)).toBeVisible();
await page.getByRole("button", { name: "Send this frame back", exact: true }).click();
await expect(
page.getByText(/^The daemon received [1-9]\d* bytes, sha256 [0-9a-f]{12}$/),
).toBeVisible();
});
});
});
@@ -108,7 +108,10 @@ test.describe("Question option text", () => {
}
});
test("selecting description text keeps the question open", async ({ page, context }) => {
test("selecting description text never answers, and the label still does", async ({
page,
context,
}) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
@@ -117,13 +120,16 @@ test.describe("Question option text", () => {
await dragAcross(page, description);
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
await expectChecked(done, false);
await description.getByText("Logs are at", { exact: false }).dblclick({
position: { x: 4, y: 4 },
});
await expect.poll(() => selectedText(page)).toBe("Logs");
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
await expectChecked(done, true);
await continueToNextQuestion(page);
await expectChecked(done, false);
await done.getByText("Done", { exact: true }).click();
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await openQuestion(page, { index: 1, total: 2 });
await expectChecked(done, true);
@@ -132,17 +138,11 @@ test.describe("Question option text", () => {
}
});
test("double-clicking a single-select label selects once without advancing", async ({
page,
context,
}) => {
test("double-clicking a single-select label answers once", async ({ page, context }) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
await continueToNextQuestion(page);
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
@@ -154,12 +154,14 @@ test.describe("Question option text", () => {
}
});
test("multi-select descriptions support links and text selection", async ({ page, context }) => {
test("multi-select description links and selection leave the option unchanged", async ({
page,
context,
}) => {
test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context);
try {
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
await continueToNextQuestion(page);
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
const lint = card.getByRole("checkbox", { name: "Lint" });
@@ -175,16 +177,18 @@ test.describe("Question option text", () => {
await dragAcross(page, description);
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
await expectChecked(lint, false);
await description.getByText("Rules:", { exact: false }).dblclick({
position: { x: 4, y: 4 },
});
await expect.poll(() => selectedText(page)).toBe("Rules");
await expectChecked(lint, true);
await expectChecked(lint, false);
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
await expectChecked(typecheck, true);
await lint.getByText("Lint", { exact: true }).click();
await expectChecked(lint, false);
await expectChecked(lint, true);
} finally {
await session.cleanup();
}
@@ -264,7 +268,6 @@ test.describe("Question prompt pagination", () => {
await expectQuestionHidden(page, SUCCESS_QUESTION);
await chooseQuestionOption(page, "App");
await continueToNextQuestion(page);
await expectCurrentQuestion(page, {
index: 2,
total: TOTAL_QUESTIONS,
@@ -285,7 +288,6 @@ test.describe("Question prompt pagination", () => {
preview: 'flags.enabled("new-surface")',
});
await chooseQuestionOption(page, "Behind feature flag");
await continueToNextQuestion(page);
await expectCurrentQuestion(page, {
index: 3,
total: TOTAL_QUESTIONS,
@@ -464,9 +466,7 @@ retryTest(
retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card");
await chooseQuestionOption(page, "App");
await continueToNextQuestion(page);
await chooseQuestionOption(page, "Behind feature flag");
await continueToNextQuestion(page);
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Preserve my answers after a failed submission.",
@@ -520,9 +520,7 @@ retryTest(
const card = page.getByTestId("question-form-card");
questionClient.releaseFirstResponse();
await chooseQuestionOption(page, "App");
await continueToNextQuestion(page);
await chooseQuestionOption(page, "Behind feature flag");
await continueToNextQuestion(page);
await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION,
answer: "Start with the internal team.",
@@ -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 });
}
@@ -1,54 +0,0 @@
// Open an existing compact chat, with the keyboard closed, then interrupt its host connection.
// Hold that interruption externally (for example, stop a disposable SSH TCP forward).
// Leave the daemon running. This test observes the public reconnecting state.
// node placement.mjs <session> <platform> <coordinate-scale> <composer-label> <artifacts>
// coordinate-scale is screenshot pixels per layout point (Android density; iOS snapshots use 1).
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
const [, , session, platform, coordinateScale, composerLabel, artifacts] = process.argv;
assert(
session && ["android", "ios"].includes(platform) && composerLabel && artifacts,
"Pass all five arguments",
);
const scale = Number(coordinateScale);
assert(scale > 0, "Pass a positive coordinate scale");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", ["--session", session, "--platform", platform, ...args], {
encoding: "utf8",
timeout: 90_000,
});
}
function capture(name) {
const snapshot = ad("snapshot", "--raw", "--json");
writeFileSync(path.join(artifacts, `${name}.json`), snapshot);
ad("screenshot", path.join(artifacts, `${name}.png`));
const nodes = JSON.parse(snapshot).data.nodes;
const header = nodes.find((node) => node.identifier === "floating-header");
const toast = nodes.find((node) => node.identifier === "agent-reconnecting-toast");
assert(header?.rect && toast?.rect, "Measured header and reconnecting toast must be present");
assert(toast.rect.height > 0 && toast.rect.width > 0, "Toast must have visible bounds");
const gap = (toast.rect.y - header.rect.y - header.rect.height) / scale;
assert(Math.abs(gap - 12) <= 1, `${name}: actual header-to-toast gap ${gap}, expected12 points`);
return toast.rect.y / scale;
}
ad("wait", 'id="agent-reconnecting-toast"', "20000");
const closedTop = capture("keyboard-closed");
ad("press", `label=${JSON.stringify(composerLabel)}`);
const opened = ad("snapshot", "--raw", "--json");
const nodes = JSON.parse(opened).data.nodes;
assert(
nodes.some(
(node) => /keyboard/i.test(node.type ?? "") || /inputmethod/i.test(node.bundleId ?? ""),
),
"Use a real visible software keyboard; open Android sessions with --no-test-ime",
);
const openTop = capture("keyboard-open");
assert(Math.abs(openTop - closedTop) <= 1, "Keyboard must not translate the toast");
console.log(
"PASS: rendered reconnecting toast stays12 points below measured header, keyboard closed/open",
);
@@ -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();
}
@@ -1,44 +0,0 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
function listingResponse(message: string | Buffer) {
try {
const envelope = JSON.parse(message.toString());
return envelope.message?.type === "file_explorer_response" ? envelope.message.payload : null;
} catch {
return null;
}
}
/** Delay one real directory response after the daemon has read its contents. */
export async function delayDirectoryListingResponse(page: Page, directoryPath: string) {
let held: { socket: WebSocketRoute; message: string | Buffer; requestId: string } | null = null;
let resolveHeld!: () => void;
const heldResponse = new Promise<void>((resolve) => {
resolveHeld = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (socket) => {
const server = socket.connectToServer();
socket.onMessage((message) => server.send(message));
server.onMessage((message) => {
const response = listingResponse(message);
if (!held && response?.mode === "list" && response.path === directoryPath) {
held = { socket, message, requestId: response.requestId };
resolveHeld();
return;
}
socket.send(message);
});
});
return {
waitUntilHeld: () => heldResponse,
release: async () => {
if (!held) throw new Error("Directory response must be held before release");
held.socket.send(held.message);
// Let the delivered reply update the UI before the user reopens the folder.
await page.evaluate(
() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
);
},
};
}
-22
View File
@@ -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();
}
@@ -69,9 +69,6 @@ function installProbe(
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
}
+6 -9
View File
@@ -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>
@@ -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
+3 -2
View File
@@ -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} />;
}
@@ -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>
);
}
@@ -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,
@@ -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}>
@@ -64,15 +64,10 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import {
DIRECTORY_LISTING_INVALIDATED,
type DirectoryListingResult,
} from "@/file-explorer/directory-listings";
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
import {
flattenExplorerTree,
reconcileRestoredExpandedPaths,
refreshExplorerDirectories,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
@@ -440,7 +435,6 @@ export function FileExplorerPane({
const {
requestDirectoryListing,
invalidateDirectoryListings,
createEntry,
renameEntry,
duplicateEntry,
@@ -892,11 +886,9 @@ export function FileExplorerPane({
refreshExplorerDirectories({
hasWorkspaceScope,
expandedPaths,
showHiddenFiles: usePanelStore.getState().explorerShowHiddenFiles,
requestDirectoryListing,
invalidateDirectoryListings,
}),
[invalidateDirectoryListings, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
[expandedPaths, hasWorkspaceScope, requestDirectoryListing],
);
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
@@ -1432,7 +1424,7 @@ function toggleDirectory({
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<DirectoryListingResult>;
) => Promise<ExplorerDirectory | null>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
}): void {
if (!workspaceStateKey) {
@@ -1569,7 +1561,7 @@ async function initializeExplorer({
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<DirectoryListingResult>;
) => Promise<ExplorerDirectory | null>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
}): Promise<void> {
if (!hasWorkspaceScope || hasInitializedRef.current) {
@@ -1580,7 +1572,6 @@ async function initializeExplorer({
recordHistory: false,
setCurrentPath: false,
});
if (rootDirectory === DIRECTORY_LISTING_INVALIDATED) return;
if (!rootDirectory) {
hasInitializedRef.current = false;
return;
@@ -1602,10 +1593,7 @@ async function initializeExplorer({
const hiddenPersistedPaths = showHiddenFiles
? []
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
const restoredPathsWithHidden =
restoredPaths === DIRECTORY_LISTING_INVALIDATED
? DIRECTORY_LISTING_INVALIDATED
: [...restoredPaths, ...hiddenPersistedPaths];
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
reconcileRestoredExpandedPaths({
persistedExpandedPaths,
@@ -1615,6 +1603,39 @@ async function initializeExplorer({
);
}
async function refreshExplorerDirectories({
hasWorkspaceScope,
expandedPaths,
requestDirectoryListing,
}: {
hasWorkspaceScope: boolean;
expandedPaths: Set<string>;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<ExplorerDirectory | null>;
}): Promise<null> {
if (!hasWorkspaceScope) {
return null;
}
const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles;
const directoryPaths = Array.from(expandedPaths).filter(
(path) => showHiddenFiles || !isHiddenExplorerPath(path),
);
if (!directoryPaths.includes(".")) {
directoryPaths.unshift(".");
}
await Promise.all(
directoryPaths.map((path) =>
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
}),
),
);
return null;
}
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
if (!state) {
return ".";
@@ -1,26 +1,8 @@
import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react";
import { useCallback, useState, type ReactNode } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Portal, PortalHost } from "@gorhom/portal";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { EdgeFade } from "@/components/ui/edge-fade";
const OverlayClearanceContext = createContext(0);
const OverlayHostContext = createContext<string | null>(null);
/** Keep status overlays above the fade without lifting the scrolling content. */
export function FloatingHeaderOverlay({ children }: { children: ReactNode }) {
const hostName = useContext(OverlayHostContext);
const active = useRetainedPanelActive();
if (!hostName) return children;
return active ? <Portal hostName={hostName}>{children}</Portal> : null;
}
/** Clearance for stationary overlays inside this layout, measured to the header bottom. */
export function useFloatingHeaderOverlayClearance(): number {
return useContext(OverlayClearanceContext);
}
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
const FADE_OVERHANG = 48;
/** Share of the fade that stays opaque, so the header's title and icons keep contrast. */
@@ -38,24 +20,15 @@ interface FloatingHeaderLayoutProps {
* same tree position in both modes, so toggling `floating` never remounts them.
*/
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
const overlayHostName = useId();
const [headerHeight, setHeaderHeight] = useState(0);
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
setHeaderHeight(Math.round(event.nativeEvent.layout.height));
}, []);
const overlayClearance = floating ? headerHeight : 0;
return (
<View style={styles.root}>
{floating ? null : header}
<View style={styles.content}>
<OverlayClearanceContext.Provider value={overlayClearance}>
<OverlayHostContext.Provider value={floating ? overlayHostName : null}>
{children(floating ? headerHeight : 0)}
</OverlayHostContext.Provider>
</OverlayClearanceContext.Provider>
</View>
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
{floating ? (
<View style={styles.overlay} pointerEvents="box-none">
<EdgeFade
@@ -64,19 +37,11 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
backdrop="surfaceWorkspace"
style={styles.fade}
/>
<View
testID="floating-header"
collapsable={false}
pointerEvents="box-none"
onLayout={handleHeaderLayout}
>
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
{header}
</View>
</View>
) : null}
<View style={styles.statusOverlay} pointerEvents="box-none">
<PortalHost name={overlayHostName} />
</View>
</View>
);
}
@@ -90,13 +55,6 @@ const styles = StyleSheet.create({
flex: 1,
minHeight: 0,
},
statusOverlay: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
overlay: {
position: "absolute",
top: 0,
+9 -4
View File
@@ -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&amp;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![Local](assets/a.png) | 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&amp;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&amp;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&amp;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",
+5 -24
View File
@@ -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 {
@@ -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,
}));
@@ -39,10 +39,7 @@ afterEach(() => {
}
});
function buildPermission(
question: Record<string, unknown>,
following: Record<string, unknown>[] = [],
): PendingPermission {
function buildPermission(question: Record<string, unknown>): PendingPermission {
return {
key: "perm-1",
agentId: "agent-1",
@@ -51,12 +48,12 @@ function buildPermission(
provider: "claude",
name: "AskUserQuestion",
kind: "question",
input: { questions: [question, ...following] },
input: { questions: [question] },
},
};
}
function mountCard(question: Record<string, unknown>, following: Record<string, unknown>[] = []) {
function mountCard(question: Record<string, unknown>) {
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
const container = document.createElement("div");
document.body.appendChild(container);
@@ -64,7 +61,7 @@ function mountCard(question: Record<string, unknown>, following: Record<string,
act(() =>
root.render(
<QuestionFormCard
permission={buildPermission(question, following)}
permission={buildPermission(question)}
onRespond={onRespond}
isResponding={false}
responseError={null}
@@ -74,14 +71,10 @@ function mountCard(question: Record<string, unknown>, following: Record<string,
mounted.push({ root, container });
const view = within(container);
const optionRole = question.multiSelect ? "checkbox" : "radio";
const otherInput = () =>
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
const check = (label: string) =>
act(() =>
(
view.queryByRole("radio", { name: label }) ?? view.getByRole("checkbox", { name: label })
).click(),
);
const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
const type = (text: string) => {
const input = otherInput();
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
@@ -97,7 +90,7 @@ function mountCard(question: Record<string, unknown>, following: Record<string,
if (!response || response.behavior !== "allow") throw new Error("card did not submit");
return (response.updatedInput as { answers: Record<string, string> }).answers;
};
return { check, type, otherInput, submit, submittedAnswers, view, onRespond };
return { check, type, otherInput, submit, submittedAnswers };
}
const multiSelectQuestion = {
@@ -161,31 +154,3 @@ describe("QuestionFormCard other answers", () => {
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
});
});
describe("QuestionFormCard explicit navigation", () => {
it("keeps a picked option visible until Next and submits only on Submit", () => {
const card = mountCard(singleSelectQuestion, [multiSelectQuestion]);
card.check("Codex");
expect(card.view.getByRole("radio", { name: "Codex" }).getAttribute("aria-checked")).toBe(
"true",
);
expect(card.onRespond).not.toHaveBeenCalled();
act(() => card.view.getByRole("button", { name: "Next" }).click());
card.check("Apple");
expect(card.onRespond).not.toHaveBeenCalled();
card.submit();
expect(card.submittedAnswers()).toEqual({ Provider: "Codex", Fruits: "Apple" });
});
it("selects an option when its description is clicked without submitting", () => {
const card = mountCard({
...singleSelectQuestion,
options: [{ label: "Codex", description: "Use this provider" }],
});
act(() => card.view.getByText("Use this provider").click());
expect(card.view.getByRole("radio", { name: "Codex" }).getAttribute("aria-checked")).toBe(
"true",
);
expect(card.onRespond).not.toHaveBeenCalled();
});
});
@@ -43,16 +43,20 @@ const IS_WEB = isWeb;
// drag to the preview's nested scroll, so lines past a cap would be unreachable.
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
// Links are independent actions. Ignore repeat clicks so double-click text selection
// toggles an option only once.
function isNonAnsweringClick(event: GestureResponderEvent): boolean {
// Web clicks that must not answer: a link in the label, anything in the description, and the
// second click of a double-click, which would otherwise land on the next question's options.
function isNonAnsweringClick(event: GestureResponderEvent, description: View | null): boolean {
if (!IS_WEB) return false;
const click = event.nativeEvent as unknown as MouseEvent;
if (click.detail > 1) return true;
const target = click.target as Element | null;
return Boolean(target?.closest("a"));
const descriptionElement = description as unknown as Element | null;
return Boolean(target?.closest("a") || descriptionElement?.contains(target));
}
// The description is selectable text, so a touch on it belongs to the text, not the option.
const claimResponder = () => true;
function getQuestionInputPlaceholder({
question,
answerPlaceholder,
@@ -88,9 +92,10 @@ function QuestionOptionRow({
}: QuestionOptionRowProps) {
const { theme } = useUnistyles();
const descriptionRef = useRef<View>(null);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
if (isNonAnsweringClick(event)) return;
if (isNonAnsweringClick(event, descriptionRef.current)) return;
onToggle(qIndex, optIndex, multiSelect);
},
[onToggle, qIndex, optIndex, multiSelect],
@@ -147,7 +152,11 @@ function QuestionOptionRow({
<View style={styles.optionTextBlock}>
<MarkdownRenderer text={option.label} compact muted={!isSelected} selectable={false} />
{option.description ? (
<View testID="question-form-option-description">
<View
ref={descriptionRef}
testID="question-form-option-description"
onStartShouldSetResponder={claimResponder}
>
<MarkdownRenderer text={option.description} compact muted />
</View>
) : null}
@@ -404,8 +413,12 @@ export function QuestionFormCard({
});
otherInputRef.current?.replaceText("");
}
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
}
},
[otherTexts, selections],
[activeQuestionIndex, otherTexts, questions, selections],
);
const setOtherText = useCallback(
+2 -8
View File
@@ -1,7 +1,3 @@
import {
FloatingHeaderOverlay,
useFloatingHeaderOverlayClearance,
} from "@/components/floating-header";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import {
@@ -194,11 +190,9 @@ export function ToastViewport({
return clearTimer;
}, [clearTimer, durationMs, scheduleDismiss, toastId]);
const panelClearance = useFloatingHeaderOverlayClearance();
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const topOffset =
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0;
const content = (
<View
@@ -226,7 +220,7 @@ export function ToastViewport({
return createPortal(content, getOverlayRoot());
}
return placement === "panel" ? <FloatingHeaderOverlay>{content}</FloatingHeaderOverlay> : content;
return content;
}
function ToastCard({
+48 -7
View File
@@ -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>
);
@@ -179,8 +179,8 @@ export function ComposerDock({
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
</ComposerViewportContent>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
{overlay}
</ComposerViewport>
);
}
@@ -214,8 +214,8 @@ export function ComposerDock({
{composer}
</View>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
{overlay}
</ComposerViewport>
);
}
@@ -1,31 +0,0 @@
import { readFileSync } from "node:fs";
import ts from "typescript";
import { expect, it } from "vitest";
it("keeps dock overlays outside keyboard translation in every native layout", () => {
const source = ts.createSourceFile(
"dock.tsx",
readFileSync(new URL("./index.native.tsx", import.meta.url), "utf8"),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
let surfaces = 0;
function visit(node: ts.Node) {
if (
ts.isJsxElement(node) &&
node.openingElement.tagName.getText(source) === "KeyboardTranslateView"
) {
surfaces++;
function assertStationary(child: ts.Node) {
if (ts.isJsxExpression(child))
expect(child.expression?.getText(source)).not.toBe("overlay");
ts.forEachChild(child, assertStationary);
}
ts.forEachChild(node, assertStationary);
}
ts.forEachChild(node, visit);
}
visit(source);
expect(surfaces).toBe(2);
});
@@ -1,180 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store";
import {
refreshExplorerDirectories,
restoreExpandedDirectories,
showHiddenFilesAndRestoreExpandedDirectories,
reconcileRestoredExpandedPaths,
} from "./tree";
import {
DIRECTORY_LISTING_INVALIDATED,
invalidateExplorerDirectoryListings,
loadExplorerDirectory,
} from "./directory-listings";
function directory(path: string, names: string[]): ExplorerDirectory {
return {
path,
entries: names.map((name) => ({
path: `${path}/${name}`,
name,
kind: "file",
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
})),
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function explorer() {
let state: AgentFileExplorerState = {
listingGeneration: 0,
directories: new Map([[".", directory(".", ["docs"])]]),
isLoading: false,
lastError: null,
pendingRequest: null,
currentPath: ".",
history: ["."],
lastVisitedPath: ".",
selectedEntryPath: null,
};
const updateState = (update: (state: AgentFileExplorerState) => AgentFileExplorerState) => {
state = update(state);
return state;
};
return {
state: () => state,
load: (path: string, result: Promise<ExplorerDirectory>) =>
loadExplorerDirectory({
path,
updateState,
listDirectory: () => result,
fallbackError: "Directory listing failed",
options: { recordHistory: false, setCurrentPath: false },
}),
refresh: (retained: readonly string[]) =>
invalidateExplorerDirectoryListings(updateState, retained),
};
}
describe("directory listings across Refresh", () => {
it("ignores an earlier reply for a collapsed folder while the fresh root is loading", async () => {
const model = explorer();
const old = deferred<ExplorerDirectory>();
const oldLoad = model.load("docs", old.promise);
model.refresh(["."]);
const fresh = deferred<ExplorerDirectory>();
const freshLoad = model.load(".", fresh.promise);
old.resolve(directory("docs", ["OLD.md"]));
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
expect(model.state().directories.has("docs")).toBe(false);
expect(model.state().isLoading).toBe(true);
expect(model.state().pendingRequest?.path).toBe(".");
const root = directory(".", ["docs", "NEW.md"]);
fresh.resolve(root);
await expect(freshLoad).resolves.toEqual(root);
expect(model.state().directories.get(".")).toEqual(root);
expect(model.state().isLoading).toBe(false);
});
it("does not let a pre-refresh reply replace a newer listing of the same folder", async () => {
const model = explorer();
const old = deferred<ExplorerDirectory>();
const oldLoad = model.load("docs", old.promise);
model.refresh([".", "docs"]);
const fresh = directory("docs", ["NEW.md"]);
await model.load("docs", Promise.resolve(fresh));
old.resolve(directory("docs", ["OLD.md"]));
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
expect(model.state().directories.get("docs")).toEqual(fresh);
});
it("ignores errors from requests invalidated by Refresh", async () => {
const model = explorer();
const old = deferred<ExplorerDirectory>();
const oldLoad = model.load("docs", old.promise);
model.refresh(["."]);
const fresh = deferred<ExplorerDirectory>();
const freshLoad = model.load(".", fresh.promise);
old.reject(new Error("Old request failed"));
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
expect(model.state().lastError).toBeNull();
expect(model.state().isLoading).toBe(true);
fresh.resolve(directory(".", ["docs"]));
await freshLoad;
});
it("keeps expanded listings visible and discards collapsed listings on Refresh", async () => {
const model = explorer();
const docs = directory("docs", ["guide.md"]);
await model.load("docs", Promise.resolve(docs));
await model.load("assets", Promise.resolve(directory("assets", ["logo.png"])));
model.refresh([".", "docs"]);
expect(model.state().directories.get("docs")).toEqual(docs);
expect(model.state().directories.has("assets")).toBe(false);
expect(model.state().currentPath).toBe(".");
expect(model.state().history).toEqual(["."]);
});
});
it.each([false, true])(
"preserves saved expansion when Refresh invalidates restoration (hidden: %s)",
async (hidden) => {
const model = explorer();
const folder = hidden ? ".hidden" : "docs";
const root: ExplorerDirectory = {
path: ".",
entries: [
{
name: folder,
path: folder,
kind: "directory",
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
},
],
};
const saved = new Set([".", folder]);
const pending = deferred<ExplorerDirectory>();
const input = {
rootDirectory: root,
persistedExpandedPaths: saved,
requestDirectoryListing: (path: string) => model.load(path, pending.promise),
};
const restoration = hidden
? showHiddenFilesAndRestoreExpandedDirectories({ ...input, showHiddenFiles: () => {} })
: restoreExpandedDirectories({ ...input, showHiddenFiles: false });
const fresh = directory(folder, ["NEW.md"]);
await refreshExplorerDirectories({
hasWorkspaceScope: true,
expandedPaths: saved,
showHiddenFiles: hidden,
invalidateDirectoryListings: model.refresh,
requestDirectoryListing: (path) =>
model.load(path, Promise.resolve(path === "." ? root : fresh)),
});
pending.resolve(directory(folder, ["OLD.md"]));
const restored = await restoration;
const expanded = reconcileRestoredExpandedPaths({
persistedExpandedPaths: saved,
currentExpandedPaths: saved,
restoredExpandedPaths: restored,
});
expect(model.state().directories.get(folder)).toEqual(fresh);
expect(expanded).toEqual([".", folder]);
},
);
@@ -1,91 +0,0 @@
import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store";
export const DIRECTORY_LISTING_INVALIDATED = Symbol("directory-listing-invalidated");
export type DirectoryListingResult =
| ExplorerDirectory
| null
| typeof DIRECTORY_LISTING_INVALIDATED;
type UpdateExplorerState = (
update: (state: AgentFileExplorerState) => AgentFileExplorerState,
) => AgentFileExplorerState | null;
function pushHistory(history: string[], path: string): string[] {
const normalizedHistory = history.length === 0 ? ["."] : history;
return normalizedHistory.at(-1) === path ? normalizedHistory : [...normalizedHistory, path];
}
export async function loadExplorerDirectory({
path,
options,
updateState,
listDirectory,
fallbackError,
}: {
path: string;
options?: { recordHistory?: boolean; setCurrentPath?: boolean };
updateState: UpdateExplorerState;
listDirectory: () => Promise<ExplorerDirectory>;
fallbackError: string;
}): Promise<DirectoryListingResult> {
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath;
const initial = updateState((state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path, mode: "list" },
...(shouldSetCurrentPath
? {
currentPath: path,
history: shouldRecordHistory ? pushHistory(state.history, path) : state.history,
lastVisitedPath: path,
}
: {}),
}));
if (!initial) return null;
const generation = initial.listingGeneration;
try {
const directory = await listDirectory();
let accepted = false;
updateState((state) => {
if (state.listingGeneration !== generation) return state;
accepted = true;
const directories = new Map(state.directories);
directories.set(directory.path, directory);
return { ...state, directories, isLoading: false, lastError: null, pendingRequest: null };
});
return accepted ? directory : DIRECTORY_LISTING_INVALIDATED;
} catch (error) {
let invalidated = false;
updateState((state) => {
if (state.listingGeneration !== generation) {
invalidated = true;
return state;
}
return {
...state,
isLoading: false,
lastError: error instanceof Error ? error.message : fallbackError,
pendingRequest: null,
};
});
return invalidated ? DIRECTORY_LISTING_INVALIDATED : null;
}
}
/** Refresh invalidates earlier replies as well as listings of collapsed folders. */
export function invalidateExplorerDirectoryListings(
updateState: UpdateExplorerState,
retainedPaths: readonly string[],
): void {
const retained = new Set(retainedPaths);
updateState((state) => ({
...state,
listingGeneration: state.listingGeneration + 1,
directories: new Map([...state.directories].filter(([path]) => retained.has(path))),
isLoading: false,
pendingRequest: null,
lastError: null,
}));
}
+1 -75
View File
@@ -1,10 +1,9 @@
import { describe, expect, it } from "vitest";
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { ExplorerEntry } from "@/stores/session-store";
import {
MAX_AUTO_EXPANDED_DIRECTORY_DEPTH,
flattenExplorerTree,
reconcileRestoredExpandedPaths,
refreshExplorerDirectories,
restoreExpandedDirectories,
setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories,
@@ -196,76 +195,3 @@ describe("file explorer tree", () => {
await expect(restoration).resolves.toEqual([".", ".hidden"]);
});
});
describe("refreshExplorerDirectories", () => {
function makeListing(path: string, names: string[]): ExplorerDirectory {
return {
path,
entries: names.map((name) => ({
name,
path: path === "." ? name : `${path}/${name}`,
kind: "file" as const,
size: 0,
modifiedAt: "2026-01-01T00:00:00.000Z",
})),
};
}
function createExplorer(disk: Record<string, string[]>, cached: Record<string, string[]>) {
const directories = new Map(
Object.entries(cached).map(([path, names]) => [path, makeListing(path, names)]),
);
return {
directories,
requestDirectoryListing: async (path: string) => {
const listing = makeListing(path, disk[path] ?? []);
directories.set(path, listing);
return listing;
},
invalidateDirectoryListings: (retainedPaths: readonly string[]) => {
const retained = new Set(retainedPaths);
for (const path of directories.keys()) if (!retained.has(path)) directories.delete(path);
},
};
}
it("does not keep a stale listing for a collapsed folder", async () => {
// docs/ was expanded and collapsed earlier, then NEW.md was created in it.
const explorer = createExplorer(
{ ".": ["docs"], docs: ["README.md", "NEW.md"] },
{ ".": ["docs"], docs: ["README.md"] },
);
await refreshExplorerDirectories({
hasWorkspaceScope: true,
expandedPaths: new Set(["."]),
showHiddenFiles: false,
requestDirectoryListing: explorer.requestDirectoryListing,
invalidateDirectoryListings: explorer.invalidateDirectoryListings,
});
// Expanding docs/ only fetches when it has no cached listing.
expect(explorer.directories.has("docs")).toBe(false);
expect(explorer.directories.get(".")?.entries.map((entry) => entry.name)).toEqual(["docs"]);
});
it("refreshes expanded folders and keeps their listings", async () => {
const explorer = createExplorer(
{ ".": ["src"], src: ["a.ts", "b.ts"] },
{ ".": ["src"], src: ["a.ts"] },
);
await refreshExplorerDirectories({
hasWorkspaceScope: true,
expandedPaths: new Set([".", "src"]),
showHiddenFiles: false,
requestDirectoryListing: explorer.requestDirectoryListing,
invalidateDirectoryListings: explorer.invalidateDirectoryListings,
});
expect(explorer.directories.get("src")?.entries.map((entry) => entry.name)).toEqual([
"a.ts",
"b.ts",
]);
});
});
+7 -58
View File
@@ -1,6 +1,5 @@
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { SortOption } from "@/stores/panel-store/state";
import { DIRECTORY_LISTING_INVALIDATED, type DirectoryListingResult } from "./directory-listings";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility";
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
@@ -17,13 +16,11 @@ interface FlattenExplorerTreeInput {
showHiddenFiles: boolean;
}
type RestoredExpandedPaths = string[] | typeof DIRECTORY_LISTING_INVALIDATED;
interface RestoreExpandedDirectoriesInput {
rootDirectory: ExplorerDirectory;
persistedExpandedPaths: ReadonlySet<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
}
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
@@ -36,7 +33,7 @@ interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
interface ReconcileRestoredExpandedPathsInput {
persistedExpandedPaths: ReadonlySet<string>;
currentExpandedPaths: ReadonlySet<string>;
restoredExpandedPaths: RestoredExpandedPaths;
restoredExpandedPaths: string[];
}
interface SetExpandedDirectoryPathInput {
@@ -88,7 +85,7 @@ export async function restoreExpandedDirectories({
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: RestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
const restoredPaths = ["."];
const restoredPathSet = new Set(restoredPaths);
let parentDirectories = [rootDirectory];
@@ -113,11 +110,9 @@ export async function restoreExpandedDirectories({
const requestedDirectories = await Promise.all(
pathsToRequest.map((path) => requestDirectoryListing(path)),
);
if (requestedDirectories.includes(DIRECTORY_LISTING_INVALIDATED))
return DIRECTORY_LISTING_INVALIDATED;
parentDirectories = [];
for (const directory of requestedDirectories) {
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) {
if (!directory) {
continue;
}
restoredPaths.push(directory.path);
@@ -133,7 +128,7 @@ export function showHiddenFilesAndRestoreExpandedDirectories({
persistedExpandedPaths,
showHiddenFiles,
requestDirectoryListing,
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
showHiddenFiles();
return restoreExpandedDirectories({
rootDirectory,
@@ -148,8 +143,6 @@ export function reconcileRestoredExpandedPaths({
currentExpandedPaths,
restoredExpandedPaths,
}: ReconcileRestoredExpandedPathsInput): string[] {
if (restoredExpandedPaths === DIRECTORY_LISTING_INVALIDATED)
return Array.from(currentExpandedPaths);
const reconciledPaths = new Set(restoredExpandedPaths);
for (const path of persistedExpandedPaths) {
@@ -212,7 +205,7 @@ function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption):
/** Load and expand the path to a directory selected outside the tree. */
export async function revealExplorerDirectory(input: {
directoryPath: string;
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
expandPaths: (paths: string[]) => void;
showHiddenFiles: () => void;
}): Promise<void> {
@@ -225,51 +218,7 @@ export async function revealExplorerDirectory(input: {
}
}
for (const path of paths) {
const directory = await input.requestDirectoryListing(path);
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) return;
if (!(await input.requestDirectoryListing(path))) return;
}
input.expandPaths(paths);
}
/**
* Files Refresh: re-lists the expanded directories (and the root), and drops the cached
* listing of every other directory. Expanding a directory only fetches when it has no
* cached listing, so keeping a collapsed directory's old listing would show it as it was
* before the refresh.
*/
export async function refreshExplorerDirectories({
hasWorkspaceScope,
expandedPaths,
showHiddenFiles,
requestDirectoryListing,
invalidateDirectoryListings,
}: {
hasWorkspaceScope: boolean;
expandedPaths: Set<string>;
showHiddenFiles: boolean;
requestDirectoryListing: (
path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<DirectoryListingResult>;
invalidateDirectoryListings: (retainedPaths: readonly string[]) => void;
}): Promise<null> {
if (!hasWorkspaceScope) {
return null;
}
const directoryPaths = Array.from(expandedPaths).filter(
(path) => showHiddenFiles || !isHiddenExplorerPath(path),
);
if (!directoryPaths.includes(".")) {
directoryPaths.unshift(".");
}
invalidateDirectoryListings(directoryPaths);
await Promise.all(
directoryPaths.map((path) =>
requestDirectoryListing(path, {
recordHistory: false,
setCurrentPath: false,
}),
),
);
return null;
}
@@ -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,18 +1,16 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
import {
useSessionStore,
type AgentFileExplorerState,
type ExplorerDirectory,
} from "@/stores/session-store";
import { usePanelStore } from "@/stores/panel-store";
import { revealExplorerDirectory } from "@/file-explorer/tree";
import {
loadExplorerDirectory,
type DirectoryListingResult,
invalidateExplorerDirectoryListings,
} from "@/file-explorer/directory-listings";
import { parentExplorerPath } from "@/utils/explorer-paths";
function createExplorerState(): AgentFileExplorerState {
return {
listingGeneration: 0,
directories: new Map(),
isLoading: false,
lastError: null,
@@ -24,6 +22,15 @@ function createExplorerState(): AgentFileExplorerState {
};
}
function pushHistory(history: string[], path: string): string[] {
const normalizedHistory = history.length === 0 ? ["."] : history;
const last = normalizedHistory[normalizedHistory.length - 1];
if (last === path) {
return normalizedHistory;
}
return [...normalizedHistory, path];
}
export interface FileExplorerWorkspaceScope {
workspaceId?: string | null;
workspaceRoot?: string | null;
@@ -68,21 +75,16 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
);
const updateExplorerState = useCallback(
(
updater: (prev: AgentFileExplorerState) => AgentFileExplorerState,
): AgentFileExplorerState | null => {
(updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => {
if (!workspaceStateKey) {
return null;
return;
}
let updated: AgentFileExplorerState | null = null;
setFileExplorer(serverId, (prev) => {
const next = new Map(prev);
const current = next.get(workspaceStateKey) ?? createExplorerState();
updated = updater(current);
next.set(workspaceStateKey, updated);
next.set(workspaceStateKey, updater(current));
return next;
});
return updated;
},
[serverId, setFileExplorer, workspaceStateKey],
);
@@ -91,23 +93,80 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
async (
path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
): Promise<DirectoryListingResult> => {
): Promise<ExplorerDirectory | null> => {
if (!workspaceStateKey) {
return null;
}
const normalizedPath = path && path.length > 0 ? path : ".";
return loadExplorerDirectory({
path: normalizedPath,
options,
updateState: updateExplorerState,
fallbackError: t("workspace.fileExplorer.errors.failedToListDirectory"),
listDirectory: async () => {
if (!normalizedWorkspaceRoot)
throw new Error(t("workspace.fileExplorer.states.unavailable"));
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
return client.listDirectory(normalizedWorkspaceRoot, normalizedPath);
},
});
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath;
updateExplorerState((state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path: normalizedPath, mode: "list" },
...(shouldSetCurrentPath
? {
currentPath: normalizedPath,
history: shouldRecordHistory
? pushHistory(state.history, normalizedPath)
: state.history,
lastVisitedPath: normalizedPath,
}
: {}),
}));
if (!normalizedWorkspaceRoot) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: t("workspace.fileExplorer.states.unavailable"),
pendingRequest: null,
}));
return null;
}
if (!client) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: t("workspace.terminal.hostDisconnected"),
pendingRequest: null,
}));
return null;
}
try {
const directory = await client.listDirectory(normalizedWorkspaceRoot, normalizedPath);
updateExplorerState((state) => {
const nextState: AgentFileExplorerState = {
...state,
isLoading: false,
lastError: null,
pendingRequest: null,
directories: state.directories,
};
const directories = new Map(state.directories);
directories.set(directory.path, directory);
nextState.directories = directories;
return nextState;
});
return directory;
} catch (error) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError:
error instanceof Error
? error.message
: t("workspace.fileExplorer.errors.failedToListDirectory"),
pendingRequest: null,
}));
return null;
}
},
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
);
@@ -249,17 +308,10 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
[updateExplorerState],
);
const invalidateDirectoryListings = useCallback(
(retainedPaths: readonly string[]) =>
invalidateExplorerDirectoryListings(updateExplorerState, retainedPaths),
[updateExplorerState],
);
return {
workspaceStateKey,
revealDirectory,
requestDirectoryListing,
invalidateDirectoryListings,
requestFileDownloadToken,
requestFileBytes,
createEntry,
-89
View File
@@ -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) });
},
};
}
+11 -4
View File
@@ -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],
);
}
@@ -17,9 +17,6 @@ function installation(): InstalledPlugin {
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
@@ -153,9 +153,6 @@ function harness() {
openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
},
});
@@ -2,7 +2,6 @@ import { createPlayAudio, type AudioEngine } from "@/audio";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { createPluginHosts } from "./hosts";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import {
createPluginAgentActionContext,
createPluginCapabilities,
@@ -37,8 +36,6 @@ export function createPluginClientRuntime(
openPanel(panelId, options) {
openClientPanel({ installation, state, panelId, options });
},
subscribe: (contract, input, onEvent, options) =>
subscribePluginRpc(contract, installation.subscribe, input, onEvent, options),
};
}
@@ -71,9 +71,6 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
clientType: "cli",
}),
),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
invoke: async (method: string, input: unknown) => {
expect(method).toBe("review.inspect");
return { value: inspect.input.parse(input).value + 1 };
-1
View File
@@ -111,7 +111,6 @@ export type PluginClientRuntime = Pick<
| "openScreen"
| "openSurface"
| "openPanel"
| "subscribe"
| "addComposerPill"
| "addHeaderButton"
> & { hosts: ReturnType<typeof createPluginHosts> };
@@ -29,9 +29,6 @@ function installation(invoked: string[]): InstalledPlugin {
invoked.push(method);
return { state: "green" };
},
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
queryClient: new QueryClient(),
cleanup: () => undefined,
settingsScreens: [],
@@ -18,7 +18,7 @@ export function PluginInstallationProvider({
return (
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={plugin.paseo}>
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
<PluginRpcProvider invoke={plugin.invoke}>
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
</PluginRpcProvider>
</PaseoApiProvider>
@@ -62,9 +62,6 @@ function registry(
openPanel: () => {},
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
},
});
@@ -39,9 +39,6 @@ function registry(version: string) {
openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
};
},
});
+1 -8
View File
@@ -8,7 +8,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import { resolveAppVersion } from "@/utils/app-version";
import { createPluginClientRuntime } from "./client-runtime";
import { createPluginSubscribe } from "./subscriptions";
import { runPluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types";
@@ -90,18 +89,12 @@ export class PluginRegistry {
return [existing];
}
const client = options.client;
const lifetime = new AbortController();
installation = {
lifetime,
lifetime: new AbortController(),
locale: this.dependencies.localeSource.getLocale(),
i18n: new PluginTranslationRuntime(this.dependencies.localeSource),
paseo: createPaseoApi(client),
invoke: (method, input) => client.invokePluginRpc(entry.id, method, input),
subscribe: createPluginSubscribe({
client,
pluginId: entry.id,
lifetime: lifetime.signal,
}),
id: entry.id,
serverId,
clientBundle: entry.clientBundle,
@@ -26,9 +26,6 @@ function installed(
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
settingsScreens: [],
surfaces: [{ id: "surface", title: "Surface", Component: () => null }],
sidebarItems: {
@@ -1,189 +0,0 @@
import { defineSubscription } from "@getpaseo/plugin";
import type { PluginSubscriptionClosed } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { SubscriptionObserver } from "@getpaseo/client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { expect, it } from "vitest";
import { z } from "zod";
import { createPluginSubscribe } from "./subscriptions";
const changes = defineSubscription({
name: "diff.watch",
input: z.object({ ref: z.string() }),
event: z.union([z.instanceof(Uint8Array), z.object({ path: z.string() })]),
});
type Observed = Parameters<DaemonClient["observePluginRpc"]>;
/** One host-side subscription as the daemon client exposes it, driven by the test. */
class HostSubscription {
readonly released: Promise<void>;
isReleased = false;
private observer: SubscriptionObserver<never> | null = null;
private accept!: () => void;
private refuse!: (error: Error) => void;
private markReleased!: () => void;
readonly ready = new Promise<never>((resolve, reject) => {
this.accept = () => resolve({ requestId: "r", subscriptionId: "s" } as never);
this.refuse = reject;
});
constructor(
readonly method: string,
readonly input: unknown,
readonly signal: AbortSignal | undefined,
) {
void this.ready.catch(() => undefined);
this.released = new Promise((resolve) => {
this.markReleased = resolve;
});
signal?.addEventListener("abort", () => void this.release(), { once: true });
}
handle() {
return {
subscriptionId: "s",
ready: this.ready,
subscribe: (observer: SubscriptionObserver<never>) => {
this.observer = observer;
return () => undefined;
},
release: () => this.release(),
};
}
acknowledge() {
this.accept();
}
refuseWith(error: Error) {
this.refuse(error);
this.observer?.error?.(error);
}
event(event: unknown) {
this.observer?.update({
type: "plugin.rpc.event",
payload: { subscriptionId: "s", event },
} as SessionOutboundMessage);
}
bytes(payload: Uint8Array) {
this.observer?.binary?.(payload);
}
closed(error?: string) {
this.observer?.update({
type: "plugin.rpc.closed",
payload: { subscriptionId: "s", ...(error ? { error } : {}) },
} as SessionOutboundMessage);
}
async release() {
this.isReleased = true;
this.markReleased();
}
}
/** An installation's subscribe on a host the test drives, and what one subscriber observed. */
function installation() {
const subscriptions: HostSubscription[] = [];
const client = {
observePluginRpc: (...[pluginId, method, input, options]: Observed) => {
expect(pluginId).toBe("diffs");
const subscription = new HostSubscription(method, input, options?.signal);
subscriptions.push(subscription);
return subscription.handle() as unknown as ReturnType<DaemonClient["observePluginRpc"]>;
},
};
const lifetime = new AbortController();
const subscribe = createPluginSubscribe({ client, pluginId: "diffs", lifetime: lifetime.signal });
function open(ref: string, signal?: AbortSignal) {
const received: string[] = [];
const closed: PluginSubscriptionClosed[] = [];
const handle = subscribePluginRpc(
changes,
subscribe,
{ ref },
(event) =>
received.push(event instanceof Uint8Array ? `bytes:${[...event]}` : `path:${event.path}`),
{ signal, onClosed: (result) => closed.push(result) },
);
return { handle, received, closed, host: subscriptions[subscriptions.length - 1]! };
}
return { open, lifetime };
}
it("delivers JSON and byte events in order and releases the host subscription", async () => {
const { open } = installation();
const { handle, received, closed, host } = open("main");
expect(host.method).toBe("diff.watch");
expect(host.input).toEqual({ ref: "main" });
host.acknowledge();
await handle.ready;
host.event({ path: "a.ts" });
host.bytes(new Uint8Array([1, 2]));
expect(received).toEqual(["path:a.ts", "bytes:1,2"]);
handle();
await host.released;
expect(closed).toEqual([]);
});
it("reports a refusal, a failure after going live, and a clean close", async () => {
const { open } = installation();
const refused = open("a");
refused.host.refuseWith(new Error("no such ref"));
await expect(refused.handle.ready).rejects.toThrow("no such ref");
expect(refused.closed).toEqual([{ error: new Error("no such ref") }]);
const failed = open("b");
failed.host.acknowledge();
await failed.handle.ready;
failed.host.closed("Plugin stopped: diffs");
expect(failed.closed).toEqual([{ error: new Error("Plugin stopped: diffs") }]);
await failed.host.released;
// A handler that returns at once closes in the same tick the host accepts it.
const finished = open("c");
finished.host.acknowledge();
finished.host.closed();
await expect(finished.handle.ready).resolves.toBeUndefined();
expect(finished.closed).toEqual([{}]);
});
it("closes a subscription whose event fails the contract and drops what follows", async () => {
const { open } = installation();
const { handle, received, closed, host } = open("a");
host.acknowledge();
await handle.ready;
host.event({ path: 3 });
host.event({ path: "late.ts" });
expect(received).toEqual([]);
expect(closed).toEqual([
{ error: new Error("Plugin subscription diff.watch sent an invalid event") },
]);
await host.released;
});
it("releases on its signal, on its remover, and when the installation stops", async () => {
const { open, lifetime } = installation();
const controller = new AbortController();
const bySignal = open("a", controller.signal);
bySignal.host.acknowledge();
await bySignal.handle.ready;
controller.abort();
await bySignal.host.released;
const byRemover = open("b");
byRemover.handle();
await byRemover.host.released;
const byLifetime = open("c");
byLifetime.host.event({ path: "a.ts" });
expect(byLifetime.received).toEqual(["path:a.ts"]);
lifetime.abort();
await byLifetime.host.released;
});
-52
View File
@@ -1,52 +0,0 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginSubscribe } from "@getpaseo/plugin/client/host";
/**
* Plugin subscriptions on the installation's client. The client re-subscribes after a reconnect;
* the installation's lifetime releases whatever its surfaces left open.
*/
export function createPluginSubscribe(input: {
client: Pick<DaemonClient, "observePluginRpc">;
pluginId: string;
lifetime: AbortSignal;
}): PluginSubscribe {
const { client, pluginId, lifetime } = input;
return (method, subscriptionInput, observer) => {
const controller = new AbortController();
const abort = () => controller.abort();
lifetime.addEventListener("abort", abort, { once: true });
controller.signal.addEventListener(
"abort",
() => lifetime.removeEventListener("abort", abort),
{ once: true },
);
if (lifetime.aborted) controller.abort();
const subscription = client.observePluginRpc(pluginId, method, subscriptionInput, {
signal: controller.signal,
});
subscription.subscribe({
snapshot: () => undefined,
update: (message) => {
if (message.type === "plugin.rpc.event") observer.event(message.payload.event);
else if (message.type === "plugin.rpc.closed") {
controller.abort();
const error = message.payload.error;
observer.closed(error === undefined ? undefined : new Error(error));
}
},
binary: (payload) => observer.event(payload),
error: (error) => {
controller.abort();
observer.closed(error instanceof Error ? error : new Error(String(error)));
},
});
const release = () => {
controller.abort();
return subscription.release();
};
return Object.assign(() => void release().catch(() => undefined), {
ready: subscription.ready.then(() => undefined),
release,
});
};
}
@@ -26,9 +26,6 @@ function installation(
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => undefined,
settingsScreens: [],
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })),
-3
View File
@@ -56,9 +56,6 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
settingsScreens: [],
surfaces: [],
sidebarItems: { header: [], footer: [] },
@@ -18,9 +18,6 @@ function plugin(input: {
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => {},
settingsScreens: [],
surfaces: [],
@@ -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":
-2
View File
@@ -71,8 +71,6 @@ export interface InstalledPlugin extends EvaluatedPlugin {
paseo: PaseoApi;
/** Calls one of the plugin's server RPC methods on its host. */
invoke(method: string, input: unknown): Promise<unknown>;
/** Subscribes to one of the plugin's server subscriptions; released with the lifetime. */
subscribe: import("@getpaseo/plugin/client/host").PluginSubscribe;
serverId: string;
requirements?: PluginRequirements;
clientBundle: string;
@@ -15,9 +15,6 @@ function installed(): InstalledPlugin {
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => {},
settingsScreens: [],
surfaces: [],
@@ -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);
+97 -8
View File
@@ -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>
);
}
-1
View File
@@ -260,7 +260,6 @@ interface ExplorerRequestState {
}
export interface AgentFileExplorerState {
listingGeneration: number;
directories: Map<string, ExplorerDirectory>;
isLoading: boolean;
lastError: string | null;
@@ -717,13 +717,11 @@ describe("terminal emulator runtime in a real browser", () => {
{ name: "DA1-zero", bytes: "\x1b[0c" },
{ name: "DA2", bytes: "\x1b[>c" },
{ name: "DA3", bytes: "\x1b[=c" },
{ name: "XTVERSION", bytes: "\x1b[>q" },
{ name: "DSR-5", bytes: "\x1b[5n" },
{ name: "DSR-6", bytes: "\x1b[6n" },
{ name: "DSR-?6", bytes: "\x1b[?6n" },
{ name: "DECRQM", bytes: "\x1b[1$p" },
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
{ name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
@@ -647,7 +647,6 @@ export class TerminalEmulatorRuntime {
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true);
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
@@ -656,7 +655,6 @@ export class TerminalEmulatorRuntime {
{ prefix: "?", intermediates: "$", final: "p" },
() => true,
);
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
for (const code of [10, 11, 12]) {
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
}
@@ -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");
});
+4
View File
@@ -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) {
@@ -1,50 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdir, mkdtemp } from "node:fs/promises";
import { resolve } from "node:path";
import { connectToDaemon } from "../../utils/client.js";
import { runPaseoCli, startTestDaemon } from "../../../tests/helpers/test-daemon.js";
describe("paseo logs JSON output", () => {
let daemon: Awaited<ReturnType<typeof startTestDaemon>>;
let agentId: string;
let host: string;
beforeAll(async () => {
await mkdir(".artifacts", { recursive: true });
daemon = await startTestDaemon({
paseoHome: await mkdtemp(resolve(".artifacts/logs-json-home-")),
workDir: await mkdtemp(resolve(".artifacts/logs-json-work-")),
});
host = `127.0.0.1:${daemon.port}`;
const client = await connectToDaemon({ target: { kind: "endpoint", host } });
try {
// Creating an idle agent needs no inference or external provider connection.
const agent = await client.createAgent({
provider: "claude",
cwd: daemon.workDir,
title: "JSON logs",
});
agentId = agent.id;
} finally {
await client.close();
}
}, 60_000);
afterAll(async () => {
await daemon?.stop();
});
it.each([
{ label: "top-level --json", args: ["logs", "--json"] },
{ label: "agent logs --json", args: ["agent", "logs", "--json"] },
{ label: "--format json alias", args: ["logs", "--format", "json"] },
])(
"prints JSON through $label",
async ({ args }) => {
const result = await runPaseoCli(daemon, [...args, agentId, "--host", host]);
expect(result.exitCode, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ agentId, items: [] });
},
30_000,
);
});
+1 -33
View File
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import {
createFollowJsonWriter,
createFollowTranscriptWriter,
formatAgentActivityTranscript,
} from "./logs.js";
import { createFollowTranscriptWriter, formatAgentActivityTranscript } from "./logs.js";
function collect(items: readonly AgentTimelineItem[], turnId?: string): string {
let output = "";
@@ -153,31 +149,3 @@ describe("createFollowTranscriptWriter", () => {
expect(output).toBe("# Title\n\nBody\n");
});
});
describe("createFollowJsonWriter", () => {
it("writes one JSON item per line and honours the filter", () => {
let output = "";
const writer = createFollowJsonWriter("tools", (chunk) => {
output += chunk;
});
const toolCall = {
type: "tool_call",
callId: "call_1",
name: "shell",
status: "completed",
detail: { type: "unknown", input: null, output: null },
error: null,
} as AgentTimelineItem;
writer.push({ type: "assistant_message", text: "Running it.", messageId: "msg_1" });
writer.push(toolCall);
writer.end();
expect(
output
.trimEnd()
.split("\n")
.map((line) => JSON.parse(line)),
).toEqual([toolCall]);
});
});
+71 -146
View File
@@ -70,35 +70,6 @@ export function formatAgentActivityTranscript(
);
}
/**
* Picks the items `--tail` keeps, the same way the transcript does: the last n
* timeline items, before any of them are dropped from the curated view.
*/
function selectTailItems(
timelineItems: AgentTimelineItem[],
tailCount?: number,
): AgentTimelineItem[] {
if (tailCount === undefined) {
return timelineItems;
}
if (tailCount === 0) {
return [];
}
return timelineItems.slice(-tailCount);
}
function formatAgentLogsJson(
agentId: string,
timelineItems: AgentTimelineItem[],
tailCount?: number,
): string {
return JSON.stringify({ agentId, items: selectTailItems(timelineItems, tailCount) }, null, 2);
}
function usesJsonOutput(options: AgentLogsOptions): boolean {
return options.json === true || options.format?.trim().toLowerCase() === "json";
}
type StreamingTextType = "assistant_message" | "reasoning";
const THOUGHT_PREFIX = "[Thought] ";
@@ -227,21 +198,6 @@ export function createFollowTranscriptWriter(
};
}
/** Follow mode with `--json`: one JSON timeline item per line (NDJSON). */
export function createFollowJsonWriter(
filter?: string,
write: (chunk: string) => void = (chunk) => void process.stdout.write(chunk),
): FollowTranscriptWriter {
return {
push(item) {
if (matchesFilter(item, filter)) {
write(`${JSON.stringify(item)}\n`);
}
},
end() {},
};
}
function parseTailCount(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const parsed = Number.parseInt(raw, 10);
@@ -350,11 +306,6 @@ export async function runLogsCommand(
await client.close();
if (usesJsonOutput(options)) {
console.log(formatAgentLogsJson(resolvedId, timelineItems, tailCount));
return;
}
// Use curateAgentActivity to format the transcript
if (tailCount === 0) {
return;
@@ -380,104 +331,78 @@ async function runFollowMode({
options,
sinceTimestampMs,
}: FollowModeInput): Promise<void> {
// Observe shutdown before history writes can encounter a closed reader.
// Keep an early stop resolved while subscription setup is in flight.
const stopController = new AbortController();
const stopped = waitForStop(stopController.signal);
let unsubscribe: (() => void) | undefined;
const json = usesJsonOutput(options);
const writer = json
? createFollowJsonWriter(options.filter)
: createFollowTranscriptWriter(undefined, options.filter);
const DEFAULT_FOLLOW_TAIL = 10;
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
// First, get existing timeline.
let existingItems: AgentTimelineItem[] = [];
try {
const DEFAULT_FOLLOW_TAIL = 10;
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
// First, get existing timeline.
let existingItems: AgentTimelineItem[] = [];
try {
const history = await Promise.race([
fetchAgentTimelineItems(client, agentId, {
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
sinceTimestampMs,
}),
stopped,
]);
if (!history) return;
existingItems = history;
} catch (error) {
console.warn("Warning: failed to fetch existing timeline", error);
}
// Apply filter to existing items
if (options.filter) {
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
}
// Print existing transcript (tail-like behavior)
if (json) {
for (const item of selectTailItems(existingItems, tailCount)) {
console.log(JSON.stringify(item));
}
} else if (tailCount > 0) {
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
console.log(existingTranscript);
}
}
// Subscribe to new events
const tailLabel =
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
// Keep stdout parseable in JSON mode: one timeline item per line, notices on stderr.
const notice = json ? console.error : console.log;
const subscription = client.subscribeAgentTimeline(agentId, (message) => {
if (message.type === "agent.timeline.replacement") {
writer.end();
notice("\n[Timeline replaced; earlier output is no longer current]");
return;
}
if (message.type === "agent.timeline.error") {
writer.end();
console.error(`Timeline observation stopped: ${message.payload.error}`);
return;
}
if (message.type === "agent.timeline.subscription_restored") {
writer.end();
notice("\n[Reconnected; live output resumed. Events may have been missed.]");
return;
}
if (message.payload.event.type === "timeline") {
const matchesSince =
sinceTimestampMs === undefined ||
Date.parse(message.payload.timestamp) >= sinceTimestampMs;
if (!matchesSince) {
return;
}
const item = message.payload.event.item;
writer.push(item, message.payload.event.turnId);
} else if (
message.payload.event.type === "turn_completed" ||
message.payload.event.type === "turn_failed" ||
message.payload.event.type === "turn_canceled"
) {
writer.end();
}
existingItems = await fetchAgentTimelineItems(client, agentId, {
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
sinceTimestampMs,
});
unsubscribe = subscription;
const ready = await Promise.race([subscription.ready.then(() => true), stopped]);
if (!ready) return;
notice(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
await stopped;
} finally {
stopController.abort();
writer.end();
unsubscribe?.();
await client.close();
} catch (error) {
console.warn("Warning: failed to fetch existing timeline", error);
}
// Apply filter to existing items
if (options.filter) {
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
}
// Print existing transcript (tail-like behavior)
if (tailCount > 0) {
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
console.log(existingTranscript);
}
}
// Subscribe to new events
const tailLabel =
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
const writer = createFollowTranscriptWriter(undefined, options.filter);
const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => {
if (message.type === "agent.timeline.replacement") {
writer.end();
console.log("\n[Timeline replaced; earlier output is no longer current]");
return;
}
if (message.type === "agent.timeline.error") {
writer.end();
console.error(`Timeline observation stopped: ${message.payload.error}`);
return;
}
if (message.type === "agent.timeline.subscription_restored") {
writer.end();
console.log("\n[Reconnected; live output resumed. Events may have been missed.]");
return;
}
if (message.payload.event.type === "timeline") {
const matchesSince =
sinceTimestampMs === undefined || Date.parse(message.payload.timestamp) >= sinceTimestampMs;
if (!matchesSince) {
return;
}
const item = message.payload.event.item;
writer.push(item, message.payload.event.turnId);
} else if (
message.payload.event.type === "turn_completed" ||
message.payload.event.type === "turn_failed" ||
message.payload.event.type === "turn_canceled"
) {
writer.end();
}
});
await unsubscribe.ready;
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
await waitForStop();
writer.end();
unsubscribe();
await client.close();
}
+3 -27
View File
@@ -4,13 +4,13 @@ import { buildAgentLsFetchOptions, listAgents } from "./ls.js";
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
function createPagingClient(archivedAgentNumber?: number) {
function createPagingClient() {
const daemonAgents: AgentSnapshotPayload[] = [3, 2, 1].map((n) => ({
id: `${n}${n}${n}${n}${n}${n}${n}${n}-0000-4000-8000-000000000000`,
provider: "codex",
title: `agent ${n}`,
status: n === archivedAgentNumber ? "idle" : "closed",
archivedAt: n === archivedAgentNumber ? "2026-10-10T00:00:00.000Z" : null,
status: "closed",
archivedAt: null,
cwd: "/tmp/project",
createdAt: `2026-10-0${n}T00:00:00.000Z`,
updatedAt: `2026-10-0${n}T00:00:00.000Z`,
@@ -130,28 +130,4 @@ describe("runLsCommand", () => {
expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]);
});
it("marks archived agents in table and JSON list data", async () => {
const result = await listAgents({ daemonTarget, all: true, global: true }, async () =>
createPagingClient(2),
);
expect(
result.data.map(({ name, archived, archivedAt }) => ({ name, archived, archivedAt })),
).toEqual([
{ name: "agent 2", archived: true, archivedAt: "2026-10-10T00:00:00.000Z" },
{ name: "agent 3", archived: false, archivedAt: null },
{ name: "agent 1", archived: false, archivedAt: null },
]);
const archivedColumn = result.schema.columns.find((column) => column.header === "ARCHIVED");
expect(archivedColumn).toBeDefined();
expect(typeof archivedColumn?.field).toBe("function");
if (!archivedColumn || typeof archivedColumn.field !== "function") {
throw new Error("Expected the ARCHIVED column to have a display accessor");
}
expect(result.data[0]!.status).toBe("idle");
expect(archivedColumn.field(result.data[0]!)).toBe("yes");
expect(archivedColumn.field(result.data[1]!)).toBe("-");
});
});
-9
View File
@@ -33,8 +33,6 @@ export interface AgentListItem {
provider: string;
thinking: string;
status: string;
archived: boolean;
archivedAt: string | null;
cwd: string;
created: string;
}
@@ -86,11 +84,6 @@ export const agentLsSchema: OutputSchema<AgentListItem> = {
return undefined;
},
},
{
header: "ARCHIVED",
field: (item) => (item.archived ? "yes" : "-"),
width: 8,
},
{ header: "CWD", field: "cwd", width: 30 },
{ header: "CREATED", field: "created", width: 15 },
],
@@ -106,8 +99,6 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
provider: model ? `${agent.provider}/${model}` : agent.provider,
thinking: agent.effectiveThinkingOptionId ?? "auto",
status: agent.status,
archived: agent.archivedAt != null,
archivedAt: agent.archivedAt ?? null,
cwd: shortenPath(agent.cwd),
created: relativeTime(agent.createdAt),
};
+1 -4
View File
@@ -3,10 +3,9 @@
// their output closes its end, as `| head` does. A closed reader shows up as
// EPIPE on the next write to stdout, so the command stops at its next line of
// output.
export function waitForStop(signal?: AbortSignal): Promise<void> {
export function waitForStop(): Promise<void> {
return new Promise((resolve) => {
const stop = () => {
signal?.removeEventListener("abort", stop);
process.off("SIGINT", stop);
process.off("SIGTERM", stop);
process.stdout.off("error", stopOnClosedStdout);
@@ -21,7 +20,5 @@ export function waitForStop(signal?: AbortSignal): Promise<void> {
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
process.stdout.on("error", stopOnClosedStdout);
signal?.addEventListener("abort", stop, { once: true });
if (signal?.aborted) stop();
});
}
-1
View File
@@ -144,7 +144,6 @@ export const DEFAULT_CLIENT_CAPABILITIES = {
[CLIENT_CAPS.pluginTimelineItems]: true,
[CLIENT_CAPS.workspaceSetupBlocked]: true,
[CLIENT_CAPS.workspaceSetupObservation]: true,
[CLIENT_CAPS.pluginBinaryPayloads]: true,
[CLIENT_CAPS.explicitEventSubscriptions]: true,
} satisfies Record<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>;
@@ -83,26 +83,6 @@ test("cancellation before the ID waits for bootstrap and releases exactly that I
expect(c.released).toEqual(["cancelled"]);
});
test("a release right after the host accepts still reports the subscription as accepted", async () => {
const c = connection();
const handle = c.observe();
c.requests[0]!("closing");
const released = handle.release();
await expect(handle.ready).resolves.toMatchObject({ subscriptionId: "closing" });
await released;
expect(c.released).toEqual(["closing"]);
});
test("a release before the host accepts rejects ready", async () => {
const c = connection();
const handle = c.observe();
const released = handle.release();
await expect(handle.ready).rejects.toThrow("Subscription released");
c.requests[0]!("late");
await released;
expect(c.released).toEqual(["late"]);
});
test("surviving handles use new remote IDs after reconnect and released handles stay gone", async () => {
const c = connection();
const a = c.observe();
+14 -32
View File
@@ -5,8 +5,6 @@ export type SubscriptionSnapshot<T> = T & { subscriptionId: string };
export interface SubscriptionObserver<T> {
snapshot(snapshot: T): void;
update(message: SessionOutboundMessage): void;
/** A binary frame the host addressed to this subscription; the payload is a view. */
binary?(payload: Uint8Array): void;
error?(error: unknown): void;
}
@@ -22,12 +20,9 @@ interface Registration {
start(): void;
disconnected(): void;
receive(message: SessionOutboundMessage): void;
receiveBinary(payload: Uint8Array): void;
release(): Promise<void>;
}
type Update = { message: SessionOutboundMessage } | { binary: Uint8Array };
export interface OwnedSubscriptionsHost {
release(subscriptionId: string): Promise<void>;
failed(error: unknown): void;
@@ -55,7 +50,7 @@ export class OwnedSubscriptions {
let teardown: Promise<void> | null = null;
let disposed = false;
let overflow = false;
const buffered: Update[] = [];
const buffered: SessionOutboundMessage[] = [];
const observers = new Set<SubscriptionObserver<SubscriptionSnapshot<T>>>();
let resolve!: (snapshot: SubscriptionSnapshot<T>) => void;
let reject!: (error: unknown) => void;
@@ -65,10 +60,12 @@ export class OwnedSubscriptions {
});
void ready.catch(() => {});
const deliver = (observer: SubscriptionObserver<SubscriptionSnapshot<T>>, update?: Update) => {
const deliver = (
observer: SubscriptionObserver<SubscriptionSnapshot<T>>,
message?: SessionOutboundMessage,
) => {
try {
if (update && "binary" in update) observer.binary?.(update.binary);
else if (update) observer.update(update.message);
if (message) observer.update(message);
else if (snapshot) observer.snapshot(snapshot);
} catch (error) {
this.host.failed(error);
@@ -81,13 +78,6 @@ export class OwnedSubscriptions {
this.routes.delete(released);
await this.host.release(released);
};
const buffer = (update: Update) => {
if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(update);
else {
buffered.length = 0;
overflow = true;
}
};
const registration: Registration = {
start: () => {
if (disposed || pending || id || !this.connected) return;
@@ -138,14 +128,12 @@ export class OwnedSubscriptions {
},
receive: (message) => {
if (disposed || ("payload" in message && Object.is(message.payload, snapshot))) return;
buffer({ message });
for (const observer of observers) deliver(observer, { message });
},
receiveBinary: (payload) => {
if (disposed) return;
// Bytes wait only for the first observer; retaining them after would hold every frame.
if (observers.size === 0) buffer({ binary: payload });
for (const observer of observers) deliver(observer, { binary: payload });
if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(message);
else {
buffered.length = 0;
overflow = true;
}
for (const observer of observers) deliver(observer, message);
},
release: () => {
if (teardown) return teardown;
@@ -154,9 +142,7 @@ export class OwnedSubscriptions {
observers.clear();
buffered.length = 0;
if (id) this.routes.delete(id);
// A host that already accepted keeps `ready` resolved, even if released in the same tick.
if (snapshot) resolve(snapshot);
else reject(new Error("Subscription released"));
reject(new Error("Subscription released"));
teardown = (async () => {
await pending;
await releaseId();
@@ -188,7 +174,7 @@ export class OwnedSubscriptions {
.catch(this.host.failed);
} else {
deliver(observer);
for (const update of buffered) deliver(observer, update);
for (const message of buffered) deliver(observer, message);
}
return () => {
observers.delete(observer);
@@ -219,10 +205,6 @@ export class OwnedSubscriptions {
this.routes.get(payload.subscriptionId)?.receive(message);
}
receiveBinary(subscriptionId: string, payload: Uint8Array): void {
this.routes.get(subscriptionId)?.receiveBinary(payload);
}
restore(): void {
this.connected = true;
for (const registration of this.registrations) registration.start();
@@ -1106,7 +1106,6 @@ test("advertises client capabilities in hello", async () => {
terminal_chunked_replay: true,
timeline_notifications: true,
plugin_timeline_items: true,
plugin_binary_payloads: true,
workspace_setup_blocked: true,
workspace_setup_observation: true,
hello_rejection: true,
+9 -79
View File
@@ -164,11 +164,6 @@ import {
FileTransferOpcode,
TerminalStreamOpcode,
type FileTransferFrame,
assertPluginWireValue,
decodePluginFrame,
encodePluginInputFrame,
isPluginFrameOpcode,
PluginFrameOpcode,
} from "@getpaseo/protocol/binary-frames/index";
import {
createRelayE2eeTransportFactory,
@@ -2265,8 +2260,6 @@ export class DaemonClient {
timeout?: number;
select: (msg: SessionOutboundMessage) => T | null;
options?: { skipQueue?: boolean };
/** Sends the request in another form, such as a binary frame; defaults to `message`. */
transmit?: () => Promise<void>;
}): Promise<T> {
const wire = this.owned.prepareRequest(params.message);
const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS;
@@ -2297,9 +2290,7 @@ export class DaemonClient {
// The response deadline still applies while the transport waits for drain.
void promise.catch(() => undefined);
try {
const sent = params.transmit
? params.transmit()
: this.sendSessionMessageOrThrow(wire.message);
const sent = this.sendSessionMessageOrThrow(wire.message);
const result = await Promise.race([promise, sent.then(() => promise)]);
if (result.kind === "error") throw result.error;
return result.value;
@@ -2322,14 +2313,12 @@ export class DaemonClient {
responseType: TResponseType;
options?: { skipQueue?: boolean };
selectPayload?: (payload: CorrelatedResponsePayload<TResponseType>) => TResult | null;
transmit?: () => Promise<void>;
}): Promise<TResult> {
return this.sendRequest({
requestId: params.requestId,
message: params.message,
timeout: params.timeout,
options: params.options,
...(params.transmit ? { transmit: params.transmit } : {}),
select: (msg) => {
const correlated = msg as CorrelatedResponseMessage;
if (correlated.type !== params.responseType) {
@@ -2661,7 +2650,6 @@ export class DaemonClient {
resetSource = false;
if (query.type === "workspace.setup.subscribe.request")
this.requireWorkspaceSetupObservation();
if (query.type === "plugin.rpc.subscribe.request") this.requirePluginSubscriptions();
const requestId = this.createRequestId(options?.requestId);
const request = SessionInboundMessageSchema.parse({ ...query, requestId });
try {
@@ -6209,59 +6197,22 @@ export class DaemonClient {
return payload.plugin;
}
/**
* A `Uint8Array` input or output crosses the wire as a binary frame. Any other value is JSON and
* must not contain a `Uint8Array`.
*/
async invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown> {
assertPluginWireValue(input, "input");
const requestId = this.createRequestId();
if (!(input instanceof Uint8Array)) {
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "plugin.rpc.invoke.request",
requestId,
pluginId,
method,
input,
},
responseType: "plugin.rpc.invoke.response",
});
return payload.output;
}
this.requirePluginSubscriptions();
const frame = encodePluginInputFrame({ requestId, pluginId, method, payload: input });
const payload = await this.sendCorrelatedRequest({
const payload = await this.sendCorrelatedSessionRequest({
requestId,
message: { type: "plugin.rpc.invoke.request", requestId, pluginId, method, input: null },
message: {
type: "plugin.rpc.invoke.request",
requestId,
pluginId,
method,
input,
},
responseType: "plugin.rpc.invoke.response",
options: { skipQueue: true },
transmit: () => this.sendBinaryFrame(frame),
});
return payload.output;
}
/** One subscription to a plugin subscription handler; events arrive as updates and binary. */
observePluginRpc(
pluginId: string,
method: string,
input: unknown,
options?: { signal?: AbortSignal },
): OwnedSubscription<CorrelatedResponsePayload<"plugin.rpc.subscribe.response">> {
assertPluginWireValue(input, "input");
return this.observe(
"plugin.rpc.subscribe.response",
{ type: "plugin.rpc.subscribe.request", pluginId, method, input },
options,
);
}
private requirePluginSubscriptions(): void {
if (this.lastServerInfoMessage?.features?.pluginSubscriptions !== true)
throw new Error("Update the host to use plugin subscriptions and binary plugin RPC");
}
async respondToPermissionAndWait(
agentId: string,
requestId: string,
@@ -7024,23 +6975,6 @@ export class DaemonClient {
}
}
private handlePluginFrame(rawBytes: Uint8Array): void {
const frame = decodePluginFrame(rawBytes);
this.consecutiveLivenessFailures = 0;
this.runtimeMetrics?.recordBinaryFrame("plugin", rawBytes.byteLength, 0);
if (frame?.opcode === PluginFrameOpcode.Event) {
this.owned.receiveBinary(frame.subscriptionId, frame.payload);
return;
}
if (frame?.opcode === PluginFrameOpcode.Output) {
// The binary form of plugin.rpc.invoke.response.
this.resolveWaiters({
type: "plugin.rpc.invoke.response",
payload: { requestId: frame.requestId, output: frame.payload },
});
}
}
private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
const hostFrame = decodeHostRouteFrame(rawBytes);
if (rawBytes[0] === HOST_ROUTE_OPCODE && !hostFrame) {
@@ -7052,10 +6986,6 @@ export class DaemonClient {
for (const handler of this.hostFrameListeners) handler(hostFrame);
return true;
}
if (isPluginFrameOpcode(rawBytes[0])) {
this.handlePluginFrame(rawBytes);
return true;
}
const fileFrame = decodeFileTransferFrame(rawBytes);
if (fileFrame) {
this.traceInstant("paseo.ws.message.inbound", {
+3 -4
View File
@@ -204,16 +204,15 @@ export function createHostTransportFactory(
}
export function createHostClient(
connection: string,
config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
budget?: HostTransportBudget,
): DaemonClient {
const target = parseHostTarget(connection);
const { webSocketAgent, transportFactory, ...clientConfig } = config;
const { webSocketAgent, ...clientConfig } = config;
return new DaemonClient({
...clientConfig,
url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}),
transportFactory:
transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }),
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
});
}
-16
View File
@@ -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({
+1 -1
View File
@@ -41,7 +41,7 @@
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "44.2.0",
"electron-builder": "26.18.0",
"electron-builder": "26.8.1",
"typescript": "5.9.3",
"unzip-crx-3": "^0.2.0",
"vitest": "^4.1.6",
-65
View File
@@ -323,68 +323,3 @@ describe("agent session reload SDK boundary", () => {
).toEqual([]);
});
});
describe("plugin subscription SDK boundary", () => {
it("types contracts, overloaded handlers, the client hook, and the client entry handle", () => {
expect(
typeErrors(`
import { defineRpc, defineSubscription, type SubscriptionEvent, type SubscriptionInput } from "@getpaseo/plugin";
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { useRpc, useSubscription } from "@getpaseo/plugin/client";
import type { PluginServerContext, PluginSubscriptionContext } from "@getpaseo/plugin/server";
import { z } from "zod";
const diffWatch = defineSubscription({
name: "diff.watch",
input: z.object({ a: z.string(), b: z.string() }),
event: z.object({ path: z.string(), status: z.enum(["added", "modified", "deleted"]) }),
});
const frames = defineSubscription({
name: "screen.capture",
input: z.object({ display: z.number() }),
event: z.union([z.instanceof(Uint8Array), z.object({ type: z.literal("resized"), width: z.number() })]),
});
const thumbnail = defineRpc({ name: "thumbnail.render", input: z.object({ path: z.string() }), output: z.instanceof(Uint8Array) });
const input: SubscriptionInput<typeof diffWatch> = { a: "main", b: "HEAD" };
const event: SubscriptionEvent<typeof diffWatch> = { path: "a.ts", status: "added" };
function server(server: PluginServerContext) {
server.handle(diffWatch, ({ a, b }, { push, signal, paseo }: PluginSubscriptionContext<SubscriptionEvent<typeof diffWatch>>) => {
const pushed: Promise<void> = push({ path: a + b, status: "modified" });
// @ts-expect-error events follow the contract
void push({ path: 1, status: "modified" });
signal.addEventListener("abort", () => undefined, { once: true });
});
server.handle(frames, async (_input, { push }) => {
await push(new Uint8Array(4));
await push({ type: "resized", width: 2 });
});
server.handle(thumbnail, ({ path }) => new Uint8Array(path.length));
// @ts-expect-error subscription handlers receive push, not an output
server.handle(diffWatch, () => ({ path: "a", status: "added" as const }) as unknown as number);
}
function Panel() {
const state = useSubscription(diffWatch, { a: "main", b: "HEAD" }, (change) => {
const status: "added" | "modified" | "deleted" = change.status;
});
if (state.status === "error") state.error.message;
// @ts-expect-error the hook has no ready state, only live
if (state.status === "ready") return null;
useSubscription(frames, { display: 1 }, (event) => {
if (event instanceof Uint8Array) event.byteLength;
else event.width;
});
const render = useRpc(thumbnail);
void render({ path: "x" }).then((png: Uint8Array) => png.byteLength);
return null;
}
function client(client: PluginClientContext) {
const controller = new AbortController();
const remover = client.subscribe(diffWatch, { a: "main", b: "HEAD" }, (change) => change.path, { signal: controller.signal });
const ready: Promise<void> = remover.ready;
const released: Promise<void> = remover.release();
remover();
return () => {};
}
`),
).toEqual([]);
});
});
+2 -18
View File
@@ -4,13 +4,7 @@ import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginFile } from "./file.js";
import type {
PluginRpcContract,
PluginSubscribeOptions,
PluginSubscriptionContract,
PluginSubscriptionHandle,
PluginWireInput,
} from "../rpc.js";
import type { PluginRpcContract } from "../rpc.js";
import type {
PluginButtonRegistration,
PluginHeaderButtonContribution,
@@ -209,16 +203,6 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
contribution: PluginTimelineRendererContribution<Schema>,
): PluginCleanup;
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
/**
* Subscribes to a plugin subscription on this client's host. Paseo re-subscribes after a
* reconnect and releases what is left when the plugin stops.
*/
subscribe<InputSchema extends ZodType, EventSchema extends ZodType>(
contract: PluginSubscriptionContract<InputSchema, EventSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
onEvent: (event: ZodOutput<EventSchema>) => void,
options?: PluginSubscribeOptions,
): PluginSubscriptionHandle;
}
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
@@ -311,7 +295,7 @@ export interface PluginCommandCapabilities {
paseo: PaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
input: ZodInput<InputSchema>,
): Promise<ZodOutput<OutputSchema>>;
openScreen(input: PluginOpenScreenInput): void;
/** @deprecated Use `openScreen`. */
+10 -16
View File
@@ -24,23 +24,17 @@ describe("plugin RPC contracts", () => {
expectTypeOf<RpcOutput<typeof contract>>().toEqualTypeOf<{ value: number }>();
});
it("sends input unchanged for the plugin to parse", async () => {
let parses = 0;
const transformed = defineRpc({
name: "transformed",
input: z.string().transform((text) => {
parses += 1;
return Number(text);
}),
output: z.number(),
});
const invoke = async (_method: string, input: unknown) => {
expect(input).toBe("2");
expect(parses).toBe(0);
return transformed.input.parseAsync(input);
it("validates input before invoking the daemon", async () => {
let invocations = 0;
const invoke = async () => {
invocations += 1;
return { value: 2 };
};
await expect(callPluginRpc(transformed, invoke, "2")).resolves.toBe(2);
expect(parses).toBe(1);
await expect(callPluginRpc(contract, invoke, { value: "wrong" })).rejects.toBeInstanceOf(
z.ZodError,
);
expect(invocations).toBe(0);
});
it("validates output before resolving", async () => {
+2 -4
View File
@@ -8,7 +8,7 @@ export {
import type { PluginAttachmentSourceContribution } from "../contracts.js";
import { PluginRpcProvider } from "./rpc-context.js";
import { PaseoApiProvider } from "./paseo-context.js";
import { callPluginRpc, subscribePluginRpc, type PluginSubscriptionObserver } from "../rpc.js";
import { callPluginRpc } from "../rpc.js";
export async function searchPluginAttachments(
source: PluginAttachmentSourceContribution<PluginText>,
@@ -19,9 +19,7 @@ export async function searchPluginAttachments(
return PluginAttachmentSearchPayloadSchema.parseAsync(output);
}
export { callPluginRpc, subscribePluginRpc, PaseoApiProvider, PluginRpcProvider };
export type { PluginSubscriptionObserver };
export type { PluginSubscribe } from "./rpc-context.js";
export { callPluginRpc, PaseoApiProvider, PluginRpcProvider };
export {
PluginTranslationRuntime,
+1 -6
View File
@@ -71,12 +71,7 @@ export {
} from "./file.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc, useSubscription, type PluginSubscriptionState } from "./rpc-context.js";
export type {
PluginSubscribeOptions,
PluginSubscriptionClosed,
PluginSubscriptionHandle,
} from "../rpc.js";
export { useRpc } from "./rpc-context.js";
import type { SettingsDefinition } from "../settings.js";
import type { SettingsState } from "./contracts.js";
import type { ZodType } from "zod";
+5 -108
View File
@@ -1,34 +1,9 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import {
callPluginRpc,
subscribePluginRpc,
type PluginRpcContract,
type PluginSubscriptionContract,
type PluginSubscriptionHandle,
type PluginSubscriptionObserver,
type PluginWireInput,
} from "../rpc.js";
import { shallowEqual } from "./shallow.js";
export type PluginSubscribe = (
method: string,
input: unknown,
observer: PluginSubscriptionObserver,
) => PluginSubscriptionHandle;
import { callPluginRpc, type PluginRpcContract } from "../rpc.js";
export interface PluginRpcContextValue {
invoke(method: string, input: unknown): Promise<unknown>;
subscribe: PluginSubscribe;
}
const PluginRpcContext = createContext<PluginRpcContextValue | null>(null);
@@ -40,100 +15,22 @@ export function usePluginRpcContextValue(): PluginRpcContextValue | null {
export function PluginRpcProvider({
children,
invoke,
subscribe,
}: {
children: ReactNode;
invoke(method: string, input: unknown): Promise<unknown>;
subscribe: PluginSubscribe;
}) {
const value = useMemo(() => ({ invoke, subscribe }), [invoke, subscribe]);
const value = useMemo(() => ({ invoke }), [invoke]);
return <PluginRpcContext.Provider value={value}>{children}</PluginRpcContext.Provider>;
}
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: PluginWireInput<ZodInput<InputSchema>>) => Promise<ZodOutput<OutputSchema>> {
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
const context = usePluginRpcContextValue();
if (!context) throw new Error("useRpc must run inside a contributed plugin surface");
return useCallback(
(input: PluginWireInput<ZodInput<InputSchema>>) =>
callPluginRpc(contract, context.invoke, input),
(input: ZodInput<InputSchema>) => callPluginRpc(contract, context.invoke, input),
[context, contract],
);
}
export type PluginSubscriptionState =
| { status: "connecting" }
| { status: "live" }
/** The plugin's handler finished. The hook subscribes again when `input` changes. */
| { status: "closed" }
| { status: "error"; error: Error };
/**
* Subscribes while the component is mounted. Re-subscribes when `input` changes by shallow
* comparison; `onEvent` always runs the latest callback.
*/
export function useSubscription<InputSchema extends ZodType, EventSchema extends ZodType>(
contract: PluginSubscriptionContract<InputSchema, EventSchema>,
input: PluginWireInput<ZodInput<InputSchema>>,
onEvent: (event: ZodOutput<EventSchema>) => void,
): PluginSubscriptionState {
const context = usePluginRpcContextValue();
if (!context) throw new Error("useSubscription must run inside a contributed plugin surface");
const subscribe = context.subscribe;
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const stableInput = useShallowStable(input);
const [state, setState] = useState<PluginSubscriptionState>({ status: "connecting" });
useEffect(() => {
let active = true;
setState({ status: "connecting" });
let handle: PluginSubscriptionHandle;
try {
handle = subscribePluginRpc(
contract,
subscribe,
stableInput,
(event) => onEventRef.current(event),
{
onClosed: ({ error }) => {
if (active) setState(error ? { status: "error", error } : CLOSED);
},
},
);
} catch (error) {
setState({ status: "error", error: toError(error) });
return;
}
handle.ready.then(
() => {
if (active) setState((current) => (current.status === "connecting" ? LIVE : current));
return undefined;
},
(error: unknown) => {
if (active) setState({ status: "error", error: toError(error) });
},
);
return () => {
active = false;
handle();
};
}, [contract, subscribe, stableInput]);
return state;
}
const LIVE: PluginSubscriptionState = { status: "live" };
const CLOSED: PluginSubscriptionState = { status: "closed" };
function useShallowStable<Value>(value: Value): Value {
const ref = useRef(value);
if (!shallowEqual(ref.current, value)) ref.current = value;
return ref.current;
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}

Some files were not shown because too many files have changed in this diff Show More