mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49a02d8eb9 | ||
|
|
ee6b095d0e | ||
|
|
fb065afa2f | ||
|
|
6eee6a6b3a | ||
|
|
5805b776c2 | ||
|
|
4400445edb | ||
|
|
337e81a9c6 | ||
|
|
b923cde6cf | ||
|
|
058375dac8 | ||
|
|
eb051ea81e | ||
|
|
1839de5b1d | ||
|
|
ec6393f31f | ||
|
|
b5e51cf839 | ||
|
|
b0f054de90 | ||
|
|
e8108d9092 | ||
|
|
9eaf7ad0c1 | ||
|
|
be0acb2935 | ||
|
|
196dc3acd3 | ||
|
|
e867773932 | ||
|
|
bfe38d92f6 | ||
|
|
9551b2cc11 | ||
|
|
6f62125ee0 |
@@ -93,3 +93,6 @@ app.json
|
||||
|
||||
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
|
||||
*.heapsnapshot
|
||||
|
||||
# Project-local Serena configuration and memories.
|
||||
.serena/
|
||||
|
||||
@@ -151,11 +151,12 @@ lifecycle queue. Resume chooses its history or interactive purpose from the dura
|
||||
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
|
||||
close retains the runtime for cleanup and blocks replacement through that close operation.
|
||||
|
||||
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
|
||||
purpose initializes a temporary app-server, reads the persisted thread and child histories, and
|
||||
releases the process before returning. It never loads, resumes, or unarchives a native thread,
|
||||
including legacy records whose native archive failed. The retained history session contains only
|
||||
the read results. Interactive resume remains responsible for repairing a provider session archived
|
||||
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. The retained
|
||||
history session contains only the read results and must release its provider process once replay
|
||||
finishes. Codex reads the persisted thread and child histories on a temporary app-server and never
|
||||
loads, resumes, or unarchives a native thread, including legacy records whose native archive failed.
|
||||
ACP providers spawn the CLI, replay the session through `session/load`, and terminate the process
|
||||
before returning. Interactive resume remains responsible for repairing a provider session archived
|
||||
outside Paseo while its Paseo agent is active.
|
||||
|
||||
Provider session connection owns every process it spawns until the session is registered with
|
||||
|
||||
@@ -311,6 +311,22 @@ frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
|
||||
battery while the app is idle — so do not re-add it. The probe's visibility
|
||||
guards already prevent throttling from causing a false stall.
|
||||
|
||||
### Desktop Linux hidden-window fallback
|
||||
|
||||
On Wayland (seen on ChromeOS Linux) a window created with `show: false` can sit
|
||||
forever without a single compositor frame: the renderer loads and runs, but
|
||||
`ready-to-show` never fires, so the window is never shown and the app looks like
|
||||
it did not start while every process is alive. `showWindowWhenReady`
|
||||
(`packages/desktop/src/window/show-when-ready.ts`) shows the main window when
|
||||
the document finishes loading if the event has not arrived. The fallback is
|
||||
Linux-only; macOS and Windows keep waiting for `ready-to-show`.
|
||||
|
||||
Run the startup regression on Linux with an isolated display:
|
||||
|
||||
```bash
|
||||
xvfb-run -a npm run test:e2e:window-startup --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
|
||||
@@ -4,7 +4,7 @@ Paseo proxies HTTP traffic to services running inside your workspaces. Localhost
|
||||
|
||||
## How it works
|
||||
|
||||
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
|
||||
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port. HTTP requests, WebSocket upgrades, and service health checks select the reachable IPv4 or IPv6 loopback address. Services can bind to `127.0.0.1` or `::1` without changing their proxy URL.
|
||||
|
||||
The generated hostname is built from the script name, branch, and project:
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function changesTree(page: Page) {
|
||||
return page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function expectEmptyChanges(page: Page) {
|
||||
await expect(changesTree(page).getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function editReadme(repoPath: string, text: string) {
|
||||
await appendFile(path.join(repoPath, "README.md"), text);
|
||||
}
|
||||
|
||||
async function expectReadmeChanges(page: Page, additions: number) {
|
||||
await expect(changesTree(page).getByText("README.md", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(changesTree(page)).toContainText(`+${additions}`);
|
||||
}
|
||||
|
||||
async function openEmptyDiff(page: Page) {
|
||||
await changesTree(page).getByTestId("changes-options-menu").click();
|
||||
await page.getByTestId("changes-open-tab").click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("working-diff-panel")
|
||||
.filter({ visible: true })
|
||||
.getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectReadmeDiff(page: Page, additions: number) {
|
||||
await expect(
|
||||
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
||||
).toHaveAccessibleName(`README.md, +${additions}, -0`, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function openDiffFromNewTabMenu(page: Page) {
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first().click();
|
||||
await page.getByTestId("workspace-new-tab-menu-diff").filter({ visible: true }).first().click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("working-diff-panel")
|
||||
.filter({ visible: true })
|
||||
.getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function refreshDiff(page: Page) {
|
||||
await page
|
||||
.getByTestId("working-diff-panel")
|
||||
.filter({ visible: true })
|
||||
.getByTestId("changes-refresh")
|
||||
.click();
|
||||
}
|
||||
|
||||
async function reloadWorkspace(page: Page) {
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
test("Changes follows the first edit to a clean workspace without reloading", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace();
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await openChangesTreePanel(page);
|
||||
await expectEmptyChanges(page);
|
||||
|
||||
await editReadme(workspace.repoPath, "first edit\n");
|
||||
await expectReadmeChanges(page, 1);
|
||||
|
||||
await editReadme(workspace.repoPath, "second edit\n");
|
||||
await expectReadmeChanges(page, 2);
|
||||
});
|
||||
|
||||
test("the full diff follows the first edit to a clean workspace without reloading", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace();
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await openChangesTreePanel(page);
|
||||
await expectEmptyChanges(page);
|
||||
await openEmptyDiff(page);
|
||||
|
||||
await editReadme(workspace.repoPath, "first edit\n");
|
||||
await expectReadmeDiff(page, 1);
|
||||
|
||||
await editReadme(workspace.repoPath, "second edit\n");
|
||||
await expectReadmeDiff(page, 2);
|
||||
});
|
||||
|
||||
test("a directly opened Diff tab follows the first edit without an active Changes tree", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace();
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await openDiffFromNewTabMenu(page);
|
||||
|
||||
await editReadme(workspace.repoPath, "first edit\n");
|
||||
await expectReadmeDiff(page, 1);
|
||||
|
||||
await editReadme(workspace.repoPath, "second edit\n");
|
||||
await expectReadmeDiff(page, 2);
|
||||
|
||||
await refreshDiff(page);
|
||||
await expectReadmeDiff(page, 2);
|
||||
await reloadWorkspace(page);
|
||||
await expectReadmeDiff(page, 2);
|
||||
await editReadme(workspace.repoPath, "third edit\n");
|
||||
await expectReadmeDiff(page, 3);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expandFolder,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
expectFileTabOpen,
|
||||
@@ -70,6 +71,14 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<v
|
||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
|
||||
const image = page.getByRole("img", { name, exact: true });
|
||||
await expect(image).toBeVisible();
|
||||
await expect
|
||||
.poll(() => image.locator("img").evaluate((element: HTMLImageElement) => element.naturalWidth))
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
function watchRequestsTo(page: Page, origin: string): string[] {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
@@ -684,6 +693,40 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
|
||||
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
|
||||
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
|
||||
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs", "guide.md"),
|
||||
[
|
||||
"# Guide",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
await expectFileTabOpen(page, "docs/guide.md");
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
|
||||
await expectMarkdownImageLoaded(page, "sibling");
|
||||
await expectMarkdownImageLoaded(page, "hero");
|
||||
await visibleFilePane.screenshot({
|
||||
path: test.info().outputPath("markdown-relative-images.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("previews and refreshes an HTML plan while preserving source access", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
expectPairingOffer,
|
||||
expectCompletePairingQr,
|
||||
expectRelayConsent,
|
||||
openPairDeviceModal,
|
||||
preparePairingHost,
|
||||
@@ -18,3 +20,20 @@ test("opens relay consent in browser web", async ({ page }) => {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the pairing QR code inside its compact tile", async ({ page }, testInfo) => {
|
||||
const daemon = await startIsolatedHostDaemon("pair-device-browser-compact", {
|
||||
mutableRelay: { enabled: true },
|
||||
});
|
||||
try {
|
||||
await preparePairingHost(page, daemon);
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
|
||||
await expectCompletePairingQr(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("pairing-compact.png") });
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,16 +10,29 @@ import {
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
|
||||
const CUSTOM_PROVIDER = {
|
||||
id: "junie",
|
||||
name: "Junie",
|
||||
} as const;
|
||||
|
||||
const PLUGIN_PROVIDER = {
|
||||
id: "direct-example",
|
||||
name: "Direct provider example",
|
||||
pluginId: "provider-direct-example",
|
||||
} as const;
|
||||
|
||||
interface ProviderRemovalDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
|
||||
getDaemonConfig(): Promise<{ config: { pluginsEnabled?: boolean } }>;
|
||||
patchDaemonConfig(config: {
|
||||
removeProviders?: string[];
|
||||
pluginsEnabled?: boolean;
|
||||
}): Promise<unknown>;
|
||||
installDirectoryPlugin(path: string): Promise<unknown>;
|
||||
removePlugin(pluginId: string): Promise<void>;
|
||||
getProvidersSnapshot(): Promise<{
|
||||
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
|
||||
}>;
|
||||
@@ -93,4 +106,45 @@ test.describe("provider removal", () => {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not offer Remove for a plugin-registered provider", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
|
||||
clientIdPrefix: "provider-removal-e2e",
|
||||
});
|
||||
|
||||
const plugin = await copyPluginExample("provider-direct");
|
||||
try {
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
|
||||
const pluginProviderRow = page.getByRole("button", {
|
||||
name: `${PLUGIN_PROVIDER.name} provider details`,
|
||||
exact: true,
|
||||
});
|
||||
await expect(pluginProviderRow).toBeVisible();
|
||||
await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
|
||||
|
||||
// The provider survives a removeProviders patch pointed at it: the
|
||||
// plugin, not config.json, owns the definition.
|
||||
await client.patchDaemonConfig({ removeProviders: [PLUGIN_PROVIDER.id] });
|
||||
await expect(pluginProviderRow).toBeVisible();
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_PROVIDER.pluginId).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await plugin.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const DRAG_DISTANCE = -40;
|
||||
|
||||
function splitDividers(page: Page): Locator {
|
||||
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function dividerX(divider: Locator): Promise<number> {
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
return box.x;
|
||||
}
|
||||
|
||||
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
|
||||
const page = divider.page();
|
||||
const box = await divider.boundingBox();
|
||||
if (!box) throw new Error("Divider has no bounding box");
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x + deltaX / 2, y, { steps: 4 });
|
||||
await page.mouse.move(x + deltaX, y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function expectDividerFollowsDrag({
|
||||
page,
|
||||
index,
|
||||
}: {
|
||||
page: Page;
|
||||
index: number;
|
||||
}): Promise<void> {
|
||||
const divider = splitDividers(page).nth(index);
|
||||
const before = await dividerX(divider);
|
||||
await dragDivider(divider, DRAG_DISTANCE);
|
||||
await expect
|
||||
.poll(async () => (await dividerX(divider)) - before, {
|
||||
message: `divider ${index + 1} should follow the drag`,
|
||||
})
|
||||
.toBeLessThan(DRAG_DISTANCE * 0.75);
|
||||
}
|
||||
|
||||
async function splitRight({
|
||||
page,
|
||||
expectedDividers,
|
||||
}: {
|
||||
page: Page;
|
||||
expectedDividers: number;
|
||||
}): Promise<void> {
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(splitDividers(page)).toHaveCount(expectedDividers);
|
||||
}
|
||||
|
||||
test.describe("Split pane resize", () => {
|
||||
test("every divider drags in a four-pane row", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "split-resize-fresh-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await splitRight({ page, expectedDividers: 1 });
|
||||
await splitRight({ page, expectedDividers: 2 });
|
||||
await splitRight({ page, expectedDividers: 3 });
|
||||
|
||||
for (const index of [0, 1, 2]) {
|
||||
await expectDividerFollowsDrag({ page, index });
|
||||
}
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("every divider drags after panes are added to a resized row", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
const workspace = await seedWorkspace({ repoPrefix: "split-resize-grown-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await splitRight({ page, expectedDividers: 1 });
|
||||
await expectDividerFollowsDrag({ page, index: 0 });
|
||||
|
||||
await splitRight({ page, expectedDividers: 2 });
|
||||
await splitRight({ page, expectedDividers: 3 });
|
||||
|
||||
for (const index of [0, 1, 2]) {
|
||||
await expectDividerFollowsDrag({ page, index });
|
||||
}
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Start on Settings > Host > Pair device with relay enabled in the Android app.
|
||||
// node packages/app/e2e/mobile/pair-device/android.mjs <agent-device-session>
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , session] = process.argv;
|
||||
assert(session, "Pass the agent-device session for the connected Android app");
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
ad(
|
||||
"press",
|
||||
'label="Pair a device, Scan a QR code or copy a link to connect your phone to this host"',
|
||||
"--settle",
|
||||
);
|
||||
ad("wait", "text", "Pairing QR code", "10000");
|
||||
const { data } = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const sheet = data.nodes.find((node) => node.label === "Bottom Sheet");
|
||||
const qr = data.nodes.find((node) => node.label === "Pairing QR code");
|
||||
assert(sheet?.rect, "Pairing sheet must be visible");
|
||||
assert(qr?.rect, "Pairing QR code must be visible");
|
||||
assert(qr.rect.x > sheet.rect.x, "QR must have an unclipped left margin inside the sheet");
|
||||
assert(
|
||||
qr.rect.x + qr.rect.width < sheet.rect.x + sheet.rect.width,
|
||||
"QR must have an unclipped right margin inside the sheet",
|
||||
);
|
||||
assert(Math.abs(qr.rect.width - qr.rect.height) <= 1, "Complete QR must remain square");
|
||||
assert(qr.rect.y >= sheet.rect.y, "QR top must be visible inside the sheet");
|
||||
assert(
|
||||
qr.rect.y + qr.rect.height <= sheet.rect.y + sheet.rect.height,
|
||||
"QR bottom must be visible inside the sheet",
|
||||
);
|
||||
assert(
|
||||
data.nodes.some((node) => node.label === "Copy" && node.enabled),
|
||||
"Copy must remain available",
|
||||
);
|
||||
console.log("PASS: complete square QR fits inside the pairing sheet", qr.rect);
|
||||
ad("press", 'label="Close"', "--settle");
|
||||
@@ -220,3 +220,24 @@ export function expectDaemonPidUnchanged(
|
||||
expect(before).toBeDefined();
|
||||
expect(after).toBe(before);
|
||||
}
|
||||
|
||||
export async function expectCompletePairingQr(page: Page): Promise<void> {
|
||||
const qr = page.getByRole("img", { name: "Pairing QR code" });
|
||||
await qr.scrollIntoViewIfNeeded();
|
||||
// IntersectionObserver rounds fractional sheet/scroll coordinates.
|
||||
await expect(qr).toBeInViewport({ ratio: 0.999 });
|
||||
const bounds = await qr.evaluate((element) => {
|
||||
const tile = element.parentElement;
|
||||
if (!tile) throw new Error("Pairing QR tile is missing");
|
||||
const qrRect = element.getBoundingClientRect();
|
||||
const tileRect = tile.getBoundingClientRect();
|
||||
return {
|
||||
qr: { x: qrRect.x, y: qrRect.y, right: qrRect.right, bottom: qrRect.bottom },
|
||||
tile: { x: tileRect.x, y: tileRect.y, right: tileRect.right, bottom: tileRect.bottom },
|
||||
};
|
||||
});
|
||||
expect(bounds.qr.x).toBeGreaterThanOrEqual(bounds.tile.x);
|
||||
expect(bounds.qr.y).toBeGreaterThanOrEqual(bounds.tile.y);
|
||||
expect(bounds.qr.right).toBeLessThanOrEqual(bounds.tile.right);
|
||||
expect(bounds.qr.bottom).toBeLessThanOrEqual(bounds.tile.bottom);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
// Polyfill crypto.randomUUID for React Native before any other imports
|
||||
import { polyfillCrypto } from "./src/polyfills/crypto";
|
||||
polyfillCrypto();
|
||||
|
||||
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
|
||||
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
||||
polyfillScreenOrientation();
|
||||
// Install runtime polyfills before any other module evaluates. Keep this the first import.
|
||||
// oxlint-disable-next-line import/no-unassigned-import -- Polyfills install on import.
|
||||
import "./src/polyfills/install-early";
|
||||
|
||||
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
||||
import "./src/styles/unistyles";
|
||||
|
||||
@@ -103,7 +103,6 @@ import {
|
||||
useKeyboardActionDispatcher,
|
||||
} from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { polyfillNavigator } from "@/polyfills/navigator";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
@@ -139,7 +138,6 @@ import {
|
||||
type WebNotificationClickDetail,
|
||||
} from "@/utils/os-notifications";
|
||||
|
||||
polyfillNavigator();
|
||||
polyfillCrypto();
|
||||
|
||||
export interface HostRuntimeBootstrapState {
|
||||
|
||||
@@ -968,8 +968,12 @@ function SplitNodeView({
|
||||
groupId ? state.splitSizesByWorkspace[workspaceKey]?.[groupId] : undefined,
|
||||
);
|
||||
const groupChildren = node.kind === "group" ? node.group.children : EMPTY_SPLIT_NODES;
|
||||
// Dragged sizes live per group id, apart from the layout tree, and a group keeps its id when
|
||||
// panes are added or removed. They only apply while they still have one entry per child;
|
||||
// otherwise the dividers past the end of the list could not move.
|
||||
const layoutGroupSizes = node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES;
|
||||
const groupSizes =
|
||||
storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES);
|
||||
storedGroupSizes?.length === groupChildren.length ? storedGroupSizes : layoutGroupSizes;
|
||||
const visibleFlex = useMemo(
|
||||
() => resolveVisibleGroupFlex(groupChildren, groupSizes, maximizedPaneId),
|
||||
[groupChildren, groupSizes, maximizedPaneId],
|
||||
|
||||
@@ -199,6 +199,71 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage {
|
||||
}
|
||||
|
||||
describe("server data push router", () => {
|
||||
it("keeps a visible diff subscribed when a hidden panel switches to the same comparison", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
const serverId = "server-1";
|
||||
const cwd = "/repo";
|
||||
const options = (mode: "base" | "uncommitted", enabled: boolean) => {
|
||||
const queryKey = checkoutDiffQueryKey(
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
mode === "base" ? "main" : undefined,
|
||||
);
|
||||
return {
|
||||
queryKey,
|
||||
queryFn: skipToken as typeof skipToken,
|
||||
gcTime: Infinity,
|
||||
meta: checkoutDiffPushRoute({
|
||||
enabled,
|
||||
serverId,
|
||||
cwd,
|
||||
subscriptionId: `checkoutDiff:${JSON.stringify(queryKey)}`,
|
||||
compare: { mode, ...(mode === "base" ? { baseRef: "main" } : {}) },
|
||||
}),
|
||||
};
|
||||
};
|
||||
const visible = new QueryObserver(queryClient, options("base", true));
|
||||
const hidden = new QueryObserver(queryClient, options("base", false));
|
||||
const unsubscribeVisible = visible.subscribe(() => {});
|
||||
const unsubscribeHidden = hidden.subscribe(() => {});
|
||||
const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId });
|
||||
try {
|
||||
visible.setOptions(options("uncommitted", true));
|
||||
hidden.setOptions(options("uncommitted", false));
|
||||
const subscription = fake.subscribeCheckoutDiffCalls.findLast(
|
||||
(call) => call.compare.mode === "uncommitted",
|
||||
)!;
|
||||
const files = [
|
||||
{
|
||||
path: "README.md",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
},
|
||||
];
|
||||
fake.emit({
|
||||
type: "checkout_diff_update",
|
||||
payload: { subscriptionId: subscription.subscriptionId, cwd, files, error: null },
|
||||
});
|
||||
expect(queryClient.getQueryData(options("uncommitted", true).queryKey)).toMatchObject({
|
||||
files,
|
||||
});
|
||||
expect(fake.unsubscribeCheckoutDiffCalls).not.toContain(subscription.subscriptionId);
|
||||
|
||||
unsubscribeVisible();
|
||||
expect(fake.unsubscribeCheckoutDiffCalls).toContain(subscription.subscriptionId);
|
||||
} finally {
|
||||
unsubscribeVisible();
|
||||
unsubscribeHidden();
|
||||
unmount();
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains equal diff files without reconstructing changed file bodies", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
|
||||
@@ -494,6 +494,14 @@ function getActiveServerDataRoute(
|
||||
if (query.getObserversCount() === 0) {
|
||||
return null;
|
||||
}
|
||||
// Query metadata belongs to the last observer that updated its options. A
|
||||
// retained hidden panel must not cancel demand from another visible panel.
|
||||
const observerRoutes = query.observers
|
||||
.map((observer) => getServerDataRoute({ meta: observer.options.meta }))
|
||||
.filter((route): route is ServerDataRoute => route !== null);
|
||||
if (observerRoutes.length > 0) {
|
||||
return observerRoutes.find((route) => route.enabled && route.serverId === serverId) ?? null;
|
||||
}
|
||||
const route = getServerDataRoute(query);
|
||||
if (route) {
|
||||
return route.enabled && route.serverId === serverId ? route : null;
|
||||
@@ -590,7 +598,7 @@ function canEventChangeDesiredSubscriptions(type: QueryCacheNotifyEvent["type"])
|
||||
);
|
||||
}
|
||||
|
||||
function getServerDataRoute(query: Query): ServerDataRoute | null {
|
||||
function getServerDataRoute(query: Pick<Query, "meta">): ServerDataRoute | null {
|
||||
const meta = query.meta;
|
||||
if (!isRecord(meta) || !isRecord(meta.serverData)) {
|
||||
return null;
|
||||
|
||||
@@ -80,7 +80,6 @@ export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps)
|
||||
type: "svg",
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 480,
|
||||
}),
|
||||
enabled: Boolean(pairingQuery.data?.url),
|
||||
dataShape: "value",
|
||||
@@ -275,7 +274,8 @@ function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) {
|
||||
return (
|
||||
<SvgXml
|
||||
xml={svg}
|
||||
style={styles.qrImage}
|
||||
width="100%"
|
||||
height="100%"
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={t("pairing.device.qrAccessibility")}
|
||||
/>
|
||||
@@ -365,10 +365,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.palette.white,
|
||||
},
|
||||
qrImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
linkRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveMarkdownFileDirectory, resolveMarkdownPreviewImageSource } from "./image-source";
|
||||
|
||||
describe("resolveMarkdownFileDirectory", () => {
|
||||
it("uses the parent of an absolute markdown path", () => {
|
||||
expect(
|
||||
resolveMarkdownFileDirectory({
|
||||
markdownPath: "/Users/test/project/docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/Users/test/project/docs");
|
||||
});
|
||||
|
||||
it("joins a workspace-relative markdown path to the workspace root", () => {
|
||||
expect(
|
||||
resolveMarkdownFileDirectory({
|
||||
markdownPath: "docs/星谷云/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/Users/test/project/docs/星谷云");
|
||||
});
|
||||
|
||||
it("preserves the host home directory for home-relative markdown paths", () => {
|
||||
expect(
|
||||
resolveMarkdownFileDirectory({
|
||||
markdownPath: "~/.paseo/plans/note.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("~/.paseo/plans");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMarkdownPreviewImageSource", () => {
|
||||
it("passes through remote and data URIs", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "https://example.com/image.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("https://example.com/image.png");
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "data:image/png;base64,abc",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("data:image/png;base64,abc");
|
||||
});
|
||||
|
||||
it("resolves a sibling image against the markdown file", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "./images/hero.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/Users/test/project/docs/images/hero.png");
|
||||
});
|
||||
|
||||
it("ignores local URL query strings and fragments", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "./images/hero.png?v=2#preview",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
).toBe("/workspace/docs/images/hero.png");
|
||||
});
|
||||
|
||||
it("preserves encoded filename characters when stripping URL suffixes", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "./hero%23draft%3Fv.png?v=2#preview",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
).toBe("/workspace/docs/hero#draft?v.png");
|
||||
});
|
||||
|
||||
it("preserves remote URL query strings and fragments", () => {
|
||||
const source = "https://example.com/hero.png?v=2#preview";
|
||||
expect(resolveMarkdownPreviewImageSource({ source, markdownPath: "docs/guide.md" })).toBe(
|
||||
source,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves parent-relative images that stay inside the workspace", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "../../data/cache/xinggu_docs/12-linkedin/token.png",
|
||||
markdownPath: "docs/星谷云/12-LinkedIn.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/Users/test/project/data/cache/xinggu_docs/12-linkedin/token.png");
|
||||
});
|
||||
|
||||
it("keeps absolute and home-relative image paths", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "/tmp/screenshot.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/tmp/screenshot.png");
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "~/.paseo/screenshots/output.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("~/.paseo/screenshots/output.png");
|
||||
});
|
||||
|
||||
it("normalizes file URIs into host paths", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "file:///tmp/paseo-preview.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/tmp/paseo-preview.png");
|
||||
});
|
||||
|
||||
it("resolves images beside a home-relative document without using the workspace root", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "screenshots/output.png",
|
||||
markdownPath: "~/.paseo/plans/note.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("~/.paseo/plans/screenshots/output.png");
|
||||
});
|
||||
|
||||
it("decodes a file URI exactly once", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "file:///tmp/hero%2520image.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
).toBe("/tmp/hero%20image.png");
|
||||
});
|
||||
|
||||
it("resolves parent segments from a home-relative document", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "../images/hero.png",
|
||||
markdownPath: "~/docs/guide.md",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
).toBe("~/images/hero.png");
|
||||
});
|
||||
|
||||
it("resolves Windows relative images against a drive-rooted markdown file", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "..\\images\\hero.png",
|
||||
markdownPath: "C:\\Users\\test\\repo\\docs\\guide.md",
|
||||
workspaceRoot: "C:\\Users\\test\\repo",
|
||||
}),
|
||||
).toBe("C:/Users/test/repo/images/hero.png");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { localFileSourceToPath } from "@/attachments/utils";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
function trimTrailingSeparators(value: string): string {
|
||||
if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
return value.replace(/[\\/]+$/, "");
|
||||
}
|
||||
|
||||
function normalizePathSegments(value: string): string {
|
||||
const segments: string[] = [];
|
||||
for (const segment of value.split("/")) {
|
||||
if (!segment || segment === ".") {
|
||||
continue;
|
||||
}
|
||||
if (segment === "..") {
|
||||
if (segments.length > 0) {
|
||||
segments.pop();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function normalizeAbsolutePath(value: string): string | null {
|
||||
const normalizedInput = trimTrailingSeparators(value.replace(/\\/g, "/"));
|
||||
if (isHomeRelativePath(normalizedInput)) {
|
||||
return `~/${normalizePathSegments(normalizedInput.slice(2))}`;
|
||||
}
|
||||
if (!isAbsolutePath(normalizedInput)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput);
|
||||
if (drivePath) {
|
||||
return trimTrailingSeparators(`${drivePath[1]}/${normalizePathSegments(drivePath[2])}`);
|
||||
}
|
||||
|
||||
const prefix = normalizedInput.startsWith("//") ? "//" : "/";
|
||||
return trimTrailingSeparators(
|
||||
`${prefix}${normalizePathSegments(normalizedInput.replace(/^\/+/, ""))}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parentDirectory(path: string): string | null {
|
||||
const normalized = trimTrailingSeparators(path.replace(/\\/g, "/"));
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) {
|
||||
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
||||
}
|
||||
|
||||
const index = normalized.lastIndexOf("/");
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
if (index === 0) {
|
||||
return "/";
|
||||
}
|
||||
const parent = normalized.slice(0, index);
|
||||
if (/^[A-Za-z]:$/.test(parent)) {
|
||||
return `${parent}/`;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
function joinAbsolute(baseDirectory: string, relativePath: string): string | null {
|
||||
const directory = trimTrailingSeparators(baseDirectory.replace(/\\/g, "/"));
|
||||
const relative = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!directory) {
|
||||
return null;
|
||||
}
|
||||
return normalizeAbsolutePath(`${directory}/${relative}`);
|
||||
}
|
||||
|
||||
function isHomeRelativePath(value: string): boolean {
|
||||
return value === "~" || value.startsWith("~/") || value.startsWith("~\\");
|
||||
}
|
||||
|
||||
function decodeRelativeSource(source: string): string {
|
||||
try {
|
||||
return decodeURIComponent(source);
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMarkdownFileDirectory(input: {
|
||||
markdownPath: string;
|
||||
workspaceRoot?: string;
|
||||
}): string | null {
|
||||
const markdownPath = input.markdownPath.trim().replace(/\\/g, "/");
|
||||
if (!markdownPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAbsolutePath(markdownPath) || isHomeRelativePath(markdownPath)) {
|
||||
const absolute = normalizeAbsolutePath(markdownPath);
|
||||
return absolute ? parentDirectory(absolute) : null;
|
||||
}
|
||||
|
||||
const workspaceRoot = input.workspaceRoot?.trim();
|
||||
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const absoluteMarkdown = joinAbsolute(workspaceRoot, markdownPath);
|
||||
return absoluteMarkdown ? parentDirectory(absoluteMarkdown) : null;
|
||||
}
|
||||
|
||||
export function resolveMarkdownPreviewImageSource(input: {
|
||||
source: string;
|
||||
markdownPath: string;
|
||||
workspaceRoot?: string;
|
||||
}): string | null {
|
||||
const source = input.source.trim();
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^(https?:|data:|blob:)/i.test(source)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const pathSource = source.replace(/[?#].*$/, "");
|
||||
const path = localFileSourceToPath(
|
||||
pathSource.startsWith("file://") ? pathSource : decodeRelativeSource(pathSource),
|
||||
);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isHomeRelativePath(path) || isAbsolutePath(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const directory = resolveMarkdownFileDirectory({
|
||||
markdownPath: input.markdownPath,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
});
|
||||
if (!directory) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return joinAbsolute(directory, path);
|
||||
}
|
||||
@@ -1,11 +1,59 @@
|
||||
import { useMemo } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ASTNode, RenderRules } from "react-native-markdown-display";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import {
|
||||
createSharedMarkdownRules,
|
||||
MarkdownRenderer,
|
||||
type MarkdownStyles,
|
||||
} from "@/components/markdown/renderer";
|
||||
import { parseMarkdownPreviewDocument } from "./document";
|
||||
import { resolveMarkdownPreviewImageSource } from "./image-source";
|
||||
import { FileMarkdownPreviewImage } from "./preview-image";
|
||||
|
||||
export function FileMarkdownPreview({ source }: { source: string }) {
|
||||
export function FileMarkdownPreview({
|
||||
source,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
client,
|
||||
serverId,
|
||||
}: {
|
||||
source: string;
|
||||
filePath: string;
|
||||
workspaceRoot?: string;
|
||||
client?: DaemonClient | null;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]);
|
||||
const rules = useMemo<RenderRules>(
|
||||
() => ({
|
||||
...createSharedMarkdownRules(),
|
||||
image: (node: ASTNode, _children, _parent: ASTNode[], _styles: MarkdownStyles) => {
|
||||
const resolved = resolveMarkdownPreviewImageSource({
|
||||
source: String(node.attributes?.src ?? ""),
|
||||
markdownPath: filePath,
|
||||
workspaceRoot,
|
||||
});
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FileMarkdownPreviewImage
|
||||
key={node.key}
|
||||
source={resolved}
|
||||
occurrenceKey={`${filePath}:${node.key}`}
|
||||
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
|
||||
client={client}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[client, filePath, serverId, workspaceRoot],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.outerGutter}>
|
||||
@@ -31,7 +79,7 @@ export function FileMarkdownPreview({ source }: { source: string }) {
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<MarkdownRenderer text={document.body} />
|
||||
<MarkdownRenderer text={document.body} rules={rules} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const PREVIEW_IMAGE_MIN_HEIGHT = 160;
|
||||
|
||||
export function FileMarkdownPreviewImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
alt,
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
}: {
|
||||
source: string;
|
||||
occurrenceKey: string;
|
||||
alt?: string;
|
||||
client?: DaemonClient | null;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const openViewer = useCallback(() => setViewerOpen(true), []);
|
||||
const closeViewer = useCallback(() => setViewerOpen(false), []);
|
||||
const image = useAssistantImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
});
|
||||
const binding = image.status === "failed" ? null : image.binding;
|
||||
const aspectRatio = image.status === "failed" ? null : image.aspectRatio;
|
||||
const imageUri = binding?.uri ?? "";
|
||||
const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]);
|
||||
const imageSizeStyle = useMemo<ViewStyle>(() => {
|
||||
if (aspectRatio) {
|
||||
return { aspectRatio };
|
||||
}
|
||||
return { height: PREVIEW_IMAGE_MIN_HEIGHT };
|
||||
}, [aspectRatio]);
|
||||
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.surface, imageSizeStyle],
|
||||
[imageSizeStyle],
|
||||
);
|
||||
const lightboxSource = useMemo<ImageLightboxSource | null>(() => {
|
||||
if (!viewerOpen || !imageUri) return null;
|
||||
return {
|
||||
type: "uri",
|
||||
uri: imageUri,
|
||||
contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined,
|
||||
};
|
||||
}, [aspectRatio, imageUri, viewerOpen]);
|
||||
|
||||
if (image.status === "failed") {
|
||||
return (
|
||||
<View style={[styles.frame, styles.state]} testID="markdown-preview-image-failed">
|
||||
<Text style={styles.errorText}>{image.message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!binding) {
|
||||
return (
|
||||
<View style={[styles.frame, styles.state]} testID="markdown-preview-image-loading">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.frame} testID="markdown-preview-image">
|
||||
<Pressable
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
accessibilityRole="button"
|
||||
disabled={image.status !== "loaded"}
|
||||
onPress={openViewer}
|
||||
style={surfaceStyle}
|
||||
>
|
||||
<View style={styles.image} accessibilityRole="image" accessibilityLabel={alt}>
|
||||
<Image
|
||||
ref={binding.onRef}
|
||||
source={imageSource}
|
||||
style={styles.image}
|
||||
resizeMode="contain"
|
||||
onLoad={binding.onLoad}
|
||||
onError={binding.onError}
|
||||
/>
|
||||
{image.status === "loading" ? (
|
||||
<View pointerEvents="none" style={styles.loadingOverlay}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
<AttachmentLightbox source={lightboxSource} onClose={closeViewer} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
frame: {
|
||||
width: "100%",
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
surface: {
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
image: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
state: {
|
||||
minHeight: PREVIEW_IMAGE_MIN_HEIGHT,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -54,6 +54,9 @@ interface FilePreviewBodyProps {
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
imagePreviewUri: string | null;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||
@@ -135,6 +138,9 @@ function FilePreviewBody({
|
||||
location,
|
||||
navigationRevision,
|
||||
imagePreviewUri,
|
||||
workspaceRoot,
|
||||
client,
|
||||
serverId,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
@@ -182,7 +188,13 @@ function FilePreviewBody({
|
||||
style={styles.previewContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<FileMarkdownPreview source={preview.content ?? ""} />
|
||||
<FileMarkdownPreview
|
||||
source={preview.content ?? ""}
|
||||
filePath={filePath}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
</RNScrollView>
|
||||
</View>
|
||||
);
|
||||
@@ -297,6 +309,7 @@ export function FilePane({
|
||||
return (
|
||||
<FilePanePresentation
|
||||
serverId={serverId}
|
||||
workspaceRoot={normalizedWorkspaceRoot}
|
||||
client={client}
|
||||
readTarget={readTarget}
|
||||
preview={preview}
|
||||
@@ -338,6 +351,7 @@ function isEditableTextFile(input: {
|
||||
|
||||
function FilePanePresentation({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
client,
|
||||
readTarget,
|
||||
preview,
|
||||
@@ -359,6 +373,7 @@ function FilePanePresentation({
|
||||
imagePreviewUri,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
readTarget: { cwd: string; path: string } | null;
|
||||
preview: ExplorerFile | null;
|
||||
@@ -393,6 +408,8 @@ function FilePanePresentation({
|
||||
return (
|
||||
<EditableFilePane
|
||||
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
cwd={readTarget.cwd}
|
||||
path={readTarget.path}
|
||||
@@ -449,12 +466,17 @@ function FilePanePresentation({
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableFilePane({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
client,
|
||||
cwd,
|
||||
path,
|
||||
@@ -470,6 +492,8 @@ function EditableFilePane({
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient;
|
||||
cwd: string;
|
||||
path: string;
|
||||
@@ -622,6 +646,9 @@ function EditableFilePane({
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={null}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1027,6 +1027,71 @@ describe("unassigned shortcuts", () => {
|
||||
});
|
||||
|
||||
describe("matching", () => {
|
||||
it("dispatches a captured Russian letter shortcut on the same key", () => {
|
||||
const event = keyboardInput({ key: "и", code: "KeyB", ctrlKey: true });
|
||||
const combo = keyboardEventToComboString(event as KeyboardEvent);
|
||||
expect(combo).toBe("Ctrl+B");
|
||||
const bindings = buildEffectiveBindings({
|
||||
"sidebar-toggle-left-ctrl-period-non-mac": combo!,
|
||||
});
|
||||
|
||||
expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe(
|
||||
"sidebar.toggle.left",
|
||||
);
|
||||
});
|
||||
|
||||
it("dispatches a recorded dotted-uppercase letter with Shift held", () => {
|
||||
const event = keyboardInput({ key: "İ", code: "KeyS", ctrlKey: true, shiftKey: true });
|
||||
const combo = keyboardEventToComboString(event as KeyboardEvent);
|
||||
expect(combo).toBe("Ctrl+Shift+S");
|
||||
const bindings = buildEffectiveBindings({
|
||||
"sidebar-toggle-left-ctrl-period-non-mac": combo!,
|
||||
});
|
||||
expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe(
|
||||
"sidebar.toggle.left",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["и", "И", "β", "ش", "ב", "é"])(
|
||||
"matches default and recorded letter bindings for unsupported letter %s",
|
||||
(key) => {
|
||||
const event = keyboardInput({ key, code: "KeyB", ctrlKey: true });
|
||||
expectShortcutResolution({
|
||||
event,
|
||||
context: desktopNonMac,
|
||||
action: "sidebar.toggle.left",
|
||||
});
|
||||
const combo = keyboardEventToComboString(event as KeyboardEvent);
|
||||
expect(combo).toBe("Ctrl+B");
|
||||
expect(
|
||||
resolveShortcut({
|
||||
event,
|
||||
context: desktopNonMac,
|
||||
bindings: buildEffectiveBindings({
|
||||
"sidebar-toggle-left-ctrl-period-non-mac": combo!,
|
||||
}),
|
||||
}).match?.action,
|
||||
).toBe("sidebar.toggle.left");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["v", "†", "Dead", "Unidentified", ""])(
|
||||
"does not match an unrelated character or named key %s by physical letter",
|
||||
(key) => {
|
||||
expectNoShortcutResolution({
|
||||
event: { key, code: "KeyB", ctrlKey: true },
|
||||
context: desktopNonMac,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not match non-letter bindings by physical key for unsupported letters", () => {
|
||||
expectNoShortcutResolution({
|
||||
event: { key: "ю", code: "Period", ctrlKey: true },
|
||||
context: desktopNonMac,
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches a captured Dvorak binding on the same logical key", () => {
|
||||
const event = keyboardInput({ key: "b", code: "KeyN", metaKey: true });
|
||||
const combo = keyboardEventToComboString(event as KeyboardEvent);
|
||||
|
||||
@@ -1285,6 +1285,12 @@ function matchesKeyOrCode(combo: KeyCombo, event: KeyboardShortcutInput): boolea
|
||||
if (combo.shift === true && combo.shiftedKey !== undefined && eventKey === combo.shiftedKey) {
|
||||
return true;
|
||||
}
|
||||
// Recording an unsupported letter (for example Cyrillic и on KeyB)
|
||||
// stores its physical A-Z key. Dispatch must accept that same key without
|
||||
// giving Latin layouts such as Dvorak a second physical-key binding.
|
||||
if (/^[a-z]$/.test(combo.key) && /^\p{Letter}$/u.test(event.key) && !/^[a-z]$/.test(eventKey)) {
|
||||
return event.code === combo.code;
|
||||
}
|
||||
// macOS rewrites event.key when Option is held (Option+T -> "†",
|
||||
// Option+[ -> "“"), so Alt-bound letter / bracket bindings can only
|
||||
// match by event.code. Stay key-first for non-Alt bindings so Dvorak
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Side-effect module imported first by index.ts. Babel hoists imports above
|
||||
// statements, so polyfill calls written in index.ts would run only after every
|
||||
// imported module had evaluated. Modules that read these globals at load time,
|
||||
// such as @xterm/headless reading navigator.userAgent, need them installed here.
|
||||
import { polyfillCrypto } from "./crypto";
|
||||
import { polyfillNavigator } from "./navigator";
|
||||
import { polyfillScreenOrientation } from "./screen-orientation";
|
||||
|
||||
polyfillCrypto();
|
||||
polyfillNavigator();
|
||||
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
|
||||
polyfillScreenOrientation();
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { createContext, runInContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("installs navigator before the app entry loads its other dependencies", () => {
|
||||
const entry = ts.createSourceFile(
|
||||
"index.ts",
|
||||
readFileSync(new URL("../../index.ts", import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
);
|
||||
const firstImport = entry.statements.find(ts.isImportDeclaration);
|
||||
expect(firstImport?.moduleSpecifier.getText(entry)).toBe('"./src/polyfills/install-early"');
|
||||
const early = ts.createSourceFile(
|
||||
"install-early.ts",
|
||||
readFileSync(new URL("./install-early.ts", import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
);
|
||||
expect(
|
||||
early.statements.some(
|
||||
(statement) =>
|
||||
ts.isExpressionStatement(statement) &&
|
||||
ts.isCallExpression(statement.expression) &&
|
||||
statement.expression.expression.getText(early) === "polyfillNavigator",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes the native navigator before evaluating the real terminal dependency", () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const terminalSource = readFileSync(require.resolve("@xterm/headless"), "utf8");
|
||||
const navigatorSource = ts.transpileModule(
|
||||
readFileSync(new URL("./navigator.ts", import.meta.url), "utf8"),
|
||||
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } },
|
||||
).outputText;
|
||||
// Each VM owns its globals, so the native navigator shape never replaces
|
||||
// globals in the test runner or needs a native-module mock.
|
||||
const uninitialized = createContext({
|
||||
navigator: {},
|
||||
window: {},
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
});
|
||||
expect(() => runInContext(terminalSource, uninitialized)).toThrow(/includes/);
|
||||
|
||||
const initialized = createContext({
|
||||
navigator: {},
|
||||
window: {},
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
});
|
||||
runInContext(navigatorSource, initialized);
|
||||
runInContext("exports.polyfillNavigator()", initialized);
|
||||
expect(() => runInContext(terminalSource, initialized)).not.toThrow();
|
||||
});
|
||||
@@ -328,7 +328,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
|
||||
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const openProviderSettings = useProviderSettingsStore((state) => state.open);
|
||||
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
|
||||
const [removingProviderId, setRemovingProviderId] = useState<string | null>(null);
|
||||
@@ -338,6 +338,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
|
||||
const hasServer = serverId.length > 0;
|
||||
|
||||
/** Custom providers are deletable only when config.json holds their `extends` definition. */
|
||||
const isConfigOwnedProvider = useCallback(
|
||||
(providerId: string) => Boolean(config?.providers?.[providerId]?.extends),
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOpenProviderSettings = useCallback(
|
||||
(providerId: string) => {
|
||||
openProviderSettings({ serverId, provider: providerId });
|
||||
@@ -444,7 +450,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
enabled={entry.enabled ?? true}
|
||||
isToggling={pendingProviderId === def.id}
|
||||
isRemoving={removingProviderId === def.id}
|
||||
canRemove={supportsProviderRemoval && entry.source === "custom"}
|
||||
canRemove={
|
||||
supportsProviderRemoval &&
|
||||
entry.source === "custom" &&
|
||||
isConfigOwnedProvider(def.id)
|
||||
}
|
||||
isFirst={index === 0}
|
||||
onPress={handleOpenProviderSettings}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
|
||||
@@ -1051,7 +1051,7 @@ async function waitForProvidersReady(
|
||||
|
||||
try {
|
||||
await observation.ready;
|
||||
signal.throwIfAborted();
|
||||
if (signal.aborted) throw new Error("Paseo API is disposed");
|
||||
return await new Promise<PaseoProviderSnapshotResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let requestId: string | null = null;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { _electron as electron, expect } from "playwright/test";
|
||||
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-window-startup-"));
|
||||
const modulePath = fileURLToPath(new URL("../dist/window/show-when-ready.js", import.meta.url));
|
||||
|
||||
async function launchWindowWithoutHiddenFrames() {
|
||||
const main = path.join(root, "main.cjs");
|
||||
await writeFile(
|
||||
main,
|
||||
`const { app, BrowserWindow } = require("electron");
|
||||
const { showWindowWhenReady } = require(${JSON.stringify(modulePath)});
|
||||
app.setPath("userData", ${JSON.stringify(path.join(root, "user-data"))});
|
||||
app.whenReady().then(async () => {
|
||||
const win = global.mainWindow = new BrowserWindow({ show: false, paintWhenInitiallyHidden: false });
|
||||
showWindowWhenReady(win, { showOnLoad: true });
|
||||
await win.loadURL("data:text/html,<h1>Desktop ready</h1>");
|
||||
global.visibleAfterLoad = win.isVisible();
|
||||
});`,
|
||||
);
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => key !== "ELECTRON_RUN_AS_NODE"),
|
||||
);
|
||||
const displayArgs = process.platform === "linux" ? ["--ozone-platform=x11", "--disable-gpu"] : [];
|
||||
return electron.launch({
|
||||
args: ["--no-sandbox", ...displayArgs, main],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectLoadedDesktopIsShown(desktop) {
|
||||
await expect.poll(() => desktop.evaluate(() => global.visibleAfterLoad)).toBe(true);
|
||||
const page = await desktop.firstWindow();
|
||||
await expect(page.getByRole("heading", { name: "Desktop ready" })).toBeVisible();
|
||||
}
|
||||
|
||||
let desktop;
|
||||
try {
|
||||
desktop = await launchWindowWithoutHiddenFrames();
|
||||
await expectLoadedDesktopIsShown(desktop);
|
||||
console.log("Loaded desktop is visible without hidden compositor frames");
|
||||
} finally {
|
||||
await desktop?.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -25,7 +25,8 @@
|
||||
"verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs",
|
||||
"test": "install-electron && vitest run --exclude \"e2e/**\"",
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
||||
"test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs"
|
||||
"test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs",
|
||||
"test:e2e:window-startup": "npm run build:main && node ./e2e/window-startup.e2e.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
buildStandardContextMenuItems,
|
||||
} from "./window/window-manager.js";
|
||||
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
|
||||
import { showWindowWhenReady } from "./window/show-when-ready.js";
|
||||
import { resolveDesktopWindowChromeMode, windowChromeModeArgument } from "./window/chrome.js";
|
||||
import { registerDialogHandlers } from "./features/dialogs.js";
|
||||
import {
|
||||
@@ -773,8 +774,8 @@ async function createWindow(
|
||||
registerBrowserWebviewNavigationGuards(contents);
|
||||
});
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
showWindowWhenReady(mainWindow, {
|
||||
showOnLoad: process.platform === "linux",
|
||||
});
|
||||
|
||||
if (!app.isPackaged) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { showWindowWhenReady } from "./show-when-ready.js";
|
||||
|
||||
class Window extends EventEmitter {
|
||||
webContents = new EventEmitter();
|
||||
shows = 0;
|
||||
destroyed = false;
|
||||
show() {
|
||||
this.shows += 1;
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
close() {
|
||||
this.destroyed = true;
|
||||
this.emit("closed");
|
||||
}
|
||||
}
|
||||
|
||||
describe("showWindowWhenReady", () => {
|
||||
it("shows a loaded Linux window even when no initial frame was painted", () => {
|
||||
const win = new Window();
|
||||
showWindowWhenReady(win, { showOnLoad: true });
|
||||
expect(win.shows).toBe(0);
|
||||
win.webContents.emit("did-finish-load");
|
||||
expect(win.shows).toBe(1);
|
||||
win.emit("ready-to-show");
|
||||
expect(win.shows).toBe(1);
|
||||
});
|
||||
|
||||
it("shows on the first frame without showing again after load", () => {
|
||||
const win = new Window();
|
||||
showWindowWhenReady(win, { showOnLoad: true });
|
||||
win.emit("ready-to-show");
|
||||
win.webContents.emit("did-finish-load");
|
||||
expect(win.shows).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves waiting for the initial frame when show on load is disabled", () => {
|
||||
const win = new Window();
|
||||
showWindowWhenReady(win, { showOnLoad: false });
|
||||
win.webContents.emit("did-finish-load");
|
||||
expect(win.shows).toBe(0);
|
||||
win.emit("ready-to-show");
|
||||
expect(win.shows).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps a closed window closed when readiness arrives later", () => {
|
||||
const win = new Window();
|
||||
showWindowWhenReady(win, { showOnLoad: true });
|
||||
win.close();
|
||||
win.webContents.emit("did-finish-load");
|
||||
win.emit("ready-to-show");
|
||||
expect(win.shows).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
interface ReadyToShowWindow {
|
||||
once(event: "ready-to-show", listener: () => void): unknown;
|
||||
webContents: {
|
||||
once(event: "did-finish-load", listener: () => void): unknown;
|
||||
};
|
||||
show(): void;
|
||||
isDestroyed(): boolean;
|
||||
}
|
||||
|
||||
export function showWindowWhenReady(
|
||||
win: ReadyToShowWindow,
|
||||
options: { showOnLoad: boolean },
|
||||
): void {
|
||||
let shown = false;
|
||||
const show = () => {
|
||||
if (shown || win.isDestroyed()) return;
|
||||
shown = true;
|
||||
win.show();
|
||||
};
|
||||
|
||||
win.once("ready-to-show", show);
|
||||
// On Linux a hidden window can wait for compositor frames until it is shown.
|
||||
// Loading the document is sufficient to reveal it without depending on a frame.
|
||||
if (options.showOnLoad) win.webContents.once("did-finish-load", show);
|
||||
}
|
||||
@@ -144,6 +144,34 @@ const title = "Hello";
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights Astro markup containing only an empty expression", () => {
|
||||
const code = "<p>{}</p>";
|
||||
|
||||
const tokens = highlightCode(code, "Empty.astro").flat();
|
||||
|
||||
expect(tokens.map((token) => token.text).join("")).toBe(code);
|
||||
expect(tokens).toContainEqual({ text: "p", style: "tag" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"<p>{title} {} {title}</p>",
|
||||
'<p class="{} {title}">{title}</p>',
|
||||
"<p class={} title={title}>{title}</p>",
|
||||
])("highlights Astro containing an empty expression: %s", (markup) => {
|
||||
const code = `---\nconst title = "Hello";\n---\n${markup}\n<style>.page { color: red; }</style>`;
|
||||
|
||||
const lines = highlightCode(code, "Empty.astro");
|
||||
|
||||
expect(lines).toHaveLength(code.split("\n").length);
|
||||
for (const [index, tokens] of lines.entries()) {
|
||||
expect(tokens.map((token) => token.text).join("")).toBe(code.split("\n")[index]);
|
||||
}
|
||||
expect(lines.flat()).toContainEqual({ text: "const", style: "keyword" });
|
||||
expect(lines.flat()).toContainEqual({ text: "p", style: "tag" });
|
||||
expect(lines.flat()).toContainEqual({ text: "color", style: "property" });
|
||||
expect(lines.flat()).toContainEqual({ text: "title", style: "variable" });
|
||||
});
|
||||
|
||||
it("keeps an Astro expression open through nested template strings", () => {
|
||||
const code = "<p>{`a ${`}`} b`}</p>";
|
||||
|
||||
|
||||
@@ -45,10 +45,12 @@ function maskExpressions(text: string): string {
|
||||
}
|
||||
|
||||
function expressionOverlays(node: SyntaxNodeRef, input: Input): Range[] | null {
|
||||
const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({
|
||||
from: node.from + from + 1,
|
||||
to: node.from + to,
|
||||
}));
|
||||
const overlays = findExpressions(input.read(node.from, node.to))
|
||||
.filter(({ from, to }) => to > from + 1)
|
||||
.map(({ from, to }) => ({
|
||||
from: node.from + from + 1,
|
||||
to: node.from + to,
|
||||
}));
|
||||
return overlays.length > 0 ? overlays : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1750,6 +1750,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private fallbackAssistantMessageId: string | null = null;
|
||||
private closed = false;
|
||||
private historyPending = false;
|
||||
private historyRuntimeReleased = false;
|
||||
private replayingHistory = false;
|
||||
private bootstrapThreadEventPending = false;
|
||||
private readonly terminateProcess: ProcessTerminator;
|
||||
@@ -1867,11 +1868,38 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
await this.applyConfiguredOverrides();
|
||||
|
||||
if (this.resumePurpose === "history") {
|
||||
await this.releaseHistoryRuntime();
|
||||
}
|
||||
} catch (error) {
|
||||
await this.closeAfterInitializationFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
// History resumes exist to replay persisted events into the canonical
|
||||
// timeline; the provider process is released once replay finishes so
|
||||
// archived agents hold no resident runtime. Interactive work goes through
|
||||
// reloadAgentSession, which spawns a fresh process.
|
||||
private async releaseHistoryRuntime(): Promise<void> {
|
||||
const child = this.child;
|
||||
if (child) {
|
||||
const result = await this.terminateProcess(child, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
});
|
||||
if (result === "kill-timeout") {
|
||||
// Keep the child registered so close() can retry the shutdown.
|
||||
this.logger.warn("ACP history resume could not release the provider process");
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.historyRuntimeReleased = true;
|
||||
this.connection = null;
|
||||
this.child = null;
|
||||
this.settleCommandsReady();
|
||||
}
|
||||
|
||||
private async closeAfterInitializationFailure(error: unknown): Promise<never> {
|
||||
try {
|
||||
await this.close();
|
||||
@@ -2841,7 +2869,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
});
|
||||
const spawnError = rejectOnSpawnError(child, stderrChunks);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (this.closed) {
|
||||
if (this.closed || this.historyRuntimeReleased) {
|
||||
return;
|
||||
}
|
||||
if (this.activeForegroundTurnId) {
|
||||
|
||||
+17
-10
@@ -1,5 +1,5 @@
|
||||
import { createServer } from "node:http";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
@@ -13,12 +13,18 @@ test.each([
|
||||
{ placement: "delta", cacheTokens: 0 },
|
||||
{ placement: "start", cacheTokens: 1_000 },
|
||||
{ placement: "delta", cacheTokens: 1_000 },
|
||||
{ placement: "start", cacheTokens: 0, model: "glm-5.2[1m]" },
|
||||
{ placement: "start", cacheTokens: 0, model: "sonnet", resolvedModel: "glm-5.2[1m]" },
|
||||
])(
|
||||
"counts $placement input and $cacheTokens cached tokens through Claude Code",
|
||||
async ({ placement, cacheTokens }) => {
|
||||
"counts $placement input and $cacheTokens cached tokens through Claude Code ($model)",
|
||||
async ({ placement, cacheTokens, model = "claude-sonnet-4-6", resolvedModel = model }) => {
|
||||
const cwd = await mkdtemp(path.resolve(".claude-context-usage-"));
|
||||
const configDir = path.join(cwd, "config");
|
||||
await mkdir(configDir);
|
||||
await writeFile(
|
||||
path.join(configDir, "settings.json"),
|
||||
JSON.stringify({ env: { ANTHROPIC_DEFAULT_SONNET_MODEL: resolvedModel } }),
|
||||
);
|
||||
let requestCount = 0;
|
||||
const server = createServer(async (request, response) => {
|
||||
for await (const chunk of request) {
|
||||
@@ -45,7 +51,7 @@ test.each([
|
||||
id: `msg_context_usage_${requestCount}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
model: resolvedModel,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
@@ -86,11 +92,9 @@ test.each([
|
||||
},
|
||||
},
|
||||
});
|
||||
const session = await client.createSession(
|
||||
{ provider: "claude", cwd, model: "claude-sonnet-4-6" },
|
||||
undefined,
|
||||
{ persistSession: false },
|
||||
);
|
||||
const session = await client.createSession({ provider: "claude", cwd, model }, undefined, {
|
||||
persistSession: false,
|
||||
});
|
||||
try {
|
||||
for (let turn = 0; turn < 2; turn++) {
|
||||
const events = await collectSessionTurnEvents(session, "say hello", {
|
||||
@@ -111,7 +115,10 @@ test.each([
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
usage: expect.objectContaining({ contextWindowUsedTokens: expectedTokens }),
|
||||
usage: expect.objectContaining({
|
||||
contextWindowUsedTokens: expectedTokens,
|
||||
contextWindowMaxTokens: resolvedModel.endsWith("[1m]") ? 1_000_000 : 200_000,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import type { ClaudeQueryFactory } from "./query.js";
|
||||
|
||||
// A query dependency that speaks the same stream interface as Claude Code.
|
||||
// Each request repeats init so a second turn exercises runtime-limit retention.
|
||||
function contextQuery(runtimeModel: string, reportedLimit: number): ClaudeQueryFactory {
|
||||
return ({ prompt }) => {
|
||||
let model = runtimeModel;
|
||||
const messages = (async function* () {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
for await (const _message of prompt) {
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "context-session",
|
||||
permissionMode: "default",
|
||||
model,
|
||||
};
|
||||
yield {
|
||||
type: "stream_event",
|
||||
session_id: "context-session",
|
||||
event: { type: "message_start", message: { usage: { input_tokens: 249_950 } } },
|
||||
};
|
||||
yield {
|
||||
type: "stream_event",
|
||||
session_id: "context-session",
|
||||
event: { type: "message_delta", usage: { output_tokens: 50 } },
|
||||
};
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "context-session",
|
||||
duration_ms: 1,
|
||||
duration_api_ms: 1,
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
result: "done",
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0,
|
||||
usage: { input_tokens: 249_950, output_tokens: 50, cache_read_input_tokens: 0 },
|
||||
modelUsage: { [model]: { contextWindow: reportedLimit } },
|
||||
permission_denials: [],
|
||||
uuid: "context-result",
|
||||
};
|
||||
}
|
||||
})();
|
||||
return Object.assign(messages, {
|
||||
close: () => {
|
||||
void messages.return();
|
||||
},
|
||||
interrupt: async () => undefined,
|
||||
setModel: async (value: string) => {
|
||||
model = value;
|
||||
},
|
||||
setPermissionMode: async () => undefined,
|
||||
supportedModels: async () => [],
|
||||
supportedCommands: async () => [],
|
||||
}) as ReturnType<ClaudeQueryFactory>;
|
||||
};
|
||||
}
|
||||
|
||||
test.each([
|
||||
{ selected: "glm-5.2[1m]", runtime: "glm-5.2[1m]", expected: 1_000_000 },
|
||||
{ selected: "sonnet", runtime: "glm-5.2[1m]", expected: 1_000_000 },
|
||||
{ selected: "glm-5.2", runtime: "glm-5.2", expected: undefined },
|
||||
])(
|
||||
"streams the context limit for $selected resolved as $runtime",
|
||||
async ({ selected, runtime, expected }) => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: contextQuery(runtime, 800_000),
|
||||
resolveBinary: async () => process.execPath,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: selected,
|
||||
});
|
||||
try {
|
||||
const first = await collectSessionTurnEvents(session, "first turn");
|
||||
const live = first.filter((event) => event.type === "usage_updated");
|
||||
expect(live.map((event) => event.usage.contextWindowMaxTokens)).toEqual([expected, expected]);
|
||||
expect(live.map((event) => event.usage.contextWindowUsedTokens)).toEqual([249_950, 250_000]);
|
||||
expect(first).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "turn_completed",
|
||||
usage: expect.objectContaining({ contextWindowMaxTokens: 800_000 }),
|
||||
}),
|
||||
);
|
||||
|
||||
const second = await collectSessionTurnEvents(session, "second turn");
|
||||
expect(
|
||||
second
|
||||
.filter((event) => event.type === "usage_updated")
|
||||
.map((event) => event.usage.contextWindowMaxTokens),
|
||||
).toEqual([800_000, 800_000]);
|
||||
|
||||
await session.setModel?.("another-custom-model");
|
||||
const switched = await collectSessionTurnEvents(session, "after model switch");
|
||||
expect(
|
||||
switched
|
||||
.filter((event) => event.type === "usage_updated")
|
||||
.map((event) => event.usage.contextWindowMaxTokens),
|
||||
).toEqual([undefined, undefined]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -2805,6 +2805,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 150,
|
||||
},
|
||||
}),
|
||||
@@ -2840,7 +2841,13 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
|
||||
test("selected 1M Claude models seed active context window usage from the catalog", async () => {
|
||||
const session = await createSessionForTurns(
|
||||
[[createInitMessage(), createMessageStartEvent(), createSuccessResult()]],
|
||||
[
|
||||
[
|
||||
{ ...createInitMessage(), model: "claude-sonnet-5[1m]" },
|
||||
createMessageStartEvent(),
|
||||
createSuccessResult(),
|
||||
],
|
||||
],
|
||||
{ model: "claude-sonnet-5[1m]" },
|
||||
);
|
||||
|
||||
@@ -2880,6 +2887,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 175,
|
||||
},
|
||||
}),
|
||||
@@ -2922,6 +2930,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 20_300,
|
||||
},
|
||||
}),
|
||||
@@ -2963,6 +2972,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 1_069,
|
||||
},
|
||||
}),
|
||||
@@ -3026,6 +3036,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 55,
|
||||
},
|
||||
}),
|
||||
@@ -3035,6 +3046,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 62,
|
||||
},
|
||||
}),
|
||||
@@ -3072,6 +3084,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 704,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
readTaskNotificationToolUseIdFromHistoryRecord,
|
||||
} from "./task-notification-tool-call.js";
|
||||
import {
|
||||
findClaudeModel,
|
||||
getClaudeContextWindowMaxTokens,
|
||||
getClaudeModelsWithSettings,
|
||||
normalizeClaudeRuntimeModelId,
|
||||
resolveConfiguredClaudeModel,
|
||||
@@ -2240,7 +2240,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.resolveBinary = options.resolveBinary;
|
||||
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
|
||||
this.contextUsage = new ClaudeContextUsageState(
|
||||
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
|
||||
getClaudeContextWindowMaxTokens(this.config.model),
|
||||
);
|
||||
const handle = options.handle;
|
||||
|
||||
@@ -2572,7 +2572,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await this.applyFastModeFeature(false, activeQuery);
|
||||
}
|
||||
this.contextUsage.setInitialContextWindowMaxTokens(
|
||||
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
|
||||
getClaudeContextWindowMaxTokens(this.config.model),
|
||||
);
|
||||
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
|
||||
this.lastRuntimeModel = null;
|
||||
@@ -4821,6 +4821,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.observePermissionMode(message.permissionMode);
|
||||
this.persistence = null;
|
||||
if (message.model) {
|
||||
const runtimeContextWindowMaxTokens = getClaudeContextWindowMaxTokens(message.model);
|
||||
// Seed usage before the first result, including settings-resolved aliases.
|
||||
// A repeated init for the same model must retain the SDK's reported limit.
|
||||
if (message.model !== this.lastRuntimeModel && runtimeContextWindowMaxTokens !== undefined) {
|
||||
this.contextUsage.setInitialContextWindowMaxTokens(runtimeContextWindowMaxTokens);
|
||||
}
|
||||
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
|
||||
this.logger.debug(
|
||||
{ runtimeModel: message.model, normalizedRuntimeModel },
|
||||
|
||||
@@ -15,7 +15,27 @@ import {
|
||||
} from "./model-manifest.js";
|
||||
import { discoverClaudeModels } from "./model-discovery.js";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
|
||||
import {
|
||||
findClaudeModel,
|
||||
getClaudeContextWindowMaxTokens,
|
||||
getClaudeModels,
|
||||
normalizeClaudeRuntimeModelId,
|
||||
} from "./models.js";
|
||||
|
||||
describe("getClaudeContextWindowMaxTokens", () => {
|
||||
it.each([
|
||||
["glm-5.2[1m]", 1_000_000],
|
||||
[" glm-5.2[1M] ", 1_000_000],
|
||||
["claude-sonnet-4-6", 200_000],
|
||||
["claude-sonnet-4-6[1m]", 1_000_000],
|
||||
["glm-5.2", undefined],
|
||||
["glm-5.2[1m]-other", undefined],
|
||||
[undefined, undefined],
|
||||
[null, undefined],
|
||||
])("resolves %s to %s without guessing unknown models", (model, expected) => {
|
||||
expect(getClaudeContextWindowMaxTokens(model)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
const createdClaudeConfigDirs: string[] = [];
|
||||
|
||||
|
||||
@@ -48,6 +48,15 @@ export function findClaudeModel(
|
||||
return getClaudeModels().find((model) => model.id === normalizedModelId);
|
||||
}
|
||||
|
||||
export function getClaudeContextWindowMaxTokens(
|
||||
modelId: string | null | undefined,
|
||||
): number | undefined {
|
||||
if (modelId?.trim().toLowerCase().endsWith("[1m]")) {
|
||||
return 1_000_000;
|
||||
}
|
||||
return findClaudeModel(modelId)?.contextWindowMaxTokens;
|
||||
}
|
||||
|
||||
export async function getClaudeModelsWithSettings(
|
||||
logger: Logger,
|
||||
configDir: string,
|
||||
|
||||
+61
-2
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -68,8 +68,60 @@ describe("GenericACPAgentClient archived history", () => {
|
||||
).rejects.toThrow("ENOENT");
|
||||
});
|
||||
});
|
||||
|
||||
test("releases the provider process once an archived session's history is replayed", async () => {
|
||||
await withFakeACPAgent(async ({ command, removedCwd, pidPath }) => {
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
|
||||
undefined,
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
try {
|
||||
const pid = Number(await readFile(pidPath, "utf8"));
|
||||
expect(isProcessAlive(pid)).toBe(false);
|
||||
expect(await collectTimelineText(session.streamHistory())).toEqual([
|
||||
"user_message:What is in the README?",
|
||||
"assistant_message:It describes the project.",
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the provider process for an interactive resume", async () => {
|
||||
await withFakeACPAgent(async ({ command, removedCwd, pidPath }) => {
|
||||
await mkdir(removedCwd, { recursive: true });
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
|
||||
undefined,
|
||||
undefined,
|
||||
{ purpose: "interactive" },
|
||||
);
|
||||
try {
|
||||
const pid = Number(await readFile(pidPath, "utf8"));
|
||||
expect(isProcessAlive(pid)).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectTimelineText(events: AsyncGenerator<AgentStreamEvent>): Promise<string[]> {
|
||||
const text: string[] = [];
|
||||
for await (const event of events) {
|
||||
@@ -88,17 +140,20 @@ async function withFakeACPAgent(
|
||||
command: [string, ...string[]];
|
||||
removedCwd: string;
|
||||
loadRequestsPath: string;
|
||||
pidPath: string;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-"));
|
||||
try {
|
||||
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
|
||||
const loadRequestsPath = path.join(testDir, "load-requests.json");
|
||||
const pidPath = path.join(testDir, "agent.pid");
|
||||
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
|
||||
await run({
|
||||
command: [process.execPath, scriptPath, loadRequestsPath],
|
||||
command: [process.execPath, scriptPath, loadRequestsPath, pidPath],
|
||||
removedCwd: path.join(testDir, "removed-worktree"),
|
||||
loadRequestsPath,
|
||||
pidPath,
|
||||
});
|
||||
} finally {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
@@ -110,6 +165,10 @@ const fs = require("node:fs");
|
||||
const readline = require("node:readline");
|
||||
|
||||
const loadRequestsPath = process.argv[2];
|
||||
const pidPath = process.argv[3];
|
||||
if (pidPath) {
|
||||
fs.writeFileSync(pidPath, String(process.pid));
|
||||
}
|
||||
const loadRequests = [];
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
|
||||
|
||||
@@ -1943,6 +1943,48 @@ describe("PiRpcAgentSession", () => {
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("off");
|
||||
});
|
||||
|
||||
test.each(["high", "off", "max"] as const)(
|
||||
"publishes an extension's thinking change to %s before refreshing state",
|
||||
async (level) => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const changes: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "thinking_option_changed") {
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(level);
|
||||
changes.push(event);
|
||||
}
|
||||
});
|
||||
const runtime = pi.latestSession();
|
||||
runtime.state = { ...runtime.state, thinkingLevel: level };
|
||||
runtime.emit({ type: "thinking_level_changed", level });
|
||||
|
||||
expect(changes).toEqual([
|
||||
{ type: "thinking_option_changed", provider: "pi", thinkingOptionId: level },
|
||||
]);
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(level);
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: level });
|
||||
},
|
||||
);
|
||||
|
||||
test.each([undefined, null, "invalid", 123])(
|
||||
"ignores a thinking change with invalid level %s",
|
||||
async (level) => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const initialThinking = session.describePersistence()?.metadata?.thinkingOptionId;
|
||||
const changes: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "thinking_option_changed") changes.push(event);
|
||||
});
|
||||
|
||||
pi.latestSession().emit({ type: "thinking_level_changed", level });
|
||||
|
||||
expect(changes).toEqual([]);
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(initialThinking);
|
||||
},
|
||||
);
|
||||
|
||||
test("surfaces state refresh failures after a Pi thinking update", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
@@ -1005,6 +1005,8 @@ function isProcessExitEvent(
|
||||
|
||||
function isPiAgentSessionEvent(event: PiRuntimeEvent): event is PiAgentSessionEvent {
|
||||
switch (event.type) {
|
||||
case "thinking_level_changed":
|
||||
return typeof event.level === "string" && normalizePiThinkingOption(event.level) !== null;
|
||||
case "agent_start":
|
||||
case "turn_start":
|
||||
case "message_start":
|
||||
@@ -2136,6 +2138,15 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "thinking_level_changed":
|
||||
this.state = { ...this.state, thinkingLevel: event.level };
|
||||
this.config.thinkingOptionId = event.level;
|
||||
this.emit({
|
||||
type: "thinking_option_changed",
|
||||
provider: this.provider,
|
||||
thinkingOptionId: event.level,
|
||||
});
|
||||
return;
|
||||
case "agent_start":
|
||||
this.activeTurnStarted = true;
|
||||
this.clearNoTurnBuffers();
|
||||
|
||||
+150
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPiExtensionHost } from "../index.js";
|
||||
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
|
||||
import type { PiAgentMessage } from "../../rpc-types.js";
|
||||
|
||||
describe("@tintinweb/pi-subagents adapter", () => {
|
||||
test("exposes the background output file while the child is running", () => {
|
||||
@@ -22,6 +23,27 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
||||
]);
|
||||
expect(mapping?.childSessions).toEqual([{ id: "call-1", file: "/tmp/child.output" }]);
|
||||
});
|
||||
test("marks a foreground child that wrapped up at its turn limit as completed", () => {
|
||||
const mapping = createPiExtensionHost().mapToolCall({
|
||||
callId: "call-1",
|
||||
toolName: "Agent",
|
||||
args: { subagent_type: "Explore", prompt: "Inspect" },
|
||||
status: "completed",
|
||||
result: {
|
||||
details: { agentId: "native-1", status: "steered" },
|
||||
content: [{ type: "text", text: "Wrapped up (turn limit)" }],
|
||||
},
|
||||
});
|
||||
expect(mapping?.subagents).toEqual([
|
||||
expect.objectContaining({ id: "call-1", status: "completed" }),
|
||||
]);
|
||||
expect(mapping?.detail).toEqual({
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect",
|
||||
log: "Wrapped up (turn limit)",
|
||||
});
|
||||
});
|
||||
test("maps captured foreground lifecycle live and on replay", async () => {
|
||||
const events = await verifySubagentFixture(
|
||||
readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)),
|
||||
@@ -65,6 +87,134 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
||||
call_277179: "completed",
|
||||
});
|
||||
});
|
||||
test.each(["subagent-notification", "subagent-update"])(
|
||||
"completes turn-limited children in a grouped %s",
|
||||
(customType) => {
|
||||
const host = createPiExtensionHost();
|
||||
for (const id of ["1", "2", "3"]) {
|
||||
host.mapToolCall({
|
||||
callId: `call-${id}`,
|
||||
toolName: "Agent",
|
||||
args: { subagent_type: "Explore", prompt: "Inspect" },
|
||||
status: "completed",
|
||||
result: { details: { agentId: `native-${id}`, status: "background" } },
|
||||
});
|
||||
}
|
||||
const mapping = host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType,
|
||||
content: "Wrapped up (turn limit)",
|
||||
display: true,
|
||||
timestamp: 0,
|
||||
details: {
|
||||
id: "native-1",
|
||||
status: "steered",
|
||||
others: [
|
||||
{ id: "native-2", status: "steered" },
|
||||
{ id: "native-3", status: "running" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(mapping?.subagents).toEqual([
|
||||
expect.objectContaining({ id: "call-1", status: "completed" }),
|
||||
expect.objectContaining({ id: "call-2", status: "completed" }),
|
||||
expect.objectContaining({ id: "call-3", status: "running" }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
test.each(["get_subagent_result", "steer_subagent"])(
|
||||
"completes a turn-limited child when collected through %s",
|
||||
(toolName) => {
|
||||
const host = createPiExtensionHost();
|
||||
host.mapToolCall({
|
||||
callId: "call-1",
|
||||
toolName: "Agent",
|
||||
args: { subagent_type: "Explore", prompt: "Inspect" },
|
||||
status: "completed",
|
||||
result: { details: { agentId: "native-1", status: "background" } },
|
||||
});
|
||||
const mapping = host.mapToolCall({
|
||||
callId: "followup-1",
|
||||
toolName,
|
||||
args: { agent_id: "native-1" },
|
||||
status: "completed",
|
||||
result: {
|
||||
details: { agentId: "native-1", status: "steered" },
|
||||
content: [{ type: "text", text: "Wrapped up (turn limit)" }],
|
||||
},
|
||||
});
|
||||
expect(mapping?.subagents).toEqual([{ type: "upsert", id: "call-1", status: "completed" }]);
|
||||
expect(mapping?.detail).toEqual({
|
||||
type: "sub_agent",
|
||||
description: undefined,
|
||||
log: "Wrapped up (turn limit)",
|
||||
});
|
||||
},
|
||||
);
|
||||
test.each([
|
||||
["background", "running"],
|
||||
["queued", "running"],
|
||||
["running", "running"],
|
||||
["completed", "completed"],
|
||||
["error", "failed"],
|
||||
["aborted", "canceled"],
|
||||
["stopped", "canceled"],
|
||||
["unknown", "running"],
|
||||
])("preserves the mapping of %s to %s", (status, expected) => {
|
||||
const mapping = createPiExtensionHost().mapToolCall({
|
||||
callId: "call-1",
|
||||
toolName: "Agent",
|
||||
args: { subagent_type: "Explore", prompt: "Inspect" },
|
||||
status: "completed",
|
||||
result: { details: { agentId: "native-1", status } },
|
||||
});
|
||||
expect(mapping?.subagents).toEqual([
|
||||
expect.objectContaining({ id: "call-1", status: expected }),
|
||||
]);
|
||||
});
|
||||
test("completes a turn-limited background child live and on history replay", async () => {
|
||||
const path = new URL("./fixtures/background.json", import.meta.url);
|
||||
const source = readSubagentFixture(path);
|
||||
const notification = source.messages.find((message) => message.role === "custom");
|
||||
const notificationDetails = notification?.details;
|
||||
if (
|
||||
!notificationDetails ||
|
||||
typeof notificationDetails !== "object" ||
|
||||
!("outputFile" in notificationDetails) ||
|
||||
typeof notificationDetails.outputFile !== "string"
|
||||
) {
|
||||
throw new Error("Background fixture is missing its child output file");
|
||||
}
|
||||
const fixture = readSubagentFixture(path, {
|
||||
from: notificationDetails.outputFile,
|
||||
to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)),
|
||||
});
|
||||
function wrapUp(message: PiAgentMessage): PiAgentMessage {
|
||||
if (message.role !== "custom") return message;
|
||||
const details = message.details;
|
||||
if (!details || typeof details !== "object") {
|
||||
throw new Error("Background fixture is missing its notification details");
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
content: "Wrapped up (turn limit)",
|
||||
details: { ...details, status: "steered" },
|
||||
};
|
||||
}
|
||||
const events = await verifySubagentFixture({
|
||||
...fixture,
|
||||
events: fixture.events.map((event) =>
|
||||
event.type === "message_end" ? { ...event, message: wrapUp(event.message) } : event,
|
||||
),
|
||||
messages: fixture.messages.map(wrapUp),
|
||||
});
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.event.type === "upsert")
|
||||
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
|
||||
).toEqual(["running", "running", "completed"]);
|
||||
expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0);
|
||||
});
|
||||
test("declines foreign Agent results", () => {
|
||||
expect(
|
||||
createPiExtensionHost().mapToolCall({
|
||||
|
||||
+2
-1
@@ -33,7 +33,8 @@ const Notification = NotificationEntry.extend({
|
||||
others: z.array(NotificationEntry).optional(),
|
||||
});
|
||||
const status = (value: string): "running" | "completed" | "failed" | "canceled" => {
|
||||
if (value === "completed") return "completed";
|
||||
// A soft turn-limit wrap-up has finished; it is not an in-flight steering request.
|
||||
if (value === "completed" || value === "steered") return "completed";
|
||||
if (value === "error") return "failed";
|
||||
if (value === "aborted" || value === "stopped") return "canceled";
|
||||
return "running";
|
||||
|
||||
@@ -160,6 +160,7 @@ export type PiAssistantMessageEvent =
|
||||
| { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" };
|
||||
|
||||
export type PiAgentSessionEvent =
|
||||
| { type: "thinking_level_changed"; level: PiThinkingLevel }
|
||||
| { type: "agent_start" }
|
||||
| { type: "turn_start" }
|
||||
| { type: "message_start"; message: PiAgentMessage }
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs";
|
||||
import { describe, test, expect, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
mkdirSync,
|
||||
symlinkSync,
|
||||
readdirSync,
|
||||
readlinkSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
@@ -23,6 +32,89 @@ afterEach(async () => {
|
||||
}, 60000);
|
||||
|
||||
describe("file explorer", () => {
|
||||
test.each(["..", "..."])(
|
||||
"workspace suggestions resolve exact paths starting with %s",
|
||||
async (prefix) => {
|
||||
const cwd = tmpCwd();
|
||||
const directoryName = `${prefix}docs`;
|
||||
const nestedPath = `${directoryName}/notes.md`;
|
||||
const fileName = `${prefix}notes.md`;
|
||||
try {
|
||||
mkdirSync(path.join(cwd, directoryName));
|
||||
writeFileSync(path.join(cwd, nestedPath), "nested payload\n");
|
||||
writeFileSync(path.join(cwd, fileName), "direct payload\n");
|
||||
const file = await ctx.client.readFile(cwd, nestedPath);
|
||||
expect(new TextDecoder().decode(file.bytes)).toBe("nested payload\n");
|
||||
|
||||
const nestedEntry = { path: nestedPath, kind: "file" };
|
||||
const entries = [
|
||||
nestedEntry,
|
||||
{ path: directoryName, kind: "directory" },
|
||||
{ path: fileName, kind: "file" },
|
||||
];
|
||||
const cases = entries.flatMap((entry) => [
|
||||
{ query: `./${entry.path}`, entry },
|
||||
{ query: path.resolve(cwd, entry.path), entry },
|
||||
]);
|
||||
cases.unshift({ query: nestedPath, entry: nestedEntry });
|
||||
for (const { query, entry } of cases) {
|
||||
const response = await ctx.client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query,
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
expect(response.error).toBeNull();
|
||||
expect(response.entries).toEqual([entry]);
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("workspace suggestions exclude exact parent and sibling paths", async () => {
|
||||
const directory = tmpCwd();
|
||||
const cwd = path.join(directory, "workspace");
|
||||
const sibling = path.join(directory, "workspace-other");
|
||||
try {
|
||||
mkdirSync(cwd);
|
||||
mkdirSync(sibling);
|
||||
writeFileSync(path.join(cwd, "visible.txt"), "inside payload\n");
|
||||
writeFileSync(path.join(sibling, "outside.txt"), "outside payload\n");
|
||||
for (const query of [
|
||||
"./..",
|
||||
"../workspace-other/outside.txt",
|
||||
path.join(sibling, "outside.txt"),
|
||||
]) {
|
||||
const response = await ctx.client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query,
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
expect(response.error).toBeNull();
|
||||
expect(response.entries).toEqual([]);
|
||||
}
|
||||
const control = await ctx.client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query: "./visible.txt",
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
expect(control.error).toBeNull();
|
||||
expect(control.entries).toEqual([{ path: "visible.txt", kind: "file" }]);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ description: "a long ASCII name", fileName: `${"a".repeat(236)}.txt` },
|
||||
{ description: "an ordinary name", fileName: "notes.txt" },
|
||||
@@ -304,6 +396,84 @@ describe("file explorer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"duplicating a folder keeps relative symlinks inside the copy",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
mkdirSync(path.join(cwd, "template"));
|
||||
writeFileSync(path.join(cwd, "template", "data.txt"), "original payload\n");
|
||||
symlinkSync("data.txt", path.join(cwd, "template", "alias.txt"));
|
||||
|
||||
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "template" });
|
||||
expect(duplicated).toMatchObject({
|
||||
duplicatedPath: "template copy",
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
expect(readlinkSync(path.join(cwd, "template copy", "alias.txt"))).toBe("data.txt");
|
||||
|
||||
const copiedTarget = await ctx.client.readFile(cwd, "template copy/data.txt");
|
||||
const targetWrite = await ctx.client.writeFile({
|
||||
cwd,
|
||||
path: "template copy/data.txt",
|
||||
content: "copy payload\n",
|
||||
expectedModifiedAt: copiedTarget.modifiedAt,
|
||||
expectedRevision: copiedTarget.revision,
|
||||
});
|
||||
expect(targetWrite.status).toBe("written");
|
||||
const copiedAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
|
||||
expect(new TextDecoder().decode(copiedAlias.bytes)).toBe("copy payload\n");
|
||||
|
||||
const aliasWrite = await ctx.client.writeFile({
|
||||
cwd,
|
||||
path: "template copy/alias.txt",
|
||||
content: "edited through copied alias\n",
|
||||
expectedModifiedAt: copiedAlias.modifiedAt,
|
||||
expectedRevision: copiedAlias.revision,
|
||||
});
|
||||
expect(aliasWrite.status).toBe("written");
|
||||
const original = await ctx.client.readFile(cwd, "template/data.txt");
|
||||
expect(new TextDecoder().decode(original.bytes)).toBe("original payload\n");
|
||||
const editedCopy = await ctx.client.readFile(cwd, "template copy/data.txt");
|
||||
expect(new TextDecoder().decode(editedCopy.bytes)).toBe("edited through copied alias\n");
|
||||
|
||||
const deleted = await ctx.client.deleteFileEntry({ cwd, path: "template" });
|
||||
expect(deleted).toMatchObject({ success: true, error: null });
|
||||
const survivingAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
|
||||
expect(new TextDecoder().decode(survivingAlias.bytes)).toBe(
|
||||
"edited through copied alias\n",
|
||||
);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"duplicating a file symlink preserves an explicit absolute target",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const targetPath = path.join(cwd, "data.txt");
|
||||
try {
|
||||
writeFileSync(targetPath, "absolute target payload\n");
|
||||
symlinkSync(targetPath, path.join(cwd, "alias.txt"));
|
||||
|
||||
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "alias.txt" });
|
||||
expect(duplicated).toMatchObject({
|
||||
duplicatedPath: "alias copy.txt",
|
||||
success: true,
|
||||
error: null,
|
||||
});
|
||||
expect(readlinkSync(path.join(cwd, "alias copy.txt"))).toBe(targetPath);
|
||||
const copy = await ctx.client.readFile(cwd, "alias copy.txt");
|
||||
expect(new TextDecoder().decode(copy.bytes)).toBe("absolute target payload\n");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("lists directory contents", async () => {
|
||||
const cwd = tmpCwd();
|
||||
|
||||
@@ -406,3 +576,63 @@ describe("file explorer", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000); // 1 minute timeout
|
||||
});
|
||||
|
||||
describe("commit file diffs", () => {
|
||||
test.each([".", "apps/web"])("opens committed files from %s workspaces", async (relativePath) => {
|
||||
const cwd = tmpCwd();
|
||||
onTestFinished(() =>
|
||||
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
|
||||
);
|
||||
const workspace = path.join(cwd, relativePath);
|
||||
const filePath = "apps/web/notes.txt";
|
||||
function git(args: string[]): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8" });
|
||||
}
|
||||
git(["init", "-b", "main"]);
|
||||
git(["config", "user.name", "Test"]);
|
||||
git(["config", "user.email", "test@example.com"]);
|
||||
git(["config", "core.autocrlf", "false"]);
|
||||
mkdirSync(path.join(cwd, "apps", "web"), { recursive: true });
|
||||
writeFileSync(path.join(cwd, filePath), "before\n");
|
||||
writeFileSync(path.join(cwd, "apps/web/untouched.txt"), "unchanged\n");
|
||||
git(["add", "."]);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]);
|
||||
git(["checkout", "-b", "feature"]);
|
||||
writeFileSync(path.join(cwd, filePath), "after\n");
|
||||
git(["add", "."]);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", "edit"]);
|
||||
const sha = git(["rev-parse", "HEAD"]).trim();
|
||||
|
||||
const history = await ctx.client.listCheckoutCommits(workspace);
|
||||
const commit = history.commits.find((entry) => entry.sha === sha);
|
||||
expect(commit?.files).toEqual([
|
||||
{ path: filePath, additions: 1, deletions: 1, status: "modified" },
|
||||
]);
|
||||
const result = await ctx.client.getCommitFileDiff(workspace, sha, filePath);
|
||||
expect(result).toEqual({
|
||||
file: {
|
||||
path: filePath,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1 +1 @@" },
|
||||
{ type: "remove", content: "before" },
|
||||
{ type: "add", content: "after" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(await ctx.client.getCommitFileDiff(workspace, sha, "apps/web/untouched.txt")).toEqual({
|
||||
file: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -63,10 +63,12 @@ test("an empty project becomes Git without changing its identity or creating a w
|
||||
cleanupClients.add(client);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const projectEvents = client.observeEvents(["project.update"]);
|
||||
await projectEvents.ready;
|
||||
|
||||
const added = await client.addProject(projectRoot);
|
||||
|
||||
expect(added).toEqual({
|
||||
expect(added).toMatchObject({
|
||||
requestId: expect.any(String),
|
||||
project: {
|
||||
projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
|
||||
@@ -77,7 +79,11 @@ test("an empty project becomes Git without changing its identity or creating a w
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
const project = added.project!;
|
||||
const {
|
||||
projectIconRevision: _icon,
|
||||
projectCustomIconRevision: _customIcon,
|
||||
...project
|
||||
} = added.project!;
|
||||
const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] });
|
||||
|
||||
@@ -96,15 +102,15 @@ test("an empty project becomes Git without changing its identity or creating a w
|
||||
label: "project.update after git init",
|
||||
});
|
||||
|
||||
expect(update).toEqual({
|
||||
expect(update).toMatchObject({
|
||||
kind: "upsert",
|
||||
project: { ...project, projectKind: "git" },
|
||||
project: { ...project, projectKind: "git", projectKey: expect.any(String) },
|
||||
});
|
||||
|
||||
const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
|
||||
expect(afterGitInit).toMatchObject({
|
||||
entries: [],
|
||||
emptyProjects: [{ ...project, projectKind: "git" }],
|
||||
emptyProjects: [{ ...project, projectKind: "git", projectKey: expect.any(String) }],
|
||||
});
|
||||
|
||||
const persistedProjects = JSON.parse(
|
||||
@@ -119,5 +125,72 @@ test("an empty project becomes Git without changing its identity or creating a w
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
archivedAt: null,
|
||||
projectKey: expect.any(String),
|
||||
customIconRevision: null,
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
test("a failed worktree attempt before git init does not prevent creation afterward", async () => {
|
||||
const projectRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-worktree-")));
|
||||
const paseoHomeRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-home-")));
|
||||
cleanupPaths.add(projectRoot);
|
||||
cleanupPaths.add(paseoHomeRoot);
|
||||
await writeFile(path.join(projectRoot, "README.md"), "hi\n");
|
||||
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
|
||||
cleanupDaemons.add(daemon);
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
cleanupClients.add(client);
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const projectEvents = client.observeEvents(["project.update"]);
|
||||
await projectEvents.ready;
|
||||
const added = await client.addProject(projectRoot);
|
||||
expect(added.project?.projectKind).toBe("non_git");
|
||||
const source = {
|
||||
kind: "worktree" as const,
|
||||
cwd: projectRoot,
|
||||
projectId: added.project!.projectId,
|
||||
action: "branch-off" as const,
|
||||
refName: "main",
|
||||
branchName: "after-init",
|
||||
worktreeSlug: "after-init",
|
||||
};
|
||||
const before = await client.createWorkspace({ source });
|
||||
expect(before.error).toBe("Create worktree requires a git repository");
|
||||
|
||||
const gitProjectUpdate = waitForProjectUpdate(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.kind === "upsert" &&
|
||||
payload.project.projectId === added.project!.projectId &&
|
||||
payload.project.projectKind === "git",
|
||||
);
|
||||
await execFile("git", ["init", "-b", "main"], { cwd: projectRoot });
|
||||
await execFile("git", ["add", "README.md"], { cwd: projectRoot });
|
||||
await execFile(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=Paseo Test",
|
||||
"-c",
|
||||
"user.email=test@getpaseo.dev",
|
||||
"-c",
|
||||
"commit.gpgSign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd: projectRoot },
|
||||
);
|
||||
await withTimeout({ promise: gitProjectUpdate, timeoutMs: 10_000, label: "project becomes Git" });
|
||||
const projects = await client.fetchWorkspaces({
|
||||
filter: { projectId: added.project!.projectId },
|
||||
});
|
||||
expect(projects.emptyProjects[0]?.projectKind).toBe("git");
|
||||
|
||||
const created = await client.createWorkspace({ source });
|
||||
expect(created.error).toBeNull();
|
||||
expect(created.workspace?.workspaceKind).toBe("worktree");
|
||||
expect(created.workspace?.projectId).toBe(added.project!.projectId);
|
||||
}, 30_000);
|
||||
|
||||
@@ -669,6 +669,7 @@ export async function duplicateExplorerEntry({
|
||||
errorOnExist: true,
|
||||
force: false,
|
||||
preserveTimestamps: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
return { status: "ok", path: normalizeRelativePath({ root, targetPath }) };
|
||||
} catch (error) {
|
||||
|
||||
@@ -236,7 +236,8 @@ test("observes a thousand concurrent writes and remains healthy after delete and
|
||||
const deletions = expectFileChanges(removedPaths, "delete");
|
||||
const movedFiles = expectFileChanges(movedPaths);
|
||||
const recovery = expectFileChanges([sentinel]);
|
||||
const subscription = await subscribeToFileChanges(root, (error, events) => {
|
||||
const observer = createObserver();
|
||||
const subscription = await observer.subscribe(root, (error, events) => {
|
||||
expect(error).toBeNull();
|
||||
for (const event of events) {
|
||||
population.record(event);
|
||||
@@ -254,9 +255,18 @@ test("observes a thousand concurrent writes and remains healthy after delete and
|
||||
await Promise.all(paths.map((path, index) => writeFile(path, `${index}`)));
|
||||
await writes.complete;
|
||||
|
||||
// A native watcher may recover coalesced paths through its safety audit.
|
||||
// Completion comes from the delivered deletions, not an idle diagnostic or
|
||||
// a deadline shorter than that recovery cycle.
|
||||
// The first notice for each write can arrive before its remaining
|
||||
// classifications. Drain those deliveries before starting a separate deletion
|
||||
// phase, so a late create cannot coalesce with the expected delete as update.
|
||||
await expect
|
||||
.poll(() => observer.getDiagnostics(), { timeout: 10_000 })
|
||||
.toMatchObject({
|
||||
pendingEventCount: 0,
|
||||
pendingClassificationCount: 0,
|
||||
reconciliationInFlightCount: 0,
|
||||
});
|
||||
|
||||
// Completion still requires the delivered deletion notices.
|
||||
await Promise.all(removedPaths.map((path) => rm(path)));
|
||||
await deletions.complete;
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
|
||||
// A host can support IPv6 loopback without listing ::1 for localhost in DNS.
|
||||
const lookupLoopback: net.LookupFunction = (_hostname, _options, callback) => {
|
||||
callback(null, [
|
||||
{ address: "127.0.0.1", family: 4 },
|
||||
{ address: "::1", family: 6 },
|
||||
]);
|
||||
};
|
||||
|
||||
export function connectToLocalService(port: number): net.Socket {
|
||||
return net.connect({ host: "localhost", port, autoSelectFamily: true, lookup: lookupLoopback });
|
||||
}
|
||||
|
||||
export function requestLocalService(
|
||||
options: Pick<http.RequestOptions, "port" | "path" | "method" | "headers">,
|
||||
onResponse: (response: http.IncomingMessage) => void,
|
||||
): http.ClientRequest {
|
||||
const requestOptions = {
|
||||
...options,
|
||||
hostname: "localhost",
|
||||
autoSelectFamily: true,
|
||||
lookup: lookupLoopback,
|
||||
};
|
||||
return http.request(requestOptions, onResponse);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import { resolveDaemonVersion } from "../daemon-version.js";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
@@ -15,6 +19,59 @@ afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test.each(["React Native", "native"])(
|
||||
"provider readiness works with %s AbortController",
|
||||
async (runtime) => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const reactNativeRequire = createRequire(require.resolve("react-native/package.json"));
|
||||
const { AbortController: ReactNativeAbortController } = reactNativeRequire(
|
||||
"abort-controller/dist/abort-controller",
|
||||
);
|
||||
const bundle = await build({
|
||||
entryPoints: [fileURLToPath(new URL("../../../../client/src/index.ts", import.meta.url))],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
globalName: "paseoSdk",
|
||||
platform: "browser",
|
||||
});
|
||||
// A separate runtime starts with React Native's real abort implementation.
|
||||
// Node's globals and the daemon's runtime remain unchanged.
|
||||
const sdk: typeof import("@getpaseo/client") = runInNewContext(
|
||||
`${bundle.outputFiles[0]!.text}\npaseoSdk`,
|
||||
{
|
||||
AbortController: runtime === "React Native" ? ReactNativeAbortController : AbortController,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
queueMicrotask,
|
||||
TextEncoder,
|
||||
TextDecoder,
|
||||
},
|
||||
);
|
||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const api = sdk.createPaseoApi(client);
|
||||
try {
|
||||
await client.connect();
|
||||
const snapshot = await api.providers.waitForReady({ cwd: daemon.staticDir });
|
||||
expect(snapshot.entries.length).toBeGreaterThan(0);
|
||||
expect(snapshot.entries.every((entry) => entry.status !== "loading")).toBe(true);
|
||||
const waiting = api.providers.waitForReady({ cwd: daemon.staticDir });
|
||||
const canceled = expect(waiting).rejects.toThrow(/disposed|released/);
|
||||
await api.dispose();
|
||||
await canceled;
|
||||
const { diagnostic } = await client.collectDiagnostics();
|
||||
expect(diagnostic).toContain("Source registrations: 0");
|
||||
expect(diagnostic).toContain("Producer listeners: 0");
|
||||
expect(() => api.providers.waitForReady()).toThrow("disposed");
|
||||
} finally {
|
||||
await api.dispose();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("plugin handlers create workspaces and agents through their Paseo API", async () => {
|
||||
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-plugin-"));
|
||||
const workspaceDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-workspace-"));
|
||||
|
||||
@@ -99,14 +99,14 @@ function createStubTerminalManager(
|
||||
};
|
||||
}
|
||||
|
||||
async function startTcpServer(): Promise<TcpServerHandle> {
|
||||
async function startTcpServer(host = "127.0.0.1"): Promise<TcpServerHandle> {
|
||||
const server = net.createServer((socket) => {
|
||||
socket.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.listen(0, host, () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
@@ -153,6 +153,45 @@ describe("ScriptHealthMonitor", () => {
|
||||
servers.clear();
|
||||
});
|
||||
|
||||
it.each(["127.0.0.1", "::1"])("reports a service bound only to %s as healthy", async (host) => {
|
||||
const service = await startTcpServer(host);
|
||||
servers.add(service.server);
|
||||
const serviceProxy = new ScriptRouteStore();
|
||||
serviceProxy.registerWorkspaceService({
|
||||
workspaceId: "workspace-a",
|
||||
projectSlug: "repo",
|
||||
branchName: "main",
|
||||
scriptName: "api",
|
||||
port: service.port,
|
||||
});
|
||||
const updates: ScriptHealthEntry[][] = [];
|
||||
const monitor = new ScriptHealthMonitor({
|
||||
serviceProxy,
|
||||
onChange: (_workspaceId, scripts) => updates.push(scripts),
|
||||
pollIntervalMs: 10,
|
||||
probeTimeoutMs: 100,
|
||||
graceMs: 0,
|
||||
failuresBeforeStopped: 1,
|
||||
});
|
||||
monitor.start();
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(updates).toEqual([
|
||||
[
|
||||
{
|
||||
scriptName: "api",
|
||||
hostname: "api--repo.localhost",
|
||||
port: service.port,
|
||||
health: "healthy",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
monitor.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts new service routes in pending and transitions to healthy after the grace period", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import net from "node:net";
|
||||
import { connectToLocalService } from "./local-service-connection.js";
|
||||
import type { ServiceProxyHealthTarget, ServiceProxySubsystem } from "./service-proxy.js";
|
||||
|
||||
export type ScriptHealthState = "pending" | "healthy" | "unhealthy";
|
||||
@@ -208,7 +208,7 @@ export class ScriptHealthMonitor {
|
||||
|
||||
private probeRoute(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port });
|
||||
const socket = connectToLocalService(port);
|
||||
let settled = false;
|
||||
|
||||
const finish = (healthy: boolean) => {
|
||||
|
||||
@@ -32,15 +32,20 @@ function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[
|
||||
}
|
||||
|
||||
interface HttpGetOptions {
|
||||
hostname?: string;
|
||||
path?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
function httpGet(port: number, host: string, options: HttpGetOptions = {}) {
|
||||
const { path: requestPath = "/api/health", headers: extraHeaders = {} } = options;
|
||||
const {
|
||||
hostname = "127.0.0.1",
|
||||
path: requestPath = "/api/health",
|
||||
headers: extraHeaders = {},
|
||||
} = options;
|
||||
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const req = http.get(
|
||||
{ hostname: "127.0.0.1", port, path: requestPath, headers: { host, ...extraHeaders } },
|
||||
{ hostname, port, path: requestPath, headers: { host, ...extraHeaders } },
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
@@ -53,6 +58,31 @@ function httpGet(port: number, host: string, options: HttpGetOptions = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function httpPost(port: number, host: string, requestPath: string, payload: string) {
|
||||
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const request = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
method: "POST",
|
||||
path: requestPath,
|
||||
headers: { host, "content-type": "application/json" },
|
||||
},
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
|
||||
res.on("error", reject);
|
||||
},
|
||||
);
|
||||
request.on("error", reject);
|
||||
request.write(payload.slice(0, 5));
|
||||
request.end(payload.slice(5));
|
||||
});
|
||||
}
|
||||
|
||||
describe("service proxy subsystem shape", () => {
|
||||
it("keeps production imports behind the service-proxy entrypoint", () => {
|
||||
const offenders: string[] = [];
|
||||
@@ -303,6 +333,7 @@ describe("service proxy subsystem shape", () => {
|
||||
});
|
||||
|
||||
interface ForwardedFixture {
|
||||
upstreamPort: number;
|
||||
daemonPort: number;
|
||||
hostname: string;
|
||||
close(): Promise<void>;
|
||||
@@ -313,11 +344,16 @@ interface ForwardedFixture {
|
||||
* echoes the headers it received so tests can assert what actually crossed the
|
||||
* proxy, not what a helper returned.
|
||||
*/
|
||||
async function startForwardedHeadersFixture(): Promise<ForwardedFixture> {
|
||||
const upstreamPort = await findFreePort();
|
||||
async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> {
|
||||
const upstream = http.createServer((req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(req.headers));
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on("end", () => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
|
||||
});
|
||||
});
|
||||
const upgradeSockets: net.Socket[] = [];
|
||||
upstream.on("upgrade", (req, socket) => {
|
||||
@@ -331,7 +367,11 @@ async function startForwardedHeadersFixture(): Promise<ForwardedFixture> {
|
||||
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(upstreamPort, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upstream.once("error", reject);
|
||||
upstream.listen(0, upstreamHost, resolve);
|
||||
});
|
||||
const upstreamPort = (upstream.address() as net.AddressInfo).port;
|
||||
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
const route = serviceProxy.registerWorkspaceService({
|
||||
@@ -354,6 +394,7 @@ async function startForwardedHeadersFixture(): Promise<ForwardedFixture> {
|
||||
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
|
||||
|
||||
return {
|
||||
upstreamPort,
|
||||
daemonPort,
|
||||
hostname: route.hostname,
|
||||
async close() {
|
||||
@@ -416,6 +457,59 @@ function upgradeThroughProxy(
|
||||
});
|
||||
}
|
||||
|
||||
describe("service proxy loopback services", () => {
|
||||
it.each(["127.0.0.1", "::1"])("forwards HTTP to a service bound only to %s", async (host) => {
|
||||
const fixture = await startForwardedHeadersFixture(host);
|
||||
try {
|
||||
const direct = await httpGet(fixture.upstreamPort, fixture.hostname, { hostname: host });
|
||||
expect(direct.status).toBe(200);
|
||||
|
||||
const response = await httpGet(fixture.daemonPort, fixture.hostname);
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(response.body).host).toBe(fixture.hostname);
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["127.0.0.1", "::1"])(
|
||||
"streams POST bodies to a service bound only to %s",
|
||||
async (host) => {
|
||||
const fixture = await startForwardedHeadersFixture(host);
|
||||
try {
|
||||
const payload = JSON.stringify({ message: "hello" });
|
||||
const response = await httpPost(
|
||||
fixture.daemonPort,
|
||||
fixture.hostname,
|
||||
"/submit?q=one%20two",
|
||||
payload,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(response.body)).toMatchObject({
|
||||
host: fixture.hostname,
|
||||
"content-type": "application/json",
|
||||
method: "POST",
|
||||
url: "/submit?q=one%20two",
|
||||
body: payload,
|
||||
});
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["127.0.0.1", "::1"])("forwards upgrades to a service bound only to %s", async (host) => {
|
||||
const fixture = await startForwardedHeadersFixture(host);
|
||||
try {
|
||||
const headers = await upgradeThroughProxy(fixture.daemonPort, fixture.hostname);
|
||||
expect(headers.host).toBe(fixture.hostname);
|
||||
expect(headers.upgrade).toBe("websocket");
|
||||
} finally {
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("service proxy forwarded headers", () => {
|
||||
it("forwards the client authority with its port so services build reachable URLs", async () => {
|
||||
const fixture = await startForwardedHeadersFixture();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import http, { createServer as createHTTPServer } from "node:http";
|
||||
import net from "node:net";
|
||||
import { connectToLocalService, requestLocalService } from "./local-service-connection.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
@@ -328,9 +329,8 @@ function proxyHttpRequest({
|
||||
}): void {
|
||||
const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: req.protocol });
|
||||
|
||||
const proxyReq = http.request(
|
||||
const proxyReq = requestLocalService(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: route.port,
|
||||
path: req.originalUrl,
|
||||
method: req.method,
|
||||
@@ -368,7 +368,8 @@ function proxyUpgradeRequest({
|
||||
route: ServiceProxyRoute;
|
||||
logger: Logger;
|
||||
}): void {
|
||||
const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => {
|
||||
const targetSocket = connectToLocalService(route.port);
|
||||
targetSocket.on("connect", () => {
|
||||
const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: "http" });
|
||||
forwardedHeaders.connection = "Upgrade";
|
||||
forwardedHeaders.upgrade = req.headers.upgrade ?? "websocket";
|
||||
|
||||
@@ -625,6 +625,28 @@ describe("highlightDiffFromHunks", () => {
|
||||
});
|
||||
|
||||
describe("highlightDiffWithFileContent", () => {
|
||||
it("renders an Astro diff with an empty expression and keeps surrounding highlighting", async () => {
|
||||
const diff = ASTRO_DIFF.replace("{title}", "{title} {}");
|
||||
const file = parseDiff(diff)[0];
|
||||
const highlighted = await highlightDiffWithFileContent(file, ".", {
|
||||
oldFileContent:
|
||||
'---\nconst title = "Hello";\n---\n<main class="page">\n <h1>{title} {}</h1>\n</main>',
|
||||
newFileContent:
|
||||
'---\nconst title = "Hello, Astro";\n---\n<main class="page">\n <h1>{title} {}</h1>\n</main>',
|
||||
});
|
||||
|
||||
expect(highlighted.hunks[0].lines.find((line) => line.type === "add")?.tokens).toContainEqual({
|
||||
text: '"Hello, Astro"',
|
||||
style: "string",
|
||||
});
|
||||
expect(highlighted.hunks[0].lines.map(({ content }) => content)).toEqual(
|
||||
file.hunks[0].lines.map(({ content }) => content),
|
||||
);
|
||||
expect(
|
||||
highlightDiffFromHunks(file).hunks[0].lines.find((line) => line.type === "add")?.tokens,
|
||||
).toContainEqual({ text: '"Hello, Astro"', style: "string" });
|
||||
});
|
||||
|
||||
it("leaves files with an oversized line unhighlighted", async () => {
|
||||
const oversizedLine = `const payload = "${"x".repeat(10_001)}";`;
|
||||
const [file] = parseDiff(
|
||||
|
||||
@@ -63,6 +63,27 @@ afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a fresh checkout read makes a newly initialized repository available to cached readers", async () => {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-checkout-init-")));
|
||||
const repo = path.join(root, "repo");
|
||||
mkdirSync(repo);
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(root, "home"),
|
||||
});
|
||||
cleanup.push(async () => {
|
||||
await service.dispose();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
expect((await service.getSnapshot(repo, { includeForge: false })).git.isGit).toBe(false);
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" });
|
||||
|
||||
expect((await service.getCheckout(repo)).isGit).toBe(true);
|
||||
const repoRoot = await service.resolveRepoRoot(repo);
|
||||
expect(realpathSync.native(repoRoot)).toBe(realpathSync.native(repo));
|
||||
}, 30_000);
|
||||
|
||||
test("recursive observation updates tracked state and prunes ignored storms", async () => {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-git-observation-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
|
||||
@@ -765,6 +765,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
logger: this.logger,
|
||||
});
|
||||
const cached = this.peekSnapshot(normalizedCwd);
|
||||
if (cached && cached.git.isGit !== status.isGit) {
|
||||
// A fresh checkout read discovered a repository transition. Keep the
|
||||
// shared snapshot current before exposing the new project capability.
|
||||
await this.getSnapshot(normalizedCwd, {
|
||||
force: true,
|
||||
includeForge: false,
|
||||
reason: "checkout-classification-changed",
|
||||
});
|
||||
}
|
||||
if (!status.isGit) {
|
||||
return checkoutLiteFromGitSnapshot(normalizedCwd, {
|
||||
isGit: false,
|
||||
|
||||
@@ -2665,10 +2665,11 @@ export async function getCommitFileDiff({
|
||||
sha: string;
|
||||
path: string;
|
||||
}): Promise<ParsedDiffFile | null> {
|
||||
const worktreeRoot = await requireGitWorktreeRoot(cwd);
|
||||
const { stdout } = await runGitCommand(
|
||||
["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path],
|
||||
{
|
||||
cwd,
|
||||
cwd: worktreeRoot,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
@@ -2677,9 +2678,9 @@ export async function getCommitFileDiff({
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedFiles = await parseAndHighlightDiff(stdout, cwd, {
|
||||
getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path),
|
||||
getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path),
|
||||
const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, {
|
||||
getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path),
|
||||
getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path),
|
||||
});
|
||||
|
||||
// `--` scopes the diff to a single pathspec, so there is at most one real
|
||||
|
||||
@@ -42,6 +42,35 @@ describe("path equivalence", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ root: "/repo", candidate: "/repo/..notes.txt", inside: true },
|
||||
{ root: "/repo", candidate: "/repo/..drafts/notes.md", inside: true },
|
||||
{ root: "/repo", candidate: "/repo/...drafts/notes.md", inside: true },
|
||||
{ root: "/repo", candidate: "/repo/docs/..notes.txt", inside: true },
|
||||
{ root: "/repo", candidate: "/repo", inside: true },
|
||||
{ root: "/repo", candidate: "/repo/../sibling/notes.md", inside: false },
|
||||
{ root: "/repo", candidate: "/repo/..drafts/../../outside.txt", inside: false },
|
||||
{ root: "/repo", candidate: "/repo-other/notes.md", inside: false },
|
||||
{ root: "C:\\Repo", candidate: "c:/repo/..notes.txt", inside: true },
|
||||
{ root: "C:\\Repo", candidate: "C:\\Repo\\..drafts\\notes.md", inside: true },
|
||||
{ root: "C:\\Repo", candidate: "C:\\Repo\\...drafts\\notes.md", inside: true },
|
||||
{ root: "C:\\Repo", candidate: "C:\\Repo\\..\\sibling\\notes.md", inside: false },
|
||||
{ root: "C:\\Repo", candidate: "C:\\Repo-other\\notes.md", inside: false },
|
||||
{ root: "C:\\Repo", candidate: "D:\\Repo\\..notes.txt", inside: false },
|
||||
{
|
||||
root: String.raw`\\server\share\repo`,
|
||||
candidate: String.raw`\\server\share\repo\..drafts\notes.md`,
|
||||
inside: true,
|
||||
},
|
||||
{
|
||||
root: String.raw`\\?\C:\Repo`,
|
||||
candidate: "C:\\Repo\\..notes.txt",
|
||||
inside: true,
|
||||
},
|
||||
])("checks named path containment for $candidate", ({ root, candidate, inside }) => {
|
||||
expect(isPathInsideRoot(root, candidate)).toBe(inside);
|
||||
});
|
||||
|
||||
test("preserves the casing of Windows relative suffixes", () => {
|
||||
expect(getRealpathAwareRelativePath("C:\\Repo\\.git", "c:\\repo\\.git\\HEAD")).toBe("HEAD");
|
||||
expect(
|
||||
|
||||
@@ -114,10 +114,11 @@ function getRelativePathInsideRoot(root: string, candidate: string): string | nu
|
||||
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
|
||||
const comparableRelative = platformPath.relative(normalizedRoot, normalizedCandidate);
|
||||
|
||||
if (
|
||||
comparableRelative !== "" &&
|
||||
(comparableRelative.startsWith("..") || platformPath.isAbsolute(comparableRelative))
|
||||
) {
|
||||
const isOutsideRoot =
|
||||
comparableRelative === ".." ||
|
||||
comparableRelative.startsWith(`..${platformPath.sep}`) ||
|
||||
platformPath.isAbsolute(comparableRelative);
|
||||
if (isOutsideRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,72 +59,3 @@ test("desktop mockup corners shrink in proportion on a phone", async ({ page })
|
||||
await resizeToPhone(page);
|
||||
await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3);
|
||||
});
|
||||
|
||||
async function openAgentPage(page: Page, slug: string, width: number) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(`/${slug}`);
|
||||
await expect(page.getByRole("button", { name: "Build", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectComposerModel(page: Page, label: string) {
|
||||
const desktop = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await expect(desktop.getByText(label, { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Extend", exact: true }).click();
|
||||
await expect(desktop.getByText(label, { exact: true })).toHaveCount(1);
|
||||
await expect(desktop.getByText(label, { exact: true })).toBeVisible();
|
||||
await viewPhones(page);
|
||||
await expect(
|
||||
page
|
||||
.getByRole("img", { name: "Paseo agent chat", exact: true })
|
||||
.getByText(label, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectPiOverlay(page: Page) {
|
||||
await expect(page).toHaveTitle("Pi Agent Mobile and Desktop App, Open Source");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
|
||||
"Open source app for the Pi coding agent",
|
||||
);
|
||||
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
|
||||
"href",
|
||||
"https://paseo.sh/pi",
|
||||
);
|
||||
await expectComposerModel(page, "Pi · Sonnet 4.5");
|
||||
const section = page.getByRole("region", { name: "Your Pi setup, with a desktop and mobile UI" });
|
||||
await section.scrollIntoViewIfNeeded();
|
||||
await expect(section.getByRole("heading", { name: "Bring your extensions" })).toBeVisible();
|
||||
await expect(
|
||||
section.getByText(/pi-subagents, pi-ask-user, pi-mcp-adapter and pi-goal-x/),
|
||||
).toBeVisible();
|
||||
await expect(section.getByRole("link", { name: "Set up Paseo" })).toHaveAttribute(
|
||||
"href",
|
||||
"/docs",
|
||||
);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
|
||||
await page.evaluate(() => window.innerWidth),
|
||||
);
|
||||
}
|
||||
|
||||
for (const width of [1440, 390]) {
|
||||
test(`Pi has its own content and composer at ${width}px`, async ({ page }) => {
|
||||
await openAgentPage(page, "pi", width);
|
||||
await expectPiOverlay(page);
|
||||
});
|
||||
|
||||
test(`an agent without an overlay keeps the shared page at ${width}px`, async ({ page }) => {
|
||||
await openAgentPage(page, "opencode", width);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
|
||||
"Open source app for OpenCode",
|
||||
);
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Run OpenCode on your machine, drive it from your phone or desktop. Open source on both ends, your code stays local.",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("region", { name: "Your Pi setup, with a desktop and mobile UI" }),
|
||||
).toHaveCount(0);
|
||||
await expectComposerModel(page, "GPT-5.6-Sol");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,14 +7,7 @@ export function agentRouteOptions(slug: string) {
|
||||
return {
|
||||
head: () => pageMeta(page.metaTitle, page.metaDescription, `/${slug}`),
|
||||
component: function AgentLandingPage() {
|
||||
return (
|
||||
<LandingPage
|
||||
title={page.title}
|
||||
subtitle={page.subtitle}
|
||||
mockup={page.mockup}
|
||||
section={page.section}
|
||||
/>
|
||||
);
|
||||
return <LandingPage title={page.title} subtitle={page.subtitle} />;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
DESIGN_WIDTH,
|
||||
MOCKUP_STATES,
|
||||
type MockupStateId,
|
||||
type MockupAgent,
|
||||
MockupWindow,
|
||||
ScaledMockup,
|
||||
} from "~/components/mockup";
|
||||
@@ -16,7 +15,7 @@ const ALT =
|
||||
|
||||
const PILL_TRANSITION = { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] as const };
|
||||
|
||||
export function HeroMockup({ agent }: { agent?: MockupAgent }) {
|
||||
export function HeroMockup() {
|
||||
const [state, setState] = useState<MockupStateId>(DEFAULT_MOCKUP_STATE);
|
||||
|
||||
return (
|
||||
@@ -40,7 +39,7 @@ export function HeroMockup({ agent }: { agent?: MockupAgent }) {
|
||||
label={ALT}
|
||||
className="ring-1 ring-white/10"
|
||||
>
|
||||
<MockupWindow state={state} agent={agent} />
|
||||
<MockupWindow state={state} />
|
||||
</ScaledMockup>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -59,7 +59,7 @@ const AGENT_LIST_GRID_STYLE = {
|
||||
const PHONE_PERSPECTIVE_STYLE = { minHeight: 480, perspective: 700 };
|
||||
import { CursorFieldProvider } from "~/components/butterfly";
|
||||
import { CommandDialog } from "~/components/command-dialog";
|
||||
import { AGENT_PAGES, type AgentPage } from "~/data/agent-pages";
|
||||
import { AGENT_PAGES } from "~/data/agent-pages";
|
||||
import {
|
||||
appStoreUrl,
|
||||
playStoreUrl,
|
||||
@@ -91,12 +91,12 @@ import { SponsorSection, SponsorsSection } from "~/components/sponsorship";
|
||||
import { SiteHeader } from "~/components/site-header";
|
||||
import "~/styles.css";
|
||||
|
||||
interface LandingPageProps extends Pick<AgentPage, "mockup" | "section"> {
|
||||
interface LandingPageProps {
|
||||
title: React.ReactNode;
|
||||
subtitle: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LandingPage({ title, subtitle, mockup, section }: LandingPageProps) {
|
||||
export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
return (
|
||||
<CursorFieldProvider>
|
||||
{/* Hero section with background image */}
|
||||
@@ -115,15 +115,13 @@ export function LandingPage({ title, subtitle, mockup, section }: LandingPagePro
|
||||
className="relative px-6 md:px-8 pt-4 md:pt-8 pb-8 md:pb-16"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<HeroMockup agent={mockup} />
|
||||
<HeroMockup />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{section ? <AgentSection section={section} /> : null}
|
||||
|
||||
{/* Phone showcase */}
|
||||
<PhoneShowcase agent={mockup} />
|
||||
<PhoneShowcase />
|
||||
|
||||
{/* Content section */}
|
||||
<div className="landing-content bg-background">
|
||||
@@ -146,32 +144,6 @@ export function LandingPage({ title, subtitle, mockup, section }: LandingPagePro
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSection({ section }: { section: NonNullable<AgentPage["section"]> }) {
|
||||
return (
|
||||
<section aria-label={section.title} className="mx-auto max-w-5xl px-6 py-16 md:py-24">
|
||||
<h2 className="max-w-2xl text-3xl font-medium tracking-tight md:text-4xl">{section.title}</h2>
|
||||
<p className="mt-4 max-w-2xl text-base leading-relaxed text-muted-foreground">
|
||||
{section.description}
|
||||
</p>
|
||||
<div className="mt-10 grid gap-8 md:grid-cols-3">
|
||||
{section.items.map((item) => (
|
||||
<div key={item.title} className="border-t border-white/10 pt-5">
|
||||
<h3 className="text-lg font-medium">{item.title}</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href={section.link.href}
|
||||
className="mt-8 inline-flex items-center gap-2 text-sm text-white/80 hover:text-white"
|
||||
>
|
||||
{section.link.label}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Nav() {
|
||||
return (
|
||||
<nav className="mb-20 md:mb-24">
|
||||
@@ -1108,7 +1080,7 @@ function ServerInstallButton() {
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneShowcase({ agent }: { agent?: AgentPage["mockup"] }) {
|
||||
function PhoneShowcase() {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const textInView = useInView(containerRef, { once: true, margin: "-80px" });
|
||||
|
||||
@@ -1202,7 +1174,7 @@ function PhoneShowcase({ agent }: { agent?: AgentPage["mockup"] }) {
|
||||
aria-label="Paseo agent chat"
|
||||
>
|
||||
<PhoneFrame time="18:53">
|
||||
<MobileChat agent={agent} />
|
||||
<MobileChat />
|
||||
</PhoneFrame>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
|
||||
/** Optional branding for the shared desktop and phone composer mockups. */
|
||||
export interface MockupAgent {
|
||||
name: string;
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export function MockupAgentIcon({ agent, size }: { agent: MockupAgent; size: number }) {
|
||||
return <agent.Icon width={size} height={size} className="shrink-0" />;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MockupAgentIcon, type MockupAgent } from "./agent";
|
||||
import {
|
||||
AudioLines,
|
||||
BookOpen,
|
||||
@@ -189,10 +188,10 @@ export function PluginBuildTranscript() {
|
||||
|
||||
// --- composer ---------------------------------------------------------------
|
||||
|
||||
function ControlPill({ children, label }: { children: React.ReactNode; label: string }) {
|
||||
function ControlPill({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
|
||||
{children}
|
||||
{icon}
|
||||
{label}
|
||||
<ChevronDown size={11} className="text-mock-fg-xmuted" />
|
||||
</span>
|
||||
@@ -214,11 +213,9 @@ function BusyRing() {
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
agent,
|
||||
showProgress = true,
|
||||
busy = true,
|
||||
}: {
|
||||
agent?: MockupAgent;
|
||||
showProgress?: boolean;
|
||||
busy?: boolean;
|
||||
}) {
|
||||
@@ -246,13 +243,9 @@ export function Composer({
|
||||
</div>
|
||||
<div className="flex items-center gap-[17px] pt-[26px]">
|
||||
<Plus size={15} className="text-mock-fg-muted" />
|
||||
<ControlPill
|
||||
label={agent ? `${agent.name} · ${agent.model ?? "GPT-5.6-Sol"}` : "GPT-5.6-Sol"}
|
||||
>
|
||||
{agent ? <MockupAgentIcon agent={agent} size={13} /> : MODEL_ICON}
|
||||
</ControlPill>
|
||||
<ControlPill label="Medium">{EFFORT_ICON}</ControlPill>
|
||||
<ControlPill label="Full access">{ACCESS_ICON}</ControlPill>
|
||||
<ControlPill icon={MODEL_ICON} label="GPT-5.6-Sol" />
|
||||
<ControlPill icon={EFFORT_ICON} label="Medium" />
|
||||
<ControlPill icon={ACCESS_ICON} label="Full access" />
|
||||
<Zap size={14} className="text-mock-fg-muted" />
|
||||
<ListTodo size={14} className="text-mock-fg-muted" />
|
||||
<span className="flex-1" />
|
||||
|
||||
@@ -7,4 +7,3 @@ export { MobileSidebar } from "./mobile/sidebar";
|
||||
export { DEFAULT_MOCKUP_STATE, MOCKUP_STATES, type MockupStateId } from "./states";
|
||||
export { MockupWindow } from "./window";
|
||||
export { ScaledMockup } from "./scaled-mockup";
|
||||
export type { MockupAgent } from "./agent";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MockupAgentIcon, type MockupAgent } from "../agent";
|
||||
// Mobile agent chat — the counterpart of the desktop chat pane, drawn at phone
|
||||
// width. Maps to the "agent chat" reference screenshot: header, collapsed slash
|
||||
// command, muted tool-call rows, an assistant turn, the diff-stat pill, and the
|
||||
@@ -116,7 +115,7 @@ function Transcript() {
|
||||
);
|
||||
}
|
||||
|
||||
function Composer({ agent }: { agent?: MockupAgent }) {
|
||||
function Composer() {
|
||||
return (
|
||||
<div className="shrink-0 px-[18px] pt-[10px] pb-[30px]">
|
||||
<div className="mb-[16px]">
|
||||
@@ -130,14 +129,8 @@ function Composer({ agent }: { agent?: MockupAgent }) {
|
||||
<div className="flex items-center gap-[13px] pt-[26px]">
|
||||
<Plus size={20} className="shrink-0 text-mock-fg-muted" strokeWidth={1.9} />
|
||||
<span className="flex shrink-0 items-center gap-[5px]">
|
||||
{agent ? (
|
||||
<MockupAgentIcon agent={agent} size={19} />
|
||||
) : (
|
||||
<CodexIcon size={19} className="shrink-0 text-mock-fg" />
|
||||
)}
|
||||
<span className="whitespace-nowrap text-[16px] text-mock-fg">
|
||||
{agent ? `${agent.name} · ${agent.model ?? "GPT-5.6-Sol"}` : "GPT-5.6-Sol"}
|
||||
</span>
|
||||
<CodexIcon size={19} className="shrink-0 text-mock-fg" />
|
||||
<span className="whitespace-nowrap text-[16px] text-mock-fg">GPT-5.6-Sol</span>
|
||||
<span className="whitespace-nowrap text-[16px] text-mock-fg-muted">Medium</span>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
@@ -150,7 +143,7 @@ function Composer({ agent }: { agent?: MockupAgent }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileChat({ agent }: { agent?: MockupAgent }) {
|
||||
export function MobileChat() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
@@ -158,7 +151,7 @@ export function MobileChat({ agent }: { agent?: MockupAgent }) {
|
||||
<div className="min-h-0 flex-1 overflow-hidden pt-[6px]">
|
||||
<Transcript />
|
||||
</div>
|
||||
<Composer agent={agent} />
|
||||
<Composer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { MockupAgent } from "./agent";
|
||||
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion";
|
||||
import { AgentTranscript, Composer, PluginBuildTranscript } from "./chat";
|
||||
import { DESIGN_HEIGHT, DESIGN_WIDTH, RIGHT_PANEL_W } from "./geometry";
|
||||
@@ -27,7 +26,7 @@ function panelKey(state: MockupStateId): string {
|
||||
return "changes";
|
||||
}
|
||||
|
||||
function CenterPane({ state, agent }: { state: MockupStateId; agent?: MockupAgent }) {
|
||||
function CenterPane({ state }: { state: MockupStateId }) {
|
||||
if (state === "review") return <DiffReviewPane />;
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
|
||||
@@ -35,7 +34,7 @@ function CenterPane({ state, agent }: { state: MockupStateId; agent?: MockupAgen
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-end overflow-hidden px-[27px] pb-[6px]">
|
||||
{state === "extend" ? <PluginBuildTranscript /> : <AgentTranscript />}
|
||||
</div>
|
||||
<Composer agent={agent} showProgress={state !== "extend"} busy={state !== "extend"} />
|
||||
<Composer showProgress={state !== "extend"} busy={state !== "extend"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +59,7 @@ function PanelCrossfade({ state }: { state: MockupStateId }) {
|
||||
}
|
||||
|
||||
/** The middle content column: its own header, then the tab strip, then the pane. */
|
||||
function CenterColumn({ state, agent }: { state: MockupStateId; agent?: MockupAgent }) {
|
||||
function CenterColumn({ state }: { state: MockupStateId }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<TitleBar state={state} />
|
||||
@@ -74,7 +73,7 @@ function CenterColumn({ state, agent }: { state: MockupStateId; agent?: MockupAg
|
||||
exit={FADE_OUT}
|
||||
className="absolute inset-0 flex flex-col"
|
||||
>
|
||||
<CenterPane state={state} agent={agent} />
|
||||
<CenterPane state={state} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -97,7 +96,7 @@ function DockedPanel({ state }: { state: MockupStateId }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MockupWindow({ state, agent }: { state: MockupStateId; agent?: MockupAgent }) {
|
||||
export function MockupWindow({ state }: { state: MockupStateId }) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
@@ -110,7 +109,7 @@ export function MockupWindow({ state, agent }: { state: MockupStateId; agent?: M
|
||||
header stops at the divider — it never spans over the right panel. */}
|
||||
<Sidebar state={state} />
|
||||
<div className="w-px shrink-0 bg-mock-border" />
|
||||
<CenterColumn state={state} agent={agent} />
|
||||
<CenterColumn state={state} />
|
||||
<AnimatePresence initial={false}>
|
||||
{hasRightPanel(state) ? <DockedPanel key="right-panel" state={state} /> : null}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { PiIcon } from "~/components/agent-icons";
|
||||
import type { MockupAgent } from "~/components/mockup";
|
||||
|
||||
// Source of truth for per-agent marketing landing pages.
|
||||
// To add a new agent, append an entry here and create a 4-line route file at
|
||||
// `src/routes/<slug>.tsx`. The sitemap (vite.config) reads `AGENT_PAGE_SLUGS`.
|
||||
@@ -12,14 +9,6 @@ export interface AgentPage {
|
||||
subtitle: string;
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
// Omit either field to keep the shared landing page unchanged.
|
||||
mockup?: MockupAgent;
|
||||
section?: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: readonly { title: string; description: string }[];
|
||||
link: { href: string; label: string };
|
||||
};
|
||||
}
|
||||
|
||||
export const AGENT_PAGES = [
|
||||
@@ -56,33 +45,9 @@ export const AGENT_PAGES = [
|
||||
{
|
||||
slug: "pi",
|
||||
name: "Pi Agent",
|
||||
mockup: { name: "Pi", Icon: PiIcon, model: "Sonnet 4.5" },
|
||||
section: {
|
||||
title: "Your Pi setup, with a desktop and mobile UI",
|
||||
description:
|
||||
"Paseo runs your installed Pi CLI. Start a session from the app, or import one you started in the terminal and continue from there.",
|
||||
items: [
|
||||
{
|
||||
title: "Bring your extensions",
|
||||
description:
|
||||
"Paseo supports Pi extensions including pi-subagents, pi-ask-user, pi-mcp-adapter and pi-goal-x. Follow subagents, answer questions and see task progress in the app. Extensions that depend on a custom terminal UI do not carry over.",
|
||||
},
|
||||
{
|
||||
title: "Pick from your Pi models",
|
||||
description:
|
||||
"The model picker reads the models available in your Pi setup and uses Pi’s configured model by default. Keep your provider configuration in Pi.",
|
||||
},
|
||||
{
|
||||
title: "Start with Pi on your machine",
|
||||
description:
|
||||
"Install and configure Pi on the machine where you run Paseo. Open a workspace in Paseo and choose Pi when you create an agent. Pair your phone to keep working on that machine when you step away.",
|
||||
},
|
||||
],
|
||||
link: { href: "/docs", label: "Set up Paseo" },
|
||||
},
|
||||
title: "Open source app for the Pi coding agent",
|
||||
subtitle:
|
||||
"Use Pi from your desktop or phone, with the models and extensions you already use. Pi runs on your machine. Paseo gives you a UI for it.",
|
||||
"Run the Pi coding agent on your machine, drive it from your phone or desktop. Self-hosted and open source.",
|
||||
metaTitle: "Pi Agent Mobile and Desktop App, Open Source",
|
||||
metaDescription:
|
||||
"Open source mobile and desktop app for the Pi coding agent. Launch sessions on your machine, monitor progress, merge from anywhere. Self-hosted.",
|
||||
|
||||
Reference in New Issue
Block a user