mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e06adf7a3e | ||
|
|
d2cc18b3a1 | ||
|
|
5062d5ee90 | ||
|
|
71dc9f44c9 | ||
|
|
7495c20f78 | ||
|
|
b92a34ab3d | ||
|
|
8018231a00 | ||
|
|
555818f5f7 | ||
|
|
27caf913f6 | ||
|
|
835bc49487 | ||
|
|
63302287fe | ||
|
|
3e3a707f5a | ||
|
|
d4ac64f335 | ||
|
|
70cb34927a | ||
|
|
b280ca5355 | ||
|
|
da8d58bfee | ||
|
|
7c2812dde6 | ||
|
|
b474beedce | ||
|
|
e2f0779379 | ||
|
|
a166a3317a | ||
|
|
efc64a5443 | ||
|
|
7efa14238d | ||
|
|
dbbcb103ba | ||
|
|
7f318bf7cf | ||
|
|
04fe4be40f | ||
|
|
2d75cc80b2 | ||
|
|
bfac132920 | ||
|
|
34f996b32a | ||
|
|
3e83f433da | ||
|
|
674530152e |
@@ -406,10 +406,7 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
@@ -428,7 +425,6 @@
|
|||||||
"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",
|
||||||
@@ -543,14 +539,10 @@
|
|||||||
"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",
|
||||||
|
|||||||
+4
-4
@@ -35,7 +35,7 @@ The authored interface ramp uses a 14px base. New native installs default to 15p
|
|||||||
Weight has three tiers, applied by role:
|
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/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`).
|
- **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`).
|
||||||
- **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/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>` 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>` 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/components/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/hosts/add-host/internal/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/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.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` |
|
||||||
| 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` |
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ 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.
|
||||||
|
|||||||
+21
-3
@@ -357,9 +357,27 @@ 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 contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
|
RPC and subscription contracts validate inputs, outputs, and events in both the app and plugin
|
||||||
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
|
subprocess. `useRpc` returns a typed async function. Use the host-provided `@tanstack/react-query`
|
||||||
Paseo gives each plugin installation its own query client.
|
for request state and caching; Paseo gives each plugin installation its own query client.
|
||||||
|
|
||||||
|
A subscription is a session-owned observation (`plugin-rpc` delivery family): the session that
|
||||||
|
subscribed owns its lifetime, so release, disconnect, plugin stop, and reload need no plugin code.
|
||||||
|
Each subscriber gets its own handler call in the subprocess; there is no fan-out or replay. The
|
||||||
|
runtime buffers pushes until the session has sent `plugin.rpc.subscribe.response`, so a client
|
||||||
|
never sees an event for an unknown subscription. The app installation's lifetime aborts every
|
||||||
|
subscription its surfaces left open, and a reload replaces the installation, which resubscribes
|
||||||
|
mounted hooks.
|
||||||
|
|
||||||
|
The byte rule (a top-level `Uint8Array` is binary, a nested one is a validation error) is checked
|
||||||
|
once per hop by `assertPluginWireValue`. Bytes ride plugin binary frames, opcodes `0x20`-`0x2f`
|
||||||
|
(`packages/protocol/src/binary-frames/plugin.ts`); nothing on the path copies or re-encodes them,
|
||||||
|
and `plugin-subscriptions.perf.e2e.test.ts` locks that. Keep it that way: never stringify a plugin
|
||||||
|
payload for a log, and the client's bootstrap buffer holds bytes only until the first observer
|
||||||
|
attaches. Binary outputs reach only clients that send `pluginBinaryPayloads`. A binary input
|
||||||
|
frame (`0x22`) becomes a synthetic `plugin.rpc.invoke.request` and runs through the normal message
|
||||||
|
path, so it is authorized like any other invoke. The author contract, including the native upload
|
||||||
|
limit, is in [stream events from the daemon](../public-docs/plugins/reference.md#subscriptions).
|
||||||
|
|
||||||
`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,6 +5,7 @@ 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,
|
||||||
@@ -19,6 +20,7 @@ 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,
|
||||||
@@ -69,6 +71,19 @@ 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,
|
||||||
submitMessage,
|
fillComposerDraft,
|
||||||
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,8 +12,14 @@ 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 submitMessage(page, text);
|
await fillComposerDraft(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, "");
|
||||||
}
|
}
|
||||||
@@ -49,7 +55,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 pressInComposer(page, "Enter");
|
await sendDraftWhenReady(page);
|
||||||
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,6 +63,24 @@ 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,
|
||||||
@@ -826,6 +844,192 @@ 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,4 +1,6 @@
|
|||||||
import { test } from "../support/fixtures";
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
import {
|
import {
|
||||||
collapseFolder,
|
collapseFolder,
|
||||||
expandFolder,
|
expandFolder,
|
||||||
@@ -8,6 +10,7 @@ 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";
|
||||||
|
|
||||||
@@ -49,3 +52,66 @@ 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,10 +294,17 @@ 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$/);
|
||||||
const deleteLabelColor = await deleteAction
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const labelColor = await deleteAction
|
||||||
.getByText("Delete", { exact: true })
|
.getByText("Delete", { exact: true })
|
||||||
.evaluate((element) => getComputedStyle(element).color);
|
.evaluate((element) => getComputedStyle(element).color);
|
||||||
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
|
const iconColor = await deleteAction
|
||||||
|
.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,10 +122,14 @@ 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(page).toHaveURL(/\/settings\/general\?addHost=/);
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Direct connection", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(originalUrl);
|
||||||
} finally {
|
} finally {
|
||||||
await seeded.cleanup();
|
await seeded.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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,10 +108,7 @@ test.describe("Question option text", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selecting description text never answers, and the label still does", async ({
|
test("selecting description text keeps the question open", 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 {
|
||||||
@@ -120,16 +117,13 @@ 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, false);
|
await expectChecked(done, true);
|
||||||
|
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);
|
||||||
@@ -138,11 +132,17 @@ test.describe("Question option text", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("double-clicking a single-select label answers once", async ({ page, context }) => {
|
test("double-clicking a single-select label selects once without advancing", 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 }).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,14 +154,12 @@ test.describe("Question option text", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("multi-select description links and selection leave the option unchanged", async ({
|
test("multi-select descriptions support links and text selection", 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 }).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" });
|
||||||
@@ -177,18 +175,16 @@ 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, false);
|
await expectChecked(lint, true);
|
||||||
|
|
||||||
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, true);
|
await expectChecked(lint, false);
|
||||||
} finally {
|
} finally {
|
||||||
await session.cleanup();
|
await session.cleanup();
|
||||||
}
|
}
|
||||||
@@ -268,6 +264,7 @@ 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,
|
||||||
@@ -288,6 +285,7 @@ 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,
|
||||||
@@ -466,7 +464,9 @@ 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,7 +520,9 @@ 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,3 +1,8 @@
|
|||||||
|
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";
|
||||||
@@ -273,3 +278,71 @@ 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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// 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,3 +113,7 @@ 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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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())),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -273,3 +273,25 @@ 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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ 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,3 +1,4 @@
|
|||||||
|
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";
|
||||||
@@ -465,6 +466,7 @@ 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">
|
||||||
@@ -805,7 +807,6 @@ 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" />
|
||||||
@@ -890,11 +891,13 @@ 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>
|
||||||
|
|||||||
@@ -35,10 +35,9 @@ function SettingsDaemonRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsSectionRoute() {
|
export default function SettingsSectionRoute() {
|
||||||
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
const params = useLocalSearchParams<{ section?: 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.
|
||||||
@@ -50,5 +49,5 @@ export default function SettingsSectionRoute() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
return <SettingsScreen view={view} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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";
|
||||||
@@ -92,7 +93,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, buildSettingsAddHostRoute } from "@/utils/host-routes";
|
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
interface AddProjectFlowProps {
|
interface AddProjectFlowProps {
|
||||||
request: AddProjectFlowRequest;
|
request: AddProjectFlowRequest;
|
||||||
@@ -325,6 +326,7 @@ 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);
|
||||||
@@ -616,9 +618,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();
|
||||||
usePanelStore.getState().showMobileAgent();
|
addHost();
|
||||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -717,6 +719,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
|||||||
directoryPaths,
|
directoryPaths,
|
||||||
githubQuery.data,
|
githubQuery.data,
|
||||||
host,
|
host,
|
||||||
|
addHost,
|
||||||
onClose,
|
onClose,
|
||||||
openAddedProject,
|
openAddedProject,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -64,10 +64,15 @@ 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,
|
||||||
@@ -435,6 +440,7 @@ export function FileExplorerPane({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
|
invalidateDirectoryListings,
|
||||||
createEntry,
|
createEntry,
|
||||||
renameEntry,
|
renameEntry,
|
||||||
duplicateEntry,
|
duplicateEntry,
|
||||||
@@ -886,9 +892,11 @@ export function FileExplorerPane({
|
|||||||
refreshExplorerDirectories({
|
refreshExplorerDirectories({
|
||||||
hasWorkspaceScope,
|
hasWorkspaceScope,
|
||||||
expandedPaths,
|
expandedPaths,
|
||||||
|
showHiddenFiles: usePanelStore.getState().explorerShowHiddenFiles,
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
|
invalidateDirectoryListings,
|
||||||
}),
|
}),
|
||||||
[expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
[invalidateDirectoryListings, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
||||||
);
|
);
|
||||||
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
|
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
|
||||||
queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
|
queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
|
||||||
@@ -1424,7 +1432,7 @@ function toggleDirectory({
|
|||||||
requestDirectoryListing: (
|
requestDirectoryListing: (
|
||||||
path: string,
|
path: string,
|
||||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||||
) => Promise<ExplorerDirectory | null>;
|
) => Promise<DirectoryListingResult>;
|
||||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
||||||
}): void {
|
}): void {
|
||||||
if (!workspaceStateKey) {
|
if (!workspaceStateKey) {
|
||||||
@@ -1561,7 +1569,7 @@ async function initializeExplorer({
|
|||||||
requestDirectoryListing: (
|
requestDirectoryListing: (
|
||||||
path: string,
|
path: string,
|
||||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||||
) => Promise<ExplorerDirectory | null>;
|
) => Promise<DirectoryListingResult>;
|
||||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (!hasWorkspaceScope || hasInitializedRef.current) {
|
if (!hasWorkspaceScope || hasInitializedRef.current) {
|
||||||
@@ -1572,6 +1580,7 @@ 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;
|
||||||
@@ -1593,7 +1602,10 @@ async function initializeExplorer({
|
|||||||
const hiddenPersistedPaths = showHiddenFiles
|
const hiddenPersistedPaths = showHiddenFiles
|
||||||
? []
|
? []
|
||||||
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
|
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
|
||||||
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
|
const restoredPathsWithHidden =
|
||||||
|
restoredPaths === DIRECTORY_LISTING_INVALIDATED
|
||||||
|
? DIRECTORY_LISTING_INVALIDATED
|
||||||
|
: [...restoredPaths, ...hiddenPersistedPaths];
|
||||||
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
||||||
reconcileRestoredExpandedPaths({
|
reconcileRestoredExpandedPaths({
|
||||||
persistedExpandedPaths,
|
persistedExpandedPaths,
|
||||||
@@ -1603,39 +1615,6 @@ 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,8 +1,26 @@
|
|||||||
import { useCallback, useState, type ReactNode } from "react";
|
import { createContext, useContext, useCallback, useState, useId, 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. */
|
||||||
@@ -20,15 +38,24 @@ 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}>{children(floating ? headerHeight : 0)}</View>
|
<View style={styles.content}>
|
||||||
|
<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
|
||||||
@@ -37,11 +64,19 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
|
|||||||
backdrop="surfaceWorkspace"
|
backdrop="surfaceWorkspace"
|
||||||
style={styles.fade}
|
style={styles.fade}
|
||||||
/>
|
/>
|
||||||
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
|
<View
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -55,6 +90,13 @@ 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,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useAddHost } from "@/hosts/add-host";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import {
|
import {
|
||||||
CircleGauge,
|
CircleGauge,
|
||||||
@@ -62,7 +63,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 { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
import { 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";
|
||||||
@@ -177,14 +178,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
router.push(buildSettingsRoute());
|
router.push(buildSettingsRoute());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddHostMobile = useCallback(() => {
|
const handleAddHostMobile = useAddHost();
|
||||||
showMobileAgent();
|
const handleAddHostDesktop = handleAddHostMobile;
|
||||||
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,6 +130,186 @@ it("preserves empty table cells while escaping spoilers in cell text", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a table directly after a paragraph through the library parser", async () => {
|
||||||
|
const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
|
||||||
|
const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
|
||||||
|
const flags = { hardSoftBreaks: true, latexMath: false };
|
||||||
|
const expected = await parseMarkdown(separated, flags);
|
||||||
|
expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
|
||||||
|
expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"a quote",
|
||||||
|
"> Notes\n> | Item |\n> | --- |\n> | A |",
|
||||||
|
"> Notes\n>\n> | Item |\n> | --- |\n> | A |",
|
||||||
|
["Document", ["Blockquote", "Paragraph", "Table"]],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a list item",
|
||||||
|
"- Notes\n | Item |\n | --- |\n | A |\n- Next",
|
||||||
|
"- Notes\n\n | Item |\n | --- |\n | A |\n- Next",
|
||||||
|
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a quoted list item",
|
||||||
|
"> - Notes\n> | Item |\n> | --- |\n> | A |",
|
||||||
|
"> - Notes\n>\n> | Item |\n> | --- |\n> | A |",
|
||||||
|
["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a quote inside a list item",
|
||||||
|
"- > Notes\n > | Item |\n > | --- |\n > | A |",
|
||||||
|
"- > Notes\n >\n > | Item |\n > | --- |\n > | A |",
|
||||||
|
["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"aligned columns without outer pipes",
|
||||||
|
"Notes\nItem | Detail\n:--- | ---:\nA | one",
|
||||||
|
"Notes\n\nItem | Detail\n:--- | ---:\nA | one",
|
||||||
|
["Document", "Paragraph", "Table"],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"an entity at the start of a header without outer pipes",
|
||||||
|
"Notes\nAT&T | Detail\n--- | ---\nA | one",
|
||||||
|
"Notes\n\nAT\\&T | Detail\n--- | ---\nA | one",
|
||||||
|
["Document", "Paragraph", "Table"],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a local image at the start of a header without outer pipes",
|
||||||
|
"Notes\n | Detail\n--- | ---\nA | one",
|
||||||
|
"Notes\n\nLocal | Detail\n--- | ---\nA | one",
|
||||||
|
["Document", "Paragraph", "Table"],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"CRLF lines",
|
||||||
|
"Notes\r\n| Item |\r\n| --- |\r\n| A |",
|
||||||
|
"Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |",
|
||||||
|
["Document", "Paragraph", "Table"],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"CR lines",
|
||||||
|
"Notes\r| Item |\r| --- |\r| A |",
|
||||||
|
"Notes\r\r| Item |\r| --- |\r| A |",
|
||||||
|
["Document", "Paragraph", "Table"],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a quote with CRLF lines",
|
||||||
|
"> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |",
|
||||||
|
"> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |",
|
||||||
|
["Document", ["Blockquote", "Paragraph", "Table"]],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a list item with CRLF lines",
|
||||||
|
"- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
|
||||||
|
"- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
|
||||||
|
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a quote with CR lines",
|
||||||
|
"> Notes\r> | Item |\r> | --- |\r> | A |",
|
||||||
|
"> Notes\r>\r> | Item |\r> | --- |\r> | A |",
|
||||||
|
["Document", ["Blockquote", "Paragraph", "Table"]],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a list item with CR lines",
|
||||||
|
"- Notes\r | Item |\r | --- |\r | A |\r- Next",
|
||||||
|
"- Notes\r\r | Item |\r | --- |\r | A |\r- Next",
|
||||||
|
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a non-interrupting ordered marker with tildes",
|
||||||
|
"Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |",
|
||||||
|
"Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |",
|
||||||
|
["Document", "Paragraph", "Table"],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a non-interrupting ordered marker with backticks",
|
||||||
|
"Notes\n2. ```\n | Item |\n | --- |\n | A |",
|
||||||
|
"Notes\n2. ```\n\n | Item |\n | --- |\n | A |",
|
||||||
|
["Document", "Paragraph", "Table"],
|
||||||
|
],
|
||||||
|
])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => {
|
||||||
|
const rendered = prepareMarkdownSource(source).render();
|
||||||
|
expect(rendered).toEqual(separated);
|
||||||
|
const flags = { hardSoftBreaks: true, latexMath: false };
|
||||||
|
function blocks(node: ASTNode): unknown {
|
||||||
|
if (node.type === "Paragraph" || node.type === "Table") return node.type;
|
||||||
|
return [node.type, ...(node.children ?? []).map(blocks)];
|
||||||
|
}
|
||||||
|
expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"a literal backslash",
|
||||||
|
"Notes\\\n| Item |\n| --- |\n| A |",
|
||||||
|
"Notes\\\n\n| Item |\n| --- |\n| A |",
|
||||||
|
"Notes\\",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"an entity and literal backslash with LF lines",
|
||||||
|
"AT&T\\\n| Item |\n| --- |\n| A |",
|
||||||
|
"AT\\&T\\\n\n| Item |\n| --- |\n| A |",
|
||||||
|
"AT&T\\",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"an entity and literal backslash with CRLF lines",
|
||||||
|
"AT&T\\\r\n| Item |\r\n| --- |\r\n| A |",
|
||||||
|
"AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |",
|
||||||
|
"AT&T\\",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"an entity and literal backslash with CR lines",
|
||||||
|
"AT&T\\\r| Item |\r| --- |\r| A |",
|
||||||
|
"AT\\&T\\\r\r| Item |\r| --- |\r| A |",
|
||||||
|
"AT&T\\",
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"preserves paragraph text before an interrupting table in %s",
|
||||||
|
async (_name, source, separated, text) => {
|
||||||
|
const rendered = prepareMarkdownSource(source).render();
|
||||||
|
expect(rendered).toEqual(separated);
|
||||||
|
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
|
||||||
|
expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
|
||||||
|
expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"```yaml\n---\nkey: value\n---\n```\n",
|
||||||
|
"~~~md\n| Item |\n| --- |\n| A |\n~~~\n",
|
||||||
|
"````md\n```\n| Item |\n| --- |\n```\n`````\n",
|
||||||
|
"> ```md\n> | Item |\n> | --- |\n> ```\n",
|
||||||
|
"> ```md\n> Code\n",
|
||||||
|
"- ```md\n Code\n",
|
||||||
|
" ```\n\n",
|
||||||
|
"```not`a-fence\n",
|
||||||
|
])("renders a table after a fence without changing its code: %s", async (prefix) => {
|
||||||
|
const table = "| Item |\n| --- |\n| A |";
|
||||||
|
const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render();
|
||||||
|
expect(rendered).toEqual(`${prefix}Notes\n\n${table}`);
|
||||||
|
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
|
||||||
|
expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
|
||||||
|
"Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
|
||||||
|
"Notes\n\n| Item |\n| --- |\n| A |",
|
||||||
|
"| Item |\n| --- |\n| A |",
|
||||||
|
"```md\nNotes\n| Item |\n| --- |\n| A |\n```",
|
||||||
|
"```yaml\n---\nkey: value\n---\n```",
|
||||||
|
"```yaml\n---\nkey: value",
|
||||||
|
"~~~md\n| Item |\n| --- |\n| A |\n~~~",
|
||||||
|
"````md\n```\n| Item |\n| --- |\n```\n````",
|
||||||
|
" Notes\n | Item |\n | --- |\n | A |",
|
||||||
|
"Notes\n| Item | Detail |\n| --- |\n| A |",
|
||||||
|
])("leaves separated tables and table-like text unchanged: %s", (source) => {
|
||||||
|
expect(prepareMarkdownSource(source).render()).toEqual(source);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
"src/components/message.tsx",
|
"src/components/message.tsx",
|
||||||
"C:\\repo\\file.ts",
|
"C:\\repo\\file.ts",
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ 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 };
|
||||||
@@ -212,11 +216,25 @@ export function prepareMarkdownSource(
|
|||||||
if (lineEnd < end) split(lineEnd);
|
if (lineEnd < end) split(lineEnd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function visit(node: Nodes) {
|
function visit(node: Nodes, previous?: Nodes) {
|
||||||
const start = node.position?.start.offset;
|
const position = node.position;
|
||||||
const end = node.position?.end.offset;
|
if (position?.start.offset !== undefined && position.end.offset !== undefined) {
|
||||||
if (start !== undefined && end !== undefined) {
|
const start = position.start.offset;
|
||||||
|
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)
|
||||||
@@ -244,7 +262,8 @@ 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) node.children.forEach(visit);
|
if ("children" in node)
|
||||||
|
node.children.forEach((child, index) => visit(child, node.children[index - 1]));
|
||||||
}
|
}
|
||||||
visit(getTree());
|
visit(getTree());
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ afterEach(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildPermission(question: Record<string, unknown>): PendingPermission {
|
function buildPermission(
|
||||||
|
question: Record<string, unknown>,
|
||||||
|
following: Record<string, unknown>[] = [],
|
||||||
|
): PendingPermission {
|
||||||
return {
|
return {
|
||||||
key: "perm-1",
|
key: "perm-1",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -48,12 +51,12 @@ function buildPermission(question: Record<string, unknown>): PendingPermission {
|
|||||||
provider: "claude",
|
provider: "claude",
|
||||||
name: "AskUserQuestion",
|
name: "AskUserQuestion",
|
||||||
kind: "question",
|
kind: "question",
|
||||||
input: { questions: [question] },
|
input: { questions: [question, ...following] },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountCard(question: Record<string, unknown>) {
|
function mountCard(question: Record<string, unknown>, following: 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);
|
||||||
@@ -61,7 +64,7 @@ function mountCard(question: Record<string, unknown>) {
|
|||||||
act(() =>
|
act(() =>
|
||||||
root.render(
|
root.render(
|
||||||
<QuestionFormCard
|
<QuestionFormCard
|
||||||
permission={buildPermission(question)}
|
permission={buildPermission(question, following)}
|
||||||
onRespond={onRespond}
|
onRespond={onRespond}
|
||||||
isResponding={false}
|
isResponding={false}
|
||||||
responseError={null}
|
responseError={null}
|
||||||
@@ -71,10 +74,14 @@ function mountCard(question: Record<string, unknown>) {
|
|||||||
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) => act(() => view.getByRole(optionRole, { name: label }).click());
|
const check = (label: string) =>
|
||||||
|
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;
|
||||||
@@ -90,7 +97,7 @@ function mountCard(question: Record<string, unknown>) {
|
|||||||
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 };
|
return { check, type, otherInput, submit, submittedAnswers, view, onRespond };
|
||||||
}
|
}
|
||||||
|
|
||||||
const multiSelectQuestion = {
|
const multiSelectQuestion = {
|
||||||
@@ -154,3 +161,31 @@ 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,20 +43,16 @@ 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;
|
||||||
|
|
||||||
// Web clicks that must not answer: a link in the label, anything in the description, and the
|
// Links are independent actions. Ignore repeat clicks so double-click text selection
|
||||||
// second click of a double-click, which would otherwise land on the next question's options.
|
// toggles an option only once.
|
||||||
function isNonAnsweringClick(event: GestureResponderEvent, description: View | null): boolean {
|
function isNonAnsweringClick(event: GestureResponderEvent): 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;
|
||||||
const descriptionElement = description as unknown as Element | null;
|
return Boolean(target?.closest("a"));
|
||||||
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,
|
||||||
@@ -92,10 +88,9 @@ 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, descriptionRef.current)) return;
|
if (isNonAnsweringClick(event)) return;
|
||||||
onToggle(qIndex, optIndex, multiSelect);
|
onToggle(qIndex, optIndex, multiSelect);
|
||||||
},
|
},
|
||||||
[onToggle, qIndex, optIndex, multiSelect],
|
[onToggle, qIndex, optIndex, multiSelect],
|
||||||
@@ -152,11 +147,7 @@ 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
|
<View testID="question-form-option-description">
|
||||||
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}
|
||||||
@@ -413,12 +404,8 @@ 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));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[activeQuestionIndex, otherTexts, questions, selections],
|
[otherTexts, selections],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setOtherText = useCallback(
|
const setOtherText = useCallback(
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
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 {
|
||||||
@@ -190,9 +194,11 @@ 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 = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0;
|
const topOffset =
|
||||||
|
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<View
|
<View
|
||||||
@@ -220,7 +226,7 @@ export function ToastViewport({
|
|||||||
return createPortal(content, getOverlayRoot());
|
return createPortal(content, getOverlayRoot());
|
||||||
}
|
}
|
||||||
|
|
||||||
return content;
|
return placement === "panel" ? <FloatingHeaderOverlay>{content}</FloatingHeaderOverlay> : content;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToastCard({
|
function ToastCard({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
import { useAddHost } from "@/hosts/add-host";
|
||||||
|
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";
|
||||||
@@ -12,11 +13,7 @@ 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";
|
||||||
@@ -162,20 +159,13 @@ function useAnyHostOnline(serverIds: string[]): string | null {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WelcomeScreenProps {
|
export function WelcomeScreen() {
|
||||||
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));
|
||||||
|
|
||||||
@@ -196,23 +186,10 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
router.push("/settings");
|
router.push("/settings");
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
|
const handleOpenDirect = useAddHost({ method: "direct", onAdded: finishOnboarding });
|
||||||
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
|
const handleOpenRemoteSsh = useAddHost({ method: "ssh", onAdded: finishOnboarding });
|
||||||
const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []);
|
const handleOpenPasteLink = useAddHost({ method: "link", onAdded: finishOnboarding });
|
||||||
const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []);
|
const handleScanQr = useAddHost({ method: "scan", onAdded: finishOnboarding });
|
||||||
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
|
||||||
@@ -316,24 +293,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
</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>
|
||||||
{overlay}
|
|
||||||
</KeyboardTranslateView>
|
</KeyboardTranslateView>
|
||||||
|
{overlay}
|
||||||
</ComposerViewport>
|
</ComposerViewport>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -214,8 +214,8 @@ export function ComposerDock({
|
|||||||
{composer}
|
{composer}
|
||||||
</View>
|
</View>
|
||||||
</ComposerViewportContent>
|
</ComposerViewportContent>
|
||||||
{overlay}
|
|
||||||
</KeyboardTranslateView>
|
</KeyboardTranslateView>
|
||||||
|
{overlay}
|
||||||
</ComposerViewport>
|
</ComposerViewport>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
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]);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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,9 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ExplorerEntry } from "@/stores/session-store";
|
import type { ExplorerDirectory, 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,
|
||||||
@@ -195,3 +196,76 @@ 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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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;
|
||||||
@@ -16,11 +17,13 @@ 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<ExplorerDirectory | null>;
|
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
|
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
|
||||||
@@ -33,7 +36,7 @@ interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
|
|||||||
interface ReconcileRestoredExpandedPathsInput {
|
interface ReconcileRestoredExpandedPathsInput {
|
||||||
persistedExpandedPaths: ReadonlySet<string>;
|
persistedExpandedPaths: ReadonlySet<string>;
|
||||||
currentExpandedPaths: ReadonlySet<string>;
|
currentExpandedPaths: ReadonlySet<string>;
|
||||||
restoredExpandedPaths: string[];
|
restoredExpandedPaths: RestoredExpandedPaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetExpandedDirectoryPathInput {
|
interface SetExpandedDirectoryPathInput {
|
||||||
@@ -85,7 +88,7 @@ export async function restoreExpandedDirectories({
|
|||||||
persistedExpandedPaths,
|
persistedExpandedPaths,
|
||||||
showHiddenFiles,
|
showHiddenFiles,
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
|
}: RestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
|
||||||
const restoredPaths = ["."];
|
const restoredPaths = ["."];
|
||||||
const restoredPathSet = new Set(restoredPaths);
|
const restoredPathSet = new Set(restoredPaths);
|
||||||
let parentDirectories = [rootDirectory];
|
let parentDirectories = [rootDirectory];
|
||||||
@@ -110,9 +113,11 @@ 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) {
|
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
restoredPaths.push(directory.path);
|
restoredPaths.push(directory.path);
|
||||||
@@ -128,7 +133,7 @@ export function showHiddenFilesAndRestoreExpandedDirectories({
|
|||||||
persistedExpandedPaths,
|
persistedExpandedPaths,
|
||||||
showHiddenFiles,
|
showHiddenFiles,
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
|
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
|
||||||
showHiddenFiles();
|
showHiddenFiles();
|
||||||
return restoreExpandedDirectories({
|
return restoreExpandedDirectories({
|
||||||
rootDirectory,
|
rootDirectory,
|
||||||
@@ -143,6 +148,8 @@ 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) {
|
||||||
@@ -205,7 +212,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<ExplorerDirectory | null>;
|
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
|
||||||
expandPaths: (paths: string[]) => void;
|
expandPaths: (paths: string[]) => void;
|
||||||
showHiddenFiles: () => void;
|
showHiddenFiles: () => void;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -218,7 +225,51 @@ export async function revealExplorerDirectory(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
if (!(await input.requestDirectoryListing(path))) return;
|
const directory = await input.requestDirectoryListing(path);
|
||||||
|
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,12 +25,17 @@ 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();
|
||||||
const document = useMemo(() => withPreviewCsp(html), [html]);
|
// A document URL lets the browser resolve fragment links within the preview.
|
||||||
|
// srcDoc would inherit the app URL and navigate the frame back into Paseo.
|
||||||
|
const source = useMemo(
|
||||||
|
() => `data:text/html;charset=utf-8,${encodeURIComponent(withPreviewCsp(html))}`,
|
||||||
|
[html],
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
data-testid={testID}
|
data-testid={testID}
|
||||||
title={t("panels.file.filePanels.builtin.html")}
|
title={t("panels.file.filePanels.builtin.html")}
|
||||||
srcDoc={document}
|
src={source}
|
||||||
sandbox={SANDBOX}
|
sandbox={SANDBOX}
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
style={iframeStyle}
|
style={iframeStyle}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
|
||||||
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,
|
||||||
@@ -22,15 +24,6 @@ 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;
|
||||||
@@ -75,16 +68,21 @@ 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
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();
|
||||||
next.set(workspaceStateKey, updater(current));
|
updated = updater(current);
|
||||||
|
next.set(workspaceStateKey, updated);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
return updated;
|
||||||
},
|
},
|
||||||
[serverId, setFileExplorer, workspaceStateKey],
|
[serverId, setFileExplorer, workspaceStateKey],
|
||||||
);
|
);
|
||||||
@@ -93,80 +91,23 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
|||||||
async (
|
async (
|
||||||
path: string,
|
path: string,
|
||||||
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||||
): Promise<ExplorerDirectory | null> => {
|
): Promise<DirectoryListingResult> => {
|
||||||
if (!workspaceStateKey) {
|
if (!workspaceStateKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||||
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
|
return loadExplorerDirectory({
|
||||||
const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath;
|
path: normalizedPath,
|
||||||
|
options,
|
||||||
updateExplorerState((state) => ({
|
updateState: updateExplorerState,
|
||||||
...state,
|
fallbackError: t("workspace.fileExplorer.errors.failedToListDirectory"),
|
||||||
isLoading: true,
|
listDirectory: async () => {
|
||||||
lastError: null,
|
if (!normalizedWorkspaceRoot)
|
||||||
pendingRequest: { path: normalizedPath, mode: "list" },
|
throw new Error(t("workspace.fileExplorer.states.unavailable"));
|
||||||
...(shouldSetCurrentPath
|
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||||
? {
|
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],
|
||||||
);
|
);
|
||||||
@@ -308,10 +249,17 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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%"];
|
||||||
+11
-10
@@ -1,9 +1,10 @@
|
|||||||
|
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 { Pressable, Text, View } from "react-native";
|
import { 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 "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/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";
|
||||||
@@ -84,7 +85,7 @@ export function AddHostMethodModal({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
testID="add-host-method-modal"
|
testID="add-host-method-modal"
|
||||||
>
|
>
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handleDirect}
|
onPress={handleDirect}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -98,10 +99,10 @@ export function AddHostMethodModal({
|
|||||||
{t("pairing.connectionMethods.direct.description")}
|
{t("pairing.connectionMethods.direct.description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
|
|
||||||
{isElectronRuntime() ? (
|
{isElectronRuntime() ? (
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handleRemoteSsh}
|
onPress={handleRemoteSsh}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -115,11 +116,11 @@ export function AddHostMethodModal({
|
|||||||
{t("pairing.connectionMethods.remoteSsh.description")}
|
{t("pairing.connectionMethods.remoteSsh.description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isNative && !isFdroidBuild ? (
|
{isNative && !isFdroidBuild ? (
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handleScan}
|
onPress={handleScan}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -132,10 +133,10 @@ export function AddHostMethodModal({
|
|||||||
{t("pairing.connectionMethods.scanQr.description")}
|
{t("pairing.connectionMethods.scanQr.description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handlePaste}
|
onPress={handlePaste}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -149,7 +150,7 @@ export function AddHostMethodModal({
|
|||||||
{t("pairing.connectionMethods.pasteLink.description")}
|
{t("pairing.connectionMethods.pasteLink.description")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+47
-30
@@ -1,17 +1,22 @@
|
|||||||
|
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, Pressable, Text, View } from "react-native";
|
import { Alert, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } 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 "./connection-failure-copy";
|
import { buildConnectionFailureCopy } from "@/components/connection-failure-copy";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
import {
|
||||||
|
AdaptiveModalSheet,
|
||||||
|
AdaptiveTextInput,
|
||||||
|
type SheetHeader,
|
||||||
|
} from "@/components/adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
import { PairingTargetTracker } from "@/components/pair-link-credentials";
|
||||||
import { buildDirectConnectionUri } from "./direct-connection-host";
|
import { buildDirectConnectionUri } from "@/components/direct-connection-host";
|
||||||
|
|
||||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||||
|
|
||||||
@@ -188,7 +193,6 @@ 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();
|
||||||
@@ -207,10 +211,7 @@ 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(
|
const connectIcon = useMemo(() => <ThemedLink2 size={16} />, []);
|
||||||
() => <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(
|
||||||
@@ -415,8 +416,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 ? ChevronDown : ChevronRight;
|
const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight;
|
||||||
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
|
const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
@@ -430,7 +431,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>
|
||||||
<AdaptiveTextInput
|
<ThemedAdaptiveTextInput
|
||||||
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")}
|
||||||
@@ -438,7 +439,6 @@ 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>
|
||||||
<AdaptiveTextInput
|
<ThemedAdaptiveTextInput
|
||||||
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,7 +457,6 @@ 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}
|
||||||
@@ -469,7 +468,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.checkboxRow}
|
style={styles.checkboxRow}
|
||||||
onPress={handleToggleUseTls}
|
onPress={handleToggleUseTls}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -482,17 +481,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">
|
||||||
<Check size={14} color={theme.colors.accentForeground} />
|
<ThemedCheck size={14} />
|
||||||
</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>
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
|
|
||||||
<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}>
|
||||||
<AdaptiveTextInput
|
<ThemedAdaptiveTextInput
|
||||||
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")}
|
||||||
@@ -500,7 +499,6 @@ 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}
|
||||||
@@ -509,7 +507,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
onSubmitEditing={handleSubmitEditing}
|
onSubmitEditing={handleSubmitEditing}
|
||||||
/>
|
/>
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.iconButton}
|
style={styles.iconButton}
|
||||||
onPress={handleTogglePasswordVisibility}
|
onPress={handleTogglePasswordVisibility}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -521,13 +519,13 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
}
|
}
|
||||||
testID="direct-password-visibility-toggle"
|
testID="direct-password-visibility-toggle"
|
||||||
>
|
>
|
||||||
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
|
<PasswordIcon size={18} />
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Pressable
|
<PressHighlight
|
||||||
style={styles.advancedToggle}
|
style={styles.advancedToggle}
|
||||||
onPress={handleToggleAdvanced}
|
onPress={handleToggleAdvanced}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -537,11 +535,11 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
}
|
}
|
||||||
testID="direct-host-advanced-toggle"
|
testID="direct-host-advanced-toggle"
|
||||||
>
|
>
|
||||||
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
|
<AdvancedIcon size={16} />
|
||||||
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
{isAdvancedOpen ? (
|
{isAdvancedOpen ? (
|
||||||
<AdaptiveTextInput
|
<ThemedAdaptiveTextInput
|
||||||
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")}
|
||||||
@@ -549,7 +547,6 @@ 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}
|
||||||
@@ -585,3 +582,23 @@ 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,
|
||||||
|
}));
|
||||||
+5
-5
@@ -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 "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/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>
|
||||||
<Pressable
|
<PressHighlight
|
||||||
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} />}
|
||||||
</Pressable>
|
</PressHighlight>
|
||||||
</View>
|
</View>
|
||||||
</Field>
|
</Field>
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+16
-11
@@ -1,16 +1,20 @@
|
|||||||
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, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } 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 { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
import {
|
||||||
|
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 "./pair-link-credentials";
|
import { PairingTargetTracker } from "@/components/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";
|
||||||
|
|
||||||
@@ -100,7 +104,6 @@ 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();
|
||||||
@@ -126,10 +129,7 @@ function PairLinkModalContent({
|
|||||||
resetPasswordInput();
|
resetPasswordInput();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pairIcon = useMemo(
|
const pairIcon = useMemo(() => <ThemedLink size={16} />, []);
|
||||||
() => <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>
|
||||||
<AdaptiveTextInput
|
<ThemedAdaptiveTextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
initialValue={initialUrl}
|
initialValue={initialUrl}
|
||||||
testID="pair-link-input"
|
testID="pair-link-input"
|
||||||
@@ -220,7 +220,6 @@ 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"
|
||||||
@@ -235,7 +234,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>
|
||||||
<AdaptiveTextInput
|
<ThemedAdaptiveTextInput
|
||||||
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")}
|
||||||
@@ -274,3 +273,9 @@ function PairLinkModalContent({
|
|||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground }));
|
||||||
|
|
||||||
|
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
|
||||||
|
placeholderTextColor: theme.colors.foregroundMuted,
|
||||||
|
}));
|
||||||
+24
-59
@@ -1,17 +1,14 @@
|
|||||||
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 { Pressable, Text, View } from "react-native";
|
import { 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, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet } 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 "@/hosts/pair-scan-model";
|
import { openPairScan } from "./pair-scan-model";
|
||||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { PairLinkModal } from "./pair-link-modal";
|
||||||
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) => ({
|
||||||
@@ -113,50 +110,26 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default function PairScanScreen() {
|
export function PairScanContent({
|
||||||
const { theme } = useUnistyles();
|
onConnected,
|
||||||
|
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) =>
|
importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"),
|
||||||
getHostRuntimeStore().importConnectionLink(
|
onConnected,
|
||||||
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;
|
||||||
@@ -169,35 +142,29 @@ export default function PairScanScreen() {
|
|||||||
[scan],
|
[scan],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
|
||||||
const savePasswordPairing = useCallback(
|
const savePasswordPairing = useCallback(
|
||||||
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
({ serverId }: { serverId: string }) => onConnected(serverId),
|
||||||
[navigateToPairedHost],
|
[onConnected],
|
||||||
);
|
);
|
||||||
const handleRequestPermission = useCallback(() => {
|
const handleRequestPermission = useCallback(() => {
|
||||||
void requestPermission();
|
void requestPermission();
|
||||||
}, [requestPermission]);
|
}, [requestPermission]);
|
||||||
|
|
||||||
const bodyStyle = useMemo(
|
const bodyStyle = useMemo(
|
||||||
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
|
() => [styles.body, { paddingBottom: insets.bottom + 24 }],
|
||||||
[insets.bottom, theme.spacing],
|
[insets.bottom],
|
||||||
);
|
|
||||||
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>
|
||||||
<Pressable style={styles.permissionButton} onPress={closeToSource}>
|
<Button style={styles.permissionButton} onPress={onClose}>
|
||||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
|
<Text style={styles.permissionButtonText}>{t("common.actions.back")}</Text>
|
||||||
</Pressable>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -208,16 +175,14 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
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>
|
||||||
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
|
<Button style={styles.permissionButton} onPress={handleRequestPermission}>
|
||||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
|
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
|
||||||
</Pressable>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.cameraWrap}>
|
<View style={styles.cameraWrap}>
|
||||||
@@ -235,10 +200,10 @@ export default function PairScanScreen() {
|
|||||||
<View style={[styles.corner, styles.cornerBR]} />
|
<View style={[styles.corner, styles.cornerBR]} />
|
||||||
</View>
|
</View>
|
||||||
{scanState.status === "pairing" ? (
|
{scanState.status === "pairing" ? (
|
||||||
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
|
<Text style={styles.helperText}>{t("pairing.scan.pairing")}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{scanState.status === "stopped" && scanState.error ? (
|
{scanState.status === "stopped" && scanState.error ? (
|
||||||
<Text style={helperTextStyle}>{scanState.error}</Text>
|
<Text style={styles.helperText}>{scanState.error}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{scanState.status === "stopped" ? (
|
{scanState.status === "stopped" ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
|
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";
|
||||||
@@ -20,7 +19,6 @@ 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>;
|
||||||
@@ -65,6 +63,7 @@ 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);
|
||||||
@@ -76,13 +75,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +91,7 @@ export function useHostChooser() {
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[hosts, localServerId, open],
|
[hosts, localServerId, open, addHost],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ 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,6 +153,9 @@ 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,6 +2,7 @@ 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,
|
||||||
@@ -36,6 +37,8 @@ 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,6 +71,9 @@ 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 };
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export type PluginClientRuntime = Pick<
|
|||||||
| "openScreen"
|
| "openScreen"
|
||||||
| "openSurface"
|
| "openSurface"
|
||||||
| "openPanel"
|
| "openPanel"
|
||||||
|
| "subscribe"
|
||||||
| "addComposerPill"
|
| "addComposerPill"
|
||||||
| "addHeaderButton"
|
| "addHeaderButton"
|
||||||
> & { hosts: ReturnType<typeof createPluginHosts> };
|
> & { hosts: ReturnType<typeof createPluginHosts> };
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ 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}>
|
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
|
||||||
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
|
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
|
||||||
</PluginRpcProvider>
|
</PluginRpcProvider>
|
||||||
</PaseoApiProvider>
|
</PaseoApiProvider>
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ 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,6 +39,9 @@ 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");
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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";
|
||||||
|
|
||||||
@@ -89,12 +90,18 @@ export class PluginRegistry {
|
|||||||
return [existing];
|
return [existing];
|
||||||
}
|
}
|
||||||
const client = options.client;
|
const client = options.client;
|
||||||
|
const lifetime = new AbortController();
|
||||||
installation = {
|
installation = {
|
||||||
lifetime: new AbortController(),
|
lifetime,
|
||||||
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,6 +26,9 @@ 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: {
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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,6 +26,9 @@ 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 })),
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ 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,6 +18,9 @@ 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,6 +14,45 @@ 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,13 +22,14 @@ function cloneAndFreeze<T>(value: T): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
|
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
|
||||||
const { callId, name, status, error, detail, metadata } = data;
|
const { callId, name, status, error, detail, metadata, agentMessage } = 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":
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ 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,6 +15,9 @@ 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,3 +1,4 @@
|
|||||||
|
import { useAddHost } from "@/hosts/add-host";
|
||||||
import type {
|
import type {
|
||||||
CreateAgentRequestOptions,
|
CreateAgentRequestOptions,
|
||||||
CreateWorkspaceRequestOptions,
|
CreateWorkspaceRequestOptions,
|
||||||
@@ -8,7 +9,6 @@ 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,7 +62,6 @@ 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,
|
||||||
@@ -1166,9 +1165,7 @@ export function NewWorkspaceScreen({
|
|||||||
openAddProjectPicker(selectedServerId);
|
openAddProjectPicker(selectedServerId);
|
||||||
}, [openAddProjectPicker, selectedServerId]);
|
}, [openAddProjectPicker, selectedServerId]);
|
||||||
|
|
||||||
const handleAddHost = useCallback(() => {
|
const handleAddHost = useAddHost();
|
||||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openPicker = useCallback(() => {
|
const openPicker = useCallback(() => {
|
||||||
setPickerOpen(true);
|
setPickerOpen(true);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useAddHost } from "@/hosts/add-host";
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import type { ComponentType, ReactNode } from "react";
|
import type { ComponentType, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -74,10 +75,6 @@ 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";
|
||||||
@@ -1089,21 +1086,15 @@ function SettingsSidebar({
|
|||||||
|
|
||||||
export interface SettingsScreenProps {
|
export interface SettingsScreenProps {
|
||||||
view: SettingsView;
|
view: SettingsView;
|
||||||
openAddHostIntent?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
|
export default function SettingsScreen({ view }: 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);
|
||||||
@@ -1164,50 +1155,10 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
}
|
}
|
||||||
}, [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 }: { serverId: string }) => {
|
(serverId: string) => {
|
||||||
const target = buildSettingsHostSectionRoute(serverId, "connections");
|
selectSettingsHost(serverId);
|
||||||
|
const target = buildSettingsHostSectionRoute(serverId, "host");
|
||||||
if (isCompactLayout) {
|
if (isCompactLayout) {
|
||||||
router.push(target);
|
router.push(target);
|
||||||
} else {
|
} else {
|
||||||
@@ -1217,6 +1168,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
[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);
|
||||||
@@ -1272,14 +1225,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
[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) {
|
||||||
@@ -1388,37 +1333,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
||||||
@@ -1436,7 +1350,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
layout="mobile"
|
layout="mobile"
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
{addHostModals}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1447,7 +1360,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
|
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
|
||||||
{content}
|
{content}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
{addHostModals}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1482,7 +1394,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
|||||||
</View>
|
</View>
|
||||||
</WindowChromeRegion>
|
</WindowChromeRegion>
|
||||||
</View>
|
</View>
|
||||||
{addHostModals}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ 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,7 +8,6 @@ import {
|
|||||||
buildOpenProjectRoute,
|
buildOpenProjectRoute,
|
||||||
resolveKnownHostRoute,
|
resolveKnownHostRoute,
|
||||||
buildSessionsRoute,
|
buildSessionsRoute,
|
||||||
buildSettingsAddHostRoute,
|
|
||||||
buildProjectSettingsRoute,
|
buildProjectSettingsRoute,
|
||||||
buildProjectsSettingsRoute,
|
buildProjectsSettingsRoute,
|
||||||
decodeFilePathFromPathSegment,
|
decodeFilePathFromPathSegment,
|
||||||
@@ -185,14 +184,6 @@ 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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -553,10 +553,6 @@ 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) {
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ 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,6 +83,26 @@ 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();
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,9 +22,12 @@ 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;
|
||||||
@@ -50,7 +55,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: SessionOutboundMessage[] = [];
|
const buffered: Update[] = [];
|
||||||
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;
|
||||||
@@ -60,12 +65,10 @@ export class OwnedSubscriptions {
|
|||||||
});
|
});
|
||||||
void ready.catch(() => {});
|
void ready.catch(() => {});
|
||||||
|
|
||||||
const deliver = (
|
const deliver = (observer: SubscriptionObserver<SubscriptionSnapshot<T>>, update?: Update) => {
|
||||||
observer: SubscriptionObserver<SubscriptionSnapshot<T>>,
|
|
||||||
message?: SessionOutboundMessage,
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
if (message) observer.update(message);
|
if (update && "binary" in update) observer.binary?.(update.binary);
|
||||||
|
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);
|
||||||
@@ -78,6 +81,13 @@ 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;
|
||||||
@@ -128,12 +138,14 @@ 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;
|
||||||
if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(message);
|
buffer({ message });
|
||||||
else {
|
for (const observer of observers) deliver(observer, { message });
|
||||||
buffered.length = 0;
|
},
|
||||||
overflow = true;
|
receiveBinary: (payload) => {
|
||||||
}
|
if (disposed) return;
|
||||||
for (const observer of observers) deliver(observer, message);
|
// Bytes wait only for the first observer; retaining them after would hold every frame.
|
||||||
|
if (observers.size === 0) buffer({ binary: payload });
|
||||||
|
for (const observer of observers) deliver(observer, { binary: payload });
|
||||||
},
|
},
|
||||||
release: () => {
|
release: () => {
|
||||||
if (teardown) return teardown;
|
if (teardown) return teardown;
|
||||||
@@ -142,7 +154,9 @@ 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);
|
||||||
reject(new Error("Subscription released"));
|
// A host that already accepted keeps `ready` resolved, even if released in the same tick.
|
||||||
|
if (snapshot) resolve(snapshot);
|
||||||
|
else reject(new Error("Subscription released"));
|
||||||
teardown = (async () => {
|
teardown = (async () => {
|
||||||
await pending;
|
await pending;
|
||||||
await releaseId();
|
await releaseId();
|
||||||
@@ -174,7 +188,7 @@ export class OwnedSubscriptions {
|
|||||||
.catch(this.host.failed);
|
.catch(this.host.failed);
|
||||||
} else {
|
} else {
|
||||||
deliver(observer);
|
deliver(observer);
|
||||||
for (const message of buffered) deliver(observer, message);
|
for (const update of buffered) deliver(observer, update);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
observers.delete(observer);
|
observers.delete(observer);
|
||||||
@@ -205,6 +219,10 @@ 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,6 +1106,7 @@ 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,
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ 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,
|
||||||
@@ -2260,6 +2265,8 @@ 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;
|
||||||
@@ -2290,7 +2297,9 @@ 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 = this.sendSessionMessageOrThrow(wire.message);
|
const sent = params.transmit
|
||||||
|
? 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;
|
||||||
@@ -2313,12 +2322,14 @@ 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) {
|
||||||
@@ -2650,6 +2661,7 @@ 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 {
|
||||||
@@ -6197,8 +6209,14 @@ 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: {
|
||||||
@@ -6212,6 +6230,37 @@ 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,
|
||||||
@@ -6975,6 +7024,23 @@ 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) {
|
||||||
@@ -6986,6 +7052,10 @@ 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", {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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";
|
||||||
@@ -198,6 +199,20 @@ 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" });
|
||||||
@@ -1208,6 +1223,7 @@ 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({
|
||||||
|
|||||||
@@ -323,3 +323,68 @@ 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ 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 { PluginRpcContract } from "../rpc.js";
|
import type {
|
||||||
|
PluginRpcContract,
|
||||||
|
PluginSubscribeOptions,
|
||||||
|
PluginSubscriptionContract,
|
||||||
|
PluginSubscriptionHandle,
|
||||||
|
PluginWireInput,
|
||||||
|
} from "../rpc.js";
|
||||||
import type {
|
import type {
|
||||||
PluginButtonRegistration,
|
PluginButtonRegistration,
|
||||||
PluginHeaderButtonContribution,
|
PluginHeaderButtonContribution,
|
||||||
@@ -203,6 +209,16 @@ 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;
|
||||||
@@ -295,7 +311,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: ZodInput<InputSchema>,
|
input: PluginWireInput<ZodInput<InputSchema>>,
|
||||||
): Promise<ZodOutput<OutputSchema>>;
|
): Promise<ZodOutput<OutputSchema>>;
|
||||||
openScreen(input: PluginOpenScreenInput): void;
|
openScreen(input: PluginOpenScreenInput): void;
|
||||||
/** @deprecated Use `openScreen`. */
|
/** @deprecated Use `openScreen`. */
|
||||||
|
|||||||
@@ -24,17 +24,23 @@ describe("plugin RPC contracts", () => {
|
|||||||
expectTypeOf<RpcOutput<typeof contract>>().toEqualTypeOf<{ value: number }>();
|
expectTypeOf<RpcOutput<typeof contract>>().toEqualTypeOf<{ value: number }>();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validates input before invoking the daemon", async () => {
|
it("sends input unchanged for the plugin to parse", async () => {
|
||||||
let invocations = 0;
|
let parses = 0;
|
||||||
const invoke = async () => {
|
const transformed = defineRpc({
|
||||||
invocations += 1;
|
name: "transformed",
|
||||||
return { value: 2 };
|
input: z.string().transform((text) => {
|
||||||
|
parses += 1;
|
||||||
|
return Number(text);
|
||||||
|
}),
|
||||||
|
output: z.number(),
|
||||||
|
});
|
||||||
|
const invoke = async (_method: string, input: unknown) => {
|
||||||
|
expect(input).toBe("2");
|
||||||
|
expect(parses).toBe(0);
|
||||||
|
return transformed.input.parseAsync(input);
|
||||||
};
|
};
|
||||||
|
await expect(callPluginRpc(transformed, invoke, "2")).resolves.toBe(2);
|
||||||
await expect(callPluginRpc(contract, invoke, { value: "wrong" })).rejects.toBeInstanceOf(
|
expect(parses).toBe(1);
|
||||||
z.ZodError,
|
|
||||||
);
|
|
||||||
expect(invocations).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validates output before resolving", async () => {
|
it("validates output before resolving", async () => {
|
||||||
|
|||||||
@@ -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 } from "../rpc.js";
|
import { callPluginRpc, subscribePluginRpc, type PluginSubscriptionObserver } from "../rpc.js";
|
||||||
|
|
||||||
export async function searchPluginAttachments(
|
export async function searchPluginAttachments(
|
||||||
source: PluginAttachmentSourceContribution<PluginText>,
|
source: PluginAttachmentSourceContribution<PluginText>,
|
||||||
@@ -19,7 +19,9 @@ export async function searchPluginAttachments(
|
|||||||
return PluginAttachmentSearchPayloadSchema.parseAsync(output);
|
return PluginAttachmentSearchPayloadSchema.parseAsync(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { callPluginRpc, PaseoApiProvider, PluginRpcProvider };
|
export { callPluginRpc, subscribePluginRpc, PaseoApiProvider, PluginRpcProvider };
|
||||||
|
export type { PluginSubscriptionObserver };
|
||||||
|
export type { PluginSubscribe } from "./rpc-context.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
PluginTranslationRuntime,
|
PluginTranslationRuntime,
|
||||||
|
|||||||
@@ -71,7 +71,12 @@ 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 } from "./rpc-context.js";
|
export { useRpc, useSubscription, type PluginSubscriptionState } 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";
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
import {
|
||||||
|
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 { callPluginRpc, type PluginRpcContract } from "../rpc.js";
|
import {
|
||||||
|
callPluginRpc,
|
||||||
|
subscribePluginRpc,
|
||||||
|
type PluginRpcContract,
|
||||||
|
type PluginSubscriptionContract,
|
||||||
|
type PluginSubscriptionHandle,
|
||||||
|
type PluginSubscriptionObserver,
|
||||||
|
type PluginWireInput,
|
||||||
|
} from "../rpc.js";
|
||||||
|
import { shallowEqual } from "./shallow.js";
|
||||||
|
|
||||||
|
export type PluginSubscribe = (
|
||||||
|
method: string,
|
||||||
|
input: unknown,
|
||||||
|
observer: PluginSubscriptionObserver,
|
||||||
|
) => PluginSubscriptionHandle;
|
||||||
|
|
||||||
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);
|
||||||
@@ -15,22 +40,100 @@ 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 }), [invoke]);
|
const value = useMemo(() => ({ invoke, subscribe }), [invoke, subscribe]);
|
||||||
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: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
|
): (input: PluginWireInput<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: ZodInput<InputSchema>) => callPluginRpc(contract, context.invoke, input),
|
(input: PluginWireInput<ZodInput<InputSchema>>) =>
|
||||||
|
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}>
|
<PluginRpcProvider invoke={rpc.invoke} subscribe={rpc.subscribe}>
|
||||||
<PluginTranslationProvider runtime={translations}>{content}</PluginTranslationProvider>
|
<PluginTranslationProvider runtime={translations}>{content}</PluginTranslationProvider>
|
||||||
</PluginRpcProvider>
|
</PluginRpcProvider>
|
||||||
</PaseoApiProvider>
|
</PaseoApiProvider>
|
||||||
|
|||||||
@@ -19,7 +19,16 @@ export {
|
|||||||
type PluginAttachmentItem,
|
type PluginAttachmentItem,
|
||||||
type PluginAttachmentSearchPayload,
|
type PluginAttachmentSearchPayload,
|
||||||
} from "./attachments.js";
|
} from "./attachments.js";
|
||||||
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
|
export {
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ 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"]>;
|
||||||
|
|
||||||
@@ -31,9 +37,98 @@ 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: ZodInput<InputSchema>,
|
input: PluginWireInput<ZodInput<InputSchema>>,
|
||||||
): Promise<ZodOutput<OutputSchema>> {
|
): Promise<ZodOutput<OutputSchema>> {
|
||||||
const parsedInput = await contract.input.parseAsync(input);
|
const output = await invoke(contract.name, 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 } from "../rpc.js";
|
import type { PluginRpcContract, PluginSubscriptionContract, PluginWireInput } 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,6 +10,18 @@ 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";
|
||||||
@@ -31,12 +43,24 @@ 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,
|
||||||
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
|
) => PluginWireInput<ZodInput<OutputSchema>> | Promise<PluginWireInput<ZodInput<OutputSchema>>>,
|
||||||
): void;
|
): void;
|
||||||
registerProvider(provider: ProviderRegistration): void;
|
registerProvider(provider: ProviderRegistration): void;
|
||||||
registerUsageSource(source: UsageSourceRegistration): void;
|
registerUsageSource(source: UsageSourceRegistration): void;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type {
|
|||||||
PluginServerContribution,
|
PluginServerContribution,
|
||||||
PluginSettings,
|
PluginSettings,
|
||||||
PluginSettingsState,
|
PluginSettingsState,
|
||||||
|
PluginSubscriptionContext,
|
||||||
} from "./contracts.js";
|
} from "./contracts.js";
|
||||||
export type {
|
export type {
|
||||||
PluginHooks,
|
PluginHooks,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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,
|
||||||
@@ -13,7 +14,8 @@ 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]) {
|
||||||
@@ -38,6 +40,12 @@ 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,3 +2,4 @@ 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";
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { MAX_ROUTED_PAYLOAD_BYTES } from "./host-route.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin values that are a `Uint8Array` travel as binary frames. Opcodes 0x20-0x2f are
|
||||||
|
* reserved for plugin traffic. Every frame is `[opcode][id length u8][id]...` and the payload
|
||||||
|
* is the rest of the frame, decoded as a view so no hop copies it.
|
||||||
|
*/
|
||||||
|
export const PluginFrameOpcode = {
|
||||||
|
/** `[0x20][id length][subscriptionId][payload]`: a subscription event. */
|
||||||
|
Event: 0x20,
|
||||||
|
/** `[0x21][id length][requestId][payload]`: an RPC output; no JSON response follows. */
|
||||||
|
Output: 0x21,
|
||||||
|
/** `[0x22][id length][requestId][header length u16][header JSON][payload]`: an RPC input. */
|
||||||
|
Input: 0x22,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PluginFrameOpcode = (typeof PluginFrameOpcode)[keyof typeof PluginFrameOpcode];
|
||||||
|
|
||||||
|
/** The largest frame header: an input's opcode, ID and header JSON, each at its maximum length. */
|
||||||
|
const MAX_PLUGIN_FRAME_HEADER_BYTES = 2 + 0xff + 2 + 0xffff;
|
||||||
|
|
||||||
|
/** The largest `Uint8Array` a plugin value carries, so its frame fits in one routed message. */
|
||||||
|
export const MAX_PLUGIN_PAYLOAD_BYTES = MAX_ROUTED_PAYLOAD_BYTES - MAX_PLUGIN_FRAME_HEADER_BYTES;
|
||||||
|
|
||||||
|
export type PluginFrame =
|
||||||
|
| { opcode: typeof PluginFrameOpcode.Event; subscriptionId: string; payload: Uint8Array }
|
||||||
|
| { opcode: typeof PluginFrameOpcode.Output; requestId: string; payload: Uint8Array }
|
||||||
|
| {
|
||||||
|
opcode: typeof PluginFrameOpcode.Input;
|
||||||
|
requestId: string;
|
||||||
|
pluginId: string;
|
||||||
|
method: string;
|
||||||
|
payload: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isPluginFrameOpcode(value: number | undefined): value is PluginFrameOpcode {
|
||||||
|
return (
|
||||||
|
value === PluginFrameOpcode.Event ||
|
||||||
|
value === PluginFrameOpcode.Output ||
|
||||||
|
value === PluginFrameOpcode.Input
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodePluginEventFrame(subscriptionId: string, payload: Uint8Array): Uint8Array {
|
||||||
|
return encodeFrame(PluginFrameOpcode.Event, subscriptionId, new Uint8Array(0), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodePluginOutputFrame(requestId: string, payload: Uint8Array): Uint8Array {
|
||||||
|
return encodeFrame(PluginFrameOpcode.Output, requestId, new Uint8Array(0), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodePluginInputFrame(input: {
|
||||||
|
requestId: string;
|
||||||
|
pluginId: string;
|
||||||
|
method: string;
|
||||||
|
payload: Uint8Array;
|
||||||
|
}): Uint8Array {
|
||||||
|
const header = new TextEncoder().encode(
|
||||||
|
JSON.stringify({ pluginId: input.pluginId, method: input.method }),
|
||||||
|
);
|
||||||
|
if (header.byteLength > 0xffff) throw new RangeError("Plugin input header is too long");
|
||||||
|
const prefix = new Uint8Array(2 + header.byteLength);
|
||||||
|
new DataView(prefix.buffer).setUint16(0, header.byteLength);
|
||||||
|
prefix.set(header, 2);
|
||||||
|
return encodeFrame(PluginFrameOpcode.Input, input.requestId, prefix, input.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodePluginFrame(bytes: Uint8Array): PluginFrame | null {
|
||||||
|
const opcode = bytes[0];
|
||||||
|
if (bytes.byteLength < 2 || !isPluginFrameOpcode(opcode)) return null;
|
||||||
|
const idLength = bytes[1];
|
||||||
|
if (idLength === 0 || idLength > bytes.byteLength - 2) return null;
|
||||||
|
const id = new TextDecoder().decode(bytes.subarray(2, 2 + idLength));
|
||||||
|
const body = bytes.subarray(2 + idLength);
|
||||||
|
if (opcode === PluginFrameOpcode.Event) return { opcode, subscriptionId: id, payload: body };
|
||||||
|
if (opcode === PluginFrameOpcode.Output) return { opcode, requestId: id, payload: body };
|
||||||
|
if (body.byteLength < 2) return null;
|
||||||
|
const headerLength = new DataView(body.buffer, body.byteOffset, body.byteLength).getUint16(0);
|
||||||
|
if (headerLength > body.byteLength - 2) return null;
|
||||||
|
const header = decodeInputHeader(body.subarray(2, 2 + headerLength));
|
||||||
|
if (!header) return null;
|
||||||
|
return { opcode, requestId: id, ...header, payload: body.subarray(2 + headerLength) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeFrame(
|
||||||
|
opcode: PluginFrameOpcode,
|
||||||
|
id: string,
|
||||||
|
prefix: Uint8Array,
|
||||||
|
payload: Uint8Array,
|
||||||
|
): Uint8Array {
|
||||||
|
const idBytes = new TextEncoder().encode(id);
|
||||||
|
if (idBytes.byteLength === 0) throw new RangeError("Plugin frame ID is required");
|
||||||
|
if (idBytes.byteLength > 0xff) throw new RangeError("Plugin frame ID is too long");
|
||||||
|
const headerLength = 2 + idBytes.byteLength + prefix.byteLength;
|
||||||
|
const frame = new Uint8Array(headerLength + payload.byteLength);
|
||||||
|
frame[0] = opcode;
|
||||||
|
frame[1] = idBytes.byteLength;
|
||||||
|
frame.set(idBytes, 2);
|
||||||
|
frame.set(prefix, 2 + idBytes.byteLength);
|
||||||
|
frame.set(payload, headerLength);
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeInputHeader(bytes: Uint8Array): { pluginId: string; method: string } | null {
|
||||||
|
try {
|
||||||
|
const header: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
||||||
|
if (typeof header !== "object" || header === null) return null;
|
||||||
|
const pluginId = Reflect.get(header, "pluginId");
|
||||||
|
const method = Reflect.get(header, "method");
|
||||||
|
if (typeof pluginId !== "string" || typeof method !== "string" || !pluginId || !method)
|
||||||
|
return null;
|
||||||
|
return { pluginId, method };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The byte rule: a plugin value is either a `Uint8Array`, sent as a binary frame, or a JSON value
|
||||||
|
* with no `Uint8Array` inside it. Checks object structure only; never reads the bytes.
|
||||||
|
*/
|
||||||
|
export function assertPluginWireValue(value: unknown, label: string): void {
|
||||||
|
if (value instanceof Uint8Array) {
|
||||||
|
if (value.byteLength > MAX_PLUGIN_PAYLOAD_BYTES)
|
||||||
|
throw new RangeError(
|
||||||
|
`${label} is ${value.byteLength} bytes; one value carries at most ${MAX_PLUGIN_PAYLOAD_BYTES} bytes`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nested = findNestedBytes(value, label);
|
||||||
|
if (nested)
|
||||||
|
throw new TypeError(
|
||||||
|
`${nested} is a Uint8Array. Bytes cross the wire only as the whole value: send the Uint8Array itself, or encode it into the value.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNestedBytes(value: unknown, path: string): string | null {
|
||||||
|
if (value instanceof Uint8Array) return path;
|
||||||
|
if (typeof value !== "object" || value === null) return null;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const found = findNestedBytes(value[index], `${path}[${index}]`);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
const found = findNestedBytes(entry, `${path}.${key}`);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -46,6 +46,9 @@ export const CLIENT_CAPS = {
|
|||||||
// COMPAT(pluginTimelineItems): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
|
// COMPAT(pluginTimelineItems): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
|
||||||
pluginTimelineItems: "plugin_timeline_items",
|
pluginTimelineItems: "plugin_timeline_items",
|
||||||
workspaceSetupObservation: "workspace_setup_observation",
|
workspaceSetupObservation: "workspace_setup_observation",
|
||||||
|
// COMPAT(pluginBinaryPayloads): added in v0.15.0, remove after 2027-05-02 once client floor >= v0.15.0.
|
||||||
|
// The daemon sends plugin RPC outputs as binary opcode 0x21 only to clients that decode it.
|
||||||
|
pluginBinaryPayloads: "plugin_binary_payloads",
|
||||||
// COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
|
// COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
|
||||||
workspaceSetupBlocked: "workspace_setup_blocked",
|
workspaceSetupBlocked: "workspace_setup_blocked",
|
||||||
browserHost: "browser_host",
|
browserHost: "browser_host",
|
||||||
|
|||||||
@@ -372,3 +372,43 @@ it("npm metadata leaves legacy plugin rows parseable without extending their clo
|
|||||||
payload: { requestId: "npm", plugins: [plugin] },
|
payload: { requestId: "npm", plugins: [plugin] },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("plugin subscription protocol", () => {
|
||||||
|
it("parses a subscribe request and its response", () => {
|
||||||
|
const request = {
|
||||||
|
type: "plugin.rpc.subscribe.request",
|
||||||
|
requestId: "r1",
|
||||||
|
pluginId: "diff-watch",
|
||||||
|
method: "diff.watch",
|
||||||
|
input: { a: "main", b: "HEAD" },
|
||||||
|
};
|
||||||
|
expect(SessionInboundMessageSchema.parse(request)).toEqual(request);
|
||||||
|
const response = {
|
||||||
|
type: "plugin.rpc.subscribe.response",
|
||||||
|
payload: { requestId: "r1", subscriptionId: "s1" },
|
||||||
|
};
|
||||||
|
expect(SessionOutboundMessageSchema.parse(response)).toEqual(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses JSON events and closes with and without an error", () => {
|
||||||
|
for (const message of [
|
||||||
|
{ type: "plugin.rpc.event", payload: { subscriptionId: "s1", event: { path: "a.ts" } } },
|
||||||
|
{ type: "plugin.rpc.closed", payload: { subscriptionId: "s1" } },
|
||||||
|
{ type: "plugin.rpc.closed", payload: { subscriptionId: "s1", error: "boom" } },
|
||||||
|
]) {
|
||||||
|
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advertises the feature as an optional flag", () => {
|
||||||
|
const status = {
|
||||||
|
type: "status",
|
||||||
|
payload: {
|
||||||
|
status: "server_info",
|
||||||
|
serverId: "srv",
|
||||||
|
features: { pluginSubscriptions: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(StatusMessageSchema.parse(status)).toMatchObject(status);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1603,6 +1603,14 @@ export const PluginRpcInvokeRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
pluginId: PluginIdSchema,
|
pluginId: PluginIdSchema,
|
||||||
method: z.string().min(1),
|
method: z.string().min(1),
|
||||||
|
input: z.unknown().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PluginRpcSubscribeRequestSchema = z.object({
|
||||||
|
type: z.literal("plugin.rpc.subscribe.request"),
|
||||||
|
requestId: z.string(),
|
||||||
|
pluginId: PluginIdSchema,
|
||||||
|
method: z.string().min(1),
|
||||||
input: z.unknown(),
|
input: z.unknown(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3339,6 +3347,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
PluginDisableRequestSchema,
|
PluginDisableRequestSchema,
|
||||||
PluginRemoveRequestSchema,
|
PluginRemoveRequestSchema,
|
||||||
PluginRpcInvokeRequestSchema,
|
PluginRpcInvokeRequestSchema,
|
||||||
|
PluginRpcSubscribeRequestSchema,
|
||||||
AgentTimelineAppendRequestSchema,
|
AgentTimelineAppendRequestSchema,
|
||||||
AgentSkillsGetStatusRequestSchema,
|
AgentSkillsGetStatusRequestSchema,
|
||||||
AgentSkillsReconcileRequestSchema,
|
AgentSkillsReconcileRequestSchema,
|
||||||
@@ -3739,6 +3748,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
pluginThemes: z.boolean().optional(),
|
pluginThemes: z.boolean().optional(),
|
||||||
pluginSettings: z.boolean().optional(),
|
pluginSettings: z.boolean().optional(),
|
||||||
pluginTimelineItems: z.boolean().optional(),
|
pluginTimelineItems: z.boolean().optional(),
|
||||||
|
// COMPAT(pluginSubscriptions): added in v0.15.0, remove gate after 2027-05-02.
|
||||||
|
pluginSubscriptions: z.boolean().optional(),
|
||||||
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
|
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
|
||||||
skillManagement: z.boolean().optional(),
|
skillManagement: z.boolean().optional(),
|
||||||
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
|
// COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26.
|
||||||
@@ -6958,10 +6969,26 @@ export const PluginRpcInvokeResponseSchema = z.object({
|
|||||||
type: z.literal("plugin.rpc.invoke.response"),
|
type: z.literal("plugin.rpc.invoke.response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
output: z.unknown(),
|
output: z.unknown().optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const PluginRpcSubscribeResponseSchema = z.object({
|
||||||
|
type: z.literal("plugin.rpc.subscribe.response"),
|
||||||
|
payload: z.object({ requestId: z.string(), subscriptionId: z.string() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A JSON subscription event. A `Uint8Array` event rides as binary opcode 0x20 instead. */
|
||||||
|
export const PluginRpcEventSchema = z.object({
|
||||||
|
type: z.literal("plugin.rpc.event"),
|
||||||
|
payload: z.object({ subscriptionId: z.string(), event: z.unknown() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PluginRpcClosedSchema = z.object({
|
||||||
|
type: z.literal("plugin.rpc.closed"),
|
||||||
|
payload: z.object({ subscriptionId: z.string(), error: z.string().optional() }),
|
||||||
|
});
|
||||||
|
|
||||||
export const AgentTimelineAppendResponseSchema = z.object({
|
export const AgentTimelineAppendResponseSchema = z.object({
|
||||||
type: z.literal("agent.timeline.append.response"),
|
type: z.literal("agent.timeline.append.response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -7035,6 +7062,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
PluginDisableResponseSchema,
|
PluginDisableResponseSchema,
|
||||||
PluginRemoveResponseSchema,
|
PluginRemoveResponseSchema,
|
||||||
PluginRpcInvokeResponseSchema,
|
PluginRpcInvokeResponseSchema,
|
||||||
|
PluginRpcSubscribeResponseSchema,
|
||||||
|
PluginRpcEventSchema,
|
||||||
|
PluginRpcClosedSchema,
|
||||||
AgentTimelineAppendResponseSchema,
|
AgentTimelineAppendResponseSchema,
|
||||||
AgentSkillsGetStatusResponseSchema,
|
AgentSkillsGetStatusResponseSchema,
|
||||||
AgentSkillsReconcileResponseSchema,
|
AgentSkillsReconcileResponseSchema,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|||||||
import { createJiti } from "jiti";
|
import { createJiti } from "jiti";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { WSOutboundMessageSchema } from "../../src/messages.js";
|
import { PluginRpcInvokeResponseSchema, WSOutboundMessageSchema } from "../../src/messages.js";
|
||||||
import { validateWSOutboundMessage } from "../../src/validation/ws-outbound.js";
|
import { validateWSOutboundMessage } from "../../src/validation/ws-outbound.js";
|
||||||
|
|
||||||
interface GeneratedSchema {
|
interface GeneratedSchema {
|
||||||
@@ -401,3 +401,8 @@ const SourceSchema = z.object({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts omitted RPC output so the receiving client can apply schema defaults", () => {
|
||||||
|
const message = { type: "plugin.rpc.invoke.response", payload: { requestId: "default-output" } };
|
||||||
|
expect(PluginRpcInvokeResponseSchema.safeParse(message).success).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ function spawnRelayDevServer(port: number): ChildProcess {
|
|||||||
wranglerCliPath,
|
wranglerCliPath,
|
||||||
"dev",
|
"dev",
|
||||||
"--local",
|
"--local",
|
||||||
|
// Local execution still inherits production vars; exercise the local Durable Object.
|
||||||
|
"--var",
|
||||||
|
"PASEO_RELAY_UPSTREAM:",
|
||||||
"--ip",
|
"--ip",
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"--port",
|
"--port",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user