mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9e56e1eb9 | ||
|
|
3274d67959 | ||
|
|
2fbc5f4b59 | ||
|
|
833b4ef80a | ||
|
|
39f1058005 |
@@ -1,3 +0,0 @@
|
||||
vendor/react-native-gpui/** linguist-vendored
|
||||
packages/app/gpui/package-lock.json linguist-generated
|
||||
packages/app/gpui/desktop/Cargo.lock linguist-generated
|
||||
@@ -98,9 +98,3 @@ app.json
|
||||
|
||||
# Project-local Serena configuration and memories.
|
||||
.serena/
|
||||
|
||||
# Vendored experimental React Native desktop runtime.
|
||||
!vendor/
|
||||
!vendor/react-native-gpui/
|
||||
!vendor/react-native-gpui/**
|
||||
!packages/app/gpui/app.json
|
||||
|
||||
+1
-1
@@ -11,5 +11,5 @@
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx", "vendor/**"]
|
||||
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"options": {
|
||||
"typeAware": false
|
||||
},
|
||||
"ignorePatterns": [".dev/**", "vendor/**"],
|
||||
"ignorePatterns": [".dev/**"],
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
|
||||
"categories": {
|
||||
|
||||
@@ -65,7 +65,6 @@ per-agent examples and provider-specific keys.
|
||||
## Table of Contents
|
||||
|
||||
- [Extending a built-in provider](#extending-a-built-in-provider)
|
||||
- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server)
|
||||
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
||||
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
||||
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
|
||||
@@ -108,35 +107,6 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai
|
||||
|
||||
---
|
||||
|
||||
## OpenCode with an existing v2 server
|
||||
|
||||
Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"opencode": {
|
||||
"env": {
|
||||
"PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096",
|
||||
"PASEO_OPENCODE_SERVER_PASSWORD": "<server password>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`.
|
||||
|
||||
A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server.
|
||||
|
||||
An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package.
|
||||
|
||||
OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service.
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
|
||||
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
|
||||
|
||||
@@ -29,13 +29,6 @@ the titlebar row. Production builds leave the variable unset and show no label.
|
||||
|
||||
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
|
||||
|
||||
## Experimental GPUI client
|
||||
|
||||
The opt-in [GPUI target](../packages/app/gpui/README.md) runs the existing React
|
||||
Native app on Linux. Its contributor guide covers setup, the renderer comparison,
|
||||
unsupported features, native checks, and experiments to pick up. It connects to
|
||||
an already-running daemon and does not replace the Electron dev command.
|
||||
|
||||
## Nix desktop package
|
||||
|
||||
The flake exposes `packages.<system>.desktop` on Linux and macOS:
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects versions older than 2.0.10. Paseo selects an explicit server, then a healthy local registered service, then its own helper. It never stops a reused service. A local service loads the Paseo bridge from its watched plugin directory; sessions bind their own bridge credentials through OpenCode RPC. See [OpenCode with an existing v2 server](custom-providers.md#opencode-with-an-existing-v2-server) for setup and isolation limits. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||
|
||||
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
|
||||
|
||||
|
||||
+1
-5
@@ -122,11 +122,7 @@
|
||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push",
|
||||
"gpui:install": "node scripts/gpui.mjs install",
|
||||
"gpui:bundle": "node scripts/gpui.mjs bundle",
|
||||
"build:gpui": "node scripts/gpui.mjs build",
|
||||
"dev:gpui": "node scripts/gpui.mjs run"
|
||||
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.14",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -68,54 +67,3 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
|
||||
await page.getByRole("link", { name: linkName, exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
async function returnToNestedLinks(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "nested-file-link-",
|
||||
title: "Nested file links",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse:
|
||||
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
|
||||
mockStreamingAssistantIntervalMs: 5,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
|
||||
await writeFile(
|
||||
path.join(agent.cwd, "repo-a/schema/x.md"),
|
||||
"# Nested file content\n\nDetails\n",
|
||||
);
|
||||
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
|
||||
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
|
||||
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
|
||||
await readLinkedGuide(page, "nested guide", "Nested file content");
|
||||
await returnToNestedLinks(page);
|
||||
await readLinkedGuide(page, "exact guide", "Exact file content");
|
||||
await returnToNestedLinks(page);
|
||||
await page.getByRole("link", { name: "missing guide", exact: true }).click();
|
||||
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
attachFileFromMenu,
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
} from "../support/helpers/composer";
|
||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
@@ -369,56 +365,6 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("inserts suggested commands from assistant messages without sending", async ({ page }) => {
|
||||
const requests = recordCommandRequestsByAgentId(page);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "suggested-command-",
|
||||
title: "Suggested commands",
|
||||
initialPrompt: "Suggest `/mock` without running it",
|
||||
featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" });
|
||||
await expect(message).toBeVisible();
|
||||
await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0);
|
||||
await expect.poll(() => requests.length).toBe(1);
|
||||
await attachFileFromMenu(page, {
|
||||
name: "note.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("attachment"),
|
||||
});
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
||||
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
||||
await expect(composerLocator(page)).toHaveValue("/mock ");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await message.getByRole("link", { name: "Run mock" }).click();
|
||||
await expect(composerLocator(page)).toHaveValue("/mock /mock ");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
||||
await composerLocator(page).fill("draft");
|
||||
await composerLocator(page).evaluate((element: HTMLTextAreaElement) =>
|
||||
element.setSelectionRange(0, 0),
|
||||
);
|
||||
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
||||
await composerLocator(page).pressSequentially("argument");
|
||||
await expect(composerLocator(page)).toHaveValue("draft /mock argument");
|
||||
await composerLocator(page).fill("/");
|
||||
await expect(
|
||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
||||
).toContainText("/mock");
|
||||
expect(requests).toHaveLength(1);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -81,8 +81,6 @@ for (const folder of [
|
||||
"docs/guides",
|
||||
"docs/guides/",
|
||||
"docs/指南",
|
||||
"examples/next.js/",
|
||||
"examples/next.js",
|
||||
]) {
|
||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -94,9 +92,6 @@ for (const folder of [
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
|
||||
await mkdir(path.dirname(competingFile), { recursive: true });
|
||||
await writeFile(competingFile, "This is a file, not the requested folder.\n");
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
desktop/build/
|
||||
desktop/autolink/
|
||||
@@ -1,3 +0,0 @@
|
||||
workspaces=false
|
||||
legacy-peer-deps=true
|
||||
install-links=true
|
||||
@@ -1,202 +0,0 @@
|
||||
# Experimental GPUI client
|
||||
|
||||
Run Paseo's existing React Native app in a native GPUI window on Linux. This is
|
||||
a community starting point: the real workspace UI runs, but terminal/diff
|
||||
renderers and several native services still need porting. The normal desktop
|
||||
build remains Electron. No daemon changes or release installers are included.
|
||||
|
||||
## Run it
|
||||
|
||||
Start from the repository's [development setup](../../../docs/development.md).
|
||||
A fresh checkout needs the root dependencies and workspace build outputs:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:server
|
||||
npm run gpui:install
|
||||
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run dev:gpui
|
||||
```
|
||||
|
||||
Use an already-running compatible daemon and replace `localhost:6768` with its
|
||||
endpoint. These GPUI commands never start or restart a daemon. The address is
|
||||
inlined at bundle time. The app's normal connection/authentication rules apply.
|
||||
|
||||
You also need Rust stable through rustup, CMake, Ninja, Clang, Linux X11/Wayland
|
||||
development libraries, and Vulkan. The tested Linux x86_64 host already had
|
||||
most build tools; it additionally needed `libxkbcommon-x11-dev`. A clean-machine
|
||||
prerequisite check is still needed. The first native build downloads dependencies
|
||||
and builds Rust/C++; allow several minutes and several GB of disk.
|
||||
|
||||
`dev:gpui` bundles the app, autolinks native modules, builds, and opens the window.
|
||||
Restart after editing. It uses an offline production JS bundle; Fast Refresh is
|
||||
not wired into this launcher. `build:gpui` builds without opening a window.
|
||||
`gpui:bundle -- --dev false` only writes the JS bundle, source map, and assets to
|
||||
`.artifacts/gpui/`. Bundle validation checks that the real routes are present and
|
||||
that no `react-native-webview` modules were included.
|
||||
|
||||
## Approach and renderer choice
|
||||
|
||||
```text
|
||||
Existing Paseo RootApp + Expo Router + state/client/plugin code
|
||||
|
|
||||
React in Hermes
|
||||
|
|
||||
React Native Fabric
|
||||
|
|
||||
Rust GPUI native renderer
|
||||
|
|
||||
Linux GPU window
|
||||
|
||||
Existing Paseo client <-- WebSocket --> separately running Node daemon
|
||||
```
|
||||
|
||||
The separate npm project is deliberate: the selected runtime uses RN 0.87.1,
|
||||
while Paseo's mobile app uses RN 0.86.3. Metro redirects native dependencies to
|
||||
that installation and resolves the rest from the app. This avoids changing the
|
||||
mobile dependency baseline just to try another renderer. It also creates a
|
||||
version-maintenance cost that needs testing before this becomes a supported target.
|
||||
|
||||
The choice was to preserve the existing app's RN component and library contracts:
|
||||
|
||||
| Candidate | Integration model | Consequence for this experiment |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Streetwriters react-native-gpui](https://github.com/streetwriters/react-native-gpui/tree/de11aa265e6fb36c80e8a30315bce6d34a9be7d6) | React Native Fabric, Hermes, and GPUI, with ports of screens, gestures, Reanimated/worklets, safe areas, and SVG | Chosen because it lets the existing RN app reach a real Linux window with targeted adapters. These ports are still experimental. |
|
||||
| [natew/react-native-gpui](https://github.com/natew/react-native-gpui/tree/b2254543357f02fdd983d0d8a584d1d1bd93f5da) | Custom React reconciler with an RN-like component surface; its README targets macOS arm64/Metal | Useful implementation reference. Running this Linux app would also require platform work and compatibility with the RN libraries it imports. |
|
||||
| [GPUIX](https://github.com/remorses/gpuix) | Custom React/Solid renderer, its own JSX primitives, and Node-API native bindings; supports Bun/Node execution | Also native GPUI, with interesting packaging and specialized surfaces. Adopting it would require replacing or adapting Paseo's RN component/library contracts. It remains an alternative worth testing, not a rejected renderer. |
|
||||
| Existing Electron target | RN Web inside Chromium, with the desktop host bridge | Preserves the working desktop feature set, but does not answer the experiment's question of running the app through a native renderer. |
|
||||
|
||||
This is an integration choice, not a performance comparison. There are no
|
||||
controlled CPU, memory, startup, or frame-time results against these alternatives.
|
||||
|
||||
## What belongs in the change
|
||||
|
||||
| Area | Why it is here |
|
||||
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `vendor/react-native-gpui/` | Editable, pinned runtime source. This accounts for roughly 130,000 lines of the diff. GitHub marks it as vendored; inspect [UPSTREAM](../../../vendor/react-native-gpui/UPSTREAM) for provenance and the two local patches. |
|
||||
| npm and Cargo lockfiles | Reproduce the experiment's dependencies. Roughly 15,000 generated lines, collapsed on GitHub. |
|
||||
| `desktop/`, `index.js`, `routes.js`, Metro/Babel config, root launcher | Register the full app, select the native runtime, compile native services, and bundle the actual route graph. The explicit route context prevents an apparently successful build with no app routes. |
|
||||
| `adapters/` | Desktop implementations of the app-used APIs, plus explicit unavailable-capability implementations for mobile features. About 700 lines; these are limited ports, not complete replacements for Expo/Unistyles. |
|
||||
| Shared app edits | One capability gate for alpha-mask shimmer. GPUI shows the logo and labels without that effect. Existing web/iOS/Android paths keep their behavior. |
|
||||
| Native tests and this guide | Make the working slice reproducible and give contributors bounded follow-up work. |
|
||||
|
||||
Upstream examples, its tester/testing package, website, unused fonts/vector-icon
|
||||
ports, starter navigation dependencies, sample icon, and unused nested Cargo config
|
||||
are omitted. Build outputs, native binaries, downloaded SDKs, logs, screenshots,
|
||||
and the exploratory size-measurement copy stay out of Git.
|
||||
|
||||
The retained runtime still includes its build machinery, platform backends,
|
||||
headers, and tests. Arbitrarily deleting those to reduce line count makes an
|
||||
upstream refresh harder. Vendoring can later be replaced by a pinned dependency
|
||||
once the local patches are upstreamed and distribution is reproducible.
|
||||
|
||||
### Editing the runtime
|
||||
|
||||
Edit `vendor/react-native-gpui`, then run `npm run gpui:install` before rebuilding.
|
||||
npm copies local packages rather than symlinking them; the install command
|
||||
refreshes those copies even when their versions have not changed.
|
||||
|
||||
There are two local renderer changes:
|
||||
|
||||
- `node_view.rs`: a `pointerEvents="box-none"` view must not install its own hit
|
||||
area. Before the patch, Paseo's empty portal overlay intercepted sidebar clicks.
|
||||
- `StatusBar.desktop.js`: desktop windows do not have the mobile OS status bar.
|
||||
|
||||
GPUI remains a pinned Cargo git dependency. Upstream tooling downloads Hermes
|
||||
and React Native prebuilt libraries. The snapshot is not an offline or fully
|
||||
source-built distribution, and release packaging has not been verified.
|
||||
|
||||
## Working slice and limits
|
||||
|
||||
Verified on Linux x86_64: all 24 app routes in the bundle, connection to the dev
|
||||
daemon, the real project/workspace sidebar, opening an existing workspace,
|
||||
existing message rendering, and native composer typing/deletion. The native
|
||||
regression test restores the draft and does not send a message. Rust tests cover
|
||||
SHA-256/random-byte bounds and real SQLite parameter binding/transaction rollback.
|
||||
|
||||
The following are not feature-parity claims:
|
||||
|
||||
| Area | Current state |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Styling | Adapter evaluates existing Unistyles theme/style factories and breakpoints. No Nitro shadow-tree integration. Full theme/resize behavior needs QA. |
|
||||
| Storage/crypto/localization | Native filesystem + SQLite + SHA-256 + OS randomness. Intl ships English locale data and IANA time zones. The Rust service contains Linux-specific OS access. |
|
||||
| Clipboard/files | Text clipboard, native file dialogs, PNG/JPEG picking, and download/export adapters exist. End-to-end dialog/transfer coverage is still missing. |
|
||||
| Voice/camera/push/power inhibition | Unavailable. Requests either report the missing capability or use the app's existing unavailable-capability implementation. |
|
||||
| Image conversion/image clipboard | Unavailable. The picker accepts PNG/JPEG without conversion. |
|
||||
| Mermaid/HTML/document previews | Mermaid and HTML show source. Document previews report unavailable. No embedded browser backend. |
|
||||
| Terminals | The mobile view expects an Expo `PaseoTerminal` component. No GPUI implementation yet. |
|
||||
| Diffs | The mobile surface uses React Native Skia. No GPUI drawing backend yet. |
|
||||
| Plugins | The existing loader evaluates JS and injects host React/RN. No end-to-end plugin verification on GPUI yet. |
|
||||
| Other platforms | The upstream runtime targets macOS/Windows too; this Paseo integration has only been exercised on Linux. |
|
||||
|
||||
The initial stripped development executable was about 68 MiB, with about 10 MiB
|
||||
of Hermes/JSI libraries and a 21 MiB JS bundle, approximately 100 MiB uncompressed.
|
||||
That measurement excludes the daemon, is not an optimized release installer, and
|
||||
does not establish memory usage or a size advantage over another renderer.
|
||||
|
||||
## Experiments to pick up
|
||||
|
||||
Each experiment should preserve the real app and demonstrate a user workflow.
|
||||
|
||||
1. **Native terminal.** Implement a GPUI component for the existing
|
||||
[`PaseoTerminalView`](../modules/paseo-terminal/index.tsx) frame contract.
|
||||
Keep the JS headless xterm emulator, transport, and frame generation initially.
|
||||
Verify resize/replay, keyboard/IME/paste, scrollback/selection, and the existing
|
||||
Unicode corpus. [Terminal rendering](../../../docs/terminal-rendering.md)
|
||||
explains why shaping must preserve xterm's cell columns.
|
||||
2. **Diff surface.** Keep `src/git/diff-document`'s model and review actions;
|
||||
add GPUI text measurement and painting instead of the Skia surface. Test
|
||||
unified/split views, wrapping, scrolling, selection, and inline reviews on
|
||||
large real diffs. The [C++ layout experiment](../modules/paseo-diff-prototype/README.md)
|
||||
is a separate candidate for shared geometry; it is not a finished renderer.
|
||||
3. **Plugins.** Load a small plugin into an isolated daemon, open its real screen
|
||||
or workspace panel, call a plugin RPC, and reload it. Verify subscriptions and
|
||||
cleanup. Track unsupported services explicitly instead of adding fake APIs.
|
||||
4. **Native services and styling.** Exercise dialogs, downloads, clipboard,
|
||||
persistence/restart, theme switching, resizing, and focus. Replace the narrow
|
||||
adapters with complete implementations only where the app needs them.
|
||||
5. **Portability and packaging.** Build on clean Linux, then macOS/Windows. Replace
|
||||
Linux-only randomness/timezone access, check font/shaping and IME behavior, and
|
||||
verify relocatable libraries/assets. Measure a release build and a controlled
|
||||
workflow before claiming a performance or size improvement.
|
||||
6. **Bundled daemon, independently.** First run the unchanged daemon under Bun
|
||||
in isolation. Verify PTY/native dependencies, terminal-worker subprocesses,
|
||||
provider launches, and dynamic plugins. Then compare standalone/AOT packaging
|
||||
and size. This branch neither ports the daemon nor embeds it in the UI process.
|
||||
|
||||
A Rust/GPUI terminal or diff painter can share desktop implementation across the
|
||||
upstream renderer's supported OSes. Reusing it on mobile still requires mobile
|
||||
bindings and graphics integration; choosing Rust or C++ alone does not provide that.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the normal checks from the root. GPUI adapters are excluded from the mobile
|
||||
TypeScript project; its typecheck alone does not validate this target.
|
||||
|
||||
```sh
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
```
|
||||
|
||||
For the native UI regression, use a daemon with an existing workspace and build
|
||||
with the driver. Run the native app on an isolated display when testing on a
|
||||
machine in use:
|
||||
|
||||
```sh
|
||||
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run build:gpui -- --features test-driver,visual-capture
|
||||
RN_GPUI_TEST_DRIVER=1 RN_GPUI_TEST_DRIVER_TOKEN=local-test \
|
||||
RN_GPUI_DEV_MODE=0 RN_GPUI_BUNDLE="$PWD/.artifacts/gpui/paseo.bundle" \
|
||||
RN_GPUI_ASSETS_DIR="$PWD/.artifacts/gpui/assets/assets" \
|
||||
xvfb-run -a -s '-screen 0 2560x1600x24' \
|
||||
packages/app/gpui/desktop/target/debug/react-native-gpui-paseo-gpui
|
||||
```
|
||||
|
||||
Use the printed `RN_GPUI_TEST_DRIVER_PORT` in another shell:
|
||||
|
||||
```sh
|
||||
PASEO_GPUI_TEST_URL=ws://127.0.0.1:PORT PASEO_GPUI_TEST_TOKEN=local-test \
|
||||
npx vitest run packages/app/gpui/native.test.ts --bail=1
|
||||
```
|
||||
|
||||
Without a driver URL the test is skipped. Put diagnostic logs/captures in
|
||||
`.artifacts/gpui/`; do not commit them or publish private workspace contents as QA.
|
||||
@@ -1,73 +0,0 @@
|
||||
import { promises as fs, paths } from "@react-native-gpui/fs";
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
|
||||
let writes = Promise.resolve();
|
||||
const directory = () => `${paths.appData}/paseo-gpui/storage`;
|
||||
const filename = (key) => `${directory()}/${Desktop.sha256(key)}.json`;
|
||||
async function read(key) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(filename(key), "utf8")).value;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function mutate(operation) {
|
||||
const result = writes.then(operation);
|
||||
writes = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
async function write(key, value) {
|
||||
await fs.mkdir(directory(), { recursive: true });
|
||||
const target = filename(key);
|
||||
await fs.writeFile(`${target}.tmp`, JSON.stringify({ key, value }), "utf8");
|
||||
await fs.rename(`${target}.tmp`, target);
|
||||
}
|
||||
async function keys() {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(directory());
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
return Promise.all(
|
||||
entries
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map(async (name) => JSON.parse(await fs.readFile(`${directory()}/${name}`, "utf8")).key),
|
||||
);
|
||||
}
|
||||
const storage = {
|
||||
async getItem(key) {
|
||||
await writes;
|
||||
return read(key);
|
||||
},
|
||||
setItem(key, value) {
|
||||
return mutate(() => write(key, value));
|
||||
},
|
||||
removeItem(key) {
|
||||
return mutate(() => fs.rm(filename(key), { force: true }));
|
||||
},
|
||||
async getAllKeys() {
|
||||
await writes;
|
||||
return keys();
|
||||
},
|
||||
async multiGet(items) {
|
||||
await writes;
|
||||
return Promise.all(items.map(async (key) => [key, await read(key)]));
|
||||
},
|
||||
multiSet(items) {
|
||||
return mutate(async () => {
|
||||
for (const [key, value] of items) await write(key, value);
|
||||
});
|
||||
},
|
||||
multiRemove(items) {
|
||||
return mutate(async () => {
|
||||
for (const key of items) await fs.rm(filename(key), { force: true });
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
return mutate(() => fs.rm(directory(), { recursive: true, force: true }));
|
||||
},
|
||||
};
|
||||
export default storage;
|
||||
@@ -1,21 +0,0 @@
|
||||
// Preserve the voice model's lifecycle without loading mobile-only Expo audio
|
||||
// during app startup. Requests fail explicitly until a Linux audio backend exists.
|
||||
export function createAudioEngine() {
|
||||
const unavailable = async () => {
|
||||
throw new Error("Voice capture and playback are not available in the GPUI build yet.");
|
||||
};
|
||||
return {
|
||||
initialize: unavailable,
|
||||
startCapture: unavailable,
|
||||
play: unavailable,
|
||||
async destroy() {},
|
||||
async stopCapture() {},
|
||||
stop() {},
|
||||
clearQueue() {},
|
||||
isPlaying: () => false,
|
||||
isMuted: () => false,
|
||||
toggleMute() {
|
||||
throw new Error("Voice capture is not available in the GPUI build yet.");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { TurboModuleRegistry } from "react-native";
|
||||
const clipboard = TurboModuleRegistry.getEnforcing("Clipboard");
|
||||
export const getStringAsync = () => clipboard.getString();
|
||||
export async function setStringAsync(value) {
|
||||
clipboard.setString(value);
|
||||
return true;
|
||||
}
|
||||
export async function hasStringAsync() {
|
||||
return (await getStringAsync()).length > 0;
|
||||
}
|
||||
export async function getImageAsync() {
|
||||
throw new Error("Image clipboard is not implemented for GPUI yet");
|
||||
}
|
||||
export async function setImageAsync() {
|
||||
throw new Error("Image clipboard is not implemented for GPUI yet");
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
export function getRandomValues(array) {
|
||||
if (
|
||||
!ArrayBuffer.isView(array) ||
|
||||
array instanceof DataView ||
|
||||
array instanceof Float32Array ||
|
||||
array instanceof Float64Array
|
||||
) {
|
||||
throw new TypeError("getRandomValues requires an integer typed array");
|
||||
}
|
||||
if (array.byteLength > 65536) throw new RangeError("Random request exceeds 65536 bytes");
|
||||
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(
|
||||
Desktop.randomBytes(array.byteLength),
|
||||
);
|
||||
return array;
|
||||
}
|
||||
export function randomUUID() {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
export const CryptoDigestAlgorithm = { SHA256: "SHA-256" };
|
||||
export async function digestStringAsync(algorithm, text) {
|
||||
if (algorithm !== "SHA-256") throw new Error(`Unsupported digest algorithm: ${algorithm}`);
|
||||
return Desktop.sha256(text);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { dialog, promises } from "@react-native-gpui/fs";
|
||||
export async function getDocumentAsync(options = {}) {
|
||||
const paths = await dialog.open({ multiple: options.multiple ?? false });
|
||||
return {
|
||||
canceled: paths.length === 0,
|
||||
assets: await Promise.all(
|
||||
paths.map(async (path) => ({
|
||||
uri: `file://${path}`,
|
||||
name: path.split("/").at(-1),
|
||||
size: (await promises.stat(path)).size,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { HighlightedCodeBlock } from "../../src/components/highlighted-code-block";
|
||||
|
||||
export function FileHtmlPreview({ html, testID }) {
|
||||
return (
|
||||
<ScrollView testID={testID}>
|
||||
<Text>HTML rendering is not available in GPUI. Showing source.</Text>
|
||||
<HighlightedCodeBlock code={html} language="html" />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
export function DocumentFrame({ testID }) {
|
||||
return (
|
||||
<View testID={testID}>
|
||||
<Text>Document preview is not available in the GPUI build yet.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import {
|
||||
EventEmitter,
|
||||
NativeModule,
|
||||
SharedObject,
|
||||
SharedRef,
|
||||
} from "expo-modules-core/src/polyfill/CoreModule";
|
||||
export function ensureNativeModulesAreInstalled() {
|
||||
if (globalThis.expo) return;
|
||||
globalThis.expo = { EventEmitter, NativeModule, SharedObject, SharedRef, modules: {} };
|
||||
}
|
||||
ensureNativeModulesAreInstalled();
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Linking } from "react-native";
|
||||
import { NativeModule } from "expo-modules-core/src/polyfill/CoreModule";
|
||||
|
||||
class DesktopLinking extends NativeModule {
|
||||
initialURL = null;
|
||||
subscription;
|
||||
constructor() {
|
||||
super();
|
||||
Linking.getInitialURL()
|
||||
.then((url) => {
|
||||
this.initialURL = url;
|
||||
if (url) this.emit("onURLReceived", { url });
|
||||
return url;
|
||||
})
|
||||
.catch((error) => console.error("[GPUI linking]", error));
|
||||
}
|
||||
getLinkingURL = () => this.initialURL;
|
||||
clearInitialURL() {
|
||||
this.initialURL = null;
|
||||
}
|
||||
startObserving() {
|
||||
this.subscription = Linking.addEventListener("url", (event) =>
|
||||
this.emit("onURLReceived", event),
|
||||
);
|
||||
}
|
||||
stopObserving() {
|
||||
this.subscription?.remove();
|
||||
}
|
||||
}
|
||||
export default new DesktopLinking();
|
||||
@@ -1,129 +0,0 @@
|
||||
import fs, { promises, paths } from "@react-native-gpui/fs";
|
||||
import { Buffer } from "buffer";
|
||||
const pathOf = (value) => String(value?.uri ?? value).replace(/^file:\/\//, "");
|
||||
const join = (parts) =>
|
||||
parts
|
||||
.map(pathOf)
|
||||
.join("/")
|
||||
.replace(/\/{2,}/g, "/");
|
||||
export const cacheDirectory = `file://${paths.appCache}/paseo-gpui/`;
|
||||
export const documentDirectory = `file://${paths.appData}/paseo-gpui/`;
|
||||
export const EncodingType = { Base64: "base64", UTF8: "utf8" };
|
||||
export const Paths = { cache: cacheDirectory, document: documentDirectory };
|
||||
export async function getInfoAsync(uri) {
|
||||
try {
|
||||
const stat = await promises.stat(pathOf(uri));
|
||||
return { exists: true, isDirectory: stat.isDirectory(), size: stat.size, uri };
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return { exists: false, uri };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export const makeDirectoryAsync = (uri, options = {}) =>
|
||||
promises.mkdir(pathOf(uri), { recursive: options.intermediates });
|
||||
export const copyAsync = ({ from, to }) => promises.copyFile(pathOf(from), pathOf(to));
|
||||
export const moveAsync = ({ from, to }) => promises.rename(pathOf(from), pathOf(to));
|
||||
export const readAsStringAsync = (uri, options = {}) =>
|
||||
promises.readFile(pathOf(uri), options.encoding ?? "utf8");
|
||||
export const writeAsStringAsync = (uri, contents, options = {}) =>
|
||||
promises.writeFile(pathOf(uri), contents, options.encoding ?? "utf8");
|
||||
export const deleteAsync = (uri, options = {}) =>
|
||||
promises.rm(pathOf(uri), { recursive: true, force: options.idempotent });
|
||||
export const readDirectoryAsync = (uri) => promises.readdir(pathOf(uri));
|
||||
export class File {
|
||||
constructor(...parts) {
|
||||
this.uri = `file://${join(parts)}`;
|
||||
}
|
||||
get name() {
|
||||
return pathOf(this).split("/").at(-1);
|
||||
}
|
||||
get exists() {
|
||||
return fs.existsSync(pathOf(this));
|
||||
}
|
||||
get size() {
|
||||
return fs.statSync(pathOf(this)).size;
|
||||
}
|
||||
create({ overwrite = false, intermediates = false } = {}) {
|
||||
if (intermediates)
|
||||
fs.mkdirSync(pathOf(this).split("/").slice(0, -1).join("/"), { recursive: true });
|
||||
fs.writeFileSync(pathOf(this), "", { flag: overwrite ? "w" : "wx" });
|
||||
}
|
||||
write(value) {
|
||||
fs.writeFileSync(pathOf(this), value);
|
||||
}
|
||||
delete() {
|
||||
fs.rmSync(pathOf(this));
|
||||
}
|
||||
async bytes() {
|
||||
return promises.readFile(pathOf(this));
|
||||
}
|
||||
async arrayBuffer() {
|
||||
const bytes = await this.bytes();
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
}
|
||||
async base64() {
|
||||
return Buffer.from(await this.bytes()).toString("base64");
|
||||
}
|
||||
async text() {
|
||||
return promises.readFile(pathOf(this), "utf8");
|
||||
}
|
||||
move(destination) {
|
||||
fs.renameSync(pathOf(this), pathOf(destination));
|
||||
this.uri = destination.uri;
|
||||
}
|
||||
copy(destination) {
|
||||
fs.copyFileSync(pathOf(this), pathOf(destination));
|
||||
}
|
||||
static async downloadFileAsync(url, destination) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
||||
const file = destination instanceof File ? destination : new File(destination);
|
||||
await promises.writeFile(pathOf(file), new Uint8Array(await response.arrayBuffer()));
|
||||
return file;
|
||||
}
|
||||
}
|
||||
export class Directory {
|
||||
constructor(...parts) {
|
||||
this.uri = `file://${join(parts)}`;
|
||||
}
|
||||
get exists() {
|
||||
return fs.existsSync(pathOf(this));
|
||||
}
|
||||
create({ intermediates = false } = {}) {
|
||||
fs.mkdirSync(pathOf(this), { recursive: intermediates });
|
||||
}
|
||||
delete() {
|
||||
fs.rmSync(pathOf(this), { recursive: true });
|
||||
}
|
||||
list() {
|
||||
return fs
|
||||
.readdirSync(pathOf(this), { withFileTypes: true })
|
||||
.map((entry) =>
|
||||
entry.isDirectory() ? new Directory(this, entry.name) : new File(this, entry.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDownloadResumable(url, uri, options = {}, onProgress) {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
async downloadAsync() {
|
||||
const response = await fetch(url, { headers: options.headers, signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const target = pathOf(uri);
|
||||
await promises.mkdir(target.split("/").slice(0, -1).join("/"), { recursive: true });
|
||||
await promises.writeFile(`${target}.part`, bytes);
|
||||
await promises.rename(`${target}.part`, target);
|
||||
onProgress?.({ totalBytesWritten: bytes.length, totalBytesExpectedToWrite: bytes.length });
|
||||
return {
|
||||
uri,
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
};
|
||||
},
|
||||
async cancelAsync() {
|
||||
controller.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// The GPUI picker accepts JPEG/PNG directly. Other formats require a future
|
||||
// native decoder; never claim conversion succeeded for an unsupported format.
|
||||
export const SaveFormat = { PNG: "png", JPEG: "jpeg", WEBP: "webp" };
|
||||
export const ImageManipulator = {
|
||||
manipulate() {
|
||||
throw new Error("Image conversion is not available in the GPUI build; select a PNG or JPEG.");
|
||||
},
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import { dialog, promises } from "@react-native-gpui/fs";
|
||||
// Desktop access is granted for each selection by the system file dialog.
|
||||
export const PermissionStatus = {
|
||||
GRANTED: "granted",
|
||||
DENIED: "denied",
|
||||
UNDETERMINED: "undetermined",
|
||||
};
|
||||
const permission = { status: "granted", granted: true, canAskAgain: true, expires: "never" };
|
||||
export const requestMediaLibraryPermissionsAsync = async () => permission;
|
||||
export const useMediaLibraryPermissions = () => [permission, requestMediaLibraryPermissionsAsync];
|
||||
export const getPendingResultAsync = async () => null;
|
||||
export async function launchImageLibraryAsync(options = {}) {
|
||||
const paths = await dialog.open({
|
||||
multiple: options.allowsMultipleSelection,
|
||||
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg"] }],
|
||||
});
|
||||
return {
|
||||
canceled: !paths.length,
|
||||
assets: await Promise.all(
|
||||
paths.map(async (path) => ({
|
||||
uri: `file://${path}`,
|
||||
fileName: path.split("/").at(-1),
|
||||
fileSize: (await promises.stat(path)).size,
|
||||
mimeType: /\.png$/i.test(path) ? "image/png" : "image/jpeg",
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/* oxlint-disable import/no-unassigned-import -- These packages install Intl and locale data by side effect. */
|
||||
// Hermes' Linux build omits Intl; install real locale algorithms before app code.
|
||||
import "@formatjs/intl-getcanonicallocales/polyfill-force.js";
|
||||
import "@formatjs/intl-locale/polyfill-force.js";
|
||||
import "@formatjs/intl-pluralrules/polyfill-force.js";
|
||||
import "@formatjs/intl-pluralrules/locale-data/en.js";
|
||||
import "@formatjs/intl-numberformat/polyfill-force.js";
|
||||
import "@formatjs/intl-numberformat/locale-data/en.js";
|
||||
import "@formatjs/intl-datetimeformat/polyfill-force.js";
|
||||
import "@formatjs/intl-datetimeformat/locale-data/en.js";
|
||||
import "@formatjs/intl-datetimeformat/add-all-tz.js";
|
||||
import "@formatjs/intl-displaynames/polyfill-force.js";
|
||||
import "@formatjs/intl-displaynames/locale-data/en.js";
|
||||
import "@formatjs/intl-segmenter/polyfill-force.js";
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
Intl.DateTimeFormat.__setDefaultTimeZone(Desktop.timezone());
|
||||
@@ -1,6 +0,0 @@
|
||||
// No desktop power-inhibition lease is acquired by this backend yet.
|
||||
export const isAvailableAsync = async () => false;
|
||||
export async function activateKeepAwakeAsync() {
|
||||
throw new Error("Power inhibition is not available in the GPUI build.");
|
||||
}
|
||||
export async function deactivateKeepAwake() {}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { Keyboard, ScrollView, useAnimatedValue } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
|
||||
// A GPUI desktop window has no software-keyboard inset. RN still owns focus
|
||||
// and physical keyboard events; the mobile layout API observes its events.
|
||||
export function KeyboardProvider({ children }) {
|
||||
return children;
|
||||
}
|
||||
export const KeyboardAwareScrollView = ScrollView;
|
||||
export const KeyboardController = {
|
||||
isVisible: () => Keyboard.isVisible(),
|
||||
dismiss: () => Keyboard.dismiss(),
|
||||
};
|
||||
export const KeyboardEvents = { addListener: (...args) => Keyboard.addListener(...args) };
|
||||
export function useReanimatedKeyboardAnimation() {
|
||||
const height = useSharedValue(0);
|
||||
const progress = useSharedValue(0);
|
||||
useEffect(() => {
|
||||
const shown = Keyboard.addListener("keyboardDidShow", (event) => {
|
||||
height.value = -event.endCoordinates.height;
|
||||
progress.value = 1;
|
||||
});
|
||||
const hidden = Keyboard.addListener("keyboardDidHide", () => {
|
||||
height.value = 0;
|
||||
progress.value = 0;
|
||||
});
|
||||
return () => {
|
||||
shown.remove();
|
||||
hidden.remove();
|
||||
};
|
||||
}, [height, progress]);
|
||||
return { height, progress };
|
||||
}
|
||||
export function useKeyboardAnimation() {
|
||||
const height = useAnimatedValue(0);
|
||||
const progress = useAnimatedValue(0);
|
||||
return { height, progress };
|
||||
}
|
||||
export function useGenericKeyboardHandler(handlers) {
|
||||
useEffect(() => {
|
||||
const subscriptions = [
|
||||
Keyboard.addListener("keyboardDidShow", (event) => {
|
||||
const value = {
|
||||
height: event.endCoordinates.height,
|
||||
progress: 1,
|
||||
duration: event.duration,
|
||||
};
|
||||
handlers.onStart?.(value);
|
||||
handlers.onEnd?.(value);
|
||||
}),
|
||||
Keyboard.addListener("keyboardDidHide", () => {
|
||||
const value = { height: 0, progress: 0, duration: 0 };
|
||||
handlers.onStart?.(value);
|
||||
handlers.onEnd?.(value);
|
||||
}),
|
||||
];
|
||||
return () => subscriptions.forEach((subscription) => subscription.remove());
|
||||
}, [handlers]);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
export function getLocales() {
|
||||
const languageTag = Desktop.locale().split(".")[0].replaceAll("_", "-");
|
||||
return [{ languageTag, languageCode: languageTag.split("-")[0] }];
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { HighlightedCodeBlock } from "../../src/components/highlighted-code-block";
|
||||
export function MermaidFenceHost(props) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={props.inheritedStyles}>
|
||||
Diagram rendering is not available in the GPUI build yet.
|
||||
</Text>
|
||||
<HighlightedCodeBlock {...props} language="mermaid" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Buffer } from "buffer";
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
export function randomBytes(length) {
|
||||
return Buffer.from(Desktop.randomBytes(length));
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// GPUI's native TextInput owns text paste, selection, and IME. The mobile
|
||||
// PasteInput module's image-paste extension is not available on this target.
|
||||
export { TextInput as default } from "react-native";
|
||||
@@ -1,11 +0,0 @@
|
||||
import { dialog, promises } from "@react-native-gpui/fs";
|
||||
// Linux exports a downloaded file through the native Save dialog.
|
||||
export const isAvailableAsync = async () => true;
|
||||
export async function shareAsync(uri, options = {}) {
|
||||
const source = uri.replace(/^file:\/\//, "");
|
||||
const destination = await dialog.save({
|
||||
title: options.dialogTitle ?? "Save file",
|
||||
defaultName: source.split("/").at(-1),
|
||||
});
|
||||
if (destination) await promises.copyFile(source, destination);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import Desktop from "../desktop/src/NativePaseoDesktop";
|
||||
let pending = Promise.resolve();
|
||||
function enqueue(operation) {
|
||||
const result = pending.then(operation);
|
||||
pending = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
const connection = {
|
||||
async execAsync(sql) {
|
||||
Desktop.sqlite(sql, "batch");
|
||||
},
|
||||
async runAsync(sql, params = []) {
|
||||
Desktop.sqlite(sql, JSON.stringify(params));
|
||||
},
|
||||
async getAllAsync(sql, params = []) {
|
||||
return JSON.parse(Desktop.sqlite(sql, JSON.stringify(params)));
|
||||
},
|
||||
};
|
||||
export async function openDatabaseAsync(name) {
|
||||
if (name !== "paseo-replica-row-store.db") throw new Error(`Unknown GPUI database: ${name}`);
|
||||
return {
|
||||
execAsync: (...args) => enqueue(() => connection.execAsync(...args)),
|
||||
runAsync: (...args) => enqueue(() => connection.runAsync(...args)),
|
||||
getAllAsync: (...args) => enqueue(() => connection.getAllAsync(...args)),
|
||||
withExclusiveTransactionAsync: (operation) =>
|
||||
enqueue(async () => {
|
||||
await connection.execAsync("BEGIN IMMEDIATE");
|
||||
try {
|
||||
await operation(connection);
|
||||
await connection.execAsync("COMMIT");
|
||||
} catch (error) {
|
||||
await connection.execAsync("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as RN from "react-native";
|
||||
import { createUnistylesElement } from "./unistyles";
|
||||
export const View = createUnistylesElement(RN.View);
|
||||
export const Text = createUnistylesElement(RN.Text);
|
||||
export const ScrollView = createUnistylesElement(RN.ScrollView);
|
||||
export const TextInput = createUnistylesElement(RN.TextInput);
|
||||
export const Pressable = createUnistylesElement(RN.Pressable);
|
||||
export const Image = createUnistylesElement(RN.Image);
|
||||
export const ImageBackground = createUnistylesElement(RN.ImageBackground);
|
||||
export const FlatList = createUnistylesElement(RN.FlatList);
|
||||
export const SectionList = createUnistylesElement(RN.SectionList);
|
||||
export const TouchableOpacity = createUnistylesElement(RN.TouchableOpacity);
|
||||
export const TouchableHighlight = createUnistylesElement(RN.TouchableHighlight);
|
||||
export const TouchableWithoutFeedback = createUnistylesElement(RN.TouchableWithoutFeedback);
|
||||
export const ActivityIndicator = createUnistylesElement(RN.ActivityIndicator);
|
||||
export const KeyboardAvoidingView = createUnistylesElement(RN.KeyboardAvoidingView);
|
||||
export const SafeAreaView = createUnistylesElement(RN.SafeAreaView);
|
||||
export const Switch = createUnistylesElement(RN.Switch);
|
||||
export const StatusBar = RN.StatusBar;
|
||||
export const Animated = {
|
||||
...RN.Animated,
|
||||
View: createUnistylesElement(RN.Animated.View),
|
||||
Text: createUnistylesElement(RN.Animated.Text),
|
||||
ScrollView: createUnistylesElement(RN.Animated.ScrollView),
|
||||
Image: createUnistylesElement(RN.Animated.Image),
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
import React, { forwardRef, useSyncExternalStore } from "react";
|
||||
import * as Native from "react-native";
|
||||
|
||||
// GPUI has Fabric but no Nitro/Unistyles shadow-tree bindings. Keep the app's
|
||||
// style factories and theme API; this backend updates subscribed React views.
|
||||
const listeners = new Set();
|
||||
const resolveStyle = Symbol("paseo.gpui.style");
|
||||
let revision = 0;
|
||||
let themes = {};
|
||||
let themeName = "dark";
|
||||
let adaptive = true;
|
||||
let breakpoints = {};
|
||||
const emit = () => {
|
||||
revision += 1;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
const subscribe = (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
const snapshot = () => revision;
|
||||
const theme = () => themes[themeName];
|
||||
const runtime = () => ({
|
||||
themeName,
|
||||
colorScheme: Native.Appearance.getColorScheme() ?? "light",
|
||||
screen: Native.Dimensions.get("window"),
|
||||
insets: { top: 0, bottom: 0, left: 0, right: 0, ime: 0 },
|
||||
pixelRatio: Native.PixelRatio.get(),
|
||||
fontScale: Native.PixelRatio.getFontScale(),
|
||||
breakpoint: Object.keys(breakpoints).findLast(
|
||||
(key) => breakpoints[key] <= Native.Dimensions.get("window").width,
|
||||
),
|
||||
});
|
||||
Native.Dimensions.addEventListener("change", emit);
|
||||
Native.Appearance.addChangeListener(({ colorScheme }) => {
|
||||
if (adaptive && themes[colorScheme]) themeName = colorScheme;
|
||||
emit();
|
||||
});
|
||||
function responsive(value) {
|
||||
if (Array.isArray(value)) return value.map(responsive);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
if (Object.getPrototypeOf(value) !== Object.prototype) return value;
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length && keys.every((key) => key in breakpoints)) {
|
||||
const width = Native.Dimensions.get("window").width;
|
||||
const key = keys
|
||||
.sort((a, b) => breakpoints[a] - breakpoints[b])
|
||||
.findLast((candidate) => breakpoints[candidate] <= width);
|
||||
return key === undefined ? undefined : responsive(value[key]);
|
||||
}
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, responsive(item)]));
|
||||
}
|
||||
function materialize(style) {
|
||||
if (Array.isArray(style)) return style.map(materialize);
|
||||
if (style && typeof style === "object" && style[resolveStyle]) return style[resolveStyle]();
|
||||
return style;
|
||||
}
|
||||
function styledProps(props) {
|
||||
const result = { ...props };
|
||||
for (const key of ["style", "contentContainerStyle"]) {
|
||||
if (typeof props[key] === "function")
|
||||
result[key] = (...args) => materialize(props[key](...args));
|
||||
else if (props[key]) result[key] = materialize(props[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export const UnistylesRuntime = {
|
||||
get themeName() {
|
||||
return themeName;
|
||||
},
|
||||
get breakpoint() {
|
||||
return runtime().breakpoint;
|
||||
},
|
||||
get colorScheme() {
|
||||
return runtime().colorScheme;
|
||||
},
|
||||
getTheme: theme,
|
||||
setTheme(name) {
|
||||
if (!themes[name]) throw new Error(`Unknown GPUI theme: ${name}`);
|
||||
themeName = name;
|
||||
emit();
|
||||
},
|
||||
updateTheme(name, update) {
|
||||
themes[name] = update(themes[name]);
|
||||
emit();
|
||||
},
|
||||
setAdaptiveThemes(value) {
|
||||
adaptive = value;
|
||||
if (value && themes[runtime().colorScheme]) themeName = runtime().colorScheme;
|
||||
emit();
|
||||
},
|
||||
};
|
||||
export const StyleSheet = {
|
||||
...Native.StyleSheet,
|
||||
configure(config) {
|
||||
themes = config.themes;
|
||||
breakpoints = config.breakpoints;
|
||||
adaptive = config.settings?.adaptiveThemes ?? false;
|
||||
themeName =
|
||||
config.settings?.initialTheme ?? (adaptive ? runtime().colorScheme : Object.keys(themes)[0]);
|
||||
emit();
|
||||
},
|
||||
create(factory) {
|
||||
const evaluate = () => (typeof factory === "function" ? factory(theme(), runtime()) : factory);
|
||||
const initial = evaluate();
|
||||
const sheet = {};
|
||||
for (const key of Object.keys(initial)) {
|
||||
const wrap = (args) => {
|
||||
const resolve = () => {
|
||||
const value = evaluate()[key];
|
||||
return responsive(typeof value === "function" ? value(...args) : value);
|
||||
};
|
||||
return Object.defineProperty({ ...resolve() }, resolveStyle, { value: resolve });
|
||||
};
|
||||
sheet[key] = typeof initial[key] === "function" ? (...args) => wrap(args) : wrap([]);
|
||||
}
|
||||
return sheet;
|
||||
},
|
||||
};
|
||||
// Existing consumers retain this API; new app code should use style factories.
|
||||
export function useUnistyles() {
|
||||
useSyncExternalStore(subscribe, snapshot, snapshot);
|
||||
return { theme: theme(), rt: runtime() };
|
||||
}
|
||||
export function withUnistyles(Component, mappings) {
|
||||
const Wrapped = forwardRef(function GpuiThemed(props, ref) {
|
||||
useSyncExternalStore(subscribe, snapshot, snapshot);
|
||||
const { uniProps, ...rest } = props;
|
||||
return React.createElement(Component, {
|
||||
...styledProps({
|
||||
...mappings?.(theme(), runtime()),
|
||||
...uniProps?.(theme(), runtime()),
|
||||
...rest,
|
||||
}),
|
||||
ref,
|
||||
});
|
||||
});
|
||||
return Wrapped;
|
||||
}
|
||||
export const createUnistylesElement = withUnistyles;
|
||||
@@ -1 +0,0 @@
|
||||
{ "name": "Paseo", "displayName": "Paseo GPUI" }
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [[require.resolve("babel-preset-expo"), { unstable_transformImportMeta: true }]],
|
||||
plugins: [[require.resolve("react-native-unistyles/plugin"), { root: "../src" }]],
|
||||
};
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
build/
|
||||
.cache/
|
||||
autolink/
|
||||
target/
|
||||
Generated
-8151
File diff suppressed because it is too large
Load Diff
@@ -1,55 +0,0 @@
|
||||
[package]
|
||||
name = "react-native-gpui-paseo-gpui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# Set CARGO_PROFILE_DEV_DEBUG=full to opt into full debug information.
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
split-debuginfo = "packed"
|
||||
|
||||
# Dependencies are optimized even in a dev build: GPUI rebuilds and paints the
|
||||
# element tree on every frame, and they are only compiled once.
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
opt-level = 3
|
||||
|
||||
# The C++ bridge picks its CMake build type from this crate's opt-level, and a
|
||||
# Release build of React Native has no dev server, inspector or Fast Refresh.
|
||||
[profile.dev.package.rn-gpui-sys]
|
||||
opt-level = 0
|
||||
|
||||
[profile.release]
|
||||
debug = "line-tables-only"
|
||||
# Cargo strips inside the linker, before dsymutil, so package-desktop writes the
|
||||
# dSYM and then strips. Full LTO: 2 MB smaller for 1.4x the build time.
|
||||
strip = "none"
|
||||
split-debuginfo = "packed"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
|
||||
[build-dependencies]
|
||||
rn-gpui-module-build = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module-build" }
|
||||
serde_json = "1"
|
||||
|
||||
[features]
|
||||
visual-capture = ["react-native-gpui/visual-capture"]
|
||||
test-driver = ["react-native-gpui/test-driver"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
rn-gpui-module = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module" }
|
||||
rn-gpui-autolink = { path = "autolink" }
|
||||
react-native-gpui = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui" }
|
||||
|
||||
[patch.crates-io]
|
||||
# Managed by @react-native-gpui/cli autolink; do not edit between these markers.
|
||||
react-native-gpui = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui" }
|
||||
rn-gpui-sys = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-sys" }
|
||||
rn-gpui-module = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module" }
|
||||
rn-gpui-module-build = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module-build" }
|
||||
gpui = { git = "https://github.com/streetwriters/gpui-fast", rev = "8da7d89" }
|
||||
gpui_platform = { git = "https://github.com/streetwriters/gpui-fast", rev = "8da7d89" }
|
||||
# End managed by autolink.
|
||||
@@ -1,172 +0,0 @@
|
||||
fn main() {
|
||||
// Forwards the engine's Node-API exports out of this executable, which is
|
||||
// what lets a stock `.node` addon resolve them on Windows.
|
||||
//
|
||||
// A DLL records every import as a name *plus the file it comes from*, and a
|
||||
// node-gyp addon records "from `node.exe`" — Windows has no global symbol
|
||||
// scope for it to fall back on. Node's own answer is that the host process
|
||||
// exports the Node-API functions, so that is what this does: the executable
|
||||
// re-exports each of the VM's `napi_*`/`node_api_*` names, forwarded to
|
||||
// `hermesvm.dll`. Addons that use node-gyp's delay-load hook (the default)
|
||||
// then reach this process instead of looking for `node.exe` on disk.
|
||||
//
|
||||
// It is a no-op on macOS and Linux, where an addon's imports resolve against
|
||||
// whatever is already loaded and need no forwarding. Declare it once, before
|
||||
// the module list; it does not depend on anything else in this file.
|
||||
rn_gpui_module_build::forward_node_api_exports();
|
||||
|
||||
// App-local Rust TurboModules: list one entry per `src/Native*.ts` spec,
|
||||
// for example `&["src/NativeGreeting.ts"]`. Their glue is generated into
|
||||
// this crate's OUT_DIR, and main.rs registers the modules.
|
||||
rn_gpui_module_build::generate(&["src/NativePaseoDesktop.ts"]);
|
||||
|
||||
let desktop_root = std::path::PathBuf::from(
|
||||
std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"),
|
||||
);
|
||||
// `app.json` names the app; `desktop/desktop.json` carries the identifier
|
||||
// and everything else the packaging step needs. The loader the CLI uses is
|
||||
// JavaScript, so this reads the same file directly and applies the two
|
||||
// defaults it would apply.
|
||||
let project_root = desktop_root
|
||||
.parent()
|
||||
.expect("desktop folder has a React Native project root");
|
||||
let app_json_path = project_root.join("app.json");
|
||||
println!("cargo:rerun-if-changed={}", app_json_path.display());
|
||||
let app_json: serde_json::Value = serde_json::from_slice(
|
||||
&std::fs::read(&app_json_path)
|
||||
.unwrap_or_else(|error| panic!("read {}: {error}", app_json_path.display())),
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("parse {}: {error}", app_json_path.display()));
|
||||
let app_name = app_json["name"]
|
||||
.as_str()
|
||||
.filter(|name| !name.is_empty())
|
||||
.expect("app.json must define a nonempty name");
|
||||
if app_json["desktop"]
|
||||
.as_object()
|
||||
.is_some_and(|desktop| !desktop.is_empty())
|
||||
{
|
||||
panic!("move `app.json` desktop.* into `desktop/desktop.json` (see the README)");
|
||||
}
|
||||
|
||||
let desktop_json_path = project_root.join("desktop/desktop.json");
|
||||
println!("cargo:rerun-if-changed={}", desktop_json_path.display());
|
||||
let desktop_json: serde_json::Value = std::fs::read(&desktop_json_path)
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let app_id = match desktop_json["identifier"].as_str() {
|
||||
Some(identifier) if !identifier.is_empty() => identifier.to_string(),
|
||||
_ => format!(
|
||||
"com.example.{}",
|
||||
app_name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
),
|
||||
};
|
||||
println!("cargo:rustc-env=RN_GPUI_APP_NAME={app_name}");
|
||||
println!("cargo:rustc-env=RN_GPUI_APP_ID={app_id}");
|
||||
// Only a debug build names the bundle it was developed against. A release
|
||||
// binary must find the bundle beside its own executable, because the path
|
||||
// baked in here is where this machine happened to build it — and on the
|
||||
// machine the package is installed on, that path does not exist.
|
||||
println!("cargo:rerun-if-env-changed=PROFILE");
|
||||
if std::env::var("PROFILE").as_deref() == Ok("debug") {
|
||||
println!(
|
||||
"cargo:rustc-env=RN_GPUI_DEFAULT_BUNDLE={}",
|
||||
desktop_root.join("build/index.bundle").display()
|
||||
);
|
||||
}
|
||||
|
||||
println!("cargo:rerun-if-env-changed=DEP_RN_GPUI_RUNTIME_RPATHS");
|
||||
println!("cargo:rerun-if-env-changed=RN_GPUI_PACKAGING");
|
||||
let Ok(paths) = std::env::var("DEP_RN_GPUI_RUNTIME_RPATHS") else {
|
||||
return;
|
||||
};
|
||||
let dirs = paths.split('|').filter(|path| !path.is_empty());
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
stage_shared_libraries(dirs);
|
||||
return;
|
||||
}
|
||||
// `RN_GPUI_PACKAGING` means this binary is about to be installed somewhere
|
||||
// else, so only the relative rpaths belong in it. Keeping the absolute ones
|
||||
// would let a packaged app load this build tree's libraries in preference to
|
||||
// its own on the machine that built it — which hides exactly the packaging
|
||||
// bugs the packaged run exists to catch.
|
||||
let packaging = std::env::var("RN_GPUI_PACKAGING").is_ok();
|
||||
if !packaging {
|
||||
for path in dirs {
|
||||
println!("cargo:rustc-link-arg=-Wl,-rpath,{path}");
|
||||
}
|
||||
}
|
||||
emit_relocatable_rpaths(app_name);
|
||||
}
|
||||
|
||||
/// rpaths that survive being installed somewhere else.
|
||||
///
|
||||
/// The absolute paths above let a development build run from this tree. A
|
||||
/// packaged app is copied to another machine, where those paths do not exist,
|
||||
/// so a release build also carries paths relative to the executable: the
|
||||
/// layout the installers produce.
|
||||
fn emit_relocatable_rpaths(app_name: &str) {
|
||||
if std::env::var("PROFILE").as_deref() != Ok("release") {
|
||||
return;
|
||||
}
|
||||
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
let mut emit = |rpath: String| println!("cargo:rustc-link-arg=-Wl,-rpath,{rpath}");
|
||||
match os.as_str() {
|
||||
// An app bundle puts the executable in Contents/MacOS and the shipped
|
||||
// libraries in Contents/Frameworks.
|
||||
"macos" => emit("@executable_path/../Frameworks".into()),
|
||||
// Linux installs the executable at `usr/bin` and ships its libraries
|
||||
// under `usr/lib`:
|
||||
// - the deb puts them in `usr/lib/<app>`;
|
||||
// - an AppImage's libraries are flat in `usr/lib`, because that is the
|
||||
// directory AppRun puts on the library path and the one linuxdeploy
|
||||
// resolves dependencies from before the image is assembled.
|
||||
// Both are covered, so one binary works in either layout.
|
||||
"linux" => {
|
||||
emit(format!("$ORIGIN/../lib/{app_name}"));
|
||||
emit("$ORIGIN/../lib".into());
|
||||
emit("$ORIGIN".into());
|
||||
}
|
||||
// Windows has no rpath: the DLLs are copied beside the executable.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows has no rpath: the loader finds a DLL next to the executable or on
|
||||
/// `PATH`, so every directory the runtime reports as holding a shared library
|
||||
/// (the Hermes binary directory in particular) is copied into the profile
|
||||
/// directory that receives this app's executable. Without this the app builds
|
||||
/// and then fails to start with "hermesvm.dll was not found".
|
||||
fn stage_shared_libraries<'a>(dirs: impl Iterator<Item = &'a str>) {
|
||||
let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR is set"));
|
||||
// <profile>/build/<package>-<hash>/out -> <profile>
|
||||
let Some(profile_dir) = out_dir.ancestors().nth(3) else {
|
||||
return;
|
||||
};
|
||||
for dir in dirs {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let source = entry.path();
|
||||
let is_shared_library = source
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("dll"));
|
||||
if !is_shared_library {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = source.file_name() else {
|
||||
continue;
|
||||
};
|
||||
println!("cargo:rerun-if-changed={}", source.display());
|
||||
std::fs::copy(&source, profile_dir.join(name)).unwrap_or_else(|error| {
|
||||
panic!("copy {} next to the executable: {error}", source.display())
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{ "identifier": "sh.paseo.gpui" }
|
||||
@@ -1,83 +0,0 @@
|
||||
const path = require("node:path");
|
||||
const { makeMetroConfig } = require("@react-native-gpui/core/metro.config");
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
process.env.EXPO_ROUTER_APP_ROOT = path.resolve(projectRoot, "../src/app");
|
||||
const config = makeMetroConfig({ projectRoot });
|
||||
const resolveRequest = config.resolver.resolveRequest;
|
||||
config.watchFolders.push(path.resolve(projectRoot, "../../.."));
|
||||
config.transformer.unstable_allowRequireContext = true;
|
||||
config.resolver.disableHierarchicalLookup = false;
|
||||
config.resolver.blockList = /[/\\]gpui[/\\]desktop[/\\](target|build|autolink)[/\\]/;
|
||||
const nativePackages = [
|
||||
"react",
|
||||
"react-native-reanimated",
|
||||
"react-native-worklets",
|
||||
"react-native-gesture-handler",
|
||||
"react-native-screens",
|
||||
"react-native-safe-area-context",
|
||||
"react-native-svg",
|
||||
];
|
||||
|
||||
const adapters = {
|
||||
"expo-keep-awake": "keep-awake.js",
|
||||
"expo-image-picker": "image-picker.js",
|
||||
"expo-image-manipulator": "image-manipulator.js",
|
||||
"expo-sharing": "sharing.js",
|
||||
"expo-document-picker": "document-picker.js",
|
||||
"@mattermost/react-native-paste-input": "paste-input.js",
|
||||
"@react-native-async-storage/async-storage": "async-storage.js",
|
||||
"expo-localization": "localization.js",
|
||||
"expo-clipboard": "clipboard.js",
|
||||
"expo-file-system": "file-system.js",
|
||||
"expo-file-system/legacy": "file-system.js",
|
||||
"react-native-keyboard-controller": "keyboard.jsx",
|
||||
crypto: "node-crypto.js",
|
||||
"node:crypto": "node-crypto.js",
|
||||
"expo-sqlite": "sqlite.js",
|
||||
"expo-crypto": "crypto.js",
|
||||
"react-native-unistyles": "unistyles.js",
|
||||
};
|
||||
const fileAdapters = {
|
||||
"/audio/audio-engine.native.ts": "audio.js",
|
||||
"/components/markdown/fence/mermaid/host.native.tsx": "mermaid.jsx",
|
||||
"/file-panels/html-preview.tsx": "document-preview.jsx",
|
||||
"/file-panels/document-frame/index.tsx": "document-preview.jsx",
|
||||
};
|
||||
config.resolver.resolveRequest = (context, name, platform) => {
|
||||
if (adapters[name]) name = path.resolve(projectRoot, "adapters", adapters[name]);
|
||||
if (
|
||||
name.endsWith("/ensureNativeModulesAreInstalled") &&
|
||||
context.originModulePath.includes("expo-modules-core")
|
||||
)
|
||||
name = path.resolve(projectRoot, "adapters/expo-install.js");
|
||||
if (name === "./ExpoLinking" && context.originModulePath.includes("expo-linking"))
|
||||
name = path.resolve(projectRoot, "adapters/expo-linking.js");
|
||||
// Linux has no Expo mobile push-token or camera provider; reuse the app's
|
||||
// existing unavailable-capability implementations.
|
||||
if (name === "expo-notifications")
|
||||
name = path.resolve(projectRoot, "../src/fdroid/expo-notifications.ts");
|
||||
if (name === "expo-camera") name = path.resolve(projectRoot, "../src/fdroid/expo-camera.tsx");
|
||||
if (name === "expo-router/_ctx") name = path.resolve(projectRoot, "routes.js");
|
||||
if (name === "qrcode") name = "qrcode/lib/browser.js";
|
||||
if (name.startsWith("react-native-unistyles/components/native/"))
|
||||
name = path.resolve(projectRoot, "adapters/unistyles-components.js");
|
||||
if (name.startsWith("@/")) name = path.resolve(projectRoot, "../src", name.slice(2));
|
||||
if (
|
||||
name.startsWith("react-native/") ||
|
||||
nativePackages.some((pkg) => name === pkg || name.startsWith(`${pkg}/`))
|
||||
) {
|
||||
context = {
|
||||
...context,
|
||||
disableHierarchicalLookup: true,
|
||||
nodeModulesPaths: [path.join(projectRoot, "node_modules")],
|
||||
};
|
||||
}
|
||||
const result = resolveRequest(context, name, platform);
|
||||
const replacement = Object.entries(fileAdapters).find(([suffix]) =>
|
||||
result.filePath?.endsWith(suffix),
|
||||
);
|
||||
if (replacement)
|
||||
return { type: "sourceFile", filePath: path.resolve(projectRoot, "adapters", replacement[1]) };
|
||||
return result;
|
||||
};
|
||||
module.exports = config;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { TurboModuleRegistry, type TurboModule } from "react-native";
|
||||
export interface Spec extends TurboModule {
|
||||
sqlite(sql: string, params: string): string;
|
||||
locale(): string;
|
||||
timezone(): string;
|
||||
sha256(text: string): string;
|
||||
randomBytes(length: number): Array<number>;
|
||||
}
|
||||
export default TurboModuleRegistry.getEnforcing<Spec>("PaseoDesktop");
|
||||
@@ -1,29 +0,0 @@
|
||||
rn_gpui_module::include_specs!();
|
||||
|
||||
mod services;
|
||||
|
||||
fn main() {
|
||||
react_native_gpui::register_module(native_paseo_desktop_module::<services::PaseoDesktop>());
|
||||
rn_gpui_autolink::ensure_linked();
|
||||
// A debug build knows where its own bundle is; a release build does not, and
|
||||
// must not: the path would name the machine that built it. A packaged release
|
||||
// app resolves the bundle beside its executable instead.
|
||||
#[cfg(debug_assertions)]
|
||||
if std::env::var_os("RN_GPUI_BUNDLE").is_none() {
|
||||
std::env::set_var("RN_GPUI_BUNDLE", env!("RN_GPUI_DEFAULT_BUNDLE"));
|
||||
}
|
||||
let config = react_native_gpui::DesktopAppConfig::from_env_with_defaults(
|
||||
env!("RN_GPUI_APP_NAME"),
|
||||
env!("RN_GPUI_APP_ID"),
|
||||
)
|
||||
.window_title("Paseo GPUI")
|
||||
.window_size(1024.0, 768.0)
|
||||
.dev_menu(true)
|
||||
.configure_window(|options| {
|
||||
// GPUI window options are public: titlebar, resizability, display,
|
||||
// decorations, min size, and other native window behavior.
|
||||
options.is_resizable = true;
|
||||
});
|
||||
|
||||
react_native_gpui::run_with(config);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
use crate::{NativePaseoDesktopEvents, NativePaseoDesktopSpec};
|
||||
use rn_gpui_module::ModuleError;
|
||||
use std::io::Read;
|
||||
|
||||
pub struct PaseoDesktop {
|
||||
database: std::sync::Mutex<Option<rusqlite::Connection>>,
|
||||
}
|
||||
impl PaseoDesktop {
|
||||
fn database(
|
||||
&self,
|
||||
) -> Result<std::sync::MutexGuard<'_, Option<rusqlite::Connection>>, ModuleError> {
|
||||
let mut guard = self
|
||||
.database
|
||||
.lock()
|
||||
.map_err(|e| ModuleError::new(e.to_string()))?;
|
||||
if guard.is_none() {
|
||||
let base = std::env::var("XDG_DATA_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
std::path::PathBuf::from(std::env::var("HOME").expect("HOME is set"))
|
||||
.join(".local/share")
|
||||
});
|
||||
let dir = base.join("paseo-gpui");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| ModuleError::new(e.to_string()))?;
|
||||
*guard = Some(
|
||||
rusqlite::Connection::open(dir.join("replica.db"))
|
||||
.map_err(|e| ModuleError::new(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
Ok(guard)
|
||||
}
|
||||
}
|
||||
impl NativePaseoDesktopSpec for PaseoDesktop {
|
||||
fn new(_events: NativePaseoDesktopEvents) -> Self {
|
||||
Self {
|
||||
database: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
fn sqlite(&self, sql: String, params: String) -> Result<String, ModuleError> {
|
||||
let fail = |e: rusqlite::Error| ModuleError::new(e.to_string());
|
||||
let db = self.database()?;
|
||||
let db = db.as_ref().unwrap();
|
||||
if params == "batch" {
|
||||
db.execute_batch(&sql).map_err(fail)?;
|
||||
return Ok("[]".into());
|
||||
}
|
||||
let values: Vec<serde_json::Value> =
|
||||
serde_json::from_str(¶ms).map_err(|e| ModuleError::new(e.to_string()))?;
|
||||
let values: Vec<rusqlite::types::Value> = values
|
||||
.into_iter()
|
||||
.map(|v| match v {
|
||||
serde_json::Value::Null => rusqlite::types::Value::Null,
|
||||
serde_json::Value::String(v) => rusqlite::types::Value::Text(v),
|
||||
serde_json::Value::Number(v) => v
|
||||
.as_i64()
|
||||
.map(rusqlite::types::Value::Integer)
|
||||
.unwrap_or_else(|| rusqlite::types::Value::Real(v.as_f64().unwrap())),
|
||||
_ => rusqlite::types::Value::Text(v.to_string()),
|
||||
})
|
||||
.collect();
|
||||
let mut stmt = db.prepare(&sql).map_err(fail)?;
|
||||
let names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
|
||||
let mut rows = stmt
|
||||
.query(rusqlite::params_from_iter(values))
|
||||
.map_err(fail)?;
|
||||
let mut results = Vec::new();
|
||||
while let Some(row) = rows.next().map_err(fail)? {
|
||||
let mut object = serde_json::Map::new();
|
||||
for (index, name) in names.iter().enumerate() {
|
||||
let value = match row.get_ref(index).map_err(fail)? {
|
||||
rusqlite::types::ValueRef::Null => serde_json::Value::Null,
|
||||
rusqlite::types::ValueRef::Integer(v) => v.into(),
|
||||
rusqlite::types::ValueRef::Real(v) => serde_json::json!(v),
|
||||
rusqlite::types::ValueRef::Text(v) => {
|
||||
String::from_utf8_lossy(v).into_owned().into()
|
||||
}
|
||||
rusqlite::types::ValueRef::Blob(_) => {
|
||||
return Err(ModuleError::new(
|
||||
"Replica store does not support blob columns",
|
||||
))
|
||||
}
|
||||
};
|
||||
object.insert(name.clone(), value);
|
||||
}
|
||||
results.push(object);
|
||||
}
|
||||
serde_json::to_string(&results).map_err(|e| ModuleError::new(e.to_string()))
|
||||
}
|
||||
fn timezone(&self) -> Result<String, ModuleError> {
|
||||
if let Ok(zone) = std::env::var("TZ") {
|
||||
if !zone.is_empty() {
|
||||
return Ok(zone.trim_start_matches(':').into());
|
||||
}
|
||||
}
|
||||
if let Ok(path) = std::fs::read_link("/etc/localtime") {
|
||||
if let Some((_, zone)) = path.to_string_lossy().split_once("/zoneinfo/") {
|
||||
return Ok(zone.into());
|
||||
}
|
||||
}
|
||||
Ok(std::fs::read_to_string("/etc/timezone")
|
||||
.map(|s| s.trim().into())
|
||||
.unwrap_or_else(|_| "UTC".into()))
|
||||
}
|
||||
fn locale(&self) -> Result<String, ModuleError> {
|
||||
Ok(["LC_ALL", "LC_MESSAGES", "LANG"]
|
||||
.iter()
|
||||
.find_map(|key| std::env::var(key).ok().filter(|v| !v.is_empty()))
|
||||
.unwrap_or_else(|| "en_US.UTF-8".into()))
|
||||
}
|
||||
fn sha256(&self, text: String) -> Result<String, ModuleError> {
|
||||
use sha2::{Digest, Sha256};
|
||||
Ok(format!("{:x}", Sha256::digest(text.as_bytes())))
|
||||
}
|
||||
fn random_bytes(&self, length: f64) -> Result<Vec<f64>, ModuleError> {
|
||||
if !length.is_finite() || length < 0.0 || length > 65536.0 || length.fract() != 0.0 {
|
||||
return Err(ModuleError::new("Invalid random byte count"));
|
||||
}
|
||||
let mut bytes = vec![0u8; length as usize];
|
||||
std::fs::File::open("/dev/urandom")
|
||||
.and_then(|mut source| source.read_exact(&mut bytes))
|
||||
.map_err(|error| ModuleError::new(error.to_string()))?;
|
||||
Ok(bytes.into_iter().map(f64::from).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn service() -> PaseoDesktop {
|
||||
PaseoDesktop {
|
||||
database: std::sync::Mutex::new(Some(rusqlite::Connection::open_in_memory().unwrap())),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn crypto_matches_sha256_and_bounds_random_requests() {
|
||||
let service = service();
|
||||
assert_eq!(
|
||||
service.sha256("abc".into()).unwrap(),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
assert_eq!(service.random_bytes(32.0).unwrap().len(), 32);
|
||||
assert!(service.random_bytes(65537.0).is_err());
|
||||
assert!(service.random_bytes(-1.0).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn replica_transactions_bind_values_and_roll_back() {
|
||||
let service = service();
|
||||
service
|
||||
.sqlite(
|
||||
"CREATE TABLE rows (key TEXT PRIMARY KEY, value TEXT);".into(),
|
||||
"batch".into(),
|
||||
)
|
||||
.unwrap();
|
||||
service
|
||||
.sqlite(
|
||||
"INSERT INTO rows VALUES (?, ?)".into(),
|
||||
r#"["key","quoted ' value"]"#.into(),
|
||||
)
|
||||
.unwrap();
|
||||
service
|
||||
.sqlite(
|
||||
"BEGIN IMMEDIATE; DELETE FROM rows; ROLLBACK;".into(),
|
||||
"batch".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let result = service
|
||||
.sqlite("SELECT key, value FROM rows".into(), "[]".into())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
|
||||
serde_json::json!([{"key":"key","value":"quoted ' value"}])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/* oxlint-disable import/no-unassigned-import -- Install globals before app modules evaluate. */
|
||||
import "./adapters/intl";
|
||||
import "../src/polyfills/install-early";
|
||||
import "../src/styles/unistyles";
|
||||
import { AppRegistry } from "react-native";
|
||||
import { RootApp } from "../src/root-app";
|
||||
|
||||
// GPUI owns the native surface. Keep Paseo's full router and providers, while
|
||||
// leaving Expo's mobile registration and WebView-based dev overlay out.
|
||||
AppRegistry.registerComponent("Paseo", () => RootApp);
|
||||
@@ -1,103 +0,0 @@
|
||||
import { afterAll, beforeAll, expect, test } from "vitest";
|
||||
|
||||
// Runs against the actual Paseo bundle in the compiled GPUI app. Start with
|
||||
// RN_GPUI_TEST_DRIVER=1 and pass its loopback URL and token to this test.
|
||||
const endpoint = process.env.PASEO_GPUI_TEST_URL;
|
||||
const token = process.env.PASEO_GPUI_TEST_TOKEN;
|
||||
let socket: WebSocket;
|
||||
let nextId = 0;
|
||||
interface Node {
|
||||
tag: number;
|
||||
component: string;
|
||||
testID: string;
|
||||
text: string;
|
||||
children?: Node[];
|
||||
}
|
||||
const pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
|
||||
function request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`GPUI ${method} timed out`));
|
||||
}, 15_000);
|
||||
pending.set(id, {
|
||||
resolve: (value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value as T);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params: { ...params, token } }));
|
||||
});
|
||||
}
|
||||
function flatten(node: Node): Node[] {
|
||||
return [node, ...(node.children ?? []).flatMap(flatten)];
|
||||
}
|
||||
async function nodes() {
|
||||
return (await request<{ children: Node[] }>("tree")).children.flatMap(flatten);
|
||||
}
|
||||
beforeAll(async () => {
|
||||
if (!endpoint) return;
|
||||
if (!token) throw new Error("PASEO_GPUI_TEST_TOKEN is required");
|
||||
socket = new WebSocket(endpoint);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.addEventListener("open", () => resolve(), { once: true });
|
||||
socket.addEventListener("error", () => reject(new Error("GPUI driver connection failed")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
const response = JSON.parse(String(event.data));
|
||||
const waiter = pending.get(response.id);
|
||||
if (!waiter) return;
|
||||
pending.delete(response.id);
|
||||
const error = response.error ?? response.result?.error;
|
||||
if (error) waiter.reject(new Error(JSON.stringify(error)));
|
||||
else waiter.resolve(response.result);
|
||||
});
|
||||
});
|
||||
afterAll(() => socket?.close());
|
||||
|
||||
test.skipIf(!endpoint)(
|
||||
"real Paseo portal passes workspace clicks and the native composer edits text",
|
||||
async () => {
|
||||
const workspace = (await nodes()).find((node) =>
|
||||
node.testID.startsWith("sidebar-workspace-row-"),
|
||||
);
|
||||
expect(workspace, "connected daemon must have an existing workspace").toBeDefined();
|
||||
// Regression: the upstream box-none portal hit area intercepted this click.
|
||||
expect(await request("click", { tag: workspace!.tag })).toEqual({ clicked: true });
|
||||
await expect
|
||||
.poll(async () => (await nodes()).some((node) => node.testID === "workspace-header-title"), {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () => (await nodes()).some((node) => node.component === "TextInput"), {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(true);
|
||||
const input = (await nodes()).find((node) => node.component === "TextInput")!;
|
||||
const original = (await request<{ text: string }>("getProps", { tag: input.tag })).text;
|
||||
try {
|
||||
await request("click", { tag: input.tag });
|
||||
await request("press", { key: "ctrl-a" });
|
||||
await request("press", { key: "backspace" });
|
||||
expect(
|
||||
await request("type", { tag: input.tag, text: "GPUI native input regression" }),
|
||||
).toEqual({ typed: true });
|
||||
await expect
|
||||
.poll(async () => (await request<{ text: string }>("getProps", { tag: input.tag })).text)
|
||||
.toBe("GPUI native input regression");
|
||||
} finally {
|
||||
await request("press", { key: "ctrl-a" });
|
||||
await request("press", { key: "backspace" });
|
||||
if (original) await request("type", { tag: input.tag, text: original });
|
||||
}
|
||||
},
|
||||
45_000,
|
||||
);
|
||||
Generated
-6661
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"name": "paseo-gpui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@formatjs/intl-datetimeformat": "^7.9.0",
|
||||
"@formatjs/intl-displaynames": "^7.4.0",
|
||||
"@formatjs/intl-getcanonicallocales": "^3.3.0",
|
||||
"@formatjs/intl-locale": "^5.4.1",
|
||||
"@formatjs/intl-numberformat": "^9.5.0",
|
||||
"@formatjs/intl-pluralrules": "^6.4.0",
|
||||
"@formatjs/intl-segmenter": "^12.3.0",
|
||||
"@react-native-gpui/codegen": "file:../../../vendor/react-native-gpui/packages/codegen",
|
||||
"@react-native-gpui/core": "file:../../../vendor/react-native-gpui/packages/core",
|
||||
"@react-native-gpui/fs": "file:../../../vendor/react-native-gpui/modules/fs",
|
||||
"@react-native-gpui/gesture-handler": "file:../../../vendor/react-native-gpui/modules/gesture-handler",
|
||||
"@react-native-gpui/hermes": "file:../../../vendor/react-native-gpui/packages/hermes",
|
||||
"@react-native-gpui/reanimated": "file:../../../vendor/react-native-gpui/modules/reanimated",
|
||||
"@react-native-gpui/safe-area-context": "file:../../../vendor/react-native-gpui/modules/safe-area-context",
|
||||
"@react-native-gpui/screens": "file:../../../vendor/react-native-gpui/modules/screens",
|
||||
"@react-native-gpui/svg": "file:../../../vendor/react-native-gpui/modules/svg",
|
||||
"@react-native-gpui/worklets": "file:../../../vendor/react-native-gpui/modules/worklets",
|
||||
"@react-native/assets-registry": "^0.87.1",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.87.1",
|
||||
"react-native-gesture-handler": "3.3.0",
|
||||
"react-native-reanimated": "https://github.com/ammarahm-ed/react-native-reanimated/releases/download/4.7.1-desktop.1/react-native-reanimated-4.7.1.tgz",
|
||||
"react-native-safe-area-context": "5.10.1",
|
||||
"react-native-screens": "4.28.0",
|
||||
"react-native-svg": "15.15.5",
|
||||
"react-native-worklets": "https://github.com/ammarahm-ed/react-native-reanimated/releases/download/worklets-0.13.0-desktop.1/react-native-worklets-0.13.0.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-community/cli": "20.2.0",
|
||||
"@react-native-gpui/cli": "file:../../../vendor/react-native-gpui/packages/cli",
|
||||
"@react-native/babel-preset": "0.87.1",
|
||||
"@react-native/metro-config": "0.87.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
/// <reference types="@react-native-gpui/core/types" />
|
||||
// Added by react-native-gpui. Do not edit: it is regenerated.
|
||||
@@ -1,17 +0,0 @@
|
||||
const path = require("node:path");
|
||||
module.exports = {
|
||||
// This target only launches desktop. RN CLI 20.2's built-in
|
||||
// run-ios/run-android option lists conflict with the CLI's global verbose
|
||||
// option, and this app has no native mobile projects to launch.
|
||||
commands: ["build-android", "build-ios", "log-android", "log-ios", "run-android", "run-ios"].map(
|
||||
(name) => ({
|
||||
name,
|
||||
description: `${name} is not configured for Paseo GPUI`,
|
||||
func: () => {
|
||||
throw new Error(`${name} is not configured for Paseo GPUI`);
|
||||
},
|
||||
}),
|
||||
),
|
||||
project: { desktop: {} },
|
||||
reactNativePath: path.dirname(require.resolve("react-native/package.json")),
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
// Use the existing route tree. Expo's transformer normally supplies this root
|
||||
// through a Babel caller field; the RN desktop transformer does not.
|
||||
export const ctx = require.context(
|
||||
"../src/app",
|
||||
true,
|
||||
/^(?:\.\/)(?!(?:(?:(?:.*\+api)|(?:\+(html|native-intent))))\.[tj]sx?$).*\.[tj]sx?$/,
|
||||
);
|
||||
@@ -1,3 +0,0 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
profile = "minimal"
|
||||
@@ -1,99 +0,0 @@
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ComposerEditingSession } from "@/composer/editing-session";
|
||||
import { agentCommandsQueryKey } from "./cache";
|
||||
import {
|
||||
useAgentCommandsQuery,
|
||||
type AgentSlashCommand,
|
||||
type DraftCommandConfig,
|
||||
} from "./use-query";
|
||||
|
||||
export type {
|
||||
AgentSlashCommand,
|
||||
DraftCommandConfig,
|
||||
DraftCommandTarget,
|
||||
AgentCommandsClient,
|
||||
} from "./use-query";
|
||||
export { fetchAgentCommands } from "./use-query";
|
||||
|
||||
type CommandQuery = ReturnType<typeof useAgentCommandsQuery>;
|
||||
const QueryContext = createContext<CommandQuery | null>(null);
|
||||
export interface CommandHints {
|
||||
names: ReadonlySet<string>;
|
||||
insert(command: string): void;
|
||||
}
|
||||
const HintsContext = createContext<CommandHints | null>(null);
|
||||
|
||||
function useLiveAgentCommands() {
|
||||
return useContext(QueryContext);
|
||||
}
|
||||
export function useAutocompleteCommands(input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
enabled: boolean;
|
||||
draftConfig?: DraftCommandConfig;
|
||||
}) {
|
||||
const live = useLiveAgentCommands();
|
||||
const fallback = useAgentCommandsQuery({
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
enabled: !live && input.enabled,
|
||||
draftConfig: input.draftConfig,
|
||||
});
|
||||
return live ?? fallback;
|
||||
}
|
||||
export function useAgentCommandHints() {
|
||||
return useContext(HintsContext);
|
||||
}
|
||||
export function matchCommandLink(
|
||||
url: string,
|
||||
sourceType: "markdown" | "text" | "inline-code" | undefined,
|
||||
names: ReadonlySet<string>,
|
||||
): string | null {
|
||||
return (sourceType === "markdown" || sourceType === "inline-code") &&
|
||||
url.startsWith("/") &&
|
||||
names.has(url.slice(1))
|
||||
? url
|
||||
: null;
|
||||
}
|
||||
|
||||
export function AgentCommandsProvider({
|
||||
serverId,
|
||||
agentId,
|
||||
editingSession,
|
||||
enabled,
|
||||
children,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
editingSession: ComposerEditingSession;
|
||||
enabled: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const query = useAgentCommandsQuery({ serverId, agentId, enabled });
|
||||
const queryClient = useQueryClient();
|
||||
const namesKey = JSON.stringify(
|
||||
[...new Set(query.commands.map((command) => command.name))].sort(),
|
||||
);
|
||||
const names = useMemo<ReadonlySet<string>>(
|
||||
() => new Set(JSON.parse(namesKey) as string[]),
|
||||
[namesKey],
|
||||
);
|
||||
const insert = useCallback(
|
||||
(token: string) => {
|
||||
const commands = queryClient.getQueryData<AgentSlashCommand[]>(
|
||||
agentCommandsQueryKey({ serverId, agentId }),
|
||||
);
|
||||
if (token.startsWith("/") && commands?.some((command) => `/${command.name}` === token)) {
|
||||
editingSession.insertCommand(token);
|
||||
}
|
||||
},
|
||||
[agentId, editingSession, queryClient, serverId],
|
||||
);
|
||||
const hints = useMemo(() => ({ names, insert }), [names, insert]);
|
||||
return (
|
||||
<QueryContext.Provider value={enabled ? query : null}>
|
||||
<HintsContext.Provider value={enabled ? hints : null}>{children}</HintsContext.Provider>
|
||||
</QueryContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -468,7 +468,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
workspaceRoot,
|
||||
resolveRelativeFiles: true,
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
|
||||
@@ -64,10 +64,7 @@ it("ignores an older probe after a newer file link opens", async () => {
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: {
|
||||
listDirectory: () => pendingProbe,
|
||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
||||
},
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
@@ -94,10 +91,7 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: {
|
||||
listDirectory: () => pendingProbe,
|
||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
||||
},
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
@@ -136,45 +130,3 @@ it("sends folders outside the workspace away from the workspace Files panel", as
|
||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||
expect(workspaceFolders).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
it("keeps a later selection when an earlier nested-file lookup finishes last", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
const lookup = Promise.withResolvers<{
|
||||
entries: Array<{ path: string; kind: "file" }>;
|
||||
error: null;
|
||||
}>();
|
||||
const queries: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
resolveRelativeFiles: true,
|
||||
client: {
|
||||
listDirectory: async () => {
|
||||
throw new Error("Not a directory");
|
||||
},
|
||||
getDirectorySuggestions: async ({ query }: { query: string }) => {
|
||||
queries.push(query);
|
||||
return lookup.promise;
|
||||
},
|
||||
},
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const earlier = opener.open(
|
||||
{ raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 },
|
||||
"side",
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(queries).toEqual(["schema/x.md"]);
|
||||
await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side");
|
||||
lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null });
|
||||
await earlier;
|
||||
expect(opened).toEqual(["guide.md"]);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type OpenFileDisposition,
|
||||
} from "@/workspace/file-open";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver";
|
||||
|
||||
interface PathOpenInput {
|
||||
file?: string;
|
||||
@@ -51,11 +50,7 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathO
|
||||
|
||||
export function createInlinePathOpener(input: {
|
||||
workspaceRoot: string;
|
||||
client: {
|
||||
listDirectory(root: string, path: string): Promise<unknown>;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
} | null;
|
||||
resolveRelativeFiles?: boolean;
|
||||
client: PathOpenInput["client"];
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
openOutsideDirectory: (path: string) => void;
|
||||
@@ -78,21 +73,10 @@ export function createInlinePathOpener(input: {
|
||||
} else if (destination.kind === "outsideDirectory") {
|
||||
input.openOutsideDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const resolvedTarget =
|
||||
input.resolveRelativeFiles && input.client
|
||||
? await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client),
|
||||
})
|
||||
: target;
|
||||
if (ownGeneration !== generation) return;
|
||||
const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot);
|
||||
if (!resolved?.file) return;
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: resolved.file,
|
||||
lineStart: resolvedTarget.lineStart,
|
||||
lineEnd: resolvedTarget.lineEnd,
|
||||
path: normalized.file,
|
||||
lineStart: target.lineStart,
|
||||
lineEnd: target.lineEnd,
|
||||
});
|
||||
if (location) input.openFile(location, disposition);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
getAssistantFileLinkToken,
|
||||
resolveMissingNestedRelativeFile,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkContext,
|
||||
type DirectorySuggestionEntry,
|
||||
@@ -425,174 +424,6 @@ describe("fetchDaemonResolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMissingNestedRelativeFile", () => {
|
||||
const target = {
|
||||
raw: "schema/x.md:12-14",
|
||||
path: "/Users/test/project/schema/x.md",
|
||||
lineStart: 12,
|
||||
lineEnd: 14,
|
||||
};
|
||||
|
||||
it("opens a nested-repo suffix when the workspace-root path is missing", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([
|
||||
{
|
||||
query: "schema/x.md",
|
||||
cwd: "/Users/test/project",
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
...target,
|
||||
path: "/Users/test/project/repo-a/schema/x.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the exact workspace path when suggestions return that relative path", async () => {
|
||||
const { getDirectorySuggestions } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(result).toEqual(target);
|
||||
});
|
||||
|
||||
it("keeps the original target when suggestions are empty", async () => {
|
||||
const { getDirectorySuggestions } = suggestionsFromMap({});
|
||||
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(target);
|
||||
});
|
||||
|
||||
it("keeps the original target when the daemon throws", async () => {
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions: unavailableSuggestions,
|
||||
}),
|
||||
).resolves.toEqual(target);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a bare filename", { ...target, path: "/Users/test/project/file.ts" }],
|
||||
["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }],
|
||||
["a home-relative path", { ...target, path: "~/schema/x.md" }],
|
||||
])("does not search %s", async (_label, candidate) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target: candidate,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([]);
|
||||
expect(result).toEqual(candidate);
|
||||
});
|
||||
|
||||
it("does not search when the workspace root is blank", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: " ",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([]);
|
||||
expect(result).toEqual(target);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/Users/test/project/schema/x.md", "/Users/test/project"],
|
||||
["file:///Users/test/project/schema/x.md", "/Users/test/project"],
|
||||
["C:/repo/schema/x.md", "C:/repo"],
|
||||
["\\\\server\\share\\schema\\x.md", "//server/share"],
|
||||
])("keeps absolute destination %s", async (raw, workspaceRoot) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` };
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target: absoluteTarget,
|
||||
workspaceRoot,
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(absoluteTarget);
|
||||
expect(searches).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["docs/guides", "./docs/guides", "docs/guides/"])(
|
||||
"keeps folder href %s direct",
|
||||
async (raw) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }],
|
||||
});
|
||||
const folderTarget = { raw, path: "/Users/test/project/docs/guides" };
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target: folderTarget,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(folderTarget);
|
||||
expect(searches).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("normalizes a Windows workspace root before the suffix lookup", async () => {
|
||||
const windowsTarget = {
|
||||
raw: "schema/x.md",
|
||||
path: "C:\\repo\\schema\\x.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
};
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target: windowsTarget,
|
||||
workspaceRoot: "C:\\repo\\",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([
|
||||
{
|
||||
query: "schema/x.md",
|
||||
cwd: "C:\\repo\\",
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(result.path).toBe("C:/repo/repo-a/schema/x.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantFileLinkToken", () => {
|
||||
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
||||
expect(
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
@@ -106,62 +105,6 @@ export async function fetchDaemonResolution({
|
||||
};
|
||||
}
|
||||
|
||||
/** Only file-looking paths nested inside the workspace need suffix search. */
|
||||
function nestedRelativeLookupQuery(
|
||||
targetPath: string,
|
||||
workspaceRoot: string | undefined,
|
||||
): string | null {
|
||||
const root = workspaceRoot?.trim();
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = targetPath.replace(/\\/g, "/");
|
||||
const prefix = `${normalizedRoot}/`;
|
||||
if (!normalizedPath.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const relative = normalizedPath.slice(prefix.length);
|
||||
if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) {
|
||||
return null;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
/** When a markdown link points at workspaceRoot/schema/x.md and that file is
|
||||
* missing, ask the same suffix lookup bare names already use. An exact hit
|
||||
* stays on that path. A suffix hit such as repo-a/schema/x.md replaces it.
|
||||
* Lookup failure keeps the original target.
|
||||
*/
|
||||
export async function resolveMissingNestedRelativeFile(input: {
|
||||
target: InlinePathTarget;
|
||||
workspaceRoot?: string;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}): Promise<InlinePathTarget> {
|
||||
if (
|
||||
isAbsoluteInlineCodeToken(input.target.raw) ||
|
||||
input.target.raw.startsWith("~/") ||
|
||||
input.target.raw.endsWith("/")
|
||||
) {
|
||||
return input.target;
|
||||
}
|
||||
const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot);
|
||||
if (!query) {
|
||||
return input.target;
|
||||
}
|
||||
try {
|
||||
return await fetchDaemonResolution({
|
||||
ambiguousQuery: query,
|
||||
token: input.target.raw,
|
||||
target: input.target,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
getDirectorySuggestions: input.getDirectorySuggestions,
|
||||
});
|
||||
} catch {
|
||||
return input.target;
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyForResolution(
|
||||
source: AssistantFileLinkSource,
|
||||
context: AssistantFileLinkContext,
|
||||
@@ -272,7 +215,11 @@ export function shouldResolveDirectFileThroughSuggestions(input: {
|
||||
}
|
||||
|
||||
function isAbsoluteInlineCodeToken(token: string): boolean {
|
||||
return isAbsolutePath(token) || token.toLowerCase().startsWith("file://");
|
||||
return (
|
||||
token.startsWith("/") ||
|
||||
token.toLowerCase().startsWith("file://") ||
|
||||
/^[A-Za-z]:[\\/]/.test(token)
|
||||
);
|
||||
}
|
||||
|
||||
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
||||
|
||||
@@ -35,15 +35,11 @@ export interface MarkdownImageContext {
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export function useMarkdownSource(
|
||||
text: string,
|
||||
context: MarkdownImageContext,
|
||||
commandNames?: ReadonlySet<string>,
|
||||
) {
|
||||
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
|
||||
const occurrenceKey = useId();
|
||||
const prepared = useMemo(
|
||||
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
|
||||
[text, context.workspaceRoot, commandNames],
|
||||
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }),
|
||||
[text, context.workspaceRoot],
|
||||
);
|
||||
const sources = [...new Set(prepared.images.map((image) => image.source))];
|
||||
const acquisitions = sources.map((source) => {
|
||||
|
||||
@@ -2,8 +2,6 @@ import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
|
||||
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
|
||||
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
|
||||
import { matchCommandLink } from "@/agent-commands";
|
||||
|
||||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||||
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
|
||||
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
|
||||
@@ -15,28 +13,6 @@ import { createMarkdownStyles } from "./styles";
|
||||
import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source";
|
||||
import { normalizeMarkdownHtml } from "./html";
|
||||
|
||||
it("recognizes complete known commands in inline code", () => {
|
||||
const names = new Set(["code-review", "review:fast"]);
|
||||
const prepared = prepareMarkdownSource("`/code-review` `/unknown`", undefined, {
|
||||
commandNames: names,
|
||||
});
|
||||
expect(prepared.linkRecognition.inlineCode.test("/code-review")).toBe(true);
|
||||
expect(prepared.linkRecognition.inlineCode.test("/unknown")).toBe(
|
||||
prepareMarkdownSource("`/unknown`").linkRecognition.inlineCode.test("/unknown"),
|
||||
);
|
||||
const nonPath = prepareMarkdownSource("`/review:fast`", undefined, { commandNames: names });
|
||||
expect(nonPath.linkRecognition.inlineCode.test("/review:fast")).toBe(true);
|
||||
expect(matchCommandLink("/code-review", "inline-code", names)).toBe("/code-review");
|
||||
});
|
||||
|
||||
it("resolves authored root-relative links only when the target is a known command", () => {
|
||||
const names = new Set(["code-review"]);
|
||||
expect(matchCommandLink("/code-review", "markdown", names)).toBe("/code-review");
|
||||
expect(matchCommandLink("/code-review/file", "markdown", names)).toBeNull();
|
||||
expect(matchCommandLink("/code-review", "text", names)).toBeNull();
|
||||
expect(matchCommandLink("https://example.com/code-review", "markdown", names)).toBeNull();
|
||||
});
|
||||
|
||||
describe("shared Markdown source adaptation", () => {
|
||||
it("keeps paragraphs, fences and quotes together for the library", () => {
|
||||
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useAssistantFileLinkEnvironment,
|
||||
} from "@/assistant-file-links";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -49,7 +48,6 @@ export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
selectable?: boolean;
|
||||
streaming?: boolean;
|
||||
onLinkPress?: (url: string) => unknown;
|
||||
agentCommands?: boolean;
|
||||
}
|
||||
|
||||
/** All Markdown surfaces share the same engine and source adaptation. */
|
||||
@@ -61,21 +59,15 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
|
||||
() => splitStreamingMarkdown(completeLines, true).head.length,
|
||||
[completeLines],
|
||||
);
|
||||
const Part = props.agentCommands ? CommandMarkdownPart : MarkdownPart;
|
||||
if (!streaming || boundary === 0) return <Part {...props} />;
|
||||
if (!streaming || boundary === 0) return <MarkdownPart {...props} />;
|
||||
return (
|
||||
<MarkdownBlockStack>
|
||||
<Part {...props} text={props.text.slice(0, boundary)} />
|
||||
<Part {...props} text={props.text.slice(boundary)} />
|
||||
<MarkdownPart {...props} text={props.text.slice(0, boundary)} />
|
||||
<MarkdownPart {...props} text={props.text.slice(boundary)} />
|
||||
</MarkdownBlockStack>
|
||||
);
|
||||
});
|
||||
|
||||
const CommandMarkdownPart = memo(function CommandMarkdownPart(props: MarkdownRendererProps) {
|
||||
const commandHints = useAgentCommandHints();
|
||||
return <MarkdownPart {...props} commandHints={commandHints} />;
|
||||
});
|
||||
|
||||
const MarkdownPart = memo(function MarkdownPart({
|
||||
text,
|
||||
compact = false,
|
||||
@@ -83,10 +75,8 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
selectable = true,
|
||||
streaming = false,
|
||||
onLinkPress,
|
||||
agentCommands: _agentCommands,
|
||||
commandHints = null,
|
||||
...imageContext
|
||||
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
|
||||
}: MarkdownRendererProps) {
|
||||
const environment = useAssistantFileLinkEnvironment();
|
||||
const actions = useAssistantFileLinkActions();
|
||||
const client =
|
||||
@@ -94,23 +84,14 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
(environment.client?.readFile
|
||||
? { readFile: environment.client.readFile.bind(environment.client) }
|
||||
: undefined);
|
||||
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(
|
||||
text,
|
||||
{
|
||||
workspaceRoot: environment.workspaceRoot,
|
||||
serverId: environment.serverId,
|
||||
...imageContext,
|
||||
client,
|
||||
},
|
||||
commandHints?.names,
|
||||
);
|
||||
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, {
|
||||
workspaceRoot: environment.workspaceRoot,
|
||||
serverId: environment.serverId,
|
||||
...imageContext,
|
||||
client,
|
||||
});
|
||||
const [image, setImage] = useState<ImageLightboxSource | null>(null);
|
||||
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
|
||||
const command = commandHints && matchCommandLink(url, sourceType, commandHints.names);
|
||||
if (command) {
|
||||
commandHints.insert(command);
|
||||
return;
|
||||
}
|
||||
const source = linkSource(url, sourceType);
|
||||
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
|
||||
else actions.open(source, "preferred");
|
||||
|
||||
@@ -56,11 +56,7 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
||||
};
|
||||
}
|
||||
|
||||
function collectInlineCodePaths(
|
||||
source: string,
|
||||
workspaceRoot?: string,
|
||||
commandNames?: ReadonlySet<string>,
|
||||
): ReadonlySet<string> {
|
||||
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> {
|
||||
const paths = new Set<string>();
|
||||
const previousEnds = new Map<number, number>();
|
||||
// Collect candidates between consecutive runs of equal length. The engine owns
|
||||
@@ -78,11 +74,7 @@ function collectInlineCodePaths(
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
const classification = classifyAssistantFileLink(value, { workspaceRoot });
|
||||
if (
|
||||
(classification && classification.kind !== "external") ||
|
||||
(value.startsWith("/") && commandNames?.has(value.slice(1)))
|
||||
)
|
||||
paths.add(value);
|
||||
if (classification && classification.kind !== "external") paths.add(value);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
@@ -120,7 +112,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
|
||||
export function prepareMarkdownSource(
|
||||
source: string,
|
||||
workspaceRoot?: string,
|
||||
options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
|
||||
options: { supportsSvg?: boolean } = {},
|
||||
) {
|
||||
source = normalizeMarkdownHtml(source);
|
||||
const images: MarkdownImage[] = [];
|
||||
@@ -130,9 +122,7 @@ export function prepareMarkdownSource(
|
||||
extensions: [gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
}));
|
||||
const metadata = linkMetadata(
|
||||
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
|
||||
);
|
||||
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
|
||||
const edits: { start: number; end: number; text: string }[] = [];
|
||||
if (
|
||||
!source.includes("![") &&
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mut
|
||||
import { TaskListRow } from "@/components/task-list-row";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb, supportsMaskedEffects } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useRevealedText, type MarkdownPhase } from "@/hooks/use-revealed-text";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
@@ -1199,7 +1199,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
>
|
||||
<MarkdownRenderer
|
||||
text={revealedMessage}
|
||||
agentCommands
|
||||
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
|
||||
client={client}
|
||||
workspaceRoot={workspaceRoot}
|
||||
@@ -1800,7 +1799,7 @@ function computeShimmerMetrics(input: {
|
||||
// React Native Web only observes a node when onLayout exists at mount. Keep
|
||||
// measuring while idle so a retained badge has dimensions when it starts loading.
|
||||
const shouldMeasureWebShimmer = isWeb;
|
||||
const shouldMeasureNativeShimmer = input.isLoading && isNative && supportsMaskedEffects;
|
||||
const shouldMeasureNativeShimmer = input.isLoading && isNative;
|
||||
const isNativeShimmer =
|
||||
shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0;
|
||||
const webShimmerSpanStartX = input.labelOffsetX;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
@@ -126,13 +126,17 @@ afterEach(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
|
||||
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
|
||||
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
beforeAll(async () => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
({ useAgentInputDraft } = await import("./input-draft"));
|
||||
});
|
||||
const { useAgentInputDraft } = await import("./input-draft");
|
||||
|
||||
describe("useAgentInputDraft live contract", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { TextReplacement } from "@/composer/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { DraftCommandTarget } from "@/agent-commands";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
useAgentFormState,
|
||||
type CreateAgentInitialValues,
|
||||
|
||||
@@ -2,31 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
import { ComposerEditingSession, ComposerEditingSessions } from "./index";
|
||||
|
||||
describe("composer editing session", () => {
|
||||
it("appends commands to the current editor buffer and focuses it", () => {
|
||||
const session = new ComposerEditingSession();
|
||||
let text = "draft typed since publication";
|
||||
let focuses = 0;
|
||||
let selection = { start: 0, end: 0 };
|
||||
const detach = session.attachEditor({
|
||||
getText: () => text,
|
||||
replaceText: (next, nextSelection) => {
|
||||
text = next;
|
||||
selection = nextSelection;
|
||||
},
|
||||
focus: () => {
|
||||
focuses += 1;
|
||||
},
|
||||
});
|
||||
session.insertCommand("/review");
|
||||
expect(selection).toEqual({ start: text.length, end: text.length });
|
||||
session.insertCommand("/test");
|
||||
expect(text).toBe("draft typed since publication /review /test ");
|
||||
expect(focuses).toBe(2);
|
||||
expect(selection).toEqual({ start: text.length, end: text.length });
|
||||
detach();
|
||||
session.insertCommand("/ignored");
|
||||
expect(text).toBe("draft typed since publication /review /test ");
|
||||
});
|
||||
it("publishes fullscreen changes once per transition", () => {
|
||||
const session = new ComposerEditingSession();
|
||||
const changes: boolean[] = [];
|
||||
|
||||
@@ -30,29 +30,10 @@ export interface ComposerRetainedEditor {
|
||||
*/
|
||||
export class ComposerEditingSession {
|
||||
private fullscreen = false;
|
||||
private editor: {
|
||||
getText(): string;
|
||||
replaceText(text: string, selection: ComposerSelection): void;
|
||||
focus(): void;
|
||||
} | null = null;
|
||||
private retained: ComposerRetainedEditor | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
constructor(private readonly onChange?: () => void) {}
|
||||
getFullscreen = () => this.fullscreen;
|
||||
attachEditor(editor: NonNullable<ComposerEditingSession["editor"]>) {
|
||||
this.editor = editor;
|
||||
return () => {
|
||||
if (this.editor === editor) this.editor = null;
|
||||
};
|
||||
}
|
||||
insertCommand(command: string) {
|
||||
const editor = this.editor;
|
||||
if (!editor) return;
|
||||
const text = editor.getText();
|
||||
const nextText = `${text}${text && !/\s$/.test(text) ? " " : ""}${command} `;
|
||||
editor.replaceText(nextText, { start: nextText.length, end: nextText.length });
|
||||
editor.focus();
|
||||
}
|
||||
/** Called by the unmounting editor; the next mount of this session takes it. */
|
||||
retainEditor(retained: ComposerRetainedEditor) {
|
||||
this.retained = retained;
|
||||
|
||||
@@ -71,7 +71,7 @@ import {
|
||||
} from "./input/input";
|
||||
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { DraftCommandTarget } from "@/agent-commands";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import {
|
||||
|
||||
@@ -1407,16 +1407,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
|
||||
);
|
||||
|
||||
useLayoutEffect(
|
||||
() =>
|
||||
editingSession.attachEditor({
|
||||
getText: () => textInputRef.current?.getText() ?? valueRef.current,
|
||||
replaceText,
|
||||
focus: () => textInputRef.current?.focus(),
|
||||
}),
|
||||
[editingSession, replaceText],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
textInputRef.current?.focus();
|
||||
|
||||
@@ -25,9 +25,6 @@ export const isWeb = Platform.OS === "web";
|
||||
/** iOS or Android — the JS runtime is React Native. */
|
||||
export const isNative = Platform.OS !== "web";
|
||||
|
||||
/** Platforms with an alpha-mask compositor for decorative shimmer effects. */
|
||||
export const supportsMaskedEffects = ["web", "ios", "android"].includes(Platform.OS);
|
||||
|
||||
/** Development build/runtime — true in Metro dev bundles, false in production. */
|
||||
export const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./provider-snapshot-cache";
|
||||
import { queryClient as singletonQueryClient } from "./query-client";
|
||||
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
|
||||
import { agentCommandsQueryRoot } from "@/agent-commands/cache";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { OwnedSubscription } from "@getpaseo/client";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import type {
|
||||
ListTerminalsResponse,
|
||||
MutableDaemonConfig,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { invalidateCommandsForProviderChange } from "@/agent-commands/cache";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import { shareCheckoutDiff } from "@/git/diff-sharing";
|
||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
@@ -187,10 +186,10 @@ export async function applyProvidersSnapshotUpdate(input: {
|
||||
const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" };
|
||||
const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd);
|
||||
const queryKey = providersSnapshotQueryKey(input.serverId, cwd);
|
||||
const previous = input.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(queryKey);
|
||||
const previous = input.queryClient.getQueryData<{ snapshotHash?: string }>(queryKey);
|
||||
let announcement: typeof snapshot | undefined = snapshot;
|
||||
void input.queryClient.cancelQueries({ queryKey, exact: true });
|
||||
const current = await input.queryClient.fetchQuery({
|
||||
await input.queryClient.fetchQuery({
|
||||
queryKey,
|
||||
staleTime: 0,
|
||||
structuralSharing: false,
|
||||
@@ -206,13 +205,12 @@ export async function applyProvidersSnapshotUpdate(input: {
|
||||
});
|
||||
},
|
||||
});
|
||||
void invalidateCommandsForProviderChange({
|
||||
queryClient: input.queryClient,
|
||||
serverId: input.serverId,
|
||||
cwd,
|
||||
previous: previous?.entries,
|
||||
current: current.entries,
|
||||
});
|
||||
if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) {
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: agentCommandsQueryRoot(input.serverId),
|
||||
exact: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function mountServerDataPushRouter(input: PushRouterInput): () => void {
|
||||
|
||||
@@ -27,7 +27,7 @@ type ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey>
|
||||
|
||||
type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
"initialData" | "placeholderData" | "queryFn" | "staleTime"
|
||||
"initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime"
|
||||
> & {
|
||||
dataShape: "list" | "value";
|
||||
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
|
||||
@@ -113,7 +113,7 @@ export function fetchQueryOptions<
|
||||
dataShape,
|
||||
},
|
||||
},
|
||||
refetchOnMount: options.refetchOnMount ?? (immutableWhen ? true : "always"),
|
||||
refetchOnMount: immutableWhen ? true : "always",
|
||||
staleTime: immutableWhen
|
||||
? (query) =>
|
||||
query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
resolveWorkingDiffComparison,
|
||||
selectWorkingDiffComparison,
|
||||
} from "@/git/working-diff-comparison";
|
||||
import { draftAgentCommandsQueryKey } from "@/agent-commands/cache";
|
||||
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||
import {
|
||||
applyCheckoutStatusUpdateFromEvent,
|
||||
ensureCheckoutStatus,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
invalidatePrPaneTimelineForCheckout,
|
||||
} from "@/git/query-keys";
|
||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
import { resetDraftAgentCommandsForCheckout } from "@/agent-commands/cache";
|
||||
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
|
||||
+1
-51
@@ -1,61 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { fetchQueryOptions } from "@/data/query";
|
||||
import {
|
||||
agentCommandsQueryKey,
|
||||
agentCommandsQueryRoot,
|
||||
draftAgentCommandsQueryKey,
|
||||
normalizeAgentCommandsCwd,
|
||||
sessionAgentCommandsQueryKey,
|
||||
sessionCommandQueryPolicy,
|
||||
} from "./cache";
|
||||
|
||||
it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { gcTime: 300_000 } } });
|
||||
const key = sessionAgentCommandsQueryKey({ serverId: "s", agentId: "a" });
|
||||
let calls = 0;
|
||||
let fail = false;
|
||||
let resolveRefresh: (value: string[]) => void = () => {};
|
||||
const refresh = new Promise<string[]>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
const options = fetchQueryOptions({
|
||||
...sessionCommandQueryPolicy,
|
||||
staleTimeMs: sessionCommandQueryPolicy.staleTime,
|
||||
dataShape: "value",
|
||||
queryKey: key,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
calls++;
|
||||
if (fail) throw new Error("offline");
|
||||
return refresh;
|
||||
},
|
||||
});
|
||||
client.setQueryData(key, ["old"]);
|
||||
const fresh = new QueryObserver(client, options);
|
||||
const unsubscribeFresh = fresh.subscribe(() => {});
|
||||
expect(calls).toBe(0);
|
||||
unsubscribeFresh();
|
||||
client.setQueryData(key, ["old"], { updatedAt: 1 });
|
||||
const first = new QueryObserver(client, options);
|
||||
const second = new QueryObserver(client, options);
|
||||
const unsubscribeFirst = first.subscribe(() => {});
|
||||
const unsubscribeSecond = second.subscribe(() => {});
|
||||
expect(calls).toBe(1);
|
||||
expect(first.getCurrentResult().data).toEqual(["old"]);
|
||||
expect(client.getQueryCache().find({ queryKey: key })?.gcTime).toBe(1_800_000);
|
||||
resolveRefresh(["new"]);
|
||||
await refresh;
|
||||
await first.refetch({ cancelRefetch: false });
|
||||
expect(second.getCurrentResult().data).toEqual(["new"]);
|
||||
fail = true;
|
||||
await client.invalidateQueries({ queryKey: key });
|
||||
expect(first.getCurrentResult().data).toEqual(["new"]);
|
||||
expect(first.getCurrentResult().isError).toBe(true);
|
||||
unsubscribeFirst();
|
||||
unsubscribeSecond();
|
||||
client.clear();
|
||||
});
|
||||
} from "./agent-commands-query";
|
||||
|
||||
describe("agent command query keys", () => {
|
||||
it("shares draft commands across draft tabs with the same provider setup", () => {
|
||||
-48
@@ -1,16 +1,8 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import equal from "fast-deep-equal";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
|
||||
export const sessionCommandQueryPolicy = {
|
||||
staleTime: 600_000,
|
||||
gcTime: 1_800_000,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
|
||||
export interface AgentCommandsDraftConfig {
|
||||
provider: AgentProvider;
|
||||
@@ -29,46 +21,6 @@ export function agentCommandsQueryRoot(serverId: string) {
|
||||
return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const;
|
||||
}
|
||||
|
||||
/** Snapshot announcements contain provider metadata, not command catalogs. Ignore initial
|
||||
* discovery and unrelated providers; explicit refresh still invalidates every catalog. */
|
||||
export function invalidateCommandsForProviderChange(input: {
|
||||
queryClient: QueryClient;
|
||||
serverId: string;
|
||||
cwd: string | null;
|
||||
previous: readonly ProviderSnapshotEntry[] | undefined;
|
||||
current: readonly ProviderSnapshotEntry[];
|
||||
}): Promise<void> {
|
||||
const previous = new Map((input.previous ?? []).map((entry) => [entry.provider, entry]));
|
||||
const current = new Map(input.current.map((entry) => [entry.provider, entry]));
|
||||
const relevant = (entry: ProviderSnapshotEntry | undefined) =>
|
||||
entry && {
|
||||
status: entry.status,
|
||||
enabled: entry.enabled,
|
||||
source: entry.source,
|
||||
models: entry.models,
|
||||
modes: entry.modes,
|
||||
defaultModeId: entry.defaultModeId,
|
||||
};
|
||||
return input.queryClient.invalidateQueries({
|
||||
queryKey: agentCommandsQueryRoot(input.serverId),
|
||||
predicate: (query) => {
|
||||
const isDraft = query.queryKey[2] === "draft";
|
||||
const provider =
|
||||
query.meta?.agentCommandProvider ?? (isDraft ? query.queryKey[6] : undefined);
|
||||
const cwd = query.meta?.agentCommandCwd ?? (isDraft ? query.queryKey[4] : undefined);
|
||||
if (input.cwd && typeof cwd === "string" && normalizeAgentCommandsCwd(cwd) !== input.cwd)
|
||||
return false;
|
||||
if (typeof provider !== "string") return false;
|
||||
const before = previous.get(provider);
|
||||
const after = current.get(provider);
|
||||
if (!before) return after?.status === "ready" && query.state.status === "error";
|
||||
if (before.status !== "ready")
|
||||
return after?.status === "ready" && query.state.status === "error";
|
||||
return !equal(relevant(before), relevant(after));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) {
|
||||
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
import {
|
||||
useAutocompleteCommands,
|
||||
useAgentCommandsQuery,
|
||||
type AgentSlashCommand,
|
||||
type DraftCommandConfig,
|
||||
type DraftCommandTarget,
|
||||
} from "@/agent-commands";
|
||||
} from "./use-agent-commands-query";
|
||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||
import { useAutocomplete } from "./use-autocomplete";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -432,7 +432,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
autocompleteCwd,
|
||||
});
|
||||
|
||||
const commandsQuery = useAutocompleteCommands({
|
||||
const commandsQuery = useAgentCommandsQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: mode === "command" && canLoadCommands && canListCommands,
|
||||
|
||||
+5
-7
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type AgentCommandsClient, type DraftCommandConfig, fetchAgentCommands } from "./use-query";
|
||||
import {
|
||||
type AgentCommandsClient,
|
||||
type DraftCommandConfig,
|
||||
fetchAgentCommands,
|
||||
} from "./use-agent-commands-query";
|
||||
|
||||
type ListCommands = AgentCommandsClient["listCommands"];
|
||||
type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
|
||||
@@ -34,12 +38,6 @@ function commandsPayload(commands: ListCommandsResult["commands"]): ListCommands
|
||||
}
|
||||
|
||||
describe("fetchAgentCommands", () => {
|
||||
it("rejects a discovery error so a previous catalog is retained", async () => {
|
||||
const client = createClient({ ...commandsPayload([]), error: "provider unavailable" });
|
||||
await expect(fetchAgentCommands({ client, agentId: "agent-1" })).rejects.toThrow(
|
||||
"provider unavailable",
|
||||
);
|
||||
});
|
||||
it("loads commands for a draft composer without an agent id", async () => {
|
||||
const client = createClient(
|
||||
commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]),
|
||||
+8
-28
@@ -1,15 +1,12 @@
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
agentCommandsQueryKey,
|
||||
sessionCommandQueryPolicy,
|
||||
type AgentCommandsDraftConfig,
|
||||
} from "./cache";
|
||||
import { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query";
|
||||
|
||||
const DRAFT_COMMANDS_STALE_TIME = Number.POSITIVE_INFINITY;
|
||||
const SESSION_COMMANDS_STALE_TIME = 60_000;
|
||||
|
||||
export interface AgentSlashCommand {
|
||||
name: string;
|
||||
@@ -44,7 +41,6 @@ export async function fetchAgentCommands(input: {
|
||||
agentId: input.agentId,
|
||||
draftConfig: input.draftConfig,
|
||||
});
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.commands as AgentSlashCommand[];
|
||||
}
|
||||
|
||||
@@ -66,16 +62,8 @@ export function useAgentCommandsQuery({
|
||||
const queryEnabled = enabled && retainedPanelActive;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { provider, cwd } = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent =
|
||||
state.sessions[serverId]?.agents?.get(agentId) ??
|
||||
state.sessions[serverId]?.agentDetails?.get(agentId);
|
||||
return { provider: agent?.provider, cwd: agent?.cwd };
|
||||
}),
|
||||
);
|
||||
|
||||
const query = useFetchQuery({
|
||||
const query = useQuery({
|
||||
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
@@ -84,15 +72,7 @@ export function useAgentCommandsQuery({
|
||||
return fetchAgentCommands({ client, agentId, draftConfig });
|
||||
},
|
||||
enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig),
|
||||
dataShape: "value",
|
||||
meta: {
|
||||
agentCommandProvider: draftConfig?.provider ?? provider,
|
||||
agentCommandCwd: draftConfig?.cwd ?? cwd,
|
||||
},
|
||||
...(draftConfig
|
||||
? { immutableWhen: () => true as const }
|
||||
: { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }),
|
||||
subscribed: queryEnabled,
|
||||
staleTime: draftConfig ? DRAFT_COMMANDS_STALE_TIME : SESSION_COMMANDS_STALE_TIME,
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
||||
});
|
||||
@@ -104,7 +84,7 @@ export function useAgentCommandsQuery({
|
||||
return {
|
||||
commands: query.data ?? [],
|
||||
isLoading,
|
||||
isError: query.isError && query.data === undefined,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchAgentHistoryEntry,
|
||||
@@ -13,8 +13,18 @@ import { allAgentHistoryQueryKey } from "./agent-history-query-key";
|
||||
}
|
||||
).__DEV__ = false;
|
||||
|
||||
const { fetchAgentHistoryBatch, fetchAgentHistoryPage, collectAgentHistoryHostErrors } =
|
||||
await import("./use-agent-history");
|
||||
type UseAgentHistoryModule = typeof import("./use-agent-history");
|
||||
|
||||
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
|
||||
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
|
||||
let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("./use-agent-history");
|
||||
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
|
||||
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
|
||||
collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors;
|
||||
});
|
||||
|
||||
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
|
||||
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
expandProviderSnapshot,
|
||||
} from "@getpaseo/protocol/provider-snapshot-codec";
|
||||
import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
|
||||
import { draftAgentCommandsQueryKey, sessionAgentCommandsQueryKey } from "@/agent-commands/cache";
|
||||
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||
import { providerSnapshotIcons } from "@/data/provider-icons";
|
||||
import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router";
|
||||
import {
|
||||
@@ -363,153 +363,6 @@ describe("applyProvidersSnapshotUpdate", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("refreshes active commands only when their provider changes after discovery", async () => {
|
||||
const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" });
|
||||
let requests = 0;
|
||||
const observer = new QueryObserver(queryClient, {
|
||||
queryKey: key,
|
||||
queryFn: async () => {
|
||||
requests++;
|
||||
return [{ name: "review" }];
|
||||
},
|
||||
staleTime: Infinity,
|
||||
meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" },
|
||||
});
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
await observer.refetch({ cancelRefetch: false });
|
||||
expect(requests).toBe(1);
|
||||
const codex = codexEntry("ready", [readyCodexModel]);
|
||||
const other = { provider: "claude", status: "loading", enabled: true } as ProviderSnapshotEntry;
|
||||
await applyProvidersSnapshotUpdate({
|
||||
client: createClient(),
|
||||
serverId,
|
||||
queryClient,
|
||||
message: updateMessage([codexEntry("loading"), other], "/repo-a"),
|
||||
});
|
||||
expect(requests).toBe(1);
|
||||
await applyProvidersSnapshotUpdate({
|
||||
client: createClient(),
|
||||
serverId,
|
||||
queryClient,
|
||||
message: updateMessage([codex, other], "/repo-a"),
|
||||
});
|
||||
expect(requests).toBe(1);
|
||||
await applyProvidersSnapshotUpdate({
|
||||
client: createClient(),
|
||||
serverId,
|
||||
queryClient,
|
||||
message: updateMessage([codex, { ...other, status: "ready" }], "/repo-a"),
|
||||
});
|
||||
expect(requests).toBe(1);
|
||||
await applyProvidersSnapshotUpdate({
|
||||
client: createClient(),
|
||||
serverId,
|
||||
queryClient,
|
||||
message: updateMessage(
|
||||
[
|
||||
{ ...codex, fetchedAt: "2026-01-01T00:00:02.000Z" },
|
||||
{ ...other, status: "ready" },
|
||||
],
|
||||
"/repo-a",
|
||||
),
|
||||
});
|
||||
expect(requests).toBe(1);
|
||||
queryClient.setQueryData(
|
||||
providersSnapshotQueryKey(serverId, "/repo-b"),
|
||||
providersSnapshot([codex]),
|
||||
);
|
||||
await applyProvidersSnapshotUpdate({
|
||||
client: createClient(),
|
||||
serverId,
|
||||
queryClient,
|
||||
message: updateMessage(
|
||||
[codexEntry("ready", [{ ...readyCodexModel, label: "Other checkout" }])],
|
||||
"/repo-b",
|
||||
),
|
||||
});
|
||||
expect(requests).toBe(1);
|
||||
await applyProvidersSnapshotUpdate({
|
||||
client: createClient(),
|
||||
serverId,
|
||||
queryClient,
|
||||
message: updateMessage(
|
||||
[
|
||||
codexEntry("ready", [{ ...readyCodexModel, label: "Updated" }]),
|
||||
{ ...other, status: "ready" },
|
||||
],
|
||||
"/repo-a",
|
||||
),
|
||||
});
|
||||
await observer.refetch({ cancelRefetch: false });
|
||||
expect(requests).toBe(2);
|
||||
unsubscribe();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("retries a failed command discovery when its provider first becomes ready", async () => {
|
||||
const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" });
|
||||
let ready = false;
|
||||
let requests = 0;
|
||||
const observer = new QueryObserver(queryClient, {
|
||||
queryKey: key,
|
||||
queryFn: async () => {
|
||||
requests++;
|
||||
if (!ready) throw new Error("provider unavailable");
|
||||
return [{ name: "review" }];
|
||||
},
|
||||
retry: false,
|
||||
meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" },
|
||||
});
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
await observer.refetch({ cancelRefetch: false });
|
||||
expect(observer.getCurrentResult().isError).toBe(true);
|
||||
ready = true;
|
||||
await applyProvidersSnapshotUpdate({
|
||||
client: createClient(),
|
||||
serverId,
|
||||
queryClient,
|
||||
message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"),
|
||||
});
|
||||
await observer.refetch({ cancelRefetch: false });
|
||||
expect(requests).toBe(2);
|
||||
expect(observer.getCurrentResult().data).toEqual([{ name: "review" }]);
|
||||
unsubscribe();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("refreshes again when its provider changes during an active refresh", async () => {
|
||||
const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" });
|
||||
queryClient.setQueryData(key, [{ name: "old" }]);
|
||||
let requests = 0;
|
||||
const waiting = new Promise<{ name: string }[]>(() => {});
|
||||
const observer = new QueryObserver(queryClient, {
|
||||
queryKey: key,
|
||||
queryFn: async () => (++requests === 1 ? waiting : [{ name: "new" }]),
|
||||
staleTime: Infinity,
|
||||
meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" },
|
||||
});
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
queryClient.setQueryData(
|
||||
providersSnapshotQueryKey(serverId, "/repo-a"),
|
||||
providersSnapshot([codexEntry("ready", [readyCodexModel])]),
|
||||
);
|
||||
const send = (label: string) =>
|
||||
applyProvidersSnapshotUpdate({
|
||||
client: createClient(),
|
||||
serverId,
|
||||
queryClient,
|
||||
message: updateMessage([codexEntry("ready", [{ ...readyCodexModel, label }])], "/repo-a"),
|
||||
});
|
||||
await send("First change");
|
||||
expect(requests).toBe(1);
|
||||
await send("Second change");
|
||||
expect(requests).toBe(2);
|
||||
await observer.refetch({ cancelRefetch: false });
|
||||
expect(observer.getCurrentResult().data).toEqual([{ name: "new" }]);
|
||||
unsubscribe();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("routes updates to the home query cache when the message carries no cwd", async () => {
|
||||
await applyProvidersSnapshotUpdate({
|
||||
client: createClient(),
|
||||
@@ -602,10 +455,6 @@ describe("applyProvidersSnapshotUpdate", () => {
|
||||
draftConfig: { provider: "codex", cwd: "/repo-a" },
|
||||
});
|
||||
queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]);
|
||||
queryClient.setQueryData(
|
||||
providersSnapshotQueryKey(serverId, "/repo-a"),
|
||||
providersSnapshot([codexEntry("ready")]),
|
||||
);
|
||||
|
||||
await applyProvidersSnapshotUpdate({
|
||||
client: createClient(),
|
||||
|
||||
@@ -28,7 +28,6 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { RetainedChatContent } from "./retained-chat-content";
|
||||
import { Composer } from "@/composer";
|
||||
import { AgentCommandsProvider } from "@/agent-commands";
|
||||
import {
|
||||
useComposerEditingSession,
|
||||
useComposerFullscreen,
|
||||
@@ -1256,7 +1255,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
</ChatSurface>
|
||||
);
|
||||
|
||||
const content = (
|
||||
return (
|
||||
<RewindComposerRestoreProvider
|
||||
textSource={agentInputDraft.textSource}
|
||||
setText={agentInputDraft.replaceText}
|
||||
@@ -1275,40 +1274,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
</View>
|
||||
</RewindComposerRestoreProvider>
|
||||
);
|
||||
return wrapAgentCommandProvider({
|
||||
enabled: showsComposer && hasActiveComposer,
|
||||
serverId,
|
||||
agentId,
|
||||
editingSession: composerEditingSession,
|
||||
children: content,
|
||||
});
|
||||
});
|
||||
|
||||
function wrapAgentCommandProvider({
|
||||
enabled,
|
||||
serverId,
|
||||
agentId,
|
||||
editingSession,
|
||||
children,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
editingSession: ComposerEditingSession;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AgentCommandsProvider
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
editingSession={editingSession}
|
||||
enabled={enabled}
|
||||
>
|
||||
{children}
|
||||
</AgentCommandsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatSurface({
|
||||
children,
|
||||
disabled,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
ProviderSnapshotEntry,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import type { DraftCommandTarget } from "@/agent-commands";
|
||||
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||
import { filterSelectableModels } from "./model-catalog";
|
||||
|
||||
@@ -19,7 +19,7 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isNative, isWeb, supportsMaskedEffects } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
|
||||
interface StartupSplashScreenProps {
|
||||
@@ -86,11 +86,7 @@ function LogoShimmer() {
|
||||
return <WebLogoShimmer color={theme.colors.foreground} />;
|
||||
}
|
||||
|
||||
return supportsMaskedEffects ? (
|
||||
<NativeLogoShimmer color={theme.colors.foreground} />
|
||||
) : (
|
||||
<PaseoLogo size={LOGO_SIZE} color={theme.colors.foreground} />
|
||||
);
|
||||
return <NativeLogoShimmer color={theme.colors.foreground} />;
|
||||
}
|
||||
|
||||
function WebLogoShimmer({ color }: { color: string }) {
|
||||
|
||||
@@ -13,6 +13,5 @@
|
||||
"expo-env.d.ts",
|
||||
"nativewind-env.d.ts",
|
||||
"react-native-web.d.ts"
|
||||
],
|
||||
"exclude": ["gpui"]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -56,9 +56,6 @@ function spawnRelayDevServer(port: number): ChildProcess {
|
||||
wranglerCliPath,
|
||||
"dev",
|
||||
"--local",
|
||||
// Exercise the local Durable Object, independent of production cutover routing.
|
||||
"--var",
|
||||
"PASEO_RELAY_UPSTREAM:",
|
||||
"--ip",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts src/server/daemon-e2e/concurrent-agent-refresh.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -27,10 +27,4 @@ for (const directory of ["", "v2/"]) {
|
||||
target: "es2022",
|
||||
outfile: outputPath,
|
||||
});
|
||||
if (directory === "v2/") {
|
||||
await copyFile(
|
||||
new URL("../src/server/agent/providers/opencode/v2/state.mjs", import.meta.url),
|
||||
path.join(path.dirname(outputPath), "state.mjs"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1220,74 +1220,6 @@ test("orders buffered pre-steer output before an immediately accepted steer", as
|
||||
}
|
||||
});
|
||||
|
||||
test("a steer the provider names without echoing it can be rewound to", async () => {
|
||||
// Claude takes a steered message into the running turn without echoing it back as a user
|
||||
// message, so the id it minted for the message reaches the manager only through the admission.
|
||||
class SilentSteeringSession extends SteeringTestSession {
|
||||
override readonly capabilities = {
|
||||
...TEST_CAPABILITIES,
|
||||
supportsRewindFiles: true,
|
||||
};
|
||||
rewindMessageIds: string[] = [];
|
||||
|
||||
override async steerActiveTurn(
|
||||
_prompt: AgentPromptInput,
|
||||
options: import("./agent-sdk-types.js").SteerActiveTurnOptions,
|
||||
): Promise<import("./agent-sdk-types.js").SteerResult> {
|
||||
if (options.expectedTurnId !== `active-turn-${this.startCount}`) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
return { status: "accepted", providerMessageId: `provider-${options.clientMessageId}` };
|
||||
}
|
||||
|
||||
override async revertFiles({ messageId }: { messageId: string }): Promise<void> {
|
||||
this.rewindMessageIds.push(messageId);
|
||||
}
|
||||
}
|
||||
const session = new SilentSteeringSession({ provider: "codex", cwd: process.cwd() });
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-rewind-"));
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
const run = manager.streamAgent(agent.id, "initial");
|
||||
void (async () => {
|
||||
for await (const _event of run) {
|
||||
}
|
||||
})();
|
||||
await manager.waitForAgentRunStart(agent.id);
|
||||
|
||||
await expect(
|
||||
manager.steerAgentRun(agent.id, "steered", { clientMessageId: "steer-client" }),
|
||||
).resolves.toMatchObject({ status: "accepted" });
|
||||
const row = manager
|
||||
.fetchTimeline(agent.id, { limit: 0 })
|
||||
.rows.find((candidate) => candidate.item.type === "user_message");
|
||||
expect(row).toMatchObject({
|
||||
providerMessageId: "provider-steer-client",
|
||||
item: { text: "steered", clientMessageId: "steer-client" },
|
||||
});
|
||||
|
||||
await manager.rewind(agent.id, "steer-client", "files");
|
||||
expect(session.rewindMessageIds).toEqual(["provider-steer-client"]);
|
||||
} finally {
|
||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("orders a concurrent replacement after a pending accepted steer", async () => {
|
||||
const entered = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
|
||||
@@ -3029,13 +3029,7 @@ export class AgentManager {
|
||||
expectedTurnId,
|
||||
});
|
||||
if (admission.status === "accepted") {
|
||||
await this.recordAcceptedSteer(
|
||||
agent,
|
||||
prompt,
|
||||
options?.clientMessageId,
|
||||
expectedTurnId,
|
||||
admission.providerMessageId,
|
||||
);
|
||||
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
|
||||
}
|
||||
return admission;
|
||||
});
|
||||
@@ -3068,13 +3062,7 @@ export class AgentManager {
|
||||
expectedTurnId,
|
||||
});
|
||||
if (admission.status === "accepted") {
|
||||
await this.recordAcceptedSteer(
|
||||
agent,
|
||||
prompt,
|
||||
options?.clientMessageId,
|
||||
expectedTurnId,
|
||||
admission.providerMessageId,
|
||||
);
|
||||
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
|
||||
}
|
||||
return admission;
|
||||
})
|
||||
@@ -3179,7 +3167,6 @@ export class AgentManager {
|
||||
prompt: AgentPromptInput,
|
||||
clientMessageId: string | undefined,
|
||||
expectedTurnId: string,
|
||||
providerMessageId: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!clientMessageId) {
|
||||
return;
|
||||
@@ -3187,7 +3174,6 @@ export class AgentManager {
|
||||
this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", {
|
||||
messageId: clientMessageId,
|
||||
turnId: expectedTurnId,
|
||||
...(providerMessageId ? { providerMessageId } : {}),
|
||||
});
|
||||
this.emitState(agent);
|
||||
}
|
||||
@@ -3519,10 +3505,6 @@ export class AgentManager {
|
||||
"agent.rewind.complete",
|
||||
);
|
||||
} catch (error) {
|
||||
// Native rewind can commit a fork before history refresh fails. Recovery
|
||||
// must resume that latest handle rather than the abandoned conversation.
|
||||
this.refreshSessionPersistence(agent);
|
||||
await this.persistSnapshot(agent);
|
||||
this.logger.warn(
|
||||
{ err: error, agentId, provider: agent.provider, messageId, mode },
|
||||
"agent.rewind.failed",
|
||||
|
||||
@@ -208,11 +208,7 @@ export interface AgentSteerOptions extends AgentRunOptions {
|
||||
clearPendingPermissions?: boolean;
|
||||
}
|
||||
|
||||
// providerMessageId names the steered message in the provider's own history, when the provider
|
||||
// mints one. Rewinding to the message needs it.
|
||||
export type SteerResult =
|
||||
| { status: "accepted"; providerMessageId?: string }
|
||||
| { status: "unavailable" };
|
||||
export type SteerResult = { status: "accepted" } | { status: "unavailable" };
|
||||
|
||||
export interface SteerActiveTurnOptions extends AgentSteerOptions {
|
||||
expectedTurnId: string;
|
||||
|
||||
@@ -53,7 +53,6 @@ const CAPABILITIES = [
|
||||
interface ProviderHarnessOptions {
|
||||
capabilities?: ProviderConnection["capabilities"];
|
||||
completeTurn?: boolean;
|
||||
closeAcknowledgementOnly?: boolean;
|
||||
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
|
||||
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
|
||||
}
|
||||
@@ -209,7 +208,6 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
}
|
||||
if (input.type === "session.close") {
|
||||
emit({ type: "session.closed", sessionId: input.sessionId });
|
||||
if (options.closeAcknowledgementOnly) return;
|
||||
}
|
||||
if ("requestId" in input) {
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
@@ -248,572 +246,6 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]
|
||||
return events.filter((event) => event.type === type);
|
||||
}
|
||||
|
||||
function createRewindProviderHarness(
|
||||
options: {
|
||||
reuseAnswerId?: boolean;
|
||||
forkOnRevert?: boolean;
|
||||
handleInput?: ProviderHarnessOptions["handleInput"];
|
||||
} = {},
|
||||
) {
|
||||
const nativeMessages: string[] = [];
|
||||
let originalMessages: string[] | null = null;
|
||||
const harness = createProviderHarness({
|
||||
closeAcknowledgementOnly: true,
|
||||
capabilities: [
|
||||
...CAPABILITIES,
|
||||
"session.revert.conversation",
|
||||
"session.revert.files",
|
||||
"session.revert.both",
|
||||
],
|
||||
async handleInput(input, emit) {
|
||||
if (await options.handleInput?.(input, emit)) return true;
|
||||
if (input.type === "session.open" && input.history === "replay") {
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: [
|
||||
...CAPABILITIES,
|
||||
"session.revert.conversation",
|
||||
"session.revert.files",
|
||||
"session.revert.both",
|
||||
],
|
||||
restoration: "core",
|
||||
persistence: input.persistence ?? { version: 1, data: { token: "root" } },
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
const messages =
|
||||
options.forkOnRevert &&
|
||||
(input.persistence?.data as { token?: string })?.token === "root" &&
|
||||
originalMessages
|
||||
? originalMessages
|
||||
: nativeMessages;
|
||||
for (const [index, text] of messages.entries()) {
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: { type: "user_message", id: `user-${text}`, text, revertToken: text },
|
||||
});
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
id: options.reuseAnswerId ? `answer-${index + 1}` : `answer-${text}`,
|
||||
text: `Reply ${text}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.prompt") {
|
||||
if (input.prompt.input.type !== "message") throw new Error("Expected a message");
|
||||
const text = input.prompt.input.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("");
|
||||
nativeMessages.push(text);
|
||||
const turnId = `turn-${text}`;
|
||||
emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "turn", turnId },
|
||||
});
|
||||
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" });
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "user_message",
|
||||
id: `user-${text}`,
|
||||
text,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
revertToken: text,
|
||||
},
|
||||
});
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
id: options.reuseAnswerId ? `answer-${nativeMessages.length}` : `answer-${text}`,
|
||||
text: `Reply ${text}`,
|
||||
},
|
||||
});
|
||||
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" });
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.revert") {
|
||||
if (options.forkOnRevert && input.scope !== "files") {
|
||||
originalMessages = [...nativeMessages];
|
||||
emit({
|
||||
type: "session.persistence",
|
||||
sessionId: input.sessionId,
|
||||
persistence: { version: 1, data: { token: "fork" } },
|
||||
});
|
||||
}
|
||||
if (input.scope !== "files")
|
||||
nativeMessages.splice(nativeMessages.indexOf(String(input.token)));
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
return { ...harness, nativeMessages };
|
||||
}
|
||||
|
||||
async function withRewindAgent(
|
||||
run: (context: {
|
||||
manager: AgentManager;
|
||||
agentId: string;
|
||||
nativeMessages: string[];
|
||||
messages: () => string[];
|
||||
}) => Promise<void>,
|
||||
options: Parameters<typeof createRewindProviderHarness>[0] = {},
|
||||
) {
|
||||
const harness = createRewindProviderHarness(options);
|
||||
const logger = createTestLogger();
|
||||
const registry = new PluginAgentClientRegistry(logger);
|
||||
registry.replace([harness.registration]);
|
||||
const manager = new AgentManager({
|
||||
logger,
|
||||
clients: registry.clients(),
|
||||
providerDefinitions: registry.definitions(),
|
||||
});
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: tmpdir() },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const messages = () =>
|
||||
manager
|
||||
.getTimeline(agent.id)
|
||||
.flatMap((item) =>
|
||||
item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [],
|
||||
);
|
||||
try {
|
||||
await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" });
|
||||
await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" });
|
||||
await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" });
|
||||
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
|
||||
await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages });
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
await registry.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => {
|
||||
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
|
||||
await manager.rewind(agentId, "user-B", "conversation");
|
||||
const afterRewind = messages();
|
||||
expect(nativeMessages).toEqual(["A"]);
|
||||
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
|
||||
const afterReplacement = messages();
|
||||
expect(nativeMessages).toEqual(["A", "D"]);
|
||||
expect({ afterRewind, afterReplacement }).toEqual({
|
||||
afterRewind: ["A", "Reply A"],
|
||||
afterReplacement: ["A", "Reply A", "D", "Reply D"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => {
|
||||
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
|
||||
await manager.rewind(agentId, "user-B", "files");
|
||||
expect(nativeMessages).toEqual(["A", "B", "C"]);
|
||||
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
|
||||
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
|
||||
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin rewind restores translation state when a replacement reuses a discarded item id", async () => {
|
||||
const harness = createRewindProviderHarness({ reuseAnswerId: true });
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
|
||||
const live: AgentStreamEvent[] = [];
|
||||
try {
|
||||
await session.run("A");
|
||||
await session.run("B");
|
||||
session.subscribe((event) => live.push(event));
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
live.length = 0;
|
||||
await session.run("B revised");
|
||||
const assistant = live.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
|
||||
);
|
||||
expect(harness.nativeMessages).toEqual(["A", "B revised"]);
|
||||
expect(assistant).toEqual(["Reply B revised"]);
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
async function withPluginRewindSession(
|
||||
options: Parameters<typeof createRewindProviderHarness>[0],
|
||||
run: (
|
||||
session: Awaited<ReturnType<AgentClient["createSession"]>>,
|
||||
harness: ReturnType<typeof createRewindProviderHarness>,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
const harness = createRewindProviderHarness(options);
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
|
||||
try {
|
||||
await session.run("A");
|
||||
await session.run("B");
|
||||
await session.run("C");
|
||||
await run(session, harness);
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSession"]>>) {
|
||||
const events = await Array.fromAsync(session.streamHistory());
|
||||
return events.flatMap((event) =>
|
||||
event.type === "timeline" &&
|
||||
(event.item.type === "user_message" || event.item.type === "assistant_message")
|
||||
? [event.item.text]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
test("combined plugin rewind replays the retained native branch", async () => {
|
||||
await withPluginRewindSession({}, async (session) => {
|
||||
await session.revertBoth!({ messageId: "user-B" });
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
|
||||
await session.revertConversation!({ messageId: "user-A" });
|
||||
expect(await pluginMessages(session)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type === "session.revert")
|
||||
emit({
|
||||
type: "session.persistence",
|
||||
sessionId: input.sessionId,
|
||||
persistence: { version: 1, data: { token: "fork" } },
|
||||
});
|
||||
return false;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
expect(harness.inputs.findLast((input) => input.type === "session.open")).toMatchObject({
|
||||
persistence: { version: 1, data: { token: "fork" } },
|
||||
history: "replay",
|
||||
});
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("failed native plugin rewind preserves history and does not reopen", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.revert") return false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "native rewind failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"native rewind failed",
|
||||
);
|
||||
expect(await pluginMessages(session)).toEqual([
|
||||
"A",
|
||||
"Reply A",
|
||||
"B",
|
||||
"Reply B",
|
||||
"C",
|
||||
"Reply C",
|
||||
]);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("failed plugin rewind replay blocks prompts until retry without repeating native rewind", async () => {
|
||||
let failReplay = true;
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
|
||||
return false;
|
||||
failReplay = false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "replay failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"replay failed",
|
||||
);
|
||||
await expect(session.run("D")).rejects.toThrow("Retry the rewind or reload");
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
await session.run("D");
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A", "D", "Reply D"]);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("retrying a failed plugin rewind replay replaces the manager timeline before prompting", async () => {
|
||||
let failReplay = true;
|
||||
await withRewindAgent(
|
||||
async ({ manager, agentId, messages }) => {
|
||||
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
|
||||
"replay failed",
|
||||
);
|
||||
await expect(manager.runAgent(agentId, "D")).rejects.toThrow("Retry the rewind or reload");
|
||||
await manager.rewind(agentId, "user-B", "conversation");
|
||||
expect(messages()).toEqual(["A", "Reply A"]);
|
||||
await manager.runAgent(agentId, "D");
|
||||
expect(messages()).toEqual(["A", "Reply A", "D", "Reply D"]);
|
||||
},
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
|
||||
return false;
|
||||
failReplay = false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "replay failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("reload after failed plugin rewind replay resumes the committed fork", async () => {
|
||||
let failReplay = true;
|
||||
await withRewindAgent(
|
||||
async ({ manager, agentId, messages }) => {
|
||||
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
|
||||
"replay failed",
|
||||
);
|
||||
await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
|
||||
await manager.hydrateTimelineFromProvider(agentId, { force: true });
|
||||
expect(messages()).toEqual(["A", "Reply A"]);
|
||||
},
|
||||
{
|
||||
forkOnRevert: true,
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type === "session.close") {
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
return true;
|
||||
}
|
||||
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
|
||||
return false;
|
||||
failReplay = false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "replay failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("closing during plugin rewind replay closes each native binding once", async () => {
|
||||
let opened!: () => void;
|
||||
let release!: () => void;
|
||||
const replayStarted = new Promise<void>((resolve) => {
|
||||
opened = resolve;
|
||||
});
|
||||
const replayAllowed = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input) => {
|
||||
if (input.type === "session.open" && input.history === "replay") {
|
||||
opened();
|
||||
await replayAllowed;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
const rewind = session.revertConversation!({ messageId: "user-B" });
|
||||
await replayStarted;
|
||||
const closing = session.close();
|
||||
release();
|
||||
await Promise.all([rewind, closing]);
|
||||
const closes = harness.inputs.flatMap((input) =>
|
||||
input.type === "session.close" ? [input.sessionId] : [],
|
||||
);
|
||||
expect(closes).toHaveLength(2);
|
||||
expect(new Set(closes).size).toBe(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin rewind retries a failed close before reopening the native writer", async () => {
|
||||
let failClose = true;
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.close" || !failClose) return false;
|
||||
failClose = false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "close failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"close failed",
|
||||
);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin rewind retries permission settlement before retiring its session", async () => {
|
||||
let failPermission = true;
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input) => {
|
||||
if (input.type !== "session.permission" || !failPermission) return false;
|
||||
failPermission = false;
|
||||
throw new Error("permission delivery failed");
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
const opened = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
if (opened.type !== "session.open") throw new Error("Missing root");
|
||||
harness.emit({
|
||||
type: "session.permission",
|
||||
sessionId: opened.sessionId,
|
||||
request: { id: "pending", name: "tool", kind: "tool" },
|
||||
});
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"permission delivery failed",
|
||||
);
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.close")).toHaveLength(0);
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.permission")).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin rewind replaces child listeners and settles old pending permissions", async () => {
|
||||
await withPluginRewindSession({}, async (session, harness) => {
|
||||
const root = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
if (root.type !== "session.open") throw new Error("Missing root");
|
||||
harness.emit({
|
||||
type: "session.permission",
|
||||
sessionId: root.sessionId,
|
||||
request: { id: "old-permission", name: "tool", kind: "tool", title: "Run tool" },
|
||||
});
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
expect(harness.inputs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.permission",
|
||||
permissionId: "old-permission",
|
||||
response: { behavior: "deny", message: "Session replaced after rewind" },
|
||||
}),
|
||||
);
|
||||
harness.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: "child-1",
|
||||
item: { type: "assistant_message", id: "child-message", text: "Discarded child update" },
|
||||
});
|
||||
const history = await Array.fromAsync(session.streamHistory());
|
||||
expect(history.filter((event) => event.type === "provider_subagent")).toEqual([]);
|
||||
const reopened = harness.inputs.findLast((input) => input.type === "session.open")!;
|
||||
if (reopened.type !== "session.open") throw new Error("Missing reopened root");
|
||||
harness.emit({
|
||||
type: "session.opened",
|
||||
sessionId: "child-1",
|
||||
parentSessionId: reopened.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: tmpdir(),
|
||||
});
|
||||
harness.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: "child-1",
|
||||
item: { type: "assistant_message", id: "child-message", text: "New child reply" },
|
||||
});
|
||||
const next = await Array.fromAsync(session.streamHistory());
|
||||
expect(next).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({ text: "New child reply" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("nonpersisted plugin sessions reject conversation rewind before native mutation", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.open") return false;
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: [...CAPABILITIES, "session.revert.conversation", "session.revert.files"],
|
||||
restoration: "core",
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
expect(session.capabilities.supportsRewindConversation).toBe(false);
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"requires session persistence",
|
||||
);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]);
|
||||
await session.revertFiles!({ messageId: "user-B" });
|
||||
expect(harness.nativeMessages).toEqual(["A", "B", "C"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) {
|
||||
for (const [sessionId, parentSessionId] of [
|
||||
["a", rootSessionId],
|
||||
|
||||
@@ -603,7 +603,6 @@ class ProviderRuntimeSession {
|
||||
>();
|
||||
private readonly activeTurnIds = new Set<string>();
|
||||
private terminal = false;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
|
||||
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
|
||||
persistence: ProviderPersistence | null = null;
|
||||
@@ -725,14 +724,6 @@ class ProviderRuntimeSession {
|
||||
this.runtime.removeSession(this.id, this.providerSessionId);
|
||||
return;
|
||||
}
|
||||
this.closePromise ??= this.closeNativeSession().catch((error) => {
|
||||
this.closePromise = null;
|
||||
throw error;
|
||||
});
|
||||
await this.closePromise;
|
||||
}
|
||||
|
||||
private async closeNativeSession(): Promise<void> {
|
||||
try {
|
||||
await this.runtime.complete({
|
||||
type: "session.close",
|
||||
@@ -741,9 +732,9 @@ class ProviderRuntimeSession {
|
||||
});
|
||||
} catch (error) {
|
||||
if (!this.runtime.isClosed) throw error;
|
||||
} finally {
|
||||
this.runtime.removeSession(this.id, this.providerSessionId);
|
||||
}
|
||||
// A failed close still needs its routing for a subsequent session.closed ack.
|
||||
this.runtime.removeSession(this.id, this.providerSessionId);
|
||||
}
|
||||
|
||||
beginOpen(requestId: string): Promise<void> {
|
||||
@@ -1101,40 +1092,11 @@ class PluginAgentClient implements AgentClient {
|
||||
persistence: input.persistence,
|
||||
history: input.history,
|
||||
});
|
||||
const forget = () => {
|
||||
for (const [id, owner] of this.rootsBySession) {
|
||||
if (owner === session) this.rootsBySession.delete(id);
|
||||
}
|
||||
};
|
||||
const bind = (next: ProviderRuntimeSession) => {
|
||||
this.rootsBySession.set(next.id, session);
|
||||
this.attachPendingChildren();
|
||||
};
|
||||
const config = mapSessionConfig(input.config, input.launchContext, input.persist);
|
||||
const session = new PluginAgentSession(
|
||||
this.provider,
|
||||
bridge,
|
||||
forget,
|
||||
async (persistence, state) => {
|
||||
forget();
|
||||
return await this.runtime.openSession({
|
||||
sessionId: randomUUID(),
|
||||
config: {
|
||||
...config,
|
||||
model: state.model,
|
||||
mode: state.mode,
|
||||
thinkingOption: state.thinkingOption,
|
||||
settings: Object.fromEntries(
|
||||
state.settings.map((setting) => [setting.id, setting.value]),
|
||||
),
|
||||
},
|
||||
persistence,
|
||||
history: "replay",
|
||||
});
|
||||
},
|
||||
bind,
|
||||
);
|
||||
bind(bridge);
|
||||
const session = new PluginAgentSession(this.provider, bridge, () => {
|
||||
this.rootsBySession.delete(bridge.id);
|
||||
});
|
||||
this.rootsBySession.set(bridge.id, session);
|
||||
this.attachPendingChildren();
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -1195,32 +1157,16 @@ class PluginAgentSession implements AgentSession {
|
||||
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
|
||||
private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>();
|
||||
private readonly subagentIdsBySession = new Map<string, string | null>();
|
||||
private readonly children = new Map<
|
||||
string,
|
||||
{ session: ProviderRuntimeSession; unsubscribe: () => void }
|
||||
>();
|
||||
private readonly childUnsubscribes = new Map<string, () => void>();
|
||||
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private currentTurnId: string | null = null;
|
||||
private closed = false;
|
||||
private replay: {
|
||||
persistence: ProviderPersistence;
|
||||
config: ProviderConfigState;
|
||||
messageId: string;
|
||||
scope: "conversation" | "both";
|
||||
closed: boolean;
|
||||
} | null = null;
|
||||
private replaying: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
readonly provider: string,
|
||||
private bridge: ProviderRuntimeSession,
|
||||
private readonly bridge: ProviderRuntimeSession,
|
||||
private readonly onClose: () => void,
|
||||
private readonly reopen: (
|
||||
persistence: ProviderPersistence,
|
||||
state: ProviderConfigState,
|
||||
) => Promise<ProviderRuntimeSession>,
|
||||
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
|
||||
) {
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
@@ -1237,13 +1183,7 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
get capabilities(): AgentCapabilityFlags {
|
||||
const capabilities = agentCapabilities(this.bridge.negotiatedCapabilities);
|
||||
const resumable = this.canReplayHistory;
|
||||
return {
|
||||
...capabilities,
|
||||
supportsRewindConversation: capabilities.supportsRewindConversation && resumable,
|
||||
supportsRewindBoth: capabilities.supportsRewindBoth && resumable,
|
||||
};
|
||||
return agentCapabilities(this.bridge.negotiatedCapabilities);
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
@@ -1271,7 +1211,6 @@ class PluginAgentSession implements AgentSession {
|
||||
prompt: AgentPromptInput,
|
||||
options: AgentRunOptions = {},
|
||||
): Promise<{ turnId: string }> {
|
||||
this.assertReplayComplete();
|
||||
const clientMessageId = options.clientMessageId ?? randomUUID();
|
||||
const result = await this.bridge.prompt({
|
||||
clientMessageId,
|
||||
@@ -1294,7 +1233,6 @@ class PluginAgentSession implements AgentSession {
|
||||
prompt: AgentPromptInput,
|
||||
options: SteerActiveTurnOptions,
|
||||
): Promise<SteerResult> {
|
||||
this.assertReplayComplete();
|
||||
if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
@@ -1318,7 +1256,6 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.ensureReplay();
|
||||
for (const event of this.history.read(this.provider)) yield event;
|
||||
}
|
||||
|
||||
@@ -1346,7 +1283,6 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({ mode: modeId });
|
||||
}
|
||||
|
||||
@@ -1383,10 +1319,17 @@ class PluginAgentSession implements AgentSession {
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.listeners.clear();
|
||||
await this.replaying?.catch(() => undefined);
|
||||
await this.clearBinding();
|
||||
this.replay = null;
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe();
|
||||
this.childUnsubscribes.clear();
|
||||
this.subagentIdsBySession.clear();
|
||||
this.history.clear();
|
||||
this.timelineSnapshots.clear();
|
||||
this.childSnapshots.clear();
|
||||
this.revertTokens.clear();
|
||||
this.pendingPermissions.clear();
|
||||
this.permissionResponses.clear();
|
||||
this.listeners.clear();
|
||||
this.onClose();
|
||||
await this.bridge.close();
|
||||
@@ -1401,17 +1344,14 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({ model: modelId });
|
||||
}
|
||||
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({ thinkingOption: thinkingOptionId });
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({
|
||||
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
|
||||
});
|
||||
@@ -1475,10 +1415,10 @@ class PluginAgentSession implements AgentSession {
|
||||
const snapshots = new Map<string, TimelineTextSnapshot>();
|
||||
this.childSnapshots.set(childId, snapshots);
|
||||
for (const event of child.history) this.acceptChildEvent(childId, event, snapshots);
|
||||
this.children.set(child.id, {
|
||||
session: child,
|
||||
unsubscribe: child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
|
||||
});
|
||||
this.childUnsubscribes.set(
|
||||
child.id,
|
||||
child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
|
||||
);
|
||||
}
|
||||
|
||||
private accept(event: ProviderEvent, live: boolean): void {
|
||||
@@ -1696,84 +1636,7 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async clearBinding(): Promise<void> {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
for (const child of this.children.values()) {
|
||||
child.unsubscribe();
|
||||
await child.session.close();
|
||||
}
|
||||
this.children.clear();
|
||||
this.subagentIdsBySession.clear();
|
||||
this.history.clear();
|
||||
this.timelineSnapshots.clear();
|
||||
this.childSnapshots.clear();
|
||||
this.revertTokens.clear();
|
||||
this.pendingPermissions.clear();
|
||||
this.permissionResponses.clear();
|
||||
this.currentTurnId = null;
|
||||
}
|
||||
|
||||
private assertReplayComplete(): void {
|
||||
if (this.replay) {
|
||||
throw new Error(
|
||||
"Plugin rewind history refresh is incomplete. Retry the rewind or reload the agent before continuing.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A native rewind has already committed. Until replay succeeds, neither history
|
||||
// nor a new prompt may use the retired binding. Keep its latest handle for retry.
|
||||
private async ensureReplay(): Promise<void> {
|
||||
if (!this.replay) return;
|
||||
if (this.closed) throw new StaleProviderSessionError(this.id);
|
||||
if (!this.replaying) {
|
||||
this.replaying = this.replayBinding().finally(() => {
|
||||
this.replaying = null;
|
||||
});
|
||||
}
|
||||
await this.replaying;
|
||||
}
|
||||
|
||||
private async replayBinding(): Promise<void> {
|
||||
const replay = this.replay!;
|
||||
if (!replay.closed) {
|
||||
for (const request of this.pendingPermissions.values()) {
|
||||
await this.respondToPermission(request.id, {
|
||||
behavior: "deny",
|
||||
message: "Session replaced after rewind",
|
||||
});
|
||||
this.pendingPermissions.delete(request.id);
|
||||
this.permissionResponses.delete(request.id);
|
||||
}
|
||||
await this.clearBinding();
|
||||
await this.bridge.close();
|
||||
replay.closed = true;
|
||||
}
|
||||
const bridge = await this.reopen(replay.persistence, replay.config);
|
||||
this.bridge = bridge;
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
this.unsubscribe = bridge.onEvent((event) => this.accept(event, true));
|
||||
this.onBind(bridge);
|
||||
this.replay = null;
|
||||
}
|
||||
|
||||
private get canReplayHistory(): boolean {
|
||||
return (
|
||||
this.bridge.restoration === "core" &&
|
||||
this.bridge.persistence !== null &&
|
||||
this.bridge.negotiatedCapabilities.includes("session.persistence")
|
||||
);
|
||||
}
|
||||
|
||||
private async revert(messageId: string, scope: "conversation" | "files" | "both") {
|
||||
const pending = this.replay;
|
||||
await this.ensureReplay();
|
||||
if (pending?.messageId === messageId && pending.scope === scope) return;
|
||||
if (scope !== "files" && !this.canReplayHistory) {
|
||||
throw new Error("Plugin conversation rewind requires session persistence for history replay");
|
||||
}
|
||||
const token = this.revertTokens.get(messageId);
|
||||
if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`);
|
||||
await this.bridge.revert({
|
||||
@@ -1783,12 +1646,6 @@ class PluginAgentSession implements AgentSession {
|
||||
token,
|
||||
scope,
|
||||
});
|
||||
if (scope !== "files") {
|
||||
const persistence = this.bridge.persistence;
|
||||
if (!persistence) throw new Error("Plugin rewind removed session persistence");
|
||||
this.replay = { persistence, config: this.bridge.config, messageId, scope, closed: false };
|
||||
await this.ensureReplay();
|
||||
}
|
||||
}
|
||||
|
||||
private publish(event: AgentStreamEvent): void {
|
||||
|
||||
@@ -22,12 +22,7 @@ interface CreateAgentProviderRuntimeOptions {
|
||||
export async function createAgentProviderRuntime(
|
||||
options: CreateAgentProviderRuntimeOptions,
|
||||
): Promise<AgentProviderRuntime> {
|
||||
const configuredPort =
|
||||
options.snapshotManager.runtimeSettings?.opencode?.env?.PASEO_OPENCODE_BRIDGE_PORT;
|
||||
const port = configuredPort === undefined ? undefined : Number(configuredPort);
|
||||
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535))
|
||||
throw new Error("PASEO_OPENCODE_BRIDGE_PORT must be an integer between 1 and 65535");
|
||||
const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger, port });
|
||||
const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger });
|
||||
try {
|
||||
await bridge.start();
|
||||
const snapshotManager = new ProviderSnapshotManager({
|
||||
|
||||
@@ -142,26 +142,14 @@ function assertChildWithPipes(
|
||||
}
|
||||
}
|
||||
|
||||
// Watch process failures as well as spawn errors: the SDK leaves initialize
|
||||
// pending when the child exits without replying. `close` waits for stderr to
|
||||
// drain, so the failure includes the launcher's diagnostic.
|
||||
function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Promise<never> {
|
||||
// Node reports a failed spawn (missing binary or missing cwd) as a child
|
||||
// `error` event; without a listener it becomes an uncaught exception.
|
||||
function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promise<never> {
|
||||
return new Promise<never>((_, reject) => {
|
||||
child.once("error", (error) => {
|
||||
const stderr = stderrChunks.join("").trim();
|
||||
reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error)));
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
const stderr = stderrChunks.join("").trim();
|
||||
const status = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
|
||||
reject(
|
||||
new Error(
|
||||
`ACP agent process exited before initialization completed (${status})${
|
||||
stderr ? `\n${stderr}` : ""
|
||||
}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -554,7 +542,7 @@ interface ACPProcessTransport {
|
||||
connection: ClientSideConnection;
|
||||
stderrChunks: string[];
|
||||
spawnReady: Promise<void>;
|
||||
processError: Promise<never>;
|
||||
spawnError: Promise<never>;
|
||||
}
|
||||
|
||||
interface ACPImportPromptPreviews {
|
||||
@@ -1392,7 +1380,6 @@ export class ACPAgentClient implements AgentClient {
|
||||
client?: ACPClient;
|
||||
},
|
||||
): Promise<SpawnedACPProcess> {
|
||||
const configuredOptions = ACPProviderOptionsSchema.parse(options?.providerOptions ?? {});
|
||||
const transport = await this.spawnTransport(launchEnv, options?.client);
|
||||
const probe: UninitializedACPProcess = {
|
||||
child: transport.child,
|
||||
@@ -1404,7 +1391,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
const initialize = await this.initializeTransport(
|
||||
transport,
|
||||
options?.initializeTimeoutMs,
|
||||
configuredOptions.clientCapabilities,
|
||||
options?.providerOptions,
|
||||
);
|
||||
const initializedProbe: SpawnedACPProcess = {
|
||||
...probe,
|
||||
@@ -1438,7 +1425,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
stderrChunks.push(chunk.toString());
|
||||
});
|
||||
|
||||
const processErrorPromise = rejectOnProcessFailure(child, stderrChunks);
|
||||
const spawnErrorPromise = rejectOnSpawnError(child, stderrChunks);
|
||||
const spawnReadyPromise = new Promise<void>((resolve) => {
|
||||
child.once("spawn", () => {
|
||||
resolve();
|
||||
@@ -1457,15 +1444,16 @@ export class ACPAgentClient implements AgentClient {
|
||||
connection,
|
||||
stderrChunks,
|
||||
spawnReady: spawnReadyPromise,
|
||||
processError: processErrorPromise,
|
||||
spawnError: spawnErrorPromise,
|
||||
};
|
||||
}
|
||||
|
||||
protected async initializeTransport(
|
||||
transport: ACPProcessTransport,
|
||||
initializeTimeoutMs?: number,
|
||||
clientCapabilities?: ACPClientCapabilities,
|
||||
providerOptions?: Record<string, unknown>,
|
||||
): Promise<InitializeResponse> {
|
||||
const configuredOptions = ACPProviderOptionsSchema.parse(providerOptions ?? {});
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const initializeTimeoutPromise = initializeTimeoutMs
|
||||
? new Promise<never>((_, reject) => {
|
||||
@@ -1482,11 +1470,11 @@ export class ACPAgentClient implements AgentClient {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
clientCapabilities: buildACPClientCapabilities(
|
||||
this.clientCapabilityMeta,
|
||||
clientCapabilities ?? this.clientCapabilities,
|
||||
configuredOptions.clientCapabilities ?? this.clientCapabilities,
|
||||
),
|
||||
clientInfo: { name: "Paseo", version: "dev" },
|
||||
}),
|
||||
transport.processError,
|
||||
transport.spawnError,
|
||||
...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []),
|
||||
]),
|
||||
);
|
||||
@@ -1567,7 +1555,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
try {
|
||||
transport = await this.spawnTransport(PROBE_ENV);
|
||||
await withTimeout(
|
||||
Promise.race([transport.spawnReady, transport.processError]),
|
||||
Promise.race([transport.spawnReady, transport.spawnError]),
|
||||
phaseTimeoutMs,
|
||||
`ACP spawn timed out after ${phaseTimeoutMs}ms`,
|
||||
);
|
||||
@@ -2922,7 +2910,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
stderrChunks.push(chunk.toString());
|
||||
});
|
||||
const processError = rejectOnProcessFailure(child, stderrChunks);
|
||||
const spawnError = rejectOnSpawnError(child, stderrChunks);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (this.closed || this.historyRuntimeReleased) {
|
||||
return;
|
||||
@@ -2959,7 +2947,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
),
|
||||
clientInfo: { name: "Paseo", version: "dev" },
|
||||
}),
|
||||
processError,
|
||||
spawnError,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
// Exercise the public catalog API with a real child, including stdout EOF and
|
||||
// stderr draining. No provider installation or external service is involved.
|
||||
const failingCommand: [string, ...string[]] = [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"process.stdin.once('data', () => { process.stderr.write('launcher failed: fixture startup error\\n'); process.exitCode = 1; process.stdin.destroy(); });",
|
||||
];
|
||||
|
||||
describe("ACP catalog startup failures", () => {
|
||||
test("reports a process that exits during initialize with its stderr", async () => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: failingCommand,
|
||||
});
|
||||
await expect(
|
||||
client.fetchCatalog({ scope: "workspace", cwd: process.cwd(), force: true }),
|
||||
).rejects.toThrow(/launcher failed: fixture startup error/);
|
||||
}, 5_000);
|
||||
|
||||
test("session startup reports the same process exit with its stderr", async () => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: failingCommand,
|
||||
});
|
||||
await expect(client.createSession({ provider: "acp", cwd: process.cwd() })).rejects.toThrow(
|
||||
/launcher failed: fixture startup error/,
|
||||
);
|
||||
}, 5_000);
|
||||
|
||||
test("provider diagnostics report the startup exit instead of an initialize timeout", async () => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: failingCommand,
|
||||
diagnosticPhaseTimeoutMs: 2_000,
|
||||
});
|
||||
const { diagnostic } = await client.getDiagnostic();
|
||||
expect(diagnostic).toContain(
|
||||
"ACP agent process exited before initialization completed (exit code 1)",
|
||||
);
|
||||
expect(diagnostic).toContain("launcher failed: fixture startup error");
|
||||
expect(diagnostic).not.toContain("initialize timed out");
|
||||
});
|
||||
|
||||
test("invalid provider options reject without crashing the host process", async () => {
|
||||
const clientModule = new URL("./generic-acp-agent.ts", import.meta.url).href;
|
||||
const loggerModule = new URL("../../../test-utils/test-logger.ts", import.meta.url).href;
|
||||
const script = `
|
||||
import { GenericACPAgentClient } from ${JSON.stringify(clientModule)};
|
||||
import { createTestLogger } from ${JSON.stringify(loggerModule)};
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: [process.execPath, '-e', 'setInterval(() => {}, 1000)'],
|
||||
});
|
||||
try {
|
||||
await client.fetchCatalog({
|
||||
scope: 'workspace', cwd: process.cwd(), force: true,
|
||||
providerOptions: { clientCapabilities: { fs: 'invalid' } },
|
||||
});
|
||||
throw new Error('Expected invalid options to reject');
|
||||
} catch (error) {
|
||||
if (error.name !== 'ZodError') throw error;
|
||||
}
|
||||
// Let child cleanup and Node's unhandled-rejection check complete.
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
console.log('host stayed alive');
|
||||
`;
|
||||
const result = await promisify(execFile)(
|
||||
process.execPath,
|
||||
["--import", "tsx", "--input-type=module", "-e", script],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
expect(result.stdout).toContain("host stayed alive");
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
test("a successful catalog probe still returns its models and closes cleanly", async () => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
`
|
||||
require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => {
|
||||
const request = JSON.parse(line);
|
||||
const result = request.method === 'initialize'
|
||||
? { protocolVersion: 1, agentCapabilities: {} }
|
||||
: { sessionId: 'fixture', models: {
|
||||
currentModelId: 'fixture-model',
|
||||
availableModels: [{ modelId: 'fixture-model', name: 'Fixture model' }]
|
||||
} };
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n');
|
||||
});
|
||||
`,
|
||||
],
|
||||
});
|
||||
const catalog = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: process.cwd(),
|
||||
force: true,
|
||||
});
|
||||
expect(catalog.models).toEqual([
|
||||
expect.objectContaining({ id: "fixture-model", label: "Fixture model" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user