Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 802359152e test(app): retain Android workspace memory diagnostic
Preserve the device assertion for a supplemental LOW_MEMORY exit seen with 800 projects. This does not establish the cause of the cold-start ANR reported in #4408 and changes no production behavior.
2026-10-13 00:41:58 +02:00
paseo-bot[bot] 196dc3acd3 fix(pi): update thinking levels when extensions change them (#6741)
Publish the existing thinking-option event as soon as Pi changes its effective level, keeping subscribers and persistence in sync before the next state refresh.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 23:56:31 +02:00
e867773932 Restore native app startup (#6734)
* fix(app): install navigator polyfill before any app module loads

@xterm/headless reads navigator.userAgent at module evaluation. Since
#6455 the root layout transitively imports it, so the layout's
module-level polyfillNavigator() ran too late and native builds crashed
at launch with "Cannot read property 'includes' of undefined".

Install the crypto, navigator and screen.orientation polyfills from a
side-effect module imported first by index.ts. Calls written in index.ts
itself ran after all hoisted imports had evaluated.

Fixes #6504

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): isolate native startup regression from shared globals

Exercise the real navigator polyfill and terminal dependency in fresh VM contexts, including the pre-initialization failure. Preserve a check that entry imports install navigator before the app loads.

Co-authored-by: panrafal <rl@stamina.pl>

* test(app): follow startup import lint rule

---------

Co-authored-by: panrafal <rl@stamina.pl>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 22:59:25 +02:00
Sevn ChenandChen-DaYi bfe38d92f6 fix(pi): mark turn-limited tintinweb subagents as completed (#6594)
Co-authored-by: Chen-DaYi <12897415+Chen-DaYi@users.noreply.github.com>
2026-10-12 22:01:25 +02:00
18 changed files with 367 additions and 609 deletions
@@ -2,7 +2,6 @@ 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,
@@ -71,14 +70,6 @@ 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) => {
@@ -693,35 +684,6 @@ 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", "", "![sibling](./sibling.png)", "", "![hero](../data/cache/hero.png)", ""].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,44 +0,0 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
const FIRST = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
"base64",
);
const SECOND = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
function displayedPixel(image: Locator): Promise<number[]> {
return image.evaluate((element: HTMLImageElement) => {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 1;
const context = canvas.getContext("2d")!;
context.drawImage(element, 0, 0, 1, 1);
return Array.from(context.getImageData(0, 0, 1, 1).data);
});
}
test("embedded Markdown image reflects a file replacement while Preview stays open", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "markdown-image-refresh-" });
const imagePath = path.join(workspace.repoPath, "hero.png");
await writeFile(imagePath, FIRST);
await writeFile(path.join(workspace.repoPath, "guide.md"), "# Guide\n\n![hero](./hero.png)\n");
await workspace.navigateTo();
await openFileExplorer(page);
await openFileFromExplorer(page, "guide.md");
const image = page.getByRole("img", { name: "hero", exact: true }).locator("img");
await expect
.poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth))
.toBeGreaterThan(0);
const initialPixel = await displayedPixel(image);
await writeFile(imagePath, SECOND);
await expect.poll(() => displayedPixel(image), { timeout: 5000 }).not.toEqual(initialPixel);
});
@@ -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"));
}
+3 -7
View File
@@ -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";
-2
View File
@@ -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 {
@@ -1,138 +0,0 @@
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("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");
});
});
@@ -1,149 +0,0 @@
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 path = localFileSourceToPath(
source.startsWith("file://") ? source : decodeRelativeSource(source),
);
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,59 +1,11 @@
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 {
createSharedMarkdownRules,
MarkdownRenderer,
type MarkdownStyles,
} from "@/components/markdown/renderer";
import { MarkdownRenderer } from "@/components/markdown/renderer";
import { parseMarkdownPreviewDocument } from "./document";
import { resolveMarkdownPreviewImageSource } from "./image-source";
import { FileMarkdownPreviewImage } from "./preview-image";
export function FileMarkdownPreview({
source,
filePath,
workspaceRoot,
client,
serverId,
}: {
source: string;
filePath: string;
workspaceRoot?: string;
client?: DaemonClient | null;
serverId?: string;
}) {
export function FileMarkdownPreview({ source }: { source: 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}>
@@ -79,7 +31,7 @@ export function FileMarkdownPreview({
))}
</View>
) : null}
<MarkdownRenderer text={document.body} rules={rules} />
<MarkdownRenderer text={document.body} />
</View>
</View>
);
@@ -1,151 +0,0 @@
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",
},
}));
+1 -28
View File
@@ -54,9 +54,6 @@ interface FilePreviewBodyProps {
location: WorkspaceFileLocation;
navigationRevision: number;
imagePreviewUri: string | null;
workspaceRoot: string;
client: DaemonClient | null;
serverId: string;
}
type TextExplorerFile = ExplorerFile & { kind: "text" };
@@ -138,9 +135,6 @@ function FilePreviewBody({
location,
navigationRevision,
imagePreviewUri,
workspaceRoot,
client,
serverId,
}: FilePreviewBodyProps) {
const { t } = useTranslation();
const filePath = location.path;
@@ -188,13 +182,7 @@ function FilePreviewBody({
style={styles.previewContent}
showsVerticalScrollIndicator
>
<FileMarkdownPreview
source={preview.content ?? ""}
filePath={filePath}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
<FileMarkdownPreview source={preview.content ?? ""} />
</RNScrollView>
</View>
);
@@ -309,7 +297,6 @@ export function FilePane({
return (
<FilePanePresentation
serverId={serverId}
workspaceRoot={normalizedWorkspaceRoot}
client={client}
readTarget={readTarget}
preview={preview}
@@ -351,7 +338,6 @@ function isEditableTextFile(input: {
function FilePanePresentation({
serverId,
workspaceRoot,
client,
readTarget,
preview,
@@ -373,7 +359,6 @@ function FilePanePresentation({
imagePreviewUri,
}: {
serverId: string;
workspaceRoot: string;
client: DaemonClient | null;
readTarget: { cwd: string; path: string } | null;
preview: ExplorerFile | null;
@@ -408,8 +393,6 @@ function FilePanePresentation({
return (
<EditableFilePane
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
serverId={serverId}
workspaceRoot={workspaceRoot}
client={client}
cwd={readTarget.cwd}
path={readTarget.path}
@@ -466,17 +449,12 @@ function FilePanePresentation({
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={imagePreviewUri}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
</View>
);
}
function EditableFilePane({
serverId,
workspaceRoot,
client,
cwd,
path,
@@ -492,8 +470,6 @@ function EditableFilePane({
location,
navigationRevision,
}: {
serverId: string;
workspaceRoot: string;
client: DaemonClient;
cwd: string;
path: string;
@@ -646,9 +622,6 @@ function EditableFilePane({
location={location}
navigationRevision={navigationRevision}
imagePreviewUri={null}
workspaceRoot={workspaceRoot}
client={client}
serverId={serverId}
/>
)}
</View>
@@ -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();
});
@@ -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();
@@ -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({
@@ -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 }