Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andluckxine 138d9d63ab fix(android): keep panel overscroll inside inverted timelines
Android forwards a nested panel’s unconsumed scroll and fling to its inverted parent, moving the timeline in the opposite visual direction. Keep that motion inside the panel while preserving ordinary parent scrolling and nested-scroll registration.

Co-authored-by: luckxine <91582901+luckxine@users.noreply.github.com>
2026-11-01 14:32:01 +00:00
184 changed files with 1165 additions and 8315 deletions
+8
View File
@@ -406,7 +406,10 @@
{ {
"files": [ "files": [
"packages/app/src/app/_layout.tsx", "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/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-list.tsx",
"packages/app/src/components/agent-status-dot.tsx", "packages/app/src/components/agent-status-dot.tsx",
"packages/app/src/components/attachment-lightbox.tsx", "packages/app/src/components/attachment-lightbox.tsx",
@@ -425,6 +428,7 @@
"packages/app/src/components/hosts/host-picker.tsx", "packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/icons/paseo-logo.tsx", "packages/app/src/components/icons/paseo-logo.tsx",
"packages/app/src/components/left-sidebar.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/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx", "packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.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-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx", "packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.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/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx", "packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.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-project-flow.tsx",
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
"packages/app/src/components/agent-list.tsx", "packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx", "packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx", "packages/app/src/components/attachment-lightbox.tsx",
+11 -1
View File
@@ -174,7 +174,7 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`. This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
## Inverted timeline selection ## Inverted timeline scrolling
Android focus and selection visibility requests must not reposition inverted timelines. The Android focus and selection visibility requests must not reposition inverted timelines. The
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for `modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
@@ -183,6 +183,16 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll `scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
commands still work; non-inverted scroll views keep Android's default behavior. commands still work; non-inverted scroll views keep Android's default behavior.
Nested panels also keep their edge overshoot and unconsumed flings inside the panel instead of
moving the inverted timeline in the opposite direction. Nested-scroll registration and scrolling
inside the panel are preserved. Run the native selection and nested-scroll regressions after prebuild:
```bash
cd packages/app/android
./gradlew :paseo-scroll:connectedDebugAndroidTest \
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
Register this package before React Native's core package through its Expo config plugin. Normal Register this package before React Native's core package through its Expo config plugin. Normal
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
not change the shipped scroll view. Keep this behavior in the app's compiled native module. not change the shipped scroll view. Keep this behavior in the app's compiled native module.
+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: 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`). - **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`). - **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. 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`). `<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. `<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 ## 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. 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` | | 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` | | 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` | | 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` | | 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` | | 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` | | 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 ## 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 When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, use `navigateToWorkspace()`. Do not make the caller into a host workspace, use `navigateToWorkspace()`. Do not make the caller
branch on its current route. 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 `openScreen` validates its input once (`parsePluginOpenScreenInput`); code past it trusts the
params. params.
RPC and subscription contracts validate inputs, outputs, and events in both the app and plugin RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
subprocess. `useRpc` returns a typed async function. Use the host-provided `@tanstack/react-query` typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
for request state and caching; Paseo gives each plugin installation its own query client. 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).
`usePaseo()` and the handler's `{ paseo }` context expose the same `PaseoApi`: projects, `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. workspaces, agents, terminals, providers, and daemon config. They do not expose connection lifecycle.
@@ -5,7 +5,6 @@ import path from "node:path";
import { test, expect } from "../support/fixtures"; import { test, expect } from "../support/fixtures";
import { import {
addProjectFlow, addProjectFlow,
chooseAddHostFromProjectFlow,
addProjectFlowBack, addProjectFlowBack,
addProjectFlowHost, addProjectFlowHost,
addProjectFlowInput, addProjectFlowInput,
@@ -20,7 +19,6 @@ import {
} from "../support/helpers/add-project-flow"; } from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app"; import { gotoAppShell } from "../support/helpers/app";
import { import {
disconnectAllHostsAndReload,
addConnectedHostAndReload, addConnectedHostAndReload,
addConnectedHostsAndReload, addConnectedHostsAndReload,
addOfflineHostAndReload, addOfflineHostAndReload,
@@ -71,19 +69,6 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => { test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 }); 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 ({ test("method selection shows the daemon's available project sources without search", async ({
page, page,
}) => { }) => {
@@ -3,7 +3,7 @@ import {
composerLocator, composerLocator,
expectComposerDraft, expectComposerDraft,
expectComposerVisible, expectComposerVisible,
fillComposerDraft, submitMessage,
typeIntoFocusedComposer, typeIntoFocusedComposer,
} from "../support/helpers/composer"; } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -12,14 +12,8 @@ function userMessages(page: Page) {
return page.getByTestId("user-message"); 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) { async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await fillComposerDraft(page, text); await submitMessage(page, text);
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 }); await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, ""); await expectComposerDraft(page, "");
} }
@@ -55,7 +49,7 @@ test.describe("Composer recall", () => {
await pressInComposer(page, "ArrowUp"); await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login"); 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)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login"); await expect(userMessages(page).last()).toContainText("add tests for login");
} finally { } 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> { async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
const option = page.getByTestId("file-panel-bar").getByRole("button", { const option = page.getByTestId("file-panel-bar").getByRole("button", {
name: view, name: view,
@@ -844,192 +826,6 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(preview.host).toBeVisible(); 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("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
test.setTimeout(90_000); test.setTimeout(90_000);
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" }); const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
@@ -1,6 +1,4 @@
import { writeFile } from "node:fs/promises"; import { test } from "../support/fixtures";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { import {
collapseFolder, collapseFolder,
expandFolder, expandFolder,
@@ -10,7 +8,6 @@ import {
openFileExplorer, openFileExplorer,
openFileFromExplorer, openFileFromExplorer,
} from "../support/helpers/file-explorer"; } from "../support/helpers/file-explorer";
import { delayDirectoryListingResponse } from "../support/helpers/directory-listing-gate";
import { gotoWorkspace } from "../support/helpers/launcher"; import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
@@ -52,66 +49,3 @@ test.describe("File explorer collapse", () => {
await expectExplorerEntryVisible(page, "guide.md"); 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" }); await entry("renamed.txt").click({ button: "right" });
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/); const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
await expect const deleteLabelColor = await deleteAction
.poll(async () => {
const labelColor = await deleteAction
.getByText("Delete", { exact: true }) .getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color); .evaluate((element) => getComputedStyle(element).color);
const iconColor = await deleteAction await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
.locator("svg")
.evaluate((element) => getComputedStyle(element).stroke);
return labelColor !== "" && iconColor === labelColor;
})
.toBe(true);
const cancelledConfirmation = new Promise<string>((resolve) => { const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => { page.once("dialog", async (dialog) => {
const message = dialog.message(); const message = dialog.message();
@@ -122,14 +122,10 @@ test.describe("New workspace entry points", () => {
}); });
const hostTrigger = page.getByTestId("host-picker-trigger"); const hostTrigger = page.getByTestId("host-picker-trigger");
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 }); await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
const originalUrl = page.url();
await hostTrigger.click(); await hostTrigger.click();
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible(); await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
await page.getByTestId("new-workspace-host-picker-add-host").click(); await page.getByTestId("new-workspace-host-picker-add-host").click();
await expect( await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
} finally { } finally {
await seeded.cleanup(); 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); test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context); const { session, card } = await openLinkedQuestions(page, context);
try { try {
@@ -117,13 +120,16 @@ test.describe("Question option text", () => {
await dragAcross(page, description); await dragAcross(page, description);
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION); await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
await expectChecked(done, false);
await description.getByText("Logs are at", { exact: false }).dblclick({ await description.getByText("Logs are at", { exact: false }).dblclick({
position: { x: 4, y: 4 }, position: { x: 4, y: 4 },
}); });
await expect.poll(() => selectedText(page)).toBe("Logs"); await expect.poll(() => selectedText(page)).toBe("Logs");
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION }); await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
await expectChecked(done, true); await expectChecked(done, false);
await continueToNextQuestion(page);
await done.getByText("Done", { exact: true }).click();
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION }); await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await openQuestion(page, { index: 1, total: 2 }); await openQuestion(page, { index: 1, total: 2 });
await expectChecked(done, true); 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 ({ test("double-clicking a single-select label answers once", async ({ page, context }) => {
page,
context,
}) => {
test.setTimeout(180_000); test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context); const { session, card } = await openLinkedQuestions(page, context);
try { try {
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick(); 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 expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false); 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); test.setTimeout(180_000);
const { session, card } = await openLinkedQuestions(page, context); const { session, card } = await openLinkedQuestions(page, context);
try { try {
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click(); await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
await continueToNextQuestion(page);
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION }); await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
const lint = card.getByRole("checkbox", { name: "Lint" }); const lint = card.getByRole("checkbox", { name: "Lint" });
@@ -175,16 +177,18 @@ test.describe("Question option text", () => {
await dragAcross(page, description); await dragAcross(page, description);
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION); await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
await expectChecked(lint, false);
await description.getByText("Rules:", { exact: false }).dblclick({ await description.getByText("Rules:", { exact: false }).dblclick({
position: { x: 4, y: 4 }, position: { x: 4, y: 4 },
}); });
await expect.poll(() => selectedText(page)).toBe("Rules"); await expect.poll(() => selectedText(page)).toBe("Rules");
await expectChecked(lint, true); await expectChecked(lint, false);
await typecheck.getByText("Typecheck", { exact: true }).dblclick(); await typecheck.getByText("Typecheck", { exact: true }).dblclick();
await expectChecked(typecheck, true); await expectChecked(typecheck, true);
await lint.getByText("Lint", { exact: true }).click(); await lint.getByText("Lint", { exact: true }).click();
await expectChecked(lint, false); await expectChecked(lint, true);
} finally { } finally {
await session.cleanup(); await session.cleanup();
} }
@@ -264,7 +268,6 @@ test.describe("Question prompt pagination", () => {
await expectQuestionHidden(page, SUCCESS_QUESTION); await expectQuestionHidden(page, SUCCESS_QUESTION);
await chooseQuestionOption(page, "App"); await chooseQuestionOption(page, "App");
await continueToNextQuestion(page);
await expectCurrentQuestion(page, { await expectCurrentQuestion(page, {
index: 2, index: 2,
total: TOTAL_QUESTIONS, total: TOTAL_QUESTIONS,
@@ -285,7 +288,6 @@ test.describe("Question prompt pagination", () => {
preview: 'flags.enabled("new-surface")', preview: 'flags.enabled("new-surface")',
}); });
await chooseQuestionOption(page, "Behind feature flag"); await chooseQuestionOption(page, "Behind feature flag");
await continueToNextQuestion(page);
await expectCurrentQuestion(page, { await expectCurrentQuestion(page, {
index: 3, index: 3,
total: TOTAL_QUESTIONS, total: TOTAL_QUESTIONS,
@@ -464,9 +466,7 @@ retryTest(
retryTest.setTimeout(180_000); retryTest.setTimeout(180_000);
const card = page.getByTestId("question-form-card"); const card = page.getByTestId("question-form-card");
await chooseQuestionOption(page, "App"); await chooseQuestionOption(page, "App");
await continueToNextQuestion(page);
await chooseQuestionOption(page, "Behind feature flag"); await chooseQuestionOption(page, "Behind feature flag");
await continueToNextQuestion(page);
await fillQuestionAnswer(page, { await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION, question: SUCCESS_QUESTION,
answer: "Preserve my answers after a failed submission.", answer: "Preserve my answers after a failed submission.",
@@ -520,9 +520,7 @@ retryTest(
const card = page.getByTestId("question-form-card"); const card = page.getByTestId("question-form-card");
questionClient.releaseFirstResponse(); questionClient.releaseFirstResponse();
await chooseQuestionOption(page, "App"); await chooseQuestionOption(page, "App");
await continueToNextQuestion(page);
await chooseQuestionOption(page, "Behind feature flag"); await chooseQuestionOption(page, "Behind feature flag");
await continueToNextQuestion(page);
await fillQuestionAnswer(page, { await fillQuestionAnswer(page, {
question: SUCCESS_QUESTION, question: SUCCESS_QUESTION,
answer: "Start with the internal team.", 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 { readFile, mkdir, rename, rm } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; 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 expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working."); 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); .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(); 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();
}
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name="sh.paseo.scroll.NestedScrollActivity" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,134 @@
package sh.paseo.scroll
import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.DisplayMetricsHolder
import com.facebook.react.uimanager.ReactStylesDiffMap
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.soloader.SoLoader
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
class NestedScrollActivity : Activity()
@RunWith(AndroidJUnit4::class)
class NestedScrollTest {
@Test fun invertedTimelineDoesNotConsumePanelOvershoot() = withNestedScroll(inverted = true) { parent, child ->
val before = parent.scrollY
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.scrollTo(0, 600)
assertEquals(600, child.scrollY)
assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null))
assertEquals("Panel overshoot must not move the inverted timeline", before, parent.scrollY)
child.scrollTo(0, 0)
assertTrue(child.dispatchNestedScroll(0, 0, 0, -100, null))
assertEquals("Overshoot at either edge must stay inside the panel", before, parent.scrollY)
child.stopNestedScroll()
}
@Test fun ordinaryParentStillConsumesPanelOvershoot() = withNestedScroll(inverted = false) { parent, child ->
val before = parent.scrollY
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.scrollTo(0, 600)
assertEquals(600, child.scrollY)
assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null))
assertEquals(before + 100, parent.scrollY)
child.stopNestedScroll()
}
@Test fun panelCanStillScrollInsideInvertedTimeline() = withNestedScroll(inverted = true) { parent, child ->
val before = parent.scrollY
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.scrollTo(0, 100)
assertTrue(child.dispatchNestedScroll(0, 100, 0, 0, null))
assertEquals(100, child.scrollY)
assertEquals(before, parent.scrollY)
child.stopNestedScroll()
parent.scrollTo(0, before + 100)
assertEquals(before + 100, parent.scrollY)
}
@Test fun invertedTimelineAbsorbsUnconsumedPanelFling() = checkFling(inverted = true)
@Test fun ordinaryParentStillAcceptsUnconsumedPanelFling() = checkFling(inverted = false)
private fun checkFling(inverted: Boolean) = withNestedScroll(
inverted = inverted,
afterFrames = { parent ->
if (inverted) assertEquals("Panel fling must not move the inverted timeline", 400, parent.scrollY)
else assertTrue("Control fling must move an ordinary parent", parent.scrollY > 400)
}
) { parent, child ->
parent.scrollTo(0, 400)
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
child.scrollTo(0, 600)
assertEquals(600, child.scrollY)
assertTrue(child.dispatchNestedFling(0f, 1500f, false))
child.stopNestedScroll()
}
private fun withNestedScroll(
inverted: Boolean,
afterFrames: ((ReactScrollView) -> Unit)? = null,
check: (ReactScrollView, ReactScrollView) -> Unit
) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val activity = instrumentation.startActivitySync(
Intent(instrumentation.context, NestedScrollActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
lateinit var parent: ReactScrollView
try {
instrumentation.runOnMainSync {
val appContext = instrumentation.targetContext
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = ScrollTestContext(appContext)
val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
parent = manager.createViewInstance(context)
val child = manager.createViewInstance(context).apply { isNestedScrollingEnabled = true }
val panelContent = View(context)
child.addView(panelContent, FrameLayout.LayoutParams(300, 800))
val content = FrameLayout(context)
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 500 })
parent.addView(content, FrameLayout.LayoutParams(300, 2000))
panelContent.measure(exact(300), exact(800))
panelContent.layout(0, 0, 300, 800)
child.measure(exact(300), exact(200))
child.layout(0, 500, 300, 700)
content.measure(exact(300), exact(2000))
content.layout(0, 0, 300, 2000)
parent.measure(exact(300), exact(400))
parent.layout(0, 0, 300, 400)
// The same transform React Native applies to an inverted FlatList on Android.
manager.updateProperties(parent, ReactStylesDiffMap(JavaOnlyMap.of(
"transform", JavaOnlyArray.of(JavaOnlyMap.of("scale", if (inverted) -1.0 else 1.0))
)))
activity.setContentView(parent, ViewGroup.LayoutParams(300, 400))
parent.scrollTo(0, 400)
check(parent, child)
}
if (afterFrames != null) {
// Let the framework's real fling animation advance, with an ordinary-parent control.
SystemClock.sleep(500)
instrumentation.runOnMainSync { afterFrames(parent) }
}
} finally {
instrumentation.runOnMainSync { activity.finish() }
}
}
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
}
@@ -8,7 +8,6 @@ import android.widget.FrameLayout
import android.widget.TextView import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.BridgeReactContext
import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping import com.facebook.react.soloader.OpenSourceMergedSoMapping
@@ -70,7 +69,7 @@ class ScrollSelectionTest {
val appContext = instrumentation.targetContext val appContext = instrumentation.targetContext
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext) DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
SoLoader.init(appContext, OpenSourceMergedSoMapping) SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = BridgeReactContext(appContext) val reactContext = ScrollTestContext(appContext)
val context = ThemedReactContext(reactContext, appContext) val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
val scroll = manager.createViewInstance(context) val scroll = manager.createViewInstance(context)
@@ -0,0 +1,19 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
ScrollTestContext(Context context) {
super(context);
}
@Override
public EventDispatcher getEventDispatcher() {
return BlackHoleEventDispatcher.INSTANCE;
}
}
@@ -2,6 +2,7 @@ package sh.paseo.scroll
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Rect import android.graphics.Rect
import android.view.View
import com.facebook.react.ReactPackage import com.facebook.react.ReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage import com.facebook.react.ViewManagerOnDemandReactPackage
import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.NativeModule
@@ -30,13 +31,30 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) { private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
private val transformValues = FloatArray(9) private val transformValues = FloatArray(9)
private val isInverted: Boolean
get() {
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
matrix.getValues(transformValues)
return transformValues[Matrix.MSCALE_Y] < 0
}
// Both RN focus handling and Android selection visibility requests use this calculation. // Both RN focus handling and Android selection visibility requests use this calculation.
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline // Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
// movement to user gestures and explicit scroll commands. // movement to user gestures and explicit scroll commands.
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int { override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY. if (isInverted) return 0
matrix.getValues(transformValues)
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
return super.computeScrollDeltaToGetChildRectOnScreen(rect) return super.computeScrollDeltaToGetChildRectOnScreen(rect)
} }
// A nested panel's remaining delta uses its own visual direction. Consuming it in
// an inverted timeline moves the timeline the opposite way when the panel hits an edge.
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int) {
if (isInverted) return
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed)
}
override fun onNestedFling(target: View, velocityX: Float, velocityY: Float, consumed: Boolean): Boolean {
if (isInverted && !consumed) return true
return super.onNestedFling(target, velocityX, velocityY, consumed)
}
} }
@@ -69,9 +69,6 @@ function installProbe(
queryClient: new QueryClient(), queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"], paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined, invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
}; };
} }
+1 -4
View File
@@ -1,4 +1,3 @@
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar"; import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
@@ -466,7 +465,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const surface = ( const surface = (
<View style={layoutStyles.surfaceFill}> <View style={layoutStyles.surfaceFill}>
{workspaceChrome} {workspaceChrome}
<AddHostFlowHost />
<AppearanceStyleBoundary> <AppearanceStyleBoundary>
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? ( {!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
<WindowChromeRegion corners="top-left"> <WindowChromeRegion corners="top-left">
@@ -807,6 +805,7 @@ function RootStack() {
<Stack.Screen name="open-project" /> <Stack.Screen name="open-project" />
<Stack.Screen name="sessions" /> <Stack.Screen name="sessions" />
<Stack.Screen name="schedules" /> <Stack.Screen name="schedules" />
<Stack.Screen name="pair-scan" />
</Stack.Protected> </Stack.Protected>
<Stack.Screen name="h/[serverId]" /> <Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" /> <Stack.Screen name="settings/hosts/[serverId]/index" />
@@ -891,13 +890,11 @@ function RootAppTree() {
<GestureHandlerRootView style={flexStyle}> <GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}> <View style={layoutStyles.surfaceFill}>
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}> <LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
<AddHostProvider>
<RootProviders> <RootProviders>
<RuntimeProviders> <RuntimeProviders>
<AppShell /> <AppShell />
</RuntimeProviders> </RuntimeProviders>
</RootProviders> </RootProviders>
</AddHostProvider>
</LucideProvider> </LucideProvider>
</View> </View>
</GestureHandlerRootView> </GestureHandlerRootView>
@@ -1,14 +1,17 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; 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 { 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 { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera"; import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { getHostRuntimeStore } from "@/runtime/host-runtime"; 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 { 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"; import { Button } from "@/components/ui/button";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
@@ -110,26 +113,50 @@ const styles = StyleSheet.create((theme) => ({
}, },
})); }));
export function PairScanContent({ export default function PairScanScreen() {
onConnected, const { theme } = useUnistyles();
onClose,
}: {
onConnected: (serverId: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); 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 [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(() => const [scan] = useState(() =>
openPairScan({ openPairScan({
importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"), importConnectionLink: (link) =>
onConnected, getHostRuntimeStore().importConnectionLink(
link,
source === "onboarding" ? "hostRoot" : "hostSettings",
),
onConnected: navigateToPairedHost,
}), }),
); );
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState); const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
useEffect(() => () => scan.close(), [scan]); useEffect(() => () => scan.close(), [scan]);
const closeToSource = useCallback(() => {
try {
router.back();
} catch {
router.replace("/" as Href);
}
}, [router]);
useEffect(() => { useEffect(() => {
if (isWeb) return; if (isWeb) return;
if (permission && permission.granted) return; if (permission && permission.granted) return;
@@ -142,29 +169,35 @@ export function PairScanContent({
[scan], [scan],
); );
const handleRouterBack = useCallback(() => router.back(), [router]);
const savePasswordPairing = useCallback( const savePasswordPairing = useCallback(
({ serverId }: { serverId: string }) => onConnected(serverId), ({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
[onConnected], [navigateToPairedHost],
); );
const handleRequestPermission = useCallback(() => { const handleRequestPermission = useCallback(() => {
void requestPermission(); void requestPermission();
}, [requestPermission]); }, [requestPermission]);
const bodyStyle = useMemo( const bodyStyle = useMemo(
() => [styles.body, { paddingBottom: insets.bottom + 24 }], () => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
[insets.bottom], [insets.bottom, theme.spacing],
);
const helperTextStyle = useMemo(
() => [styles.helperText, { color: theme.colors.foreground }],
[theme.colors.foreground],
); );
if (isWeb) { if (isWeb) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
<View style={bodyStyle}> <View style={bodyStyle}>
<View style={styles.permissionCard}> <View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text> <Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text> <Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
<Button style={styles.permissionButton} onPress={onClose}> <Pressable style={styles.permissionButton} onPress={closeToSource}>
<Text style={styles.permissionButtonText}>{t("common.actions.back")}</Text> <Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
</Button> </Pressable>
</View> </View>
</View> </View>
</View> </View>
@@ -175,14 +208,16 @@ export function PairScanContent({
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
<View style={bodyStyle}> <View style={bodyStyle}>
{!granted ? ( {!granted ? (
<View style={styles.permissionCard}> <View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text> <Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</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> <Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
</Button> </Pressable>
</View> </View>
) : ( ) : (
<View style={styles.cameraWrap}> <View style={styles.cameraWrap}>
@@ -200,10 +235,10 @@ export function PairScanContent({
<View style={[styles.corner, styles.cornerBR]} /> <View style={[styles.corner, styles.cornerBR]} />
</View> </View>
{scanState.status === "pairing" ? ( {scanState.status === "pairing" ? (
<Text style={styles.helperText}>{t("pairing.scan.pairing")}</Text> <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
) : null} ) : null}
{scanState.status === "stopped" && scanState.error ? ( {scanState.status === "stopped" && scanState.error ? (
<Text style={styles.helperText}>{scanState.error}</Text> <Text style={helperTextStyle}>{scanState.error}</Text>
) : null} ) : null}
{scanState.status === "stopped" ? ( {scanState.status === "stopped" ? (
<Button <Button
+3 -2
View File
@@ -35,9 +35,10 @@ function SettingsDaemonRedirect() {
} }
export default function SettingsSectionRoute() { 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 rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general"; 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]); const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08. // 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 { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; 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 { StyleSheet, withUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native"; 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 { isFdroidBuild } from "@/constants/build-profile";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { isElectronRuntime } from "@/desktop/host"; import { isElectronRuntime } from "@/desktop/host";
@@ -85,7 +84,7 @@ export function AddHostMethodModal({
onClose={onClose} onClose={onClose}
testID="add-host-method-modal" testID="add-host-method-modal"
> >
<PressHighlight <Pressable
style={styles.option} style={styles.option}
onPress={handleDirect} onPress={handleDirect}
accessibilityRole="button" accessibilityRole="button"
@@ -99,10 +98,10 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.direct.description")} {t("pairing.connectionMethods.direct.description")}
</Text> </Text>
</View> </View>
</PressHighlight> </Pressable>
{isElectronRuntime() ? ( {isElectronRuntime() ? (
<PressHighlight <Pressable
style={styles.option} style={styles.option}
onPress={handleRemoteSsh} onPress={handleRemoteSsh}
accessibilityRole="button" accessibilityRole="button"
@@ -116,11 +115,11 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.remoteSsh.description")} {t("pairing.connectionMethods.remoteSsh.description")}
</Text> </Text>
</View> </View>
</PressHighlight> </Pressable>
) : null} ) : null}
{isNative && !isFdroidBuild ? ( {isNative && !isFdroidBuild ? (
<PressHighlight <Pressable
style={styles.option} style={styles.option}
onPress={handleScan} onPress={handleScan}
accessibilityRole="button" accessibilityRole="button"
@@ -133,10 +132,10 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.scanQr.description")} {t("pairing.connectionMethods.scanQr.description")}
</Text> </Text>
</View> </View>
</PressHighlight> </Pressable>
) : null} ) : null}
<PressHighlight <Pressable
style={styles.option} style={styles.option}
onPress={handlePaste} onPress={handlePaste}
accessibilityRole="button" accessibilityRole="button"
@@ -150,7 +149,7 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.pasteLink.description")} {t("pairing.connectionMethods.pasteLink.description")}
</Text> </Text>
</View> </View>
</PressHighlight> </Pressable>
</AdaptiveModalSheet> </AdaptiveModalSheet>
); );
} }
@@ -1,22 +1,17 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo, useReducer, useRef, useState } from "react"; import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Text, View } from "react-native"; import { Alert, Pressable, 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 { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native"; import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints"; import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
import { buildConnectionFailureCopy } from "@/components/connection-failure-copy"; import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "@/components/pair-link-credentials"; import { PairingTargetTracker } from "./pair-link-credentials";
import { buildDirectConnectionUri } from "@/components/direct-connection-host"; import { buildDirectConnectionUri } from "./direct-connection-host";
const FLEX_ONE_STYLE = { flex: 1 } as const; 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) { function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const daemons = useHosts(); const daemons = useHosts();
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations(); 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 [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const advancedTarget = useRef(new PairingTargetTracker("", true)); 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 hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []); const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
const checkboxStyle = useMemo( const checkboxStyle = useMemo(
@@ -416,8 +415,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsAdvancedOpen(false); setIsAdvancedOpen(false);
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]); }, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight; const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye; const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
@@ -431,7 +430,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={styles.portRow}> <View style={styles.portRow}>
<View style={hostFieldStyle}> <View style={hostFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text> <Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
<ThemedAdaptiveTextInput <AdaptiveTextInput
testID="direct-host-input" testID="direct-host-input"
nativeID="direct-host-input" nativeID="direct-host-input"
accessibilityLabel={t("pairing.direct.fields.host")} accessibilityLabel={t("pairing.direct.fields.host")}
@@ -439,6 +438,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-host-${inputResetKey}`} resetKey={`direct-host-${inputResetKey}`}
onChangeText={setHost} onChangeText={setHost}
placeholder="localhost" placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input} style={styles.input}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -449,7 +449,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</View> </View>
<View style={portFieldStyle}> <View style={portFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text> <Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
<ThemedAdaptiveTextInput <AdaptiveTextInput
testID="direct-port-input" testID="direct-port-input"
nativeID="direct-port-input" nativeID="direct-port-input"
accessibilityLabel={t("pairing.direct.fields.port")} accessibilityLabel={t("pairing.direct.fields.port")}
@@ -457,6 +457,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-port-${inputResetKey}`} resetKey={`direct-port-${inputResetKey}`}
onChangeText={setPort} onChangeText={setPort}
placeholder="6767" placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input} style={styles.input}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -468,7 +469,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</View> </View>
</View> </View>
<PressHighlight <Pressable
style={styles.checkboxRow} style={styles.checkboxRow}
onPress={handleToggleUseTls} onPress={handleToggleUseTls}
disabled={isSaving} disabled={isSaving}
@@ -481,17 +482,17 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={checkboxStyle}> <View style={checkboxStyle}>
{useTls ? ( {useTls ? (
<View testID="direct-ssl-toggle-checked"> <View testID="direct-ssl-toggle-checked">
<ThemedCheck size={14} /> <Check size={14} color={theme.colors.accentForeground} />
</View> </View>
) : null} ) : null}
</View> </View>
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text> <Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
</PressHighlight> </Pressable>
<View style={styles.field}> <View style={styles.field}>
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text> <Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
<View style={styles.passwordRow}> <View style={styles.passwordRow}>
<ThemedAdaptiveTextInput <AdaptiveTextInput
testID="direct-password-input" testID="direct-password-input"
nativeID="direct-password-input" nativeID="direct-password-input"
accessibilityLabel={t("pairing.direct.fields.password")} accessibilityLabel={t("pairing.direct.fields.password")}
@@ -499,6 +500,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-password-${inputResetKey}`} resetKey={`direct-password-${inputResetKey}`}
onChangeText={setPassword} onChangeText={setPassword}
placeholder={t("pairing.direct.fields.optional")} placeholder={t("pairing.direct.fields.optional")}
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle} style={passwordInputStyle}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -507,7 +509,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
returnKeyType="done" returnKeyType="done"
onSubmitEditing={handleSubmitEditing} onSubmitEditing={handleSubmitEditing}
/> />
<PressHighlight <Pressable
style={styles.iconButton} style={styles.iconButton}
onPress={handleTogglePasswordVisibility} onPress={handleTogglePasswordVisibility}
disabled={isSaving} disabled={isSaving}
@@ -519,13 +521,13 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
} }
testID="direct-password-visibility-toggle" testID="direct-password-visibility-toggle"
> >
<PasswordIcon size={18} /> <PasswordIcon size={18} color={theme.colors.foregroundMuted} />
</PressHighlight> </Pressable>
</View> </View>
</View> </View>
<View style={styles.field}> <View style={styles.field}>
<PressHighlight <Pressable
style={styles.advancedToggle} style={styles.advancedToggle}
onPress={handleToggleAdvanced} onPress={handleToggleAdvanced}
disabled={isSaving} disabled={isSaving}
@@ -535,11 +537,11 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
} }
testID="direct-host-advanced-toggle" 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> <Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</PressHighlight> </Pressable>
{isAdvancedOpen ? ( {isAdvancedOpen ? (
<ThemedAdaptiveTextInput <AdaptiveTextInput
testID="direct-host-uri-input" testID="direct-host-uri-input"
nativeID="direct-host-uri-input" nativeID="direct-host-uri-input"
accessibilityLabel={t("pairing.direct.fields.connectionUri")} accessibilityLabel={t("pairing.direct.fields.connectionUri")}
@@ -547,6 +549,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-host-uri-${inputResetKey}`} resetKey={`direct-host-uri-${inputResetKey}`}
onChangeText={handleChangeAdvancedUri} onChangeText={handleChangeAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true" placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input} style={styles.input}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@@ -582,23 +585,3 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</AdaptiveModalSheet> </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 { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime"; 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 { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { buildNewWorkspaceRoute } from "@/utils/host-routes"; import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes";
interface AddProjectFlowProps { interface AddProjectFlowProps {
request: AddProjectFlowRequest; request: AddProjectFlowRequest;
@@ -326,7 +325,6 @@ function setPageStatus(
// The product flow is intentionally one cohesive page-stack state machine. // The product flow is intentionally one cohesive page-stack state machine.
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const addHost = useAddHost();
const hosts = useHosts(); const hosts = useHosts();
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds); const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
@@ -618,9 +616,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
icon: Plus, icon: Plus,
testID: "add-project-flow-add-host", testID: "add-project-flow-add-host",
select: () => { select: () => {
// The native project Modal must release its window before the root sheet opens.
onClose(); onClose();
addHost(); usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
}, },
}); });
} }
@@ -719,7 +717,6 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
directoryPaths, directoryPaths,
githubQuery.data, githubQuery.data,
host, host,
addHost,
onClose, onClose,
openAddedProject, openAddedProject,
page, page,
@@ -1,7 +1,7 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Pressable,
Text, Text,
View, View,
type PressableStateCallbackType, type PressableStateCallbackType,
@@ -23,7 +23,7 @@ import {
import type { EditingTextInputHandle } from "@/components/ui/text-input"; import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; 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 FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedTerminal = withUnistyles(Terminal); const ThemedTerminal = withUnistyles(Terminal);
@@ -174,11 +174,11 @@ export function AddRemoteSshHostModal({
} }
clear(); clear();
onClose();
onSaved?.({ onSaved?.({
...result, ...result,
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId), isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
}); });
onClose();
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]); }, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
const handleTargetChange = useCallback((value: string) => { const handleTargetChange = useCallback((value: string) => {
targetRef.current = value; targetRef.current = value;
@@ -260,7 +260,7 @@ export function AddRemoteSshHostModal({
onSubmitEditing={handleSubmit} onSubmitEditing={handleSubmit}
/> />
</View> </View>
<PressHighlight <Pressable
style={iconButtonStyle} style={iconButtonStyle}
onPress={handleTogglePasswordVisibility} onPress={handleTogglePasswordVisibility}
disabled={isSaving} disabled={isSaving}
@@ -273,7 +273,7 @@ export function AddRemoteSshHostModal({
testID="remote-ssh-password-visibility-toggle" testID="remote-ssh-password-visibility-toggle"
> >
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />} {isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
</PressHighlight> </Pressable>
</View> </View>
</Field> </Field>
<View style={styles.actions}> <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 { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store"; import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import {
DIRECTORY_LISTING_INVALIDATED,
type DirectoryListingResult,
} from "@/file-explorer/directory-listings";
import { isHiddenExplorerPath } from "@/file-explorer/visibility"; import { isHiddenExplorerPath } from "@/file-explorer/visibility";
import { import {
flattenExplorerTree, flattenExplorerTree,
reconcileRestoredExpandedPaths, reconcileRestoredExpandedPaths,
refreshExplorerDirectories,
restoreExpandedDirectories, restoreExpandedDirectories,
setExpandedDirectoryPath, setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories, showHiddenFilesAndRestoreExpandedDirectories,
@@ -440,7 +435,6 @@ export function FileExplorerPane({
const { const {
requestDirectoryListing, requestDirectoryListing,
invalidateDirectoryListings,
createEntry, createEntry,
renameEntry, renameEntry,
duplicateEntry, duplicateEntry,
@@ -892,11 +886,9 @@ export function FileExplorerPane({
refreshExplorerDirectories({ refreshExplorerDirectories({
hasWorkspaceScope, hasWorkspaceScope,
expandedPaths, expandedPaths,
showHiddenFiles: usePanelStore.getState().explorerShowHiddenFiles,
requestDirectoryListing, requestDirectoryListing,
invalidateDirectoryListings,
}), }),
[invalidateDirectoryListings, expandedPaths, hasWorkspaceScope, requestDirectoryListing], [expandedPaths, hasWorkspaceScope, requestDirectoryListing],
); );
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({ const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey], queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
@@ -1432,7 +1424,7 @@ function toggleDirectory({
requestDirectoryListing: ( requestDirectoryListing: (
path: string, path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<DirectoryListingResult>; ) => Promise<ExplorerDirectory | null>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void; setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
}): void { }): void {
if (!workspaceStateKey) { if (!workspaceStateKey) {
@@ -1569,7 +1561,7 @@ async function initializeExplorer({
requestDirectoryListing: ( requestDirectoryListing: (
path: string, path: string,
opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<DirectoryListingResult>; ) => Promise<ExplorerDirectory | null>;
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void; setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
}): Promise<void> { }): Promise<void> {
if (!hasWorkspaceScope || hasInitializedRef.current) { if (!hasWorkspaceScope || hasInitializedRef.current) {
@@ -1580,7 +1572,6 @@ async function initializeExplorer({
recordHistory: false, recordHistory: false,
setCurrentPath: false, setCurrentPath: false,
}); });
if (rootDirectory === DIRECTORY_LISTING_INVALIDATED) return;
if (!rootDirectory) { if (!rootDirectory) {
hasInitializedRef.current = false; hasInitializedRef.current = false;
return; return;
@@ -1602,10 +1593,7 @@ async function initializeExplorer({
const hiddenPersistedPaths = showHiddenFiles const hiddenPersistedPaths = showHiddenFiles
? [] ? []
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath); : Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
const restoredPathsWithHidden = const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
restoredPaths === DIRECTORY_LISTING_INVALIDATED
? DIRECTORY_LISTING_INVALIDATED
: [...restoredPaths, ...hiddenPersistedPaths];
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
reconcileRestoredExpandedPaths({ reconcileRestoredExpandedPaths({
persistedExpandedPaths, 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 { function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
if (!state) { if (!state) {
return "."; 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 { View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles"; 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"; 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. */ /** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
const FADE_OVERHANG = 48; const FADE_OVERHANG = 48;
/** Share of the fade that stays opaque, so the header's title and icons keep contrast. */ /** 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. * same tree position in both modes, so toggling `floating` never remounts them.
*/ */
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) { export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
const overlayHostName = useId();
const [headerHeight, setHeaderHeight] = useState(0); const [headerHeight, setHeaderHeight] = useState(0);
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => { const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
setHeaderHeight(Math.round(event.nativeEvent.layout.height)); setHeaderHeight(Math.round(event.nativeEvent.layout.height));
}, []); }, []);
const overlayClearance = floating ? headerHeight : 0;
return ( return (
<View style={styles.root}> <View style={styles.root}>
{floating ? null : header} {floating ? null : header}
<View style={styles.content}> <View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
<OverlayClearanceContext.Provider value={overlayClearance}>
<OverlayHostContext.Provider value={floating ? overlayHostName : null}>
{children(floating ? headerHeight : 0)}
</OverlayHostContext.Provider>
</OverlayClearanceContext.Provider>
</View>
{floating ? ( {floating ? (
<View style={styles.overlay} pointerEvents="box-none"> <View style={styles.overlay} pointerEvents="box-none">
<EdgeFade <EdgeFade
@@ -64,19 +37,11 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
backdrop="surfaceWorkspace" backdrop="surfaceWorkspace"
style={styles.fade} style={styles.fade}
/> />
<View <View pointerEvents="box-none" onLayout={handleHeaderLayout}>
testID="floating-header"
collapsable={false}
pointerEvents="box-none"
onLayout={handleHeaderLayout}
>
{header} {header}
</View> </View>
</View> </View>
) : null} ) : null}
<View style={styles.statusOverlay} pointerEvents="box-none">
<PortalHost name={overlayHostName} />
</View>
</View> </View>
); );
} }
@@ -90,13 +55,6 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
}, },
statusOverlay: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
overlay: { overlay: {
position: "absolute", position: "absolute",
top: 0, top: 0,
+9 -4
View File
@@ -1,4 +1,3 @@
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router"; import { router } from "expo-router";
import { import {
CircleGauge, CircleGauge,
@@ -63,7 +62,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation"; 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 { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage"; import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -178,8 +177,14 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
router.push(buildSettingsRoute()); router.push(buildSettingsRoute());
}, []); }, []);
const handleAddHostMobile = useAddHost(); const handleAddHostMobile = useCallback(() => {
const handleAddHostDesktop = handleAddHostMobile; showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
}, [showMobileAgent]);
const handleAddHostDesktop = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleOpenHostSettingsMobile = useCallback( const handleOpenHostSettingsMobile = useCallback(
(serverId: string) => { (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([ it.each([
"src/components/message.tsx", "src/components/message.tsx",
"C:\\repo\\file.ts", "C:\\repo\\file.ts",
+5 -24
View File
@@ -138,10 +138,6 @@ export function prepareMarkdownSource(
!source.includes("![") && !source.includes("![") &&
!source.includes("||") && !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("](")) !(workspaceRoot && source.includes("]("))
) { ) {
return { images, ...metadata, render: () => source }; return { images, ...metadata, render: () => source };
@@ -216,25 +212,11 @@ export function prepareMarkdownSource(
if (lineEnd < end) split(lineEnd); if (lineEnd < end) split(lineEnd);
} }
} }
function visit(node: Nodes, previous?: Nodes) { function visit(node: Nodes) {
const position = node.position; const start = node.position?.start.offset;
if (position?.start.offset !== undefined && position.end.offset !== undefined) { const end = node.position?.end.offset;
const start = position.start.offset; if (start !== undefined && end !== undefined) {
const end = position.end.offset;
if (node.type === "paragraph") splitStandaloneImages(node, start, end); 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") { if (node.type === "link") {
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot); const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
if (title !== null) if (title !== null)
@@ -262,8 +244,7 @@ export function prepareMarkdownSource(
if (text !== source.slice(start, end)) edits.push({ start, end, text }); if (text !== source.slice(start, end)) edits.push({ start, end, text });
} }
} }
if ("children" in node) if ("children" in node) node.children.forEach(visit);
node.children.forEach((child, index) => visit(child, node.children[index - 1]));
} }
visit(getTree()); visit(getTree());
return { return {
@@ -1,20 +1,16 @@
import { useCallback, useMemo, useReducer, useRef, useState } from "react"; import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Text, View } from "react-native"; 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 { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native"; import { Link } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime"; import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints"; import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer"; import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection"; 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 { Button } from "@/components/ui/button";
import type { EditingTextInputHandle } from "@/components/ui/text-input"; import type { EditingTextInputHandle } from "@/components/ui/text-input";
@@ -104,6 +100,7 @@ function PairLinkModalContent({
onCancel, onCancel,
onSaved, onSaved,
}: PairLinkModalProps) { }: PairLinkModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const daemons = useHosts(); const daemons = useHosts();
const { beginLinkPairing } = useHostMutations(); const { beginLinkPairing } = useHostMutations();
@@ -129,7 +126,10 @@ function PairLinkModalContent({
resetPasswordInput(); resetPasswordInput();
}, []); }, []);
const pairIcon = useMemo(() => <ThemedLink size={16} />, []); const pairIcon = useMemo(
() => <Link size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
if (isSaving) return; if (isSaving) return;
@@ -212,7 +212,7 @@ function PairLinkModalContent({
<View style={styles.field}> <View style={styles.field}>
<Text style={styles.label}>{t("pairing.link.label")}</Text> <Text style={styles.label}>{t("pairing.link.label")}</Text>
<ThemedAdaptiveTextInput <AdaptiveTextInput
ref={inputRef} ref={inputRef}
initialValue={initialUrl} initialValue={initialUrl}
testID="pair-link-input" testID="pair-link-input"
@@ -220,6 +220,7 @@ function PairLinkModalContent({
accessibilityLabel={t("pairing.link.label")} accessibilityLabel={t("pairing.link.label")}
onChangeText={handleChangeOfferUrl} onChangeText={handleChangeOfferUrl}
placeholder="https://app.paseo.sh/#offer=..." placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input} style={styles.input}
autoFocus autoFocus
autoCapitalize="none" autoCapitalize="none"
@@ -234,7 +235,7 @@ function PairLinkModalContent({
<Text style={styles.label}> <Text style={styles.label}>
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })} {t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
</Text> </Text>
<ThemedAdaptiveTextInput <AdaptiveTextInput
testID="pair-link-password-input" testID="pair-link-password-input"
resetKey={`pair-link-password-${passwordResetKey}`} resetKey={`pair-link-password-${passwordResetKey}`}
accessibilityLabel={t("pairing.hostPassword.label")} accessibilityLabel={t("pairing.hostPassword.label")}
@@ -273,9 +274,3 @@ function PairLinkModalContent({
</AdaptiveModalSheet> </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( function buildPermission(question: Record<string, unknown>): PendingPermission {
question: Record<string, unknown>,
following: Record<string, unknown>[] = [],
): PendingPermission {
return { return {
key: "perm-1", key: "perm-1",
agentId: "agent-1", agentId: "agent-1",
@@ -51,12 +48,12 @@ function buildPermission(
provider: "claude", provider: "claude",
name: "AskUserQuestion", name: "AskUserQuestion",
kind: "question", 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 onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
const container = document.createElement("div"); const container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
@@ -64,7 +61,7 @@ function mountCard(question: Record<string, unknown>, following: Record<string,
act(() => act(() =>
root.render( root.render(
<QuestionFormCard <QuestionFormCard
permission={buildPermission(question, following)} permission={buildPermission(question)}
onRespond={onRespond} onRespond={onRespond}
isResponding={false} isResponding={false}
responseError={null} responseError={null}
@@ -74,14 +71,10 @@ function mountCard(question: Record<string, unknown>, following: Record<string,
mounted.push({ root, container }); mounted.push({ root, container });
const view = within(container); const view = within(container);
const optionRole = question.multiSelect ? "checkbox" : "radio";
const otherInput = () => const otherInput = () =>
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) }); view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
const check = (label: string) => const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
act(() =>
(
view.queryByRole("radio", { name: label }) ?? view.getByRole("checkbox", { name: label })
).click(),
);
const type = (text: string) => { const type = (text: string) => {
const input = otherInput(); const input = otherInput();
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; 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"); if (!response || response.behavior !== "allow") throw new Error("card did not submit");
return (response.updatedInput as { answers: Record<string, string> }).answers; 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 = { const multiSelectQuestion = {
@@ -161,31 +154,3 @@ describe("QuestionFormCard other answers", () => {
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" }); 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. // drag to the preview's nested scroll, so lines past a cap would be unreachable.
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined; const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
// Links are independent actions. Ignore repeat clicks so double-click text selection // Web clicks that must not answer: a link in the label, anything in the description, and the
// toggles an option only once. // second click of a double-click, which would otherwise land on the next question's options.
function isNonAnsweringClick(event: GestureResponderEvent): boolean { function isNonAnsweringClick(event: GestureResponderEvent, description: View | null): boolean {
if (!IS_WEB) return false; if (!IS_WEB) return false;
const click = event.nativeEvent as unknown as MouseEvent; const click = event.nativeEvent as unknown as MouseEvent;
if (click.detail > 1) return true; if (click.detail > 1) return true;
const target = click.target as Element | null; 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({ function getQuestionInputPlaceholder({
question, question,
answerPlaceholder, answerPlaceholder,
@@ -88,9 +92,10 @@ function QuestionOptionRow({
}: QuestionOptionRowProps) { }: QuestionOptionRowProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const descriptionRef = useRef<View>(null);
const handlePress = useCallback( const handlePress = useCallback(
(event: GestureResponderEvent) => { (event: GestureResponderEvent) => {
if (isNonAnsweringClick(event)) return; if (isNonAnsweringClick(event, descriptionRef.current)) return;
onToggle(qIndex, optIndex, multiSelect); onToggle(qIndex, optIndex, multiSelect);
}, },
[onToggle, qIndex, optIndex, multiSelect], [onToggle, qIndex, optIndex, multiSelect],
@@ -147,7 +152,11 @@ function QuestionOptionRow({
<View style={styles.optionTextBlock}> <View style={styles.optionTextBlock}>
<MarkdownRenderer text={option.label} compact muted={!isSelected} selectable={false} /> <MarkdownRenderer text={option.label} compact muted={!isSelected} selectable={false} />
{option.description ? ( {option.description ? (
<View testID="question-form-option-description"> <View
ref={descriptionRef}
testID="question-form-option-description"
onStartShouldSetResponder={claimResponder}
>
<MarkdownRenderer text={option.description} compact muted /> <MarkdownRenderer text={option.description} compact muted />
</View> </View>
) : null} ) : null}
@@ -404,8 +413,12 @@ export function QuestionFormCard({
}); });
otherInputRef.current?.replaceText(""); 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( 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 { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import {
@@ -194,11 +190,9 @@ export function ToastViewport({
return clearTimer; return clearTimer;
}, [clearTimer, durationMs, scheduleDismiss, toastId]); }, [clearTimer, durationMs, scheduleDismiss, toastId]);
const panelClearance = useFloatingHeaderOverlayClearance();
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const topOffset = const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0;
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
const content = ( const content = (
<View <View
@@ -226,7 +220,7 @@ export function ToastViewport({
return createPortal(content, getOverlayRoot()); return createPortal(content, getOverlayRoot());
} }
return placement === "panel" ? <FloatingHeaderOverlay>{content}</FloatingHeaderOverlay> : content; return content;
} }
function ToastCard({ function ToastCard({
+48 -7
View File
@@ -1,5 +1,4 @@
import { useAddHost } from "@/hosts/add-host"; import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native"; import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
@@ -13,7 +12,11 @@ import {
Terminal, Terminal,
} from "lucide-react-native"; } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; 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 { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version"; import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; 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 { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const appVersion = resolveAppVersion(); const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion); const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
const hosts = useHosts(); const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
@@ -186,10 +196,23 @@ export function WelcomeScreen() {
router.push("/settings"); router.push("/settings");
}, [router]); }, [router]);
const handleOpenDirect = useAddHost({ method: "direct", onAdded: finishOnboarding }); const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
const handleOpenRemoteSsh = useAddHost({ method: "ssh", onAdded: finishOnboarding }); const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
const handleOpenPasteLink = useAddHost({ method: "link", onAdded: finishOnboarding }); const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []);
const handleScanQr = useAddHost({ method: "scan", onAdded: finishOnboarding }); 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[] = const actions: WelcomeAction[] =
isWeb || isFdroidBuild isWeb || isFdroidBuild
@@ -293,6 +316,24 @@ export function WelcomeScreen() {
</Button> </Button>
</View> </View>
<Text style={styles.versionLabel}>{appVersionText}</Text> <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> </ScrollView>
</View> </View>
); );
@@ -179,8 +179,8 @@ export function ComposerDock({
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View> <View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
</ComposerViewportContent> </ComposerViewportContent>
</ComposerViewportContent> </ComposerViewportContent>
</KeyboardTranslateView>
{overlay} {overlay}
</KeyboardTranslateView>
</ComposerViewport> </ComposerViewport>
); );
} }
@@ -214,8 +214,8 @@ export function ComposerDock({
{composer} {composer}
</View> </View>
</ComposerViewportContent> </ComposerViewportContent>
</KeyboardTranslateView>
{overlay} {overlay}
</KeyboardTranslateView>
</ComposerViewport> </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 { describe, expect, it } from "vitest";
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store"; import type { ExplorerEntry } from "@/stores/session-store";
import { import {
MAX_AUTO_EXPANDED_DIRECTORY_DEPTH, MAX_AUTO_EXPANDED_DIRECTORY_DEPTH,
flattenExplorerTree, flattenExplorerTree,
reconcileRestoredExpandedPaths, reconcileRestoredExpandedPaths,
refreshExplorerDirectories,
restoreExpandedDirectories, restoreExpandedDirectories,
setExpandedDirectoryPath, setExpandedDirectoryPath,
showHiddenFilesAndRestoreExpandedDirectories, showHiddenFilesAndRestoreExpandedDirectories,
@@ -196,76 +195,3 @@ describe("file explorer tree", () => {
await expect(restoration).resolves.toEqual([".", ".hidden"]); 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 { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
import type { SortOption } from "@/stores/panel-store/state"; import type { SortOption } from "@/stores/panel-store/state";
import { DIRECTORY_LISTING_INVALIDATED, type DirectoryListingResult } from "./directory-listings";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility"; import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility";
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5; export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
@@ -17,13 +16,11 @@ interface FlattenExplorerTreeInput {
showHiddenFiles: boolean; showHiddenFiles: boolean;
} }
type RestoredExpandedPaths = string[] | typeof DIRECTORY_LISTING_INVALIDATED;
interface RestoreExpandedDirectoriesInput { interface RestoreExpandedDirectoriesInput {
rootDirectory: ExplorerDirectory; rootDirectory: ExplorerDirectory;
persistedExpandedPaths: ReadonlySet<string>; persistedExpandedPaths: ReadonlySet<string>;
showHiddenFiles: boolean; showHiddenFiles: boolean;
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>; requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
} }
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit< interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
@@ -36,7 +33,7 @@ interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
interface ReconcileRestoredExpandedPathsInput { interface ReconcileRestoredExpandedPathsInput {
persistedExpandedPaths: ReadonlySet<string>; persistedExpandedPaths: ReadonlySet<string>;
currentExpandedPaths: ReadonlySet<string>; currentExpandedPaths: ReadonlySet<string>;
restoredExpandedPaths: RestoredExpandedPaths; restoredExpandedPaths: string[];
} }
interface SetExpandedDirectoryPathInput { interface SetExpandedDirectoryPathInput {
@@ -88,7 +85,7 @@ export async function restoreExpandedDirectories({
persistedExpandedPaths, persistedExpandedPaths,
showHiddenFiles, showHiddenFiles,
requestDirectoryListing, requestDirectoryListing,
}: RestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> { }: RestoreExpandedDirectoriesInput): Promise<string[]> {
const restoredPaths = ["."]; const restoredPaths = ["."];
const restoredPathSet = new Set(restoredPaths); const restoredPathSet = new Set(restoredPaths);
let parentDirectories = [rootDirectory]; let parentDirectories = [rootDirectory];
@@ -113,11 +110,9 @@ export async function restoreExpandedDirectories({
const requestedDirectories = await Promise.all( const requestedDirectories = await Promise.all(
pathsToRequest.map((path) => requestDirectoryListing(path)), pathsToRequest.map((path) => requestDirectoryListing(path)),
); );
if (requestedDirectories.includes(DIRECTORY_LISTING_INVALIDATED))
return DIRECTORY_LISTING_INVALIDATED;
parentDirectories = []; parentDirectories = [];
for (const directory of requestedDirectories) { for (const directory of requestedDirectories) {
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) { if (!directory) {
continue; continue;
} }
restoredPaths.push(directory.path); restoredPaths.push(directory.path);
@@ -133,7 +128,7 @@ export function showHiddenFilesAndRestoreExpandedDirectories({
persistedExpandedPaths, persistedExpandedPaths,
showHiddenFiles, showHiddenFiles,
requestDirectoryListing, requestDirectoryListing,
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> { }: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
showHiddenFiles(); showHiddenFiles();
return restoreExpandedDirectories({ return restoreExpandedDirectories({
rootDirectory, rootDirectory,
@@ -148,8 +143,6 @@ export function reconcileRestoredExpandedPaths({
currentExpandedPaths, currentExpandedPaths,
restoredExpandedPaths, restoredExpandedPaths,
}: ReconcileRestoredExpandedPathsInput): string[] { }: ReconcileRestoredExpandedPathsInput): string[] {
if (restoredExpandedPaths === DIRECTORY_LISTING_INVALIDATED)
return Array.from(currentExpandedPaths);
const reconciledPaths = new Set(restoredExpandedPaths); const reconciledPaths = new Set(restoredExpandedPaths);
for (const path of persistedExpandedPaths) { 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. */ /** Load and expand the path to a directory selected outside the tree. */
export async function revealExplorerDirectory(input: { export async function revealExplorerDirectory(input: {
directoryPath: string; directoryPath: string;
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>; requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
expandPaths: (paths: string[]) => void; expandPaths: (paths: string[]) => void;
showHiddenFiles: () => void; showHiddenFiles: () => void;
}): Promise<void> { }): Promise<void> {
@@ -225,51 +218,7 @@ export async function revealExplorerDirectory(input: {
} }
} }
for (const path of paths) { for (const path of paths) {
const directory = await input.requestDirectoryListing(path); if (!(await input.requestDirectoryListing(path))) return;
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) return;
} }
input.expandPaths(paths); 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 }) { export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
// A document URL lets the browser resolve fragment links within the preview. const document = useMemo(() => withPreviewCsp(html), [html]);
// 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],
);
return ( return (
<iframe <iframe
data-testid={testID} data-testid={testID}
title={t("panels.file.filePanels.builtin.html")} title={t("panels.file.filePanels.builtin.html")}
src={source} srcDoc={document}
sandbox={SANDBOX} sandbox={SANDBOX}
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
style={iframeStyle} style={iframeStyle}
@@ -1,18 +1,16 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; 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 { usePanelStore } from "@/stores/panel-store";
import { revealExplorerDirectory } from "@/file-explorer/tree"; import { revealExplorerDirectory } from "@/file-explorer/tree";
import {
loadExplorerDirectory,
type DirectoryListingResult,
invalidateExplorerDirectoryListings,
} from "@/file-explorer/directory-listings";
import { parentExplorerPath } from "@/utils/explorer-paths"; import { parentExplorerPath } from "@/utils/explorer-paths";
function createExplorerState(): AgentFileExplorerState { function createExplorerState(): AgentFileExplorerState {
return { return {
listingGeneration: 0,
directories: new Map(), directories: new Map(),
isLoading: false, isLoading: false,
lastError: null, 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 { export interface FileExplorerWorkspaceScope {
workspaceId?: string | null; workspaceId?: string | null;
workspaceRoot?: string | null; workspaceRoot?: string | null;
@@ -68,21 +75,16 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
); );
const updateExplorerState = useCallback( const updateExplorerState = useCallback(
( (updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => {
updater: (prev: AgentFileExplorerState) => AgentFileExplorerState,
): AgentFileExplorerState | null => {
if (!workspaceStateKey) { if (!workspaceStateKey) {
return null; return;
} }
let updated: AgentFileExplorerState | null = null;
setFileExplorer(serverId, (prev) => { setFileExplorer(serverId, (prev) => {
const next = new Map(prev); const next = new Map(prev);
const current = next.get(workspaceStateKey) ?? createExplorerState(); const current = next.get(workspaceStateKey) ?? createExplorerState();
updated = updater(current); next.set(workspaceStateKey, updater(current));
next.set(workspaceStateKey, updated);
return next; return next;
}); });
return updated;
}, },
[serverId, setFileExplorer, workspaceStateKey], [serverId, setFileExplorer, workspaceStateKey],
); );
@@ -91,23 +93,80 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
async ( async (
path: string, path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean }, options?: { recordHistory?: boolean; setCurrentPath?: boolean },
): Promise<DirectoryListingResult> => { ): Promise<ExplorerDirectory | null> => {
if (!workspaceStateKey) { if (!workspaceStateKey) {
return null; return null;
} }
const normalizedPath = path && path.length > 0 ? path : "."; const normalizedPath = path && path.length > 0 ? path : ".";
return loadExplorerDirectory({ const shouldSetCurrentPath = options?.setCurrentPath ?? true;
path: normalizedPath, const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath;
options,
updateState: updateExplorerState, updateExplorerState((state) => ({
fallbackError: t("workspace.fileExplorer.errors.failedToListDirectory"), ...state,
listDirectory: async () => { isLoading: true,
if (!normalizedWorkspaceRoot) lastError: null,
throw new Error(t("workspace.fileExplorer.states.unavailable")); pendingRequest: { path: normalizedPath, mode: "list" },
if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); ...(shouldSetCurrentPath
return client.listDirectory(normalizedWorkspaceRoot, normalizedPath); ? {
}, 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], [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
); );
@@ -249,17 +308,10 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
[updateExplorerState], [updateExplorerState],
); );
const invalidateDirectoryListings = useCallback(
(retainedPaths: readonly string[]) =>
invalidateExplorerDirectoryListings(updateExplorerState, retainedPaths),
[updateExplorerState],
);
return { return {
workspaceStateKey, workspaceStateKey,
revealDirectory, revealDirectory,
requestDirectoryListing, requestDirectoryListing,
invalidateDirectoryListings,
requestFileDownloadToken, requestFileDownloadToken,
requestFileBytes, requestFileBytes,
createEntry, 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 { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { import {
EditingTextInput as TextInput, EditingTextInput as TextInput,
type EditingTextInputHandle, type EditingTextInputHandle,
} from "@/components/ui/text-input"; } from "@/components/ui/text-input";
import { router } from "expo-router";
import { Server } from "lucide-react-native"; import { Server } from "lucide-react-native";
import { create } from "zustand"; import { create } from "zustand";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -19,6 +20,7 @@ import {
} from "@/lib/overlay-root"; } from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection"; import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
type HostFilter = (host: HostProfile) => boolean; type HostFilter = (host: HostProfile) => boolean;
type HostChoiceHandler = (serverId: string) => void | Promise<void>; type HostChoiceHandler = (serverId: string) => void | Promise<void>;
@@ -63,7 +65,6 @@ function matchesHostQuery(host: HostProfile, query: string): boolean {
} }
export function useHostChooser() { export function useHostChooser() {
const addHost = useAddHost();
const hosts = useHosts(); const hosts = useHosts();
const localServerId = useLocalDaemonServerId(); const localServerId = useLocalDaemonServerId();
const open = useHostChooserStore((state) => state.open); const open = useHostChooserStore((state) => state.open);
@@ -75,7 +76,13 @@ export function useHostChooser() {
); );
if (availableHosts.length === 0) { if (availableHosts.length === 0) {
(input.onNoHosts ?? addHost)(); (
input.onNoHosts ??
(() => {
usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
})
)();
return false; return false;
} }
@@ -91,7 +98,7 @@ export function useHostChooser() {
}); });
return true; return true;
}, },
[hosts, localServerId, open, addHost], [hosts, localServerId, open],
); );
} }
@@ -17,9 +17,6 @@ function installation(): InstalledPlugin {
queryClient: new QueryClient(), queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"], paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined, invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => undefined, cleanup: () => undefined,
settingsScreens: [], settingsScreens: [],
surfaces: [], surfaces: [],
@@ -153,9 +153,6 @@ function harness() {
openPanel() {}, openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }), addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ 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 { getHostRuntimeStore } from "@/runtime/host-runtime";
import { createPluginHosts } from "./hosts"; import { createPluginHosts } from "./hosts";
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
import { subscribePluginRpc } from "@getpaseo/plugin/client/host";
import { import {
createPluginAgentActionContext, createPluginAgentActionContext,
createPluginCapabilities, createPluginCapabilities,
@@ -37,8 +36,6 @@ export function createPluginClientRuntime(
openPanel(panelId, options) { openPanel(panelId, options) {
openClientPanel({ installation, state, 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", clientType: "cli",
}), }),
), ),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
invoke: async (method: string, input: unknown) => { invoke: async (method: string, input: unknown) => {
expect(method).toBe("review.inspect"); expect(method).toBe("review.inspect");
return { value: inspect.input.parse(input).value + 1 }; return { value: inspect.input.parse(input).value + 1 };
-1
View File
@@ -111,7 +111,6 @@ export type PluginClientRuntime = Pick<
| "openScreen" | "openScreen"
| "openSurface" | "openSurface"
| "openPanel" | "openPanel"
| "subscribe"
| "addComposerPill" | "addComposerPill"
| "addHeaderButton" | "addHeaderButton"
> & { hosts: ReturnType<typeof createPluginHosts> }; > & { hosts: ReturnType<typeof createPluginHosts> };
@@ -29,9 +29,6 @@ function installation(invoked: string[]): InstalledPlugin {
invoked.push(method); invoked.push(method);
return { state: "green" }; return { state: "green" };
}, },
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
queryClient: new QueryClient(), queryClient: new QueryClient(),
cleanup: () => undefined, cleanup: () => undefined,
settingsScreens: [], settingsScreens: [],
@@ -18,7 +18,7 @@ export function PluginInstallationProvider({
return ( return (
<QueryClientProvider client={plugin.queryClient}> <QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={plugin.paseo}> <PaseoApiProvider paseo={plugin.paseo}>
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}> <PluginRpcProvider invoke={plugin.invoke}>
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider> <PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
</PluginRpcProvider> </PluginRpcProvider>
</PaseoApiProvider> </PaseoApiProvider>
@@ -62,9 +62,6 @@ function registry(
openPanel: () => {}, openPanel: () => {},
addComposerPill: () => ({ update() {}, remove() {} }), addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }), addHeaderButton: () => ({ update() {}, remove() {} }),
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
}; };
}, },
}); });
@@ -39,9 +39,6 @@ function registry(version: string) {
openPanel() {}, openPanel() {},
addComposerPill: () => ({ update() {}, remove() {} }), addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ 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 { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
import { resolveAppVersion } from "@/utils/app-version"; import { resolveAppVersion } from "@/utils/app-version";
import { createPluginClientRuntime } from "./client-runtime"; import { createPluginClientRuntime } from "./client-runtime";
import { createPluginSubscribe } from "./subscriptions";
import { runPluginClientBundle } from "./evaluate"; import { runPluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types"; import type { InstalledPlugin } from "./types";
@@ -90,18 +89,12 @@ export class PluginRegistry {
return [existing]; return [existing];
} }
const client = options.client; const client = options.client;
const lifetime = new AbortController();
installation = { installation = {
lifetime, lifetime: new AbortController(),
locale: this.dependencies.localeSource.getLocale(), locale: this.dependencies.localeSource.getLocale(),
i18n: new PluginTranslationRuntime(this.dependencies.localeSource), i18n: new PluginTranslationRuntime(this.dependencies.localeSource),
paseo: createPaseoApi(client), paseo: createPaseoApi(client),
invoke: (method, input) => client.invokePluginRpc(entry.id, method, input), invoke: (method, input) => client.invokePluginRpc(entry.id, method, input),
subscribe: createPluginSubscribe({
client,
pluginId: entry.id,
lifetime: lifetime.signal,
}),
id: entry.id, id: entry.id,
serverId, serverId,
clientBundle: entry.clientBundle, clientBundle: entry.clientBundle,
@@ -26,9 +26,6 @@ function installed(
queryClient: new QueryClient(), queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"], paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined, invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
settingsScreens: [], settingsScreens: [],
surfaces: [{ id: "surface", title: "Surface", Component: () => null }], surfaces: [{ id: "surface", title: "Surface", Component: () => null }],
sidebarItems: { 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(), queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"], paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined, invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => undefined, cleanup: () => undefined,
settingsScreens: [], settingsScreens: [],
surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })), 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(), queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"], paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined, invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
settingsScreens: [], settingsScreens: [],
surfaces: [], surfaces: [],
sidebarItems: { header: [], footer: [] }, sidebarItems: { header: [], footer: [] },
@@ -18,9 +18,6 @@ function plugin(input: {
queryClient: new QueryClient(), queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"], paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined, invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => {}, cleanup: () => {},
settingsScreens: [], settingsScreens: [],
surfaces: [], surfaces: [],
@@ -14,45 +14,6 @@ function thought(text: string, status: "loading" | "ready" = "loading"): StreamI
} }
describe("plugin timeline projection", () => { 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", () => { it("does not expose mutable tool detail from stream state", () => {
const detail = { type: "read" as const, filePath: "/repo/original.ts" }; const detail = { type: "read" as const, filePath: "/repo/original.ts" };
const source: StreamItem = { const source: StreamItem = {
@@ -22,14 +22,13 @@ function cloneAndFreeze<T>(value: T): T {
} }
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem { function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
const { callId, name, status, error, detail, metadata, agentMessage } = data; const { callId, name, status, error, detail, metadata } = data;
const base = { const base = {
type: "tool_call" as const, type: "tool_call" as const,
callId, callId,
name, name,
detail, detail,
...(metadata ? { metadata } : {}), ...(metadata ? { metadata } : {}),
...(agentMessage ? { agentMessage } : {}),
}; };
switch (status) { switch (status) {
case "running": case "running":
-2
View File
@@ -71,8 +71,6 @@ export interface InstalledPlugin extends EvaluatedPlugin {
paseo: PaseoApi; paseo: PaseoApi;
/** Calls one of the plugin's server RPC methods on its host. */ /** Calls one of the plugin's server RPC methods on its host. */
invoke(method: string, input: unknown): Promise<unknown>; 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; serverId: string;
requirements?: PluginRequirements; requirements?: PluginRequirements;
clientBundle: string; clientBundle: string;
@@ -15,9 +15,6 @@ function installed(): InstalledPlugin {
queryClient: new QueryClient(), queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"], paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined, invoke: async () => undefined,
subscribe: () => {
throw new Error("Subscriptions are not used here");
},
cleanup: () => {}, cleanup: () => {},
settingsScreens: [], settingsScreens: [],
surfaces: [], surfaces: [],
@@ -1,4 +1,3 @@
import { useAddHost } from "@/hosts/add-host";
import type { import type {
CreateAgentRequestOptions, CreateAgentRequestOptions,
CreateWorkspaceRequestOptions, CreateWorkspaceRequestOptions,
@@ -9,6 +8,7 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { router } from "expo-router";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id"; 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 { useFormPreferences } from "@/hooks/use-form-preferences";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
import { import {
getHostProjectId, getHostProjectId,
getWorktreeSupportForHostProject, getWorktreeSupportForHostProject,
@@ -1165,7 +1166,9 @@ export function NewWorkspaceScreen({
openAddProjectPicker(selectedServerId); openAddProjectPicker(selectedServerId);
}, [openAddProjectPicker, selectedServerId]); }, [openAddProjectPicker, selectedServerId]);
const handleAddHost = useAddHost(); const handleAddHost = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const openPicker = useCallback(() => { const openPicker = useCallback(() => {
setPickerOpen(true); setPickerOpen(true);
+97 -8
View File
@@ -1,5 +1,4 @@
import { useAddHost } from "@/hosts/add-host"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import type { ComponentType, ReactNode } from "react"; import type { ComponentType, ReactNode } from "react";
import { import {
Alert, Alert,
@@ -75,6 +74,10 @@ import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { BackHeader } from "@/components/headers/back-header"; import { BackHeader } from "@/components/headers/back-header";
import { PageLayout } from "@/components/page-layout"; 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 { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { EditorSection } from "@/screens/settings/editor-section"; import { EditorSection } from "@/screens/settings/editor-section";
import { FilesSection } from "@/screens/settings/files-section"; import { FilesSection } from "@/screens/settings/files-section";
@@ -1086,15 +1089,21 @@ function SettingsSidebar({
export interface SettingsScreenProps { export interface SettingsScreenProps {
view: SettingsView; view: SettingsView;
openAddHostIntent?: string | null;
} }
export default function SettingsScreen({ view }: SettingsScreenProps) { export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const voiceAudioEngine = useVoiceAudioEngineOptional(); const voiceAudioEngine = useVoiceAudioEngineOptional();
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings(); 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 [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null); const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
const lastOpenedAddHostIntentRef = useRef<string | null>(null);
const isDesktopApp = isElectronRuntime(); const isDesktopApp = isElectronRuntime();
const appVersion = resolveAppVersion(); const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion); const appVersionText = formatVersionWithPrefix(appVersion);
@@ -1155,10 +1164,50 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
} }
}, [isPlaybackTestRunning, t, voiceAudioEngine]); }, [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( const handleHostAdded = useCallback(
(serverId: string) => { ({ serverId }: { serverId: string }) => {
selectSettingsHost(serverId); const target = buildSettingsHostSectionRoute(serverId, "connections");
const target = buildSettingsHostSectionRoute(serverId, "host");
if (isCompactLayout) { if (isCompactLayout) {
router.push(target); router.push(target);
} else { } else {
@@ -1168,8 +1217,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[isCompactLayout, router], [isCompactLayout, router],
); );
const handleAddHost = useAddHost({ onAdded: handleHostAdded });
const handleSelectSection = useCallback( const handleSelectSection = useCallback(
(section: SettingsSectionSlug) => { (section: SettingsSectionSlug) => {
const target = buildSettingsSectionRoute(section); const target = buildSettingsSectionRoute(section);
@@ -1225,6 +1272,14 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[activeHostServerId, handleAddHost, isCompactLayout, router], [activeHostServerId, handleAddHost, isCompactLayout, router],
); );
const handleScanQr = useCallback(() => {
closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: { source: "settings" },
});
}, [closeAddConnectionFlow, router]);
const handleHostRemoved = useCallback(() => { const handleHostRemoved = useCallback(() => {
const fallback = buildSettingsSectionRoute("general"); const fallback = buildSettingsSectionRoute("general");
if (isCompactLayout) { 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. // Mobile root: full-screen sidebar-as-list.
if (isCompactLayout && view.kind === "root") { if (isCompactLayout && view.kind === "root") {
return ( return (
@@ -1350,6 +1436,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
layout="mobile" layout="mobile"
/> />
</ScrollView> </ScrollView>
{addHostModals}
</View> </View>
); );
} }
@@ -1360,6 +1447,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<PageLayout title={detailTitle} onBack={handleBackFromDetail}> <PageLayout title={detailTitle} onBack={handleBackFromDetail}>
{content} {content}
</PageLayout> </PageLayout>
{addHostModals}
</View> </View>
); );
} }
@@ -1394,6 +1482,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
</View> </View>
</WindowChromeRegion> </WindowChromeRegion>
</View> </View>
{addHostModals}
</View> </View>
); );
} }
-1
View File
@@ -260,7 +260,6 @@ interface ExplorerRequestState {
} }
export interface AgentFileExplorerState { export interface AgentFileExplorerState {
listingGeneration: number;
directories: Map<string, ExplorerDirectory>; directories: Map<string, ExplorerDirectory>;
isLoading: boolean; isLoading: boolean;
lastError: string | null; lastError: string | null;
@@ -8,6 +8,7 @@ import {
buildOpenProjectRoute, buildOpenProjectRoute,
resolveKnownHostRoute, resolveKnownHostRoute,
buildSessionsRoute, buildSessionsRoute,
buildSettingsAddHostRoute,
buildProjectSettingsRoute, buildProjectSettingsRoute,
buildProjectsSettingsRoute, buildProjectsSettingsRoute,
decodeFilePathFromPathSegment, decodeFilePathFromPathSegment,
@@ -184,6 +185,14 @@ describe("workspace route parsing", () => {
}); });
describe("projects settings routes", () => { 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", () => { it("buildProjectsSettingsRoute scopes the list to a host", () => {
expect(buildProjectsSettingsRoute("host a")).toBe("/settings/hosts/host%20a/projects"); 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; 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) { export function buildSettingsHostRoute(serverId: string) {
const normalized = trimNonEmpty(serverId); const normalized = trimNonEmpty(serverId);
if (!normalized) { if (!normalized) {
-1
View File
@@ -144,7 +144,6 @@ export const DEFAULT_CLIENT_CAPABILITIES = {
[CLIENT_CAPS.pluginTimelineItems]: true, [CLIENT_CAPS.pluginTimelineItems]: true,
[CLIENT_CAPS.workspaceSetupBlocked]: true, [CLIENT_CAPS.workspaceSetupBlocked]: true,
[CLIENT_CAPS.workspaceSetupObservation]: true, [CLIENT_CAPS.workspaceSetupObservation]: true,
[CLIENT_CAPS.pluginBinaryPayloads]: true,
[CLIENT_CAPS.explicitEventSubscriptions]: true, [CLIENT_CAPS.explicitEventSubscriptions]: true,
} satisfies Record<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, 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"]); 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 () => { test("surviving handles use new remote IDs after reconnect and released handles stay gone", async () => {
const c = connection(); const c = connection();
const a = c.observe(); const a = c.observe();
+14 -32
View File
@@ -5,8 +5,6 @@ export type SubscriptionSnapshot<T> = T & { subscriptionId: string };
export interface SubscriptionObserver<T> { export interface SubscriptionObserver<T> {
snapshot(snapshot: T): void; snapshot(snapshot: T): void;
update(message: SessionOutboundMessage): 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; error?(error: unknown): void;
} }
@@ -22,12 +20,9 @@ interface Registration {
start(): void; start(): void;
disconnected(): void; disconnected(): void;
receive(message: SessionOutboundMessage): void; receive(message: SessionOutboundMessage): void;
receiveBinary(payload: Uint8Array): void;
release(): Promise<void>; release(): Promise<void>;
} }
type Update = { message: SessionOutboundMessage } | { binary: Uint8Array };
export interface OwnedSubscriptionsHost { export interface OwnedSubscriptionsHost {
release(subscriptionId: string): Promise<void>; release(subscriptionId: string): Promise<void>;
failed(error: unknown): void; failed(error: unknown): void;
@@ -55,7 +50,7 @@ export class OwnedSubscriptions {
let teardown: Promise<void> | null = null; let teardown: Promise<void> | null = null;
let disposed = false; let disposed = false;
let overflow = false; let overflow = false;
const buffered: Update[] = []; const buffered: SessionOutboundMessage[] = [];
const observers = new Set<SubscriptionObserver<SubscriptionSnapshot<T>>>(); const observers = new Set<SubscriptionObserver<SubscriptionSnapshot<T>>>();
let resolve!: (snapshot: SubscriptionSnapshot<T>) => void; let resolve!: (snapshot: SubscriptionSnapshot<T>) => void;
let reject!: (error: unknown) => void; let reject!: (error: unknown) => void;
@@ -65,10 +60,12 @@ export class OwnedSubscriptions {
}); });
void ready.catch(() => {}); void ready.catch(() => {});
const deliver = (observer: SubscriptionObserver<SubscriptionSnapshot<T>>, update?: Update) => { const deliver = (
observer: SubscriptionObserver<SubscriptionSnapshot<T>>,
message?: SessionOutboundMessage,
) => {
try { try {
if (update && "binary" in update) observer.binary?.(update.binary); if (message) observer.update(message);
else if (update) observer.update(update.message);
else if (snapshot) observer.snapshot(snapshot); else if (snapshot) observer.snapshot(snapshot);
} catch (error) { } catch (error) {
this.host.failed(error); this.host.failed(error);
@@ -81,13 +78,6 @@ export class OwnedSubscriptions {
this.routes.delete(released); this.routes.delete(released);
await this.host.release(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 = { const registration: Registration = {
start: () => { start: () => {
if (disposed || pending || id || !this.connected) return; if (disposed || pending || id || !this.connected) return;
@@ -138,14 +128,12 @@ export class OwnedSubscriptions {
}, },
receive: (message) => { receive: (message) => {
if (disposed || ("payload" in message && Object.is(message.payload, snapshot))) return; if (disposed || ("payload" in message && Object.is(message.payload, snapshot))) return;
buffer({ message }); if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(message);
for (const observer of observers) deliver(observer, { message }); else {
}, buffered.length = 0;
receiveBinary: (payload) => { overflow = true;
if (disposed) return; }
// Bytes wait only for the first observer; retaining them after would hold every frame. for (const observer of observers) deliver(observer, message);
if (observers.size === 0) buffer({ binary: payload });
for (const observer of observers) deliver(observer, { binary: payload });
}, },
release: () => { release: () => {
if (teardown) return teardown; if (teardown) return teardown;
@@ -154,9 +142,7 @@ export class OwnedSubscriptions {
observers.clear(); observers.clear();
buffered.length = 0; buffered.length = 0;
if (id) this.routes.delete(id); if (id) this.routes.delete(id);
// A host that already accepted keeps `ready` resolved, even if released in the same tick. reject(new Error("Subscription released"));
if (snapshot) resolve(snapshot);
else reject(new Error("Subscription released"));
teardown = (async () => { teardown = (async () => {
await pending; await pending;
await releaseId(); await releaseId();
@@ -188,7 +174,7 @@ export class OwnedSubscriptions {
.catch(this.host.failed); .catch(this.host.failed);
} else { } else {
deliver(observer); deliver(observer);
for (const update of buffered) deliver(observer, update); for (const message of buffered) deliver(observer, message);
} }
return () => { return () => {
observers.delete(observer); observers.delete(observer);
@@ -219,10 +205,6 @@ export class OwnedSubscriptions {
this.routes.get(payload.subscriptionId)?.receive(message); this.routes.get(payload.subscriptionId)?.receive(message);
} }
receiveBinary(subscriptionId: string, payload: Uint8Array): void {
this.routes.get(subscriptionId)?.receiveBinary(payload);
}
restore(): void { restore(): void {
this.connected = true; this.connected = true;
for (const registration of this.registrations) registration.start(); for (const registration of this.registrations) registration.start();
@@ -1106,7 +1106,6 @@ test("advertises client capabilities in hello", async () => {
terminal_chunked_replay: true, terminal_chunked_replay: true,
timeline_notifications: true, timeline_notifications: true,
plugin_timeline_items: true, plugin_timeline_items: true,
plugin_binary_payloads: true,
workspace_setup_blocked: true, workspace_setup_blocked: true,
workspace_setup_observation: true, workspace_setup_observation: true,
hello_rejection: true, hello_rejection: true,
+1 -71
View File
@@ -164,11 +164,6 @@ import {
FileTransferOpcode, FileTransferOpcode,
TerminalStreamOpcode, TerminalStreamOpcode,
type FileTransferFrame, type FileTransferFrame,
assertPluginWireValue,
decodePluginFrame,
encodePluginInputFrame,
isPluginFrameOpcode,
PluginFrameOpcode,
} from "@getpaseo/protocol/binary-frames/index"; } from "@getpaseo/protocol/binary-frames/index";
import { import {
createRelayE2eeTransportFactory, createRelayE2eeTransportFactory,
@@ -2265,8 +2260,6 @@ export class DaemonClient {
timeout?: number; timeout?: number;
select: (msg: SessionOutboundMessage) => T | null; select: (msg: SessionOutboundMessage) => T | null;
options?: { skipQueue?: boolean }; options?: { skipQueue?: boolean };
/** Sends the request in another form, such as a binary frame; defaults to `message`. */
transmit?: () => Promise<void>;
}): Promise<T> { }): Promise<T> {
const wire = this.owned.prepareRequest(params.message); const wire = this.owned.prepareRequest(params.message);
const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS; 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. // The response deadline still applies while the transport waits for drain.
void promise.catch(() => undefined); void promise.catch(() => undefined);
try { try {
const sent = params.transmit const sent = this.sendSessionMessageOrThrow(wire.message);
? params.transmit()
: this.sendSessionMessageOrThrow(wire.message);
const result = await Promise.race([promise, sent.then(() => promise)]); const result = await Promise.race([promise, sent.then(() => promise)]);
if (result.kind === "error") throw result.error; if (result.kind === "error") throw result.error;
return result.value; return result.value;
@@ -2322,14 +2313,12 @@ export class DaemonClient {
responseType: TResponseType; responseType: TResponseType;
options?: { skipQueue?: boolean }; options?: { skipQueue?: boolean };
selectPayload?: (payload: CorrelatedResponsePayload<TResponseType>) => TResult | null; selectPayload?: (payload: CorrelatedResponsePayload<TResponseType>) => TResult | null;
transmit?: () => Promise<void>;
}): Promise<TResult> { }): Promise<TResult> {
return this.sendRequest({ return this.sendRequest({
requestId: params.requestId, requestId: params.requestId,
message: params.message, message: params.message,
timeout: params.timeout, timeout: params.timeout,
options: params.options, options: params.options,
...(params.transmit ? { transmit: params.transmit } : {}),
select: (msg) => { select: (msg) => {
const correlated = msg as CorrelatedResponseMessage; const correlated = msg as CorrelatedResponseMessage;
if (correlated.type !== params.responseType) { if (correlated.type !== params.responseType) {
@@ -2661,7 +2650,6 @@ export class DaemonClient {
resetSource = false; resetSource = false;
if (query.type === "workspace.setup.subscribe.request") if (query.type === "workspace.setup.subscribe.request")
this.requireWorkspaceSetupObservation(); this.requireWorkspaceSetupObservation();
if (query.type === "plugin.rpc.subscribe.request") this.requirePluginSubscriptions();
const requestId = this.createRequestId(options?.requestId); const requestId = this.createRequestId(options?.requestId);
const request = SessionInboundMessageSchema.parse({ ...query, requestId }); const request = SessionInboundMessageSchema.parse({ ...query, requestId });
try { try {
@@ -6209,14 +6197,8 @@ export class DaemonClient {
return payload.plugin; 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> { async invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown> {
assertPluginWireValue(input, "input");
const requestId = this.createRequestId(); const requestId = this.createRequestId();
if (!(input instanceof Uint8Array)) {
const payload = await this.sendCorrelatedSessionRequest({ const payload = await this.sendCorrelatedSessionRequest({
requestId, requestId,
message: { message: {
@@ -6230,37 +6212,6 @@ export class DaemonClient {
}); });
return payload.output; return payload.output;
} }
this.requirePluginSubscriptions();
const frame = encodePluginInputFrame({ requestId, pluginId, method, payload: input });
const payload = await this.sendCorrelatedRequest({
requestId,
message: { type: "plugin.rpc.invoke.request", requestId, pluginId, method, input: null },
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( async respondToPermissionAndWait(
agentId: string, agentId: 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 { private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
const hostFrame = decodeHostRouteFrame(rawBytes); const hostFrame = decodeHostRouteFrame(rawBytes);
if (rawBytes[0] === HOST_ROUTE_OPCODE && !hostFrame) { if (rawBytes[0] === HOST_ROUTE_OPCODE && !hostFrame) {
@@ -7052,10 +6986,6 @@ export class DaemonClient {
for (const handler of this.hostFrameListeners) handler(hostFrame); for (const handler of this.hostFrameListeners) handler(hostFrame);
return true; return true;
} }
if (isPluginFrameOpcode(rawBytes[0])) {
this.handlePluginFrame(rawBytes);
return true;
}
const fileFrame = decodeFileTransferFrame(rawBytes); const fileFrame = decodeFileTransferFrame(rawBytes);
if (fileFrame) { if (fileFrame) {
this.traceInstant("paseo.ws.message.inbound", { this.traceInstant("paseo.ws.message.inbound", {
-16
View File
@@ -12,7 +12,6 @@ import { fileURLToPath } from "node:url";
import { experimental_createMCPClient } from "ai"; import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { chromium } from "playwright"; import { chromium } from "playwright";
import { expect } from "@playwright/test";
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs"; import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.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() { async function startTargetPage() {
const server = createServer((_request, response) => { const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
@@ -1223,7 +1208,6 @@ async function main() {
const page = await waitForAppPage(browser, expoPort); const page = await waitForAppPage(browser, expoPort);
await hideDeveloperToasts(page); await hideDeveloperToasts(page);
const status = await waitForDesktopStatus(page); const status = await waitForDesktopStatus(page);
await settleDesktopStartup(page);
const checkPluginLinks = () => const checkPluginLinks = () =>
runPluginLinksRegression({ runPluginLinksRegression({
-65
View File
@@ -323,68 +323,3 @@ describe("agent session reload SDK boundary", () => {
).toEqual([]); ).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 { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginFile } from "./file.js"; import type { PluginFile } from "./file.js";
import type { import type { PluginRpcContract } from "../rpc.js";
PluginRpcContract,
PluginSubscribeOptions,
PluginSubscriptionContract,
PluginSubscriptionHandle,
PluginWireInput,
} from "../rpc.js";
import type { import type {
PluginButtonRegistration, PluginButtonRegistration,
PluginHeaderButtonContribution, PluginHeaderButtonContribution,
@@ -209,16 +203,6 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
contribution: PluginTimelineRendererContribution<Schema>, contribution: PluginTimelineRendererContribution<Schema>,
): PluginCleanup; ): PluginCleanup;
openPanel(id: string, options: PluginClientOpenPanelOptions): void; 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; export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
@@ -311,7 +295,7 @@ export interface PluginCommandCapabilities {
paseo: PaseoApi; paseo: PaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>( rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>, contract: PluginRpcContract<InputSchema, OutputSchema>,
input: PluginWireInput<ZodInput<InputSchema>>, input: ZodInput<InputSchema>,
): Promise<ZodOutput<OutputSchema>>; ): Promise<ZodOutput<OutputSchema>>;
openScreen(input: PluginOpenScreenInput): void; openScreen(input: PluginOpenScreenInput): void;
/** @deprecated Use `openScreen`. */ /** @deprecated Use `openScreen`. */
+10 -16
View File
@@ -24,23 +24,17 @@ describe("plugin RPC contracts", () => {
expectTypeOf<RpcOutput<typeof contract>>().toEqualTypeOf<{ value: number }>(); expectTypeOf<RpcOutput<typeof contract>>().toEqualTypeOf<{ value: number }>();
}); });
it("sends input unchanged for the plugin to parse", async () => { it("validates input before invoking the daemon", async () => {
let parses = 0; let invocations = 0;
const transformed = defineRpc({ const invoke = async () => {
name: "transformed", invocations += 1;
input: z.string().transform((text) => { return { value: 2 };
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);
}; };
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 () => { it("validates output before resolving", async () => {
+2 -4
View File
@@ -8,7 +8,7 @@ export {
import type { PluginAttachmentSourceContribution } from "../contracts.js"; import type { PluginAttachmentSourceContribution } from "../contracts.js";
import { PluginRpcProvider } from "./rpc-context.js"; import { PluginRpcProvider } from "./rpc-context.js";
import { PaseoApiProvider } from "./paseo-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( export async function searchPluginAttachments(
source: PluginAttachmentSourceContribution<PluginText>, source: PluginAttachmentSourceContribution<PluginText>,
@@ -19,9 +19,7 @@ export async function searchPluginAttachments(
return PluginAttachmentSearchPayloadSchema.parseAsync(output); return PluginAttachmentSearchPayloadSchema.parseAsync(output);
} }
export { callPluginRpc, subscribePluginRpc, PaseoApiProvider, PluginRpcProvider }; export { callPluginRpc, PaseoApiProvider, PluginRpcProvider };
export type { PluginSubscriptionObserver };
export type { PluginSubscribe } from "./rpc-context.js";
export { export {
PluginTranslationRuntime, PluginTranslationRuntime,
+1 -6
View File
@@ -71,12 +71,7 @@ export {
} from "./file.js"; } from "./file.js";
export { usePaseo } from "./paseo-context.js"; export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js"; export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc, useSubscription, type PluginSubscriptionState } from "./rpc-context.js"; export { useRpc } from "./rpc-context.js";
export type {
PluginSubscribeOptions,
PluginSubscriptionClosed,
PluginSubscriptionHandle,
} from "../rpc.js";
import type { SettingsDefinition } from "../settings.js"; import type { SettingsDefinition } from "../settings.js";
import type { SettingsState } from "./contracts.js"; import type { SettingsState } from "./contracts.js";
import type { ZodType } from "zod"; import type { ZodType } from "zod";
+5 -108
View File
@@ -1,34 +1,9 @@
import { import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import { import { callPluginRpc, type PluginRpcContract } from "../rpc.js";
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;
export interface PluginRpcContextValue { export interface PluginRpcContextValue {
invoke(method: string, input: unknown): Promise<unknown>; invoke(method: string, input: unknown): Promise<unknown>;
subscribe: PluginSubscribe;
} }
const PluginRpcContext = createContext<PluginRpcContextValue | null>(null); const PluginRpcContext = createContext<PluginRpcContextValue | null>(null);
@@ -40,100 +15,22 @@ export function usePluginRpcContextValue(): PluginRpcContextValue | null {
export function PluginRpcProvider({ export function PluginRpcProvider({
children, children,
invoke, invoke,
subscribe,
}: { }: {
children: ReactNode; children: ReactNode;
invoke(method: string, input: unknown): Promise<unknown>; 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>; return <PluginRpcContext.Provider value={value}>{children}</PluginRpcContext.Provider>;
} }
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>( export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>, contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: PluginWireInput<ZodInput<InputSchema>>) => Promise<ZodOutput<OutputSchema>> { ): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
const context = usePluginRpcContextValue(); const context = usePluginRpcContextValue();
if (!context) throw new Error("useRpc must run inside a contributed plugin surface"); if (!context) throw new Error("useRpc must run inside a contributed plugin surface");
return useCallback( return useCallback(
(input: PluginWireInput<ZodInput<InputSchema>>) => (input: ZodInput<InputSchema>) => callPluginRpc(contract, context.invoke, input),
callPluginRpc(contract, context.invoke, input),
[context, contract], [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));
}
@@ -26,7 +26,7 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
); );
return ( return (
<PaseoApiProvider paseo={paseo}> <PaseoApiProvider paseo={paseo}>
<PluginRpcProvider invoke={rpc.invoke} subscribe={rpc.subscribe}> <PluginRpcProvider invoke={rpc.invoke}>
<PluginTranslationProvider runtime={translations}>{content}</PluginTranslationProvider> <PluginTranslationProvider runtime={translations}>{content}</PluginTranslationProvider>
</PluginRpcProvider> </PluginRpcProvider>
</PaseoApiProvider> </PaseoApiProvider>
+1 -10
View File
@@ -19,16 +19,7 @@ export {
type PluginAttachmentItem, type PluginAttachmentItem,
type PluginAttachmentSearchPayload, type PluginAttachmentSearchPayload,
} from "./attachments.js"; } from "./attachments.js";
export { export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
defineRpc,
defineSubscription,
type PluginRpcContract,
type PluginSubscriptionContract,
type RpcInput,
type RpcOutput,
type SubscriptionEvent,
type SubscriptionInput,
} from "./rpc.js";
export type { PluginText, PluginMessage } from "./text.js"; export type { PluginText, PluginMessage } from "./text.js";
export { export {
+3 -98
View File
@@ -9,12 +9,6 @@ export interface PluginRpcContract<
output: OutputSchema; output: OutputSchema;
} }
/**
* `z.instanceof(Uint8Array)` infers `Uint8Array<ArrayBuffer>`; values crossing the wire accept any
* `Uint8Array`, including a `Buffer` or a view of a shared buffer.
*/
export type PluginWireInput<Value> = Value extends Uint8Array ? Uint8Array : Value;
export type RpcInput<Contract extends PluginRpcContract> = ZodOutput<Contract["input"]>; export type RpcInput<Contract extends PluginRpcContract> = ZodOutput<Contract["input"]>;
export type RpcOutput<Contract extends PluginRpcContract> = ZodInput<Contract["output"]>; export type RpcOutput<Contract extends PluginRpcContract> = ZodInput<Contract["output"]>;
@@ -37,98 +31,9 @@ export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodT
export async function callPluginRpc<InputSchema extends ZodType, OutputSchema extends ZodType>( export async function callPluginRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>, contract: PluginRpcContract<InputSchema, OutputSchema>,
invoke: (method: string, input: unknown) => Promise<unknown>, invoke: (method: string, input: unknown) => Promise<unknown>,
input: PluginWireInput<ZodInput<InputSchema>>, input: ZodInput<InputSchema>,
): Promise<ZodOutput<OutputSchema>> { ): Promise<ZodOutput<OutputSchema>> {
const output = await invoke(contract.name, input); const parsedInput = await contract.input.parseAsync(input);
const output = await invoke(contract.name, parsedInput);
return contract.output.parseAsync(output); return contract.output.parseAsync(output);
} }
export interface PluginSubscriptionContract<
InputSchema extends ZodType = ZodType,
EventSchema extends ZodType = ZodType,
> {
name: string;
input: InputSchema;
event: EventSchema;
}
export type SubscriptionInput<Contract extends PluginSubscriptionContract> = ZodOutput<
Contract["input"]
>;
export type SubscriptionEvent<Contract extends PluginSubscriptionContract> = ZodOutput<
Contract["event"]
>;
interface SubscriptionDefinition<InputSchema extends ZodType, EventSchema extends ZodType> {
name: string;
input: InputSchema;
event: EventSchema;
}
export function defineSubscription<InputSchema extends ZodType, EventSchema extends ZodType>(
definition: SubscriptionDefinition<InputSchema, EventSchema>,
): PluginSubscriptionContract<InputSchema, EventSchema> {
const name = definition.name.trim();
if (!RPC_NAME.test(name)) throw new Error(`Invalid plugin subscription: ${definition.name}`);
return { ...definition, name };
}
/** One subscription from a client surface. Calling it or `release()` ends it. */
export type PluginSubscriptionHandle = (() => void) & {
/** Resolves once the plugin acknowledged the subscription; rejects if it refused. */
readonly ready: Promise<void>;
release(): Promise<void>;
};
export interface PluginSubscriptionObserver {
event(event: unknown): void;
closed(error?: Error): void;
}
export interface PluginSubscriptionClosed {
/** Set when the plugin's handler failed, the plugin stopped, or an event failed validation. */
error?: Error;
}
export interface PluginSubscribeOptions {
/** Aborting releases the subscription. */
signal?: AbortSignal;
/** Runs once when the daemon closes the subscription. Not called after a release. */
onClosed?: (closed: PluginSubscriptionClosed) => void;
}
export function subscribePluginRpc<InputSchema extends ZodType, EventSchema extends ZodType>(
contract: PluginSubscriptionContract<InputSchema, EventSchema>,
subscribe: (
method: string,
input: unknown,
observer: PluginSubscriptionObserver,
) => PluginSubscriptionHandle,
input: PluginWireInput<ZodInput<InputSchema>>,
onEvent: (event: ZodOutput<EventSchema>) => void,
options: PluginSubscribeOptions = {},
): PluginSubscriptionHandle {
// The plugin parses the input into the schema's output, so it receives the input as given.
contract.input.parse(input);
let closed = false;
function close(error?: Error) {
if (closed) return;
closed = true;
options.onClosed?.(error ? { error } : {});
}
const handle = subscribe(contract.name, input, {
event(event) {
if (closed) return;
const parsed = contract.event.safeParse(event);
if (parsed.success) return onEvent(parsed.data);
handle();
close(new Error(`Plugin subscription ${contract.name} sent an invalid event`));
},
closed: close,
});
if (options.signal) {
if (options.signal.aborted) handle();
else options.signal.addEventListener("abort", () => handle(), { once: true });
}
return handle;
}
+2 -26
View File
@@ -1,6 +1,6 @@
import type { PaseoApi } from "@getpaseo/client"; import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract, PluginSubscriptionContract, PluginWireInput } from "../rpc.js"; import type { PluginRpcContract } from "../rpc.js";
import type { PluginCleanup } from "../contracts.js"; import type { PluginCleanup } from "../contracts.js";
import type { ProviderRegistration } from "./provider.js"; import type { ProviderRegistration } from "./provider.js";
import type { UsageSourceRegistration } from "./usage.js"; import type { UsageSourceRegistration } from "./usage.js";
@@ -10,18 +10,6 @@ export interface PluginHandlerContext {
paseo: PaseoApi; paseo: PaseoApi;
} }
/** What a subscription handler receives beside its input. */
export interface PluginSubscriptionContext<Event = unknown> extends PluginHandlerContext {
/**
* Sends one event to this subscriber. A `Uint8Array` event travels as a binary frame; any other
* event is JSON and must not contain a `Uint8Array`. Resolves once the event left the plugin
* process. Events pushed after `signal` aborts are dropped.
*/
push(event: Event): Promise<void>;
/** Aborts when the subscriber releases, disconnects, or the plugin stops. */
signal: AbortSignal;
}
export type PluginSettingsState<Schema extends ZodType> = export type PluginSettingsState<Schema extends ZodType> =
| { | {
status: "ready"; status: "ready";
@@ -43,24 +31,12 @@ export interface PluginServerContext extends PluginLifecycleRegistration {
registerSettings<Schema extends ZodType>( registerSettings<Schema extends ZodType>(
definition: import("../settings.js").SettingsDefinition<Schema>, definition: import("../settings.js").SettingsDefinition<Schema>,
): PluginSettings<Schema>; ): PluginSettings<Schema>;
/**
* Serves a subscription. The handler starts the work and returns; the subscription stays open
* until `signal` aborts. A handler that throws refuses the subscription. When it returns a
* promise, settling the promise closes the subscription, with the rejection as its error.
*/
handle<InputSchema extends ZodType, EventSchema extends ZodType>(
contract: PluginSubscriptionContract<InputSchema, EventSchema>,
handler: (
input: ZodOutput<InputSchema>,
context: PluginSubscriptionContext<PluginWireInput<ZodInput<EventSchema>>>,
) => void | Promise<void>,
): void;
handle<InputSchema extends ZodType, OutputSchema extends ZodType>( handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>, contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: ( handler: (
input: ZodOutput<InputSchema>, input: ZodOutput<InputSchema>,
context: PluginHandlerContext, context: PluginHandlerContext,
) => PluginWireInput<ZodInput<OutputSchema>> | Promise<PluginWireInput<ZodInput<OutputSchema>>>, ) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
): void; ): void;
registerProvider(provider: ProviderRegistration): void; registerProvider(provider: ProviderRegistration): void;
registerUsageSource(source: UsageSourceRegistration): void; registerUsageSource(source: UsageSourceRegistration): void;
-1
View File
@@ -11,7 +11,6 @@ export type {
PluginServerContribution, PluginServerContribution,
PluginSettings, PluginSettings,
PluginSettingsState, PluginSettingsState,
PluginSubscriptionContext,
} from "./contracts.js"; } from "./contracts.js";
export type { export type {
PluginHooks, PluginHooks,
+1 -9
View File
@@ -4,7 +4,6 @@ import {
FileTransferOpcode, FileTransferOpcode,
type FileTransferFrame, type FileTransferFrame,
} from "./file-transfer.js"; } from "./file-transfer.js";
import { decodePluginFrame, PluginFrameOpcode, type PluginFrame } from "./plugin.js";
import { import {
decodeTerminalStreamFrame, decodeTerminalStreamFrame,
TerminalStreamOpcode, TerminalStreamOpcode,
@@ -14,8 +13,7 @@ import {
export type BinaryFrame = export type BinaryFrame =
| { kind: "host_route"; frame: HostRouteFrame } | { kind: "host_route"; frame: HostRouteFrame }
| { kind: "terminal"; frame: TerminalStreamFrame } | { kind: "terminal"; frame: TerminalStreamFrame }
| { kind: "file_transfer"; frame: FileTransferFrame } | { kind: "file_transfer"; frame: FileTransferFrame };
| { kind: "plugin"; frame: PluginFrame };
export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null { export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
switch (bytes[0]) { switch (bytes[0]) {
@@ -40,12 +38,6 @@ export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null {
const frame = decodeFileTransferFrame(bytes); const frame = decodeFileTransferFrame(bytes);
return frame ? { kind: "file_transfer", frame } : null; return frame ? { kind: "file_transfer", frame } : null;
} }
case PluginFrameOpcode.Event:
case PluginFrameOpcode.Output:
case PluginFrameOpcode.Input: {
const frame = decodePluginFrame(bytes);
return frame ? { kind: "plugin", frame } : null;
}
default: default:
return null; return null;
} }
@@ -2,4 +2,3 @@ export * from "./demux.js";
export * from "./file-transfer.js"; export * from "./file-transfer.js";
export * from "./terminal.js"; export * from "./terminal.js";
export * from "./host-route.js"; export * from "./host-route.js";
export * from "./plugin.js";
@@ -1,95 +0,0 @@
import { describe, expect, it } from "vitest";
import {
assertPluginWireValue,
decodeBinaryFrame,
decodePluginFrame,
encodePluginEventFrame,
encodePluginInputFrame,
encodePluginOutputFrame,
FileTransferOpcode,
MAX_PLUGIN_PAYLOAD_BYTES,
MAX_ROUTED_PAYLOAD_BYTES,
PluginFrameOpcode,
TerminalStreamOpcode,
} from "./index.js";
describe("plugin binary frames", () => {
it("uses the reserved plugin opcode range", () => {
for (const opcode of Object.values(PluginFrameOpcode)) {
expect(opcode).toBeGreaterThanOrEqual(0x20);
expect(opcode).toBeLessThanOrEqual(0x2f);
expect(Object.values(TerminalStreamOpcode)).not.toContain(opcode);
expect(Object.values(FileTransferOpcode)).not.toContain(opcode);
}
});
it("frames a subscription event as opcode, id length, id, and the untouched payload", () => {
const payload = new Uint8Array([9, 8, 7]);
const frame = encodePluginEventFrame("sub-1", payload);
expect([...frame]).toEqual([0x20, 5, ...new TextEncoder().encode("sub-1"), 9, 8, 7]);
expect(decodeBinaryFrame(frame)).toEqual({
kind: "plugin",
frame: { opcode: PluginFrameOpcode.Event, subscriptionId: "sub-1", payload },
});
});
it("decodes payloads as views into the received buffer", () => {
const frame = encodePluginOutputFrame("req-1", new Uint8Array([1, 2, 3, 4]));
const decoded = decodePluginFrame(frame);
if (decoded?.opcode !== PluginFrameOpcode.Output) throw new Error("Expected an output frame");
expect(decoded.requestId).toBe("req-1");
expect(decoded.payload.buffer).toBe(frame.buffer);
expect([...decoded.payload]).toEqual([1, 2, 3, 4]);
});
it("frames an RPC input with the plugin and method in its header", () => {
const frame = encodePluginInputFrame({
requestId: "req-2",
pluginId: "uploads",
method: "upload.store",
payload: new Uint8Array([5, 6]),
});
expect(decodePluginFrame(frame)).toEqual({
opcode: PluginFrameOpcode.Input,
requestId: "req-2",
pluginId: "uploads",
method: "upload.store",
payload: new Uint8Array([5, 6]),
});
});
it("rejects truncated frames", () => {
expect(decodePluginFrame(new Uint8Array([0x20]))).toBeNull();
expect(decodePluginFrame(new Uint8Array([0x20, 9, 1]))).toBeNull();
expect(decodePluginFrame(new Uint8Array([0x22, 1, 65, 0, 40]))).toBeNull();
});
});
describe("the plugin byte rule", () => {
it("accepts JSON values and a top-level Uint8Array", () => {
expect(() => assertPluginWireValue({ a: [1, "b", null] }, "event")).not.toThrow();
expect(() => assertPluginWireValue(new Uint8Array(4), "event")).not.toThrow();
});
it("rejects bytes that would not fit in one frame with the largest header", () => {
const bytes = new Uint8Array(MAX_PLUGIN_PAYLOAD_BYTES + 1);
expect(() => assertPluginWireValue(bytes.subarray(1), "event")).not.toThrow();
expect(() => assertPluginWireValue(bytes, "event")).toThrow(
`event is ${MAX_PLUGIN_PAYLOAD_BYTES + 1} bytes; one value carries at most ${MAX_PLUGIN_PAYLOAD_BYTES} bytes`,
);
const frame = encodePluginInputFrame({
requestId: "r".repeat(255),
pluginId: "p",
method: "m".repeat(0xffff - 30),
payload: bytes.subarray(1),
});
expect(frame.byteLength).toBeLessThanOrEqual(MAX_ROUTED_PAYLOAD_BYTES);
});
it("rejects bytes nested in a value and names the rule and the path", () => {
expect(() => assertPluginWireValue({ frames: [{ data: new Uint8Array(1) }] }, "event")).toThrow(
"event.frames[0].data is a Uint8Array. Bytes cross the wire only as the whole value: send the Uint8Array itself, or encode it into the value.",
);
});
});

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