mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
802359152e | ||
|
|
196dc3acd3 | ||
|
|
e867773932 | ||
|
|
bfe38d92f6 | ||
|
|
9551b2cc11 | ||
|
|
6f62125ee0 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Android workspace memory diagnostic
|
||||
|
||||
Supplemental reproduction for [#4408](https://github.com/getpaseo/paseo/issues/4408), using the production 0.11.2 APK on an Android 16 emulator. This tests an adapted failure: opening the sidebar with 800 projects, each containing one workspace, produced a `LOW_MEMORY` exit after 66–90 seconds. It does not reproduce the reported 18-second cold-start ANR or establish its cause.
|
||||
|
||||
Before running:
|
||||
|
||||
- Use a dedicated emulator with the production APK installed and notifications permission settled. Bind the agent-device session to that emulator before running.
|
||||
- Pair the app with an isolated daemon containing 800 active workspaces, each in its own project. Use Project grouping and leave projects expanded. The observed fixture used existing directories inside a Git checkout, with workspace titles permuted by `(index * 317) % 800`, no agents, and no terminals.
|
||||
- Record the daemon revision, emulator RAM and Java heap settings, and the project/workspace distribution. Device memory pressure controls whether Android kills the app.
|
||||
|
||||
Run from the repository root with the agent-device session, Android serial, and an artifact directory:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/workspace-memory/android.mjs workspace-memory emulator-5556 .dev/workspace-memory
|
||||
```
|
||||
|
||||
The test cold-starts the paired app, opens its sidebar, samples memory and the process ID every five seconds for two minutes, and verifies that closing the sidebar responds. A process exit or restart fails the assertion. Memory, exit records, and logs stay in the supplied artifact directory.
|
||||
|
||||
Control: repeat with 800 workspaces grouped into one project. That distribution survived the two-minute automated check and responded to closing the sidebar, with total PSS near 300 MB. The project-per-workspace fixture reached about 2.5 GB PSS before Android recorded `LOW_MEMORY`; its Java heap rose and fell rather than showing the reporter's full-heap ANR. Neither result proves that shared collators would fix the report.
|
||||
|
||||
This diagnostic is deliberately outside CI and contains no production change.
|
||||
@@ -0,0 +1,65 @@
|
||||
// Requires a paired production app and the workspace fixture described in README.md.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const [, , session, serial, artifacts] = process.argv;
|
||||
assert(session && serial && artifacts, "Pass session, Android serial, and artifact directory");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
function adb(...args) {
|
||||
return execFileSync("adb", ["-s", serial, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
function pid() {
|
||||
try {
|
||||
return adb("shell", "pidof", "sh.paseo").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
ad("open", "sh.paseo", "--relaunch", "--platform", "android");
|
||||
ad("wait", "text", "Open menu", "15000");
|
||||
assert.equal(
|
||||
JSON.parse(ad("perf", "memory", "sample", "--json")).data.deviceId,
|
||||
serial,
|
||||
"Session and adb serial must address the same device",
|
||||
);
|
||||
const initialPid = pid();
|
||||
assert(initialPid, "App must have a process before opening the sidebar");
|
||||
ad("press", 'label="Open menu"', "--settle");
|
||||
const startedAt = Date.now();
|
||||
const samples = [];
|
||||
try {
|
||||
while (Date.now() - startedAt < 120_000) {
|
||||
const currentPid = pid();
|
||||
samples.push({
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
pid: currentPid,
|
||||
meminfo: currentPid ? adb("shell", "dumpsys", "meminfo", "sh.paseo") : "",
|
||||
});
|
||||
writeFileSync(join(artifacts, "samples.json"), JSON.stringify(samples, null, 2));
|
||||
assert.equal(currentPid, initialPid, "Opening the workspace sidebar must keep the app alive");
|
||||
await delay(5_000);
|
||||
}
|
||||
ad("press", 'label="Close sidebar"', "--settle");
|
||||
ad("wait", "text", "Open menu", "10000");
|
||||
console.log("PASS: app survived two minutes with the sidebar open and responded to Close");
|
||||
} finally {
|
||||
writeFileSync(
|
||||
join(artifacts, "exit-info.txt"),
|
||||
adb("shell", "dumpsys", "activity", "exit-info", "sh.paseo"),
|
||||
);
|
||||
writeFileSync(join(artifacts, "logcat.txt"), adb("logcat", "-d", "-t", "4000"));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -23,6 +23,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" },
|
||||
|
||||
@@ -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