Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 4d08ddba94 fix(app): prevent concurrent draft agent creation
React submit state is render-lagged, so callbacks entering in the same render can all start create requests. Claim the draft synchronously in the shared create-flow store before the first request begins.
2026-08-12 11:45:51 +00:00
48 changed files with 1957 additions and 1166 deletions
-44
View File
@@ -1,49 +1,5 @@
# Changelog
## 0.4.0-beta.1 - 2026-08-12
### Breaking
- `paseo chat` and `paseo loop` have been removed ([#3053](https://github.com/getpaseo/paseo/pull/3053))
### Added
- Save host-wide agent profiles and apply them from the model picker ([#3208](https://github.com/getpaseo/paseo/pull/3208))
- Search workspace files with Cmd/Ctrl+P ([#3059](https://github.com/getpaseo/paseo/pull/3059))
- Create, rename, duplicate, delete, reveal, and discard files from Files and Changes ([#3027](https://github.com/getpaseo/paseo/pull/3027) by [@nikuscs](https://github.com/nikuscs))
- View interactive Mermaid diagrams in chats and Markdown previews ([#2306](https://github.com/getpaseo/paseo/pull/2306) by [@dmeledon](https://github.com/dmeledon))
- Follow live task progress above the composer and through the timeline ([#3227](https://github.com/getpaseo/paseo/pull/3227))
- Choose the model used for workspace titles, branch names, commits, and pull request drafts ([#3215](https://github.com/getpaseo/paseo/pull/3215))
- Deploy Hub workflows from multi-file `.paseo` bundles ([#3128](https://github.com/getpaseo/paseo/pull/3128))
- Build integrations with the supported `@getpaseo/client` TypeScript SDK ([#3141](https://github.com/getpaseo/paseo/pull/3141))
- Diagnose provider installations with `paseo provider diagnostic` ([#3243](https://github.com/getpaseo/paseo/pull/3243))
- Rename workspaces from the CLI ([#3209](https://github.com/getpaseo/paseo/pull/3209) by [@martinhanzik](https://github.com/martinhanzik))
### Improved
- Markdown file previews use a centered reading layout with formatted YAML front matter ([#3240](https://github.com/getpaseo/paseo/pull/3240))
- Switch sidebar grouping directly from the Command Center ([#3063](https://github.com/getpaseo/paseo/pull/3063) by [@cleiter](https://github.com/cleiter))
- Theme shortcuts now cycle through every built-in theme ([#3214](https://github.com/getpaseo/paseo/pull/3214))
- Newly enrolled Hub hosts use their machine hostname ([#3172](https://github.com/getpaseo/paseo/pull/3172))
### Fixed
- Terminal sessions survive host sleep and severe resource stalls ([#3235](https://github.com/getpaseo/paseo/pull/3235), [#3263](https://github.com/getpaseo/paseo/pull/3263))
- Archiving a workspace no longer risks hanging the daemon ([#3107](https://github.com/getpaseo/paseo/pull/3107))
- New Workspace no longer crashes on Android when projects span multiple hosts ([#3241](https://github.com/getpaseo/paseo/pull/3241))
- Provider caching no longer exhausts Android local storage ([#3234](https://github.com/getpaseo/paseo/pull/3234))
- Dictation recordings survive connection interruptions ([#3159](https://github.com/getpaseo/paseo/pull/3159))
- Repeated worktree creation always produces a fresh workspace ([#3224](https://github.com/getpaseo/paseo/pull/3224))
- New worktrees use the setup command currently saved in Project Settings ([#3233](https://github.com/getpaseo/paseo/pull/3233))
- Removed hosts stop sending push notifications ([#3176](https://github.com/getpaseo/paseo/pull/3176))
- Delegated work still reports back after permission prompts or workspace closure ([#3177](https://github.com/getpaseo/paseo/pull/3177), [#3192](https://github.com/getpaseo/paseo/pull/3192) by [@thomasvan](https://github.com/thomasvan), [@wilgon456](https://github.com/wilgon456))
- OMP agents start correctly with large model catalogs ([#3184](https://github.com/getpaseo/paseo/pull/3184) by [@pi3123](https://github.com/pi3123))
- OpenCode models can return to their default variant ([#3281](https://github.com/getpaseo/paseo/pull/3281))
- Completed Codex subagents no longer remain marked active ([#3188](https://github.com/getpaseo/paseo/pull/3188) by [@Strainy](https://github.com/Strainy))
- Codex compaction no longer leaves turns marked as working ([#3211](https://github.com/getpaseo/paseo/pull/3211) by [@edihasaj](https://github.com/edihasaj))
- Cursor usage works with current Cursor logins without requiring `sqlite3` ([#2704](https://github.com/getpaseo/paseo/pull/2704) by [@QuteSaltyFish](https://github.com/QuteSaltyFish))
- SVG project icons render correctly on mobile ([#2579](https://github.com/getpaseo/paseo/pull/2579) by [@colonelpanic8](https://github.com/colonelpanic8))
## 0.3.1 - 2026-08-09
### Added
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36091,7 +36091,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37139,12 +37139,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.4.0-beta.1",
"@getpaseo/protocol": "0.4.0-beta.1",
"@getpaseo/server": "0.4.0-beta.1",
"@getpaseo/client": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/server": "0.3.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37391,10 +37391,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"dependencies": {
"@getpaseo/protocol": "0.4.0-beta.1",
"@getpaseo/relay": "0.4.0-beta.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37405,7 +37405,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37648,7 +37648,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38544,7 +38544,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38573,7 +38573,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38586,7 +38586,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38804,15 +38804,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.4.0-beta.1",
"@getpaseo/highlight": "0.4.0-beta.1",
"@getpaseo/protocol": "0.4.0-beta.1",
"@getpaseo/relay": "0.4.0-beta.1",
"@getpaseo/client": "0.3.1",
"@getpaseo/highlight": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -39360,7 +39360,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+124 -52
View File
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { type Locator, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -63,6 +63,10 @@ async function failNextDiscardRequest(page: Page): Promise<void> {
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
function diffGroups(scope: Page | Locator, fileIndex = 0): Locator {
return scope.getByTestId(new RegExp(`^diff-file-${fileIndex}-group-\\d+$`));
}
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
@@ -465,12 +469,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await expect(diffGroups(visiblePanel)).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click();
@@ -486,14 +490,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
);
await page.keyboard.press("Escape");
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await expect(visiblePanel.getByTestId(/^diff-file-\d+-group-\d+$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
@@ -516,13 +518,9 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toBeVisible();
await expect(diffGroups(page.getByTestId("explorer-content-area")).first()).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
@@ -553,19 +551,8 @@ test("changes diff switches between flat and tree file lists", async ({ page })
"none",
);
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(2);
await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
const folderToggleBounds = await page.getByTestId("diff-folder-src-toggle").boundingBox();
const folderChevronBounds = await page
.getByTestId("diff-folder-src-toggle")
.locator("svg")
.boundingBox();
expect(folderToggleBounds).not.toBeNull();
expect(folderChevronBounds).not.toBeNull();
expect(folderChevronBounds!.y + folderChevronBounds!.height / 2).toBeCloseTo(
folderToggleBounds!.y + folderToggleBounds!.height / 2,
0,
);
const folderLabelBounds = await page
.getByTestId("diff-folder-src")
.getByText("src", { exact: true })
@@ -576,7 +563,7 @@ test("changes diff switches between flat and tree file lists", async ({ page })
.boundingBox();
expect(folderLabelBounds).not.toBeNull();
expect(fileLabelBounds).not.toBeNull();
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(16, 0);
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(14, 0);
const folderToggle = page.getByTestId("diff-folder-src-toggle");
await folderToggle.click();
@@ -596,7 +583,7 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByRole("button", { name: "Expand all" }).click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(page).first()).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -620,6 +607,90 @@ test("changes diff applies code size changes to gutter and code typography", asy
await expectVisibleDiffRowsShareTypography(page);
});
const LARGE_DIFF_WIDE_LINE_INDEX = 900;
async function createWorkspaceWithLargeUnwrappedDiff(): Promise<DirtyWorkspace> {
const workspace = await createWorkspaceWithMountedTabDiff();
const addedLines = Array.from({ length: 1_200 }, (_, index) => {
if (index === 0) return `\t\t\t\tconst tabbed_${index} = "tabbed";`;
if (index === LARGE_DIFF_WIDE_LINE_INDEX) {
return `const wide_${index} = "${"界".repeat(80)}";`;
}
return `const generated_${index} = ${index};`;
}).join("\n");
await writeFile(
path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"),
`${AFTER}${addedLines}\n`,
);
return workspace;
}
async function scrollToWideDiffLine(page: Page): Promise<Locator> {
await page.getByTestId("git-diff-scroll").evaluate((element, lineIndex) => {
element.scrollTop = lineIndex * 18;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
}, LARGE_DIFF_WIDE_LINE_INDEX);
const wideLine = page.getByText(new RegExp(`const wide_${LARGE_DIFF_WIDE_LINE_INDEX}`));
await expect(wideLine).toHaveCount(1);
return wideLine;
}
test("unwrapped virtualized rows use one horizontal file surface", async ({ page }) => {
const workspace = await createWorkspaceWithLargeUnwrappedDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const horizontalSurface = page.getByTestId("diff-horizontal-scroll");
await expect(horizontalSurface).toHaveCount(1);
await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0);
const codeBefore = await page.getByTestId("diff-code-row-0").boundingBox();
const gutterBefore = await page.getByTestId("diff-gutter-row-0").boundingBox();
expect(codeBefore).not.toBeNull();
expect(gutterBefore).not.toBeNull();
await horizontalSurface.evaluate((element, left) => {
element.scrollLeft = left;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
}, 120);
await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120);
await expect
.poll(async () => (await page.getByTestId("diff-code-row-0").boundingBox())?.x ?? 0)
.toBeLessThanOrEqual(codeBefore!.x - 119);
await expect
.poll(async () => (await page.getByTestId("diff-gutter-row-0").boundingBox())?.x ?? 0)
.toBeCloseTo(gutterBefore!.x, 0);
const initiallyMountedGroups = await readMountedDiffGroupIds(page);
await page.getByTestId("git-diff-scroll").evaluate((element) => {
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect
.poll(async () => {
const mountedGroups = await readMountedDiffGroupIds(page);
return mountedGroups.some((group) => !initiallyMountedGroups.includes(group));
})
.toBe(true);
await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120);
await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0);
const wideLine = await scrollToWideDiffLine(page);
await horizontalSurface.evaluate((element) => {
element.scrollLeft = element.scrollWidth;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect
.poll(async () => {
const surfaceBounds = await horizontalSurface.boundingBox();
const wideTextRight = await wideLine.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
return range.getBoundingClientRect().right;
});
if (!surfaceBounds) return false;
return wideTextRight <= surfaceBounds.x + surfaceBounds.width + 1;
})
.toBe(true);
});
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
@@ -645,6 +716,12 @@ async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
);
}
async function readMountedDiffGroupIds(page: Page): Promise<(string | null)[]> {
return diffGroups(page).evaluateAll((groups) =>
groups.map((group) => group.getAttribute("data-testid")),
);
}
async function useUnwrappedDiffLines(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
@@ -693,21 +770,22 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
codeLineHeight: number;
}[];
}> {
return page.locator("body").evaluate(({ ownerDocument }) => {
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
if (!root) {
return diffGroups(page).evaluateAll((groups) => {
if (groups.length === 0) {
throw new Error("Expanded diff body is not mounted");
}
const readRows = (prefix: string, textPrefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
});
groups.flatMap((group) =>
Array.from(group.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = row.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
}),
);
const gutters = new Map(
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
@@ -811,7 +889,7 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(diffGroups(page).first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
timeout: 30_000,
});
@@ -850,15 +928,9 @@ async function returnToWorkspaceChanges(page: Page): Promise<void> {
}
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
if (rows.length === 0) {
throw new Error("No unwrapped code rows are mounted");
}
return Math.max(
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
);
});
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
const groups = diffGroups(page);
await expect.poll(() => groups.count()).toBeGreaterThan(1);
const lastMountedGroup = groups.last();
await lastMountedGroup.scrollIntoViewIfNeeded();
await expect(lastMountedGroup.locator('[data-testid^="diff-code-row-"]').last()).toBeVisible();
}
@@ -55,7 +55,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await layoutToggle.click();
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(panel.getByTestId(/^diff-file-0-group-\d+$/).first()).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
@@ -37,7 +37,6 @@ import {
import { seedWorkspace } from "../support/helpers/seed-client";
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
import { getServerId } from "../support/helpers/server-id";
import { openFileExplorer } from "../support/helpers/file-explorer";
const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
@@ -295,28 +294,6 @@ test.describe("Composer attachments", () => {
}
});
test("Escape cancels file creation without interrupting the running agent", async ({ page }) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
prefix: "file-create-escape-",
model: "one-minute-stream",
prompt: "Stay running while a file draft is cancelled.",
});
try {
await openFileExplorer(page);
await page.getByTestId("files-new-file").click();
const nameInput = page.getByTestId("file-explorer-name-input");
await expect(nameInput).toBeVisible();
await nameInput.press("Escape");
await expect(nameInput).toBeHidden();
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
} finally {
await agent.cleanup();
}
});
test("composer is locked while new workspace agent is being created", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
@@ -124,14 +124,6 @@ test("creates, renames, copies, and deletes entries through the file explorer",
await page.getByTestId("files-new-folder").click();
await expect(nameInput).toBeVisible();
const draftRow = nameInput.locator("xpath=..");
const placeholderColor = await nameInput.evaluate(
(input) => getComputedStyle(input, "::placeholder").color,
);
const extraMutedChevronColor = await draftRow
.locator("svg")
.evaluate((icon) => getComputedStyle(icon).stroke);
expect(placeholderColor).toBe(extraMutedChevronColor);
await nameInput.press("Tab");
await expect(nameInput).toBeHidden();
@@ -197,38 +189,18 @@ test("creates, renames, copies, and deletes entries through the file explorer",
const fileRow = entry("created.txt").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
);
await expect(folderRow.locator("svg")).toHaveCount(1);
await expect(folderRow.locator("svg")).toHaveCount(2);
await expect(fileRow.locator("svg")).toHaveCount(1);
const gitRow = entry(".git").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
);
const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox();
const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox();
const collapsedChevronSlotBounds = await gitRow
.locator("svg")
.first()
.locator("xpath=../..")
.boundingBox();
const expandedChevronSlotBounds = await folderRow
.locator("svg")
.first()
.locator("xpath=../..")
.boundingBox();
expect(collapsedChevronBounds).not.toBeNull();
expect(expandedChevronBounds).not.toBeNull();
expect(collapsedChevronSlotBounds).not.toBeNull();
expect(expandedChevronSlotBounds).not.toBeNull();
const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2;
const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2;
expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0);
expect(collapsedChevronCenter).toBeCloseTo(
collapsedChevronSlotBounds!.x + collapsedChevronSlotBounds!.width / 2,
0,
);
expect(expandedChevronCenter).toBeCloseTo(
expandedChevronSlotBounds!.x + expandedChevronSlotBounds!.width / 2,
0,
);
const folderLabelBounds = await entry("folder").boundingBox();
const fileLabelBounds = await entry("created.txt").boundingBox();
expect(folderLabelBounds).not.toBeNull();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -73,32 +73,30 @@ export function AgentProfileRow({
return (
<View style={rowStyle} testID={`agent-profile-row-${profile.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconWrapper}>
<AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} />
<View style={styles.iconWrapper}>
<AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} />
</View>
<View style={settingsStyles.rowContent}>
<View style={styles.titleLine}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{profile.name}
</Text>
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
</View>
<View style={settingsStyles.rowContent}>
<View style={styles.titleLine}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{profile.name}
</Text>
<Text style={styles.summary} numberOfLines={1}>
{summary}
{profile.notes ? (
<View style={styles.notes}>
<ThemedFileText size={ICON_SIZE.xs} uniProps={mutedColorMapping} />
<Text
style={styles.notesText}
numberOfLines={2}
testID={`agent-profile-notes-${profile.id}`}
>
{profile.notes}
</Text>
</View>
{profile.notes ? (
<View style={styles.notes}>
<ThemedFileText size={ICON_SIZE.xs} uniProps={mutedColorMapping} />
<Text
style={styles.notesText}
numberOfLines={2}
testID={`agent-profile-notes-${profile.id}`}
>
{profile.notes}
</Text>
</View>
) : null}
</View>
) : null}
</View>
<View style={styles.rowActions}>
<Button
@@ -147,17 +145,12 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
paddingVertical: theme.spacing[3],
},
rowMain: {
flex: 1,
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
},
iconWrapper: {
width: theme.iconSize.md,
alignSelf: "flex-start",
alignItems: "center",
justifyContent: "center",
marginTop: 1,
marginTop: theme.spacing[1],
},
titleLine: {
flexDirection: "row",
+38 -21
View File
@@ -1,58 +1,75 @@
import { useState, useCallback, useEffect, useId, useRef } from "react";
import { useState, useCallback, useEffect, useId } from "react";
import { type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import {
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
createAnimatedComponent,
useAnimatedScrollHandler,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
const AnimatedScrollView = createAnimatedComponent(ScrollView);
interface DiffScrollProps {
children: React.ReactNode;
scrollViewWidth: number;
onScrollViewWidthChange: (width: number) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
offset: SharedValue<number>;
testID?: string;
}
export function DiffScroll({
children,
scrollViewWidth: _scrollViewWidth,
onScrollViewWidthChange,
style,
contentContainerStyle,
offset,
testID,
}: DiffScrollProps) {
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const horizontalScroll = useHorizontalScrollOptional();
const scrollId = useId();
const scrollViewRef = useRef<ScrollViewType>(null);
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const isAtLeftEdgeValue = useSharedValue(true);
const closeGestureRef = useFileExplorerCloseGestureRef();
// Register/unregister scroll offset tracking
useEffect(() => {
if (!horizontalScroll) return;
// Start at 0 (not scrolled)
horizontalScroll.registerScrollOffset(scrollId, 0);
return () => {
horizontalScroll.unregisterScrollOffset(scrollId);
};
}, [horizontalScroll, scrollId]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetX = event.nativeEvent.contentOffset.x;
// Track if we're at the left edge (with small threshold for float precision)
setIsAtLeftEdge(offsetX <= 1);
const recordScrollOffset = useCallback(
(offsetX: number) => {
if (horizontalScroll) {
horizontalScroll.registerScrollOffset(scrollId, offsetX);
}
},
[horizontalScroll, scrollId],
);
const recordLeftEdge = useCallback((atLeftEdge: boolean) => {
setIsAtLeftEdge(atLeftEdge);
}, []);
const handleScroll = useAnimatedScrollHandler({
onScroll: (event) => {
const offsetX = event.contentOffset.x;
offset.value = offsetX;
const atLeftEdge = offsetX <= 1;
if (atLeftEdge !== isAtLeftEdgeValue.value) {
isAtLeftEdgeValue.value = atLeftEdge;
scheduleOnRN(recordLeftEdge, atLeftEdge);
}
scheduleOnRN(recordScrollOffset, offsetX);
},
});
const handleLayout = useCallback(
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
@@ -60,8 +77,7 @@ export function DiffScroll({
);
return (
<ScrollView
ref={scrollViewRef}
<AnimatedScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
@@ -71,6 +87,7 @@ export function DiffScroll({
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={handleLayout}
testID={testID}
// When at left edge, wait for close gesture to fail before scrolling.
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
// so scrolling left works normally. On rightward swipes, close gesture
@@ -78,6 +95,6 @@ export function DiffScroll({
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
{children}
</ScrollView>
</AnimatedScrollView>
);
}
@@ -1,12 +1,23 @@
import { useCallback } from "react";
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import { useCallback, useId, useLayoutEffect, useState } from "react";
import {
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import type { SharedValue } from "react-native-reanimated";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
interface DiffScrollProps {
children: React.ReactNode;
scrollViewWidth: number;
onScrollViewWidthChange: (width: number) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
offset: SharedValue<number>;
testID?: string;
}
export function DiffScroll({
@@ -14,9 +25,31 @@ export function DiffScroll({
onScrollViewWidthChange,
style,
contentContainerStyle,
offset,
testID,
}: DiffScrollProps) {
const horizontalScroll = useHorizontalScrollOptional();
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const scrollId = useId();
const closeGestureRef = useFileExplorerCloseGestureRef();
useLayoutEffect(() => {
horizontalScroll?.registerScrollOffset(scrollId, 0);
return () => horizontalScroll?.unregisterScrollOffset(scrollId);
}, [horizontalScroll, scrollId]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetX = event.nativeEvent.contentOffset.x;
offset.value = offsetX;
setIsAtLeftEdge(offsetX <= 1);
horizontalScroll?.registerScrollOffset(scrollId, offsetX);
},
[horizontalScroll, offset, scrollId],
);
const handleLayout = useCallback(
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
(event: LayoutChangeEvent) => onScrollViewWidthChange(event.nativeEvent.layout.width),
[onScrollViewWidthChange],
);
@@ -25,9 +58,14 @@ export function DiffScroll({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
bounces={false}
style={style}
contentContainerStyle={contentContainerStyle}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={handleLayout}
testID={testID}
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
{children}
</ScrollView>
@@ -27,7 +27,15 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native";
import {
ChevronDown,
Eye,
EyeOff,
FilePlus,
Folder,
FolderPlus,
RotateCw,
} from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import {
TreeChevron,
@@ -81,6 +89,7 @@ const SORT_OPTIONS: { value: SortOption }[] = [
];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedFolder = withUnistyles(Folder);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -204,9 +213,12 @@ function EntryNameInputRow({
<View style={[styles.entryRow, { paddingLeft: treeRowPaddingLeft(depth) }]}>
<TreeIndentGuides depth={depth} />
<View style={styles.entryInfo}>
<View style={styles.entryChevron}>
{kind === "directory" ? <TreeChevron expanded={false} /> : null}
</View>
<View style={styles.entryIcon}>
{kind === "directory" ? (
<TreeChevron expanded={false} />
<ThemedFolder size={WORKSPACE_TREE_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
) : (
<MaterialFileIcon fileName={name || "untitled"} size={WORKSPACE_TREE_ICON_SIZE} />
)}
@@ -225,7 +237,6 @@ function EntryNameInputRow({
}
autoCapitalize="none"
autoCorrect={false}
placeholderTextColor={styles.draftPlaceholder.color}
style={styles.draftInput}
selectTextOnFocus={Boolean(initialName)}
testID="file-explorer-name-input"
@@ -370,9 +381,15 @@ function TreeRowItem({
>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryChevron}>
{isDirectory ? <DirectoryChevronIcon loading={loading} expanded={isExpanded} /> : null}
</View>
<View style={styles.entryIcon}>
{isDirectory ? (
<DirectoryChevronIcon loading={loading} expanded={isExpanded} />
<ThemedFolder
size={WORKSPACE_TREE_ICON_SIZE}
uniProps={foregroundMutedColorMapping}
/>
) : (
<MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} />
)}
@@ -1755,6 +1772,13 @@ const styles = StyleSheet.create((theme) => ({
gap: WORKSPACE_TREE_ICON_LABEL_GAP,
minWidth: 0,
},
entryChevron: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
entryIcon: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
@@ -1775,9 +1799,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: 0,
paddingHorizontal: 0,
},
draftPlaceholder: {
color: theme.colors.foregroundExtraMuted,
},
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},
+2 -12
View File
@@ -114,7 +114,7 @@ function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
accessibilityLabel={t("modelSelector.editProfilesLabel")}
testID="model-profiles-edit"
>
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
<ThemedPencil size={ICON_SIZE.sm} uniProps={foregroundExtraMutedMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -675,11 +675,7 @@ function AgentProfilesPickerSection({
<View style={styles.profilesContainer}>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>{t("modelSelector.profiles")}</Text>
{onEditProfiles ? (
<View style={styles.sectionHeadingAction}>
<AgentProfilesEditAction onPress={onEditProfiles} />
</View>
) : null}
{onEditProfiles ? <AgentProfilesEditAction onPress={onEditProfiles} /> : null}
</View>
{rows.map((row) => (
<AgentProfilePickerRowView key={row.id} row={row} onApply={handleApply} />
@@ -1221,7 +1217,6 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.border,
},
sectionHeading: {
position: "relative",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
@@ -1229,11 +1224,6 @@ const styles = StyleSheet.create((theme) => ({
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
sectionHeadingAction: {
position: "absolute",
top: theme.spacing[1],
right: isWeb ? theme.spacing[3] : theme.spacing[6],
},
sectionHeadingText: {
flex: 1,
fontSize: theme.fontSize.xs,
@@ -10,22 +10,22 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// paths) render different data, but their ROWS should look identical — same
// indentation, guide lines, and chevron. Keep those here so the two trees can't
// drift apart.
export const TREE_INDENT_PER_LEVEL = 16;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
export const WORKSPACE_TREE_ICON_SIZE = 16;
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
export const TREE_INDENT_PER_LEVEL = 14;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1];
export const WORKSPACE_TREE_ICON_SIZE = 14;
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 12;
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[1];
/**
* Trailing glyph rail shared with the explorer X and Changes options chevron.
* The extra 2px is optical: text ink ends inside its layout box, while the
* header icons' strokes extend to theirs.
*/
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[3] + 2;
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */
export function treeRowPaddingLeft(depth: number): number {
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
return SPACING[2] + depth * TREE_INDENT_PER_LEVEL;
}
const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
@@ -46,7 +46,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
key: index,
style: [
styles.indentGuide,
inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
inlineUnistylesStyle({ left: SPACING[2] + index * TREE_INDENT_PER_LEVEL + 3 }),
],
})),
[depth],
@@ -88,6 +88,43 @@ describe("useDraftAgentCreateFlow", () => {
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
});
it("starts one create request when submit is invoked twice before React rerenders", async () => {
let resolveCreate: ((value: { agentId: string; result: { id: string } }) => void) | undefined;
const createRequest = vi.fn(
() =>
new Promise<{ agentId: string; result: { id: string } }>((resolve) => {
resolveCreate = resolve;
}),
);
const onCreateSuccess = vi.fn();
const { result } = renderHook(() =>
useDraftAgentCreateFlow({
draftId: "draft-1",
getPendingServerId: () => "server-1",
buildDraftAgent: (attempt) => ({ attempt }),
createRequest,
onCreateSuccess,
}),
);
const submission = { text: "build this", attachments: [], cwd: "/repo" };
let first: Promise<void>;
let duplicate: Promise<void>;
act(() => {
first = result.current.handleCreateFromInput(submission);
duplicate = result.current.handleCreateFromInput(submission);
});
await expect(duplicate!).rejects.toThrow();
expect(createRequest).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCreate?.({ agentId: "agent-1", result: { id: "agent-1" } });
await first!;
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
});
it("allows retrying an empty prompt when the draft still has context attachments", async () => {
const attachment = {
kind: "chat_history",
+16 -3
View File
@@ -84,6 +84,17 @@ interface CreateRequestContext {
cwd: string;
}
function assertCreateAvailable(input: {
draftId: string;
isSubmitting: boolean;
alreadyLoadingMessage: string;
}): void {
const pending = useCreateFlowStore.getState().pendingByDraftId[input.draftId];
if (input.isSubmitting || pending?.lifecycle === "active") {
throw new Error(input.alreadyLoadingMessage);
}
}
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string;
getPendingServerId: () => string | null;
@@ -239,9 +250,11 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => {
if (isSubmitting) {
throw new Error(t("composer.errors.alreadyLoading"));
}
assertCreateAvailable({
draftId,
isSubmitting,
alreadyLoadingMessage: t("composer.errors.alreadyLoading"),
});
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const trimmedPrompt = text.trim();
@@ -1,134 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "./diff-flat-items";
import type { ParsedDiffFile } from "@/git/use-diff-query";
function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
return { path, isNew: false, isDeleted: false, additions, deletions, hunks: [] };
}
function summarize(items: DiffFlatItem[]): string[] {
return items.map((item) => {
if (item.type === "folder") {
return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
}
const base = item.file.path.split("/").pop();
return `${" ".repeat(item.depth)}${item.type === "body" ? "body:" : ""}${base}`;
});
}
describe("buildDiffFlatItems", () => {
const files = [createFile("src/app/a.ts"), createFile("src/app/nested/b.ts")];
it("emits a body and a sticky index for each expanded file", () => {
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files: [createFile("a.ts"), createFile("b.ts")],
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(["a.ts"]),
});
// Root-level files (no folder): header, body (for expanded a.ts), header
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
// sticky points at the header (index 0), not the body
expect(stickyHeaderIndices).toEqual([0]);
expect(items[0].type).toBe("header");
});
it("emits the old flat list without folder rows or indentation", () => {
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files,
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set(["src/app/a.ts"]),
});
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
expect(stickyHeaderIndices).toEqual([0]);
expect(items.every((item) => item.type !== "folder")).toBe(true);
});
it("groups files under compressed folder rows, all expanded by default", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
});
// dirs sort before files within a level: [nested] precedes a.ts
expect(summarize(items)).toEqual(["[src/app]", " [nested]", " b.ts", " a.ts"]);
});
it("collapsing a folder hides its descendants but keeps the row", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app/nested"]),
expandedPaths: new Set(),
});
expect(summarize(items)).toEqual(["[src/app]", " [nested] (collapsed)", " a.ts"]);
});
it("collapsing an ancestor hides everything below it", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app"]),
expandedPaths: new Set(),
});
expect(summarize(items)).toEqual(["[src/app] (collapsed)"]);
});
it("derives sticky indices file-headers-only from the post-collapse list", () => {
// dirs-first: [src/app], [nested], b.ts, a.ts. Expanding a.ts puts its
// header at index 3, with the body right after.
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(["src/app/a.ts"]),
});
expect(summarize(items)).toEqual([
"[src/app]",
" [nested]",
" b.ts",
" a.ts",
" body:a.ts",
]);
expect(stickyHeaderIndices).toEqual([3]);
for (const idx of stickyHeaderIndices) {
expect(items[idx].type).toBe("header");
}
});
it("maps tree file rows back to their original files array index", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
});
// Tree order is b.ts (fileIndex 1) then a.ts (fileIndex 0)
const headers = items.filter((i) => i.type === "header");
expect(headers.map((h) => (h.type === "header" ? h.fileIndex : -1))).toEqual([1, 0]);
});
});
describe("sumHeightsBefore", () => {
const items = buildDiffFlatItems({
files: [createFile("src/a.ts"), createFile("src/b.ts")],
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
}).items; // [folder, a.ts, b.ts]
const heightFor = (item: DiffFlatItem) => (item.type === "folder" ? 10 : 20);
it("sums the heights of items before the index, counting folder rows", () => {
expect(sumHeightsBefore(items, 0, heightFor)).toBe(0);
expect(sumHeightsBefore(items, 1, heightFor)).toBe(10); // folder above a.ts
expect(sumHeightsBefore(items, 2, heightFor)).toBe(30); // folder + a.ts above b.ts
});
it("clamps an out-of-range index to the list length", () => {
expect(sumHeightsBefore(items, 999, heightFor)).toBe(50);
});
});
-121
View File
@@ -1,121 +0,0 @@
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildDiffTree,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeDirNode,
} from "@/git/diff-tree";
// The row model for the Changes FlatList. `fileIndex` is the file's index within
// the (path-sorted) `files` array — a stable identity for testIDs/keys that is
// independent of folder rows and collapse state. `depth` drives indentation.
export type DiffFlatItem =
| {
type: "folder";
dirPath: string;
displayName: string;
depth: number;
collapsed: boolean;
additions: number;
deletions: number;
}
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean; depth: number }
| { type: "body"; file: ParsedDiffFile; fileIndex: number; depth: number };
export interface DiffFlatItemsResult {
items: DiffFlatItem[];
/** Indices into `items` of expanded file headers — folder rows are never sticky. */
stickyHeaderIndices: number[];
}
export interface BuildDiffFlatItemsInput {
files: ParsedDiffFile[];
viewMode: "flat" | "tree";
/** Full uncompressed directory paths that are collapsed (empty = all expanded). */
collapsedFolders: ReadonlySet<string>;
/** File paths whose diff body is expanded. */
expandedPaths: ReadonlySet<string>;
/**
* Pre-built compressed tree (from the same `files`). Pass it to avoid rebuilding
* the tree on every collapse toggle; omitted callers (tests) build it internally.
*/
tree?: DiffTreeDirNode;
}
/**
* Build the FlatList item model plus its sticky-header indices: files grouped
* under directory rows, single-child chains compressed, descendants of collapsed
* folders omitted.
*
* Sticky indices are derived from the FINAL (post-collapse) item list and only
* ever point at expanded FILE headers, so folder rows can't stack or collide in
* the sticky header stack.
*/
export function buildDiffFlatItems({
files,
viewMode,
collapsedFolders,
expandedPaths,
tree,
}: BuildDiffFlatItemsInput): DiffFlatItemsResult {
const items: DiffFlatItem[] = [];
const stickyHeaderIndices: number[] = [];
const pushFile = (file: ParsedDiffFile, fileIndex: number, depth: number): void => {
const isExpanded = expandedPaths.has(file.path);
items.push({ type: "header", file, fileIndex, isExpanded, depth });
if (isExpanded) {
stickyHeaderIndices.push(items.length - 1);
items.push({ type: "body", file, fileIndex, depth });
}
};
if (viewMode === "flat") {
for (const [fileIndex, file] of files.entries()) {
pushFile(file, fileIndex, 0);
}
return { items, stickyHeaderIndices };
}
const indexByPath = new Map(files.map((file, index) => [file.path, index]));
const compressedTree = tree ?? compressSingleChildChains(buildDiffTree(files));
const rows = flattenDiffTree(compressedTree, collapsedFolders);
for (const row of rows) {
if (row.kind === "folder") {
items.push({
type: "folder",
dirPath: row.dirPath,
displayName: row.displayName,
depth: row.depth,
collapsed: collapsedFolders.has(row.dirPath),
additions: row.additions,
deletions: row.deletions,
});
continue;
}
const fileIndex = indexByPath.get(row.file.path);
if (fileIndex === undefined) {
// Should never happen: the tree is built from the same `files` array.
continue;
}
pushFile(row.file, fileIndex, row.depth);
}
return { items, stickyHeaderIndices };
}
/** Cumulative height of every item before `index`. Single source of truth for
* FlatList's getItemLayout AND the collapse scroll-anchor math. */
export function sumHeightsBefore(
items: DiffFlatItem[],
index: number,
heightFor: (item: DiffFlatItem) => number,
): number {
let offset = 0;
const end = Math.min(index, items.length);
for (let i = 0; i < end; i++) {
offset += heightFor(items[i]);
}
return Math.max(0, offset);
}
+19 -3
View File
@@ -1,6 +1,7 @@
import { useCallback, useMemo } from "react";
import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Folder } from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat";
import {
TreeChevron,
@@ -17,6 +18,9 @@ import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
import { isWeb } from "@/constants/platform";
const ThemedFolder = withUnistyles(Folder);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
interface DiffFolderRowProps {
/** full uncompressed directory path — the collapse identity */
dirPath: string;
@@ -141,9 +145,15 @@ export function DiffFolderRow({
testID={testID ? `${testID}-toggle` : undefined}
>
<View style={leftStyle}>
<View style={styles.chevronSlot}>
<View style={styles.chevronOpticalOffset}>
<TreeChevron expanded={!collapsed} />
</View>
<View style={styles.folderIcon}>
<ThemedFolder
size={WORKSPACE_TREE_ICON_SIZE}
uniProps={foregroundMutedColorMapping}
/>
</View>
<Text style={styles.folderName} numberOfLines={1}>
{displayName}
</Text>
@@ -194,7 +204,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
flex: 1,
minWidth: 0,
},
chevronSlot: {
chevronOpticalOffset: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
},
folderIcon: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
@@ -0,0 +1,80 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { type LayoutChangeEvent, type StyleProp, type ViewStyle, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
import { DiffScroll } from "@/components/diff-scroll";
const FILL: ViewStyle = { flex: 1 };
const CLIP: ViewStyle = { flex: 1, overflow: "hidden" };
const DiffHorizontalOffsetContext = createContext<SharedValue<number> | null>(null);
export function DiffHorizontalSurface({
children,
contentWidth,
viewportWidth,
onViewportWidthChange,
}: {
children: ReactNode;
contentWidth: number;
viewportWidth: number;
onViewportWidthChange: (width: number) => void;
}) {
const offset = useSharedValue(0);
const pinnedViewportStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
}));
const contentContainerStyle = useMemo<StyleProp<ViewStyle>>(
() => [{ flexGrow: 1 }, { minWidth: Math.max(viewportWidth, contentWidth) }],
[contentWidth, viewportWidth],
);
return (
<DiffHorizontalOffsetContext.Provider value={offset}>
<DiffScroll
offset={offset}
onScrollViewWidthChange={onViewportWidthChange}
style={FILL}
contentContainerStyle={contentContainerStyle}
testID="diff-horizontal-scroll"
>
<Animated.View style={[FILL, { width: viewportWidth }, pinnedViewportStyle]}>
{children}
</Animated.View>
</DiffScroll>
</DiffHorizontalOffsetContext.Provider>
);
}
export function DiffHorizontalContent({
children,
viewportStyle,
contentStyle,
onContentLayout,
testID,
}: {
children: ReactNode;
viewportStyle?: StyleProp<ViewStyle>;
contentStyle?: StyleProp<ViewStyle>;
onContentLayout?: (event: LayoutChangeEvent) => void;
testID?: string;
}) {
const offset = useContext(DiffHorizontalOffsetContext);
const translatedContentStyle = useAnimatedStyle(() => ({
transform: [{ translateX: -(offset?.value ?? 0) }],
}));
return (
<View style={[CLIP, viewportStyle]}>
<Animated.View
style={[contentStyle, translatedContentStyle]}
onLayout={onContentLayout}
testID={testID}
>
{children}
</Animated.View>
</View>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,286 @@
import { describe, expect, it } from "vitest";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildDiffRowIndex,
createDiffRowCache,
DIFF_VIRTUALIZATION_CONFIG,
type DiffRowCache,
} from "./model";
function createFile(path: string, lineCount: number): ParsedDiffFile {
return {
path,
isNew: false,
isDeleted: false,
additions: lineCount,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 0,
newStart: 1,
newCount: lineCount,
lines: [
{ type: "header", content: `@@ -1,0 +1,${lineCount} @@` },
...Array.from({ length: lineCount }, (_, index) => ({
type: "add" as const,
content: `line ${index + 1}`,
})),
],
},
],
};
}
function buildIndex(files: ParsedDiffFile[], expandedPaths: string[]) {
return buildDiffRowIndex({
files,
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set(expandedPaths),
layout: "unified",
wrapLines: false,
lineHeight: 18,
viewportWidth: 400,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "test",
reviewHeightFor: () => 0,
});
}
function containsReviewedLine(group: {
lines: Array<{ reviewTarget?: { lineNumber?: number } | null }>;
}): boolean {
return group.lines.some((line) => line.reviewTarget?.lineNumber === 10);
}
function summarizeTree(items: ReturnType<typeof buildIndex>["items"]): string[] {
return items.map((item) => {
if (item.type === "folder") {
return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
}
const base = item.file.path.split("/").pop();
const isGroup = item.type === "unified_group" || item.type === "split_group";
return `${" ".repeat(item.depth)}${isGroup ? "group:" : ""}${base}`;
});
}
describe("buildDiffRowIndex", () => {
it("turns a large expanded file into bounded native-list cells", () => {
const index = buildIndex([createFile("large.ts", 1_200)], ["large.ts"]);
const groups = index.items.filter((item) => item.type === "unified_group");
expect(groups.length).toBeGreaterThan(1);
expect(Math.max(...groups.map((group) => group.lines.length))).toBeLessThanOrEqual(
DIFF_VIRTUALIZATION_CONFIG.groupRowLimit,
);
expect(groups.reduce((count, group) => count + group.lines.length, 0)).toBe(1_201);
expect(index.stickyHeaderIndices).toEqual([0]);
});
it("counts wide glyphs and tabs conservatively for horizontal scroll extent", () => {
for (const [path, content] of [
["cjk.ts", "界".repeat(40)],
["emoji.ts", "😀".repeat(40)],
["tabs.ts", "\t".repeat(10)],
]) {
const file = createFile(path, 1);
file.hunks[0].lines[1].content = content;
const index = buildIndex([file], [file.path]);
const group = index.items.find((item) => item.type === "unified_group");
expect(group?.contentColumns).toBe(80);
}
});
it("precomputes exact offsets and preserves total file height", () => {
const index = buildIndex(
[createFile("first.ts", 65), createFile("second.ts", 1)],
["first.ts"],
);
const firstGroups = index.items.filter(
(item) => item.type === "unified_group" && item.file.path === "first.ts",
);
const secondHeaderIndex = index.items.findIndex(
(item) => item.type === "header" && item.file.path === "second.ts",
);
const expectedBodyHeight = 66 * 18 + 2;
expect(firstGroups).toHaveLength(5);
expect(index.offsetAt(secondHeaderIndex)).toBe(44 + expectedBodyHeight);
expect(index.offsetOfFile("second.ts")).toBe(44 + expectedBodyHeight);
expect(index.totalHeight).toBe(44 + expectedBodyHeight + 44);
});
it("keeps a review row whole and shifts every later offset by its height", () => {
const file = createFile("review.ts", 40);
const baseline = buildIndex([file], [file.path]);
const reviewed = buildDiffRowIndex({
files: [file],
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set([file.path]),
layout: "unified",
wrapLines: false,
lineHeight: 18,
viewportWidth: 400,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "test",
reviewHeightFor: (target) => (target?.lineNumber === 10 ? 132 : 0),
});
expect(reviewed.totalHeight - baseline.totalHeight).toBe(132);
const reviewGroup = reviewed.items
.filter((item) => item.type === "unified_group")
.find(containsReviewedLine);
expect(reviewGroup).toBeDefined();
expect(reviewed.heightAt(reviewed.items.indexOf(reviewGroup!))).toBeGreaterThanOrEqual(150);
});
it("preserves compressed tree order, indentation, and collapse state", () => {
const files = [createFile("src/app/a.ts", 1), createFile("src/app/nested/b.ts", 1)];
const index = buildDiffRowIndex({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app/nested"]),
expandedPaths: new Set(["src/app/a.ts"]),
layout: "unified",
wrapLines: false,
lineHeight: 18,
viewportWidth: 400,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "tree",
reviewHeightFor: () => 0,
});
expect(summarizeTree(index.items)).toEqual([
"[src/app]",
" [nested] (collapsed)",
" a.ts",
" group:a.ts",
]);
expect(index.stickyHeaderIndices).toEqual([2]);
});
it("keeps split groups bounded and sticky indices on headers", () => {
const file = createFile("split.ts", 100);
const index = buildDiffRowIndex({
files: [file],
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set([file.path]),
layout: "split",
wrapLines: false,
lineHeight: 18,
viewportWidth: 800,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "split",
reviewHeightFor: () => 0,
});
const groups = index.items.filter((item) => item.type === "split_group");
expect(Math.max(...groups.map((group) => group.rows.length))).toBeLessThanOrEqual(
DIFF_VIRTUALIZATION_CONFIG.groupRowLimit,
);
expect(index.items[index.stickyHeaderIndices[0]].type).toBe("header");
});
it("updates measured group geometry without deriving any file rows again", () => {
const delegate = createDiffRowCache();
let derivations = 0;
const rowCache: DiffRowCache = {
unifiedFor(file) {
derivations += 1;
return delegate.unifiedFor(file);
},
splitFor(file) {
derivations += 1;
return delegate.splitFor(file);
},
};
const files = [createFile("first.ts", 100), createFile("second.ts", 100)];
const index = buildDiffRowIndex({
files,
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set(files.map((file) => file.path)),
layout: "unified",
wrapLines: true,
lineHeight: 18,
viewportWidth: 400,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "wrap",
reviewHeightFor: () => 0,
rowCache,
});
const group = index.items.find((item) => item.type === "unified_group");
expect(group).toBeDefined();
expect(derivations).toBe(2);
const groupIndex = index.indexOfGroup(group!.key);
const laterOffset = index.offsetAt(groupIndex + 1);
const update = index.updateHeight(group!.key, index.heightAt(groupIndex) + 20);
expect(update?.delta).toBe(20);
expect(index.offsetAt(groupIndex + 1)).toBe(laterOffset + 20);
expect(derivations).toBe(2);
});
it("uses the key's rounded viewport width for wrapped group membership", () => {
const file = createFile("wrapped.ts", 40);
for (const line of file.hunks[0].lines) {
if (line.type !== "header") {
line.content = "x".repeat(88);
}
}
const buildWrapped = (viewportWidth: number) =>
buildDiffRowIndex({
files: [file],
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set([file.path]),
layout: "unified",
wrapLines: true,
lineHeight: 18,
viewportWidth,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: `wrap:${Math.round(viewportWidth)}`,
reviewHeightFor: () => 0,
});
const summarizeGroups = (viewportWidth: number) =>
buildWrapped(viewportWidth)
.items.filter((item) => item.type === "unified_group")
.map((group) => ({
key: group.key,
startRowIndex: group.startRowIndex,
lineCount: group.lines.length,
}));
expect(summarizeGroups(399.2)).toEqual(summarizeGroups(399.49));
});
});
+422
View File
@@ -0,0 +1,422 @@
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildDiffTree,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeDirNode,
} from "@/git/diff-tree";
import {
buildSplitDiffRows,
buildUnifiedDiffLines,
type ReviewableDiffTarget,
type SplitDiffRow,
type UnifiedDiffDisplayLine,
} from "@/utils/diff-layout";
// Tune cell size and the outer list window together. On the 1,200-row fixture, the exact
// three-fling repro filled immediately with both 32/720 + window 21 and this lower-cost pair
// on an iPhone 17 Pro simulator. Simulator frame percentiles are unavailable in xctrace.
export const DIFF_VIRTUALIZATION_CONFIG = {
groupRowLimit: 16,
groupHeightLimit: 360,
initialNumToRender: 8,
maxToRenderPerBatch: 8,
updateCellsBatchingPeriod: 16,
windowSize: 10,
} as const;
interface FolderItem {
type: "folder";
dirPath: string;
displayName: string;
depth: number;
collapsed: boolean;
additions: number;
deletions: number;
}
interface HeaderItem {
type: "header";
file: ParsedDiffFile;
fileIndex: number;
isExpanded: boolean;
depth: number;
}
interface StatusItem {
type: "status";
file: ParsedDiffFile;
fileIndex: number;
depth: number;
}
interface RowGroupBase {
file: ParsedDiffFile;
fileIndex: number;
depth: number;
groupIndex: number;
startRowIndex: number;
isFirst: boolean;
isLast: boolean;
contentColumns: number;
key: string;
}
export interface UnifiedRowGroupItem extends RowGroupBase {
type: "unified_group";
lines: UnifiedDiffDisplayLine[];
}
export interface SplitRowGroupItem extends RowGroupBase {
type: "split_group";
rows: SplitDiffRow[];
}
export type DiffListItem =
| FolderItem
| HeaderItem
| StatusItem
| UnifiedRowGroupItem
| SplitRowGroupItem;
export interface DiffRowIndex {
items: DiffListItem[];
stickyHeaderIndices: number[];
heightAt: (index: number) => number;
offsetAt: (index: number) => number;
offsetOfFile: (path: string) => number;
indexOfFile: (path: string) => number;
indexOfGroup: (key: string) => number;
updateHeight: (
key: string,
height: number,
) => { delta: number; index: number; previousHeight: number } | null;
totalHeight: number;
}
export interface DiffRowCache {
unifiedFor: (file: ParsedDiffFile) => UnifiedDiffDisplayLine[];
splitFor: (file: ParsedDiffFile) => SplitDiffRow[];
}
export function createDiffRowCache(): DiffRowCache {
const unified = new WeakMap<ParsedDiffFile, UnifiedDiffDisplayLine[]>();
const split = new WeakMap<ParsedDiffFile, SplitDiffRow[]>();
return {
unifiedFor(file) {
const cached = unified.get(file);
if (cached) return cached;
const lines = buildUnifiedDiffLines(file);
unified.set(file, lines);
return lines;
},
splitFor(file) {
const cached = split.get(file);
if (cached) return cached;
const rows = buildSplitDiffRows(file);
split.set(file, rows);
return rows;
},
};
}
const defaultRowCache = createDiffRowCache();
export interface BuildDiffRowIndexInput {
files: ParsedDiffFile[];
viewMode: "flat" | "tree";
collapsedFolders: ReadonlySet<string>;
expandedPaths: ReadonlySet<string>;
layout: "unified" | "split";
wrapLines: boolean;
lineHeight: number;
viewportWidth: number;
codeFontSize: number;
headerHeightFor: (file: ParsedDiffFile) => number;
folderHeight: number;
statusHeight: number;
measuredGroupHeights: ReadonlyMap<string, number>;
geometryKey: string;
reviewHeightFor: (target: ReviewableDiffTarget | null | undefined) => number;
rowCache?: DiffRowCache;
tree?: DiffTreeDirNode;
}
interface PendingGroup<Row> {
rows: Row[];
startRowIndex: number;
height: number;
}
const FULL_WIDTH_CODE_POINT_RANGES = [
[0x1100, 0x115f],
[0x2329, 0x232a],
[0x2e80, 0x303e],
[0x3040, 0x3247],
[0x3250, 0x4dbf],
[0x4e00, 0xa4c6],
[0xa960, 0xa97c],
[0xac00, 0xd7a3],
[0xf900, 0xfaff],
[0xfe10, 0xfe19],
[0xfe30, 0xfe6b],
[0xff01, 0xff60],
[0xffe0, 0xffe6],
[0x1b000, 0x1b001],
[0x1f200, 0x1f251],
[0x1f300, 0x1faff],
[0x20000, 0x3fffd],
] as const;
function isFullWidthCodePoint(codePoint: number): boolean {
return FULL_WIDTH_CODE_POINT_RANGES.some(
([start, end]) => codePoint >= start && codePoint <= end,
);
}
function displayColumns(content: string): number {
let columns = 0;
for (const character of content) {
if (character === "\t") {
columns += 8 - (columns % 8);
continue;
}
columns += isFullWidthCodePoint(character.codePointAt(0) ?? 0) ? 2 : 1;
}
return columns;
}
function estimatedVisualLines(content: string, columns: number): number {
return Math.max(1, Math.ceil(displayColumns(content) / columns));
}
function groupRows<Row>(rows: Row[], heightFor: (row: Row) => number): PendingGroup<Row>[] {
const groups: PendingGroup<Row>[] = [];
let current: PendingGroup<Row> = { rows: [], startRowIndex: 0, height: 0 };
for (const [rowIndex, row] of rows.entries()) {
const rowHeight = heightFor(row);
const reachedRowLimit = current.rows.length >= DIFF_VIRTUALIZATION_CONFIG.groupRowLimit;
const reachedHeightLimit =
current.rows.length > 0 &&
current.height + rowHeight > DIFF_VIRTUALIZATION_CONFIG.groupHeightLimit;
if (reachedRowLimit || reachedHeightLimit) {
groups.push(current);
current = { rows: [], startRowIndex: rowIndex, height: 0 };
}
current.rows.push(row);
current.height += rowHeight;
}
if (current.rows.length > 0) {
groups.push(current);
}
return groups;
}
function contentColumnsForUnified(lines: UnifiedDiffDisplayLine[]): number {
return lines.reduce((longest, line) => Math.max(longest, displayColumns(line.line.content)), 0);
}
function contentColumnsForSplit(rows: SplitDiffRow[]): number {
return rows.reduce((longest, row) => {
if (row.kind === "header") {
return Math.max(longest, displayColumns(row.content));
}
return Math.max(
longest,
displayColumns(row.left?.content ?? ""),
displayColumns(row.right?.content ?? ""),
);
}, 0);
}
function availableTextColumns(input: BuildDiffRowIndexInput): number {
const viewportWidth = Math.round(input.viewportWidth);
const columnWidth = input.layout === "split" ? viewportWidth / 2 : viewportWidth;
const characterWidth = input.codeFontSize * 0.62;
return Math.max(8, Math.floor((columnWidth - 72) / characterWidth));
}
export function buildDiffRowIndex(input: BuildDiffRowIndexInput): DiffRowIndex {
const items: DiffListItem[] = [];
const heights: number[] = [];
const stickyHeaderIndices: number[] = [];
const fileIndexByPath = new Map<string, number>();
const groupIndexByKey = new Map<string, number>();
const availableColumns = availableTextColumns(input);
const rowCache = input.rowCache ?? defaultRowCache;
function push(item: DiffListItem, height: number): void {
if (item.type === "unified_group" || item.type === "split_group") {
groupIndexByKey.set(item.key, items.length);
}
items.push(item);
heights.push(height);
}
function pushUnifiedGroups(file: ParsedDiffFile, fileIndex: number, depth: number): void {
const lines = rowCache.unifiedFor(file);
const contentColumns = contentColumnsForUnified(lines);
const groups = groupRows(lines, (line) => {
const visualLines = input.wrapLines
? estimatedVisualLines(line.line.content, availableColumns)
: 1;
return visualLines * input.lineHeight + input.reviewHeightFor(line.reviewTarget);
});
for (const [groupIndex, group] of groups.entries()) {
const isFirst = groupIndex === 0;
const isLast = groupIndex === groups.length - 1;
const key = `${input.geometryKey}:${file.path}:unified:${group.startRowIndex}`;
const estimatedHeight = group.height + (isFirst ? 1 : 0) + (isLast ? 1 : 0);
push(
{
type: "unified_group",
file,
fileIndex,
depth,
groupIndex,
startRowIndex: group.startRowIndex,
isFirst,
isLast,
contentColumns,
key,
lines: group.rows,
},
input.measuredGroupHeights.get(key) ?? estimatedHeight,
);
}
}
function pushSplitGroups(file: ParsedDiffFile, fileIndex: number, depth: number): void {
const rows = rowCache.splitFor(file);
const contentColumns = contentColumnsForSplit(rows);
const groups = groupRows(rows, (row) => {
if (row.kind === "header") {
const visualLines = input.wrapLines
? estimatedVisualLines(row.content, availableColumns)
: 1;
return visualLines * input.lineHeight;
}
const visualLines = input.wrapLines
? Math.max(
estimatedVisualLines(row.left?.content ?? "", availableColumns),
estimatedVisualLines(row.right?.content ?? "", availableColumns),
)
: 1;
const reviewHeight = Math.max(
input.reviewHeightFor(row.left?.reviewTarget),
input.reviewHeightFor(row.right?.reviewTarget),
);
return visualLines * input.lineHeight + reviewHeight;
});
for (const [groupIndex, group] of groups.entries()) {
const isFirst = groupIndex === 0;
const isLast = groupIndex === groups.length - 1;
const key = `${input.geometryKey}:${file.path}:split:${group.startRowIndex}`;
const estimatedHeight = group.height + (isFirst ? 1 : 0) + (isLast ? 1 : 0);
push(
{
type: "split_group",
file,
fileIndex,
depth,
groupIndex,
startRowIndex: group.startRowIndex,
isFirst,
isLast,
contentColumns,
key,
rows: group.rows,
},
input.measuredGroupHeights.get(key) ?? estimatedHeight,
);
}
}
function pushFile(file: ParsedDiffFile, fileIndex: number, depth: number): void {
const isExpanded = input.expandedPaths.has(file.path);
const headerIndex = items.length;
fileIndexByPath.set(file.path, headerIndex);
push({ type: "header", file, fileIndex, isExpanded, depth }, input.headerHeightFor(file));
if (!isExpanded) {
return;
}
stickyHeaderIndices.push(headerIndex);
if (file.status === "binary" || file.status === "too_large") {
push({ type: "status", file, fileIndex, depth }, input.statusHeight);
return;
}
if (input.layout === "split") {
pushSplitGroups(file, fileIndex, depth);
return;
}
pushUnifiedGroups(file, fileIndex, depth);
}
if (input.viewMode === "flat") {
for (const [fileIndex, file] of input.files.entries()) {
pushFile(file, fileIndex, 0);
}
} else {
const indexByPath = new Map(input.files.map((file, index) => [file.path, index]));
const tree = input.tree ?? compressSingleChildChains(buildDiffTree(input.files));
for (const row of flattenDiffTree(tree, input.collapsedFolders)) {
if (row.kind === "folder") {
push(
{
type: "folder",
dirPath: row.dirPath,
displayName: row.displayName,
depth: row.depth,
collapsed: input.collapsedFolders.has(row.dirPath),
additions: row.additions,
deletions: row.deletions,
},
input.folderHeight,
);
continue;
}
const fileIndex = indexByPath.get(row.file.path);
if (fileIndex !== undefined) {
pushFile(row.file, fileIndex, row.depth);
}
}
}
const offsets = Array.from({ length: items.length + 1 }, () => 0);
for (let index = 0; index < items.length; index += 1) {
offsets[index + 1] = offsets[index] + heights[index];
}
return {
items,
stickyHeaderIndices,
heightAt: (index) => heights[index] ?? 0,
offsetAt: (index) => offsets[Math.max(0, Math.min(index, items.length))],
offsetOfFile: (path) => offsets[fileIndexByPath.get(path) ?? 0],
indexOfFile: (path) => fileIndexByPath.get(path) ?? -1,
indexOfGroup: (key) => groupIndexByKey.get(key) ?? -1,
updateHeight: (key, height) => {
const index = groupIndexByKey.get(key);
if (index === undefined || !Number.isFinite(height) || height < 0) {
return null;
}
const previousHeight = heights[index];
const delta = height - previousHeight;
if (delta === 0) {
return { delta, index, previousHeight };
}
heights[index] = height;
for (let offsetIndex = index + 1; offsetIndex < offsets.length; offsetIndex += 1) {
offsets[offsetIndex] += delta;
}
return { delta, index, previousHeight };
},
get totalHeight() {
return offsets[items.length];
},
};
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { boundRetainedRangeOnDataGrowth } from "@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth";
describe("bounded VirtualizedList data growth", () => {
it("limits the carried range when middle insertion grows the list", () => {
expect(boundRetainedRangeOnDataGrowth({ first: 0, last: 39 }, 40, 115, 16)).toEqual({
first: 0,
last: 15,
});
});
it("bounds from the current scrolled range instead of jumping to the start", () => {
expect(boundRetainedRangeOnDataGrowth({ first: 84, last: 123 }, 160, 235, 16)).toEqual({
first: 84,
last: 99,
});
});
it("does not alter the range when data shrinks or the carried range is already bounded", () => {
const shortRange = { first: 12, last: 18 };
expect(boundRetainedRangeOnDataGrowth(shortRange, 40, 32, 16)).toBe(shortRange);
expect(boundRetainedRangeOnDataGrowth(shortRange, 40, 115, 16)).toEqual(shortRange);
});
});
@@ -404,13 +404,6 @@ describe("keyboard-shortcuts", () => {
},
);
it("leaves Escape to an editable field", () => {
expectNoShortcutResolution({
event: { key: "Escape", code: "Escape" },
context: { focusScope: "editable" },
});
});
const nonMatchingCases: NonMatchingShortcutCase[] = [
{
name: "does not keep old Mod+Alt+N binding",
@@ -76,8 +76,8 @@ interface ShortcutWhen {
terminal?: false;
/** false = disabled when command center is open */
commandCenter?: false;
/** Allowed focus scope or scopes */
focusScope?: KeyboardFocusScope | readonly KeyboardFocusScope[];
/** Exact focus scope match */
focusScope?: KeyboardFocusScope;
}
type ShortcutPayloadDef =
@@ -1042,7 +1042,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "agent-interrupt",
action: "agent.interrupt",
combo: "Escape",
when: { commandCenter: false, focusScope: ["message-input", "other"] },
when: { commandCenter: false, terminal: false },
preventDefault: false,
stopPropagation: false,
help: {
@@ -1215,14 +1215,7 @@ export function matchesKeyboardShortcutContext(
}
if (when.terminal === false && context.focusScope === "terminal") return false;
if (when.commandCenter === false && context.commandCenterOpen) return false;
if (
when.focusScope !== undefined &&
!(typeof when.focusScope === "string"
? context.focusScope === when.focusScope
: when.focusScope.includes(context.focusScope))
) {
return false;
}
if (when.focusScope !== undefined && context.focusScope !== when.focusScope) return false;
return true;
}
+17 -17
View File
@@ -260,6 +260,7 @@ export function InlineReviewGutterCell({
onStartComment,
style,
actionTestID,
testID,
}: {
children: ReactNode;
reviewTarget: ReviewableDiffTarget | null | undefined;
@@ -270,6 +271,7 @@ export function InlineReviewGutterCell({
onStartComment: (target: ReviewableDiffTarget) => void;
style?: StyleProp<ViewStyle>;
actionTestID?: string;
testID?: string;
}) {
const { t } = useTranslation();
const canComment = Boolean(reviewTarget);
@@ -309,7 +311,6 @@ export function InlineReviewGutterCell({
}
}, [isInteractionActive]);
const pressableStyle = useCallback((): StyleProp<ViewStyle> => style, [style]);
const lineHeightStyle = useMemo<StyleProp<ViewStyle>>(
() =>
lineHeight !== undefined
@@ -318,13 +319,15 @@ export function InlineReviewGutterCell({
[lineHeight],
);
const labelStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive],
[hasComments, lineHeightStyle],
);
const innerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.gutterInner, lineHeightStyle],
[lineHeightStyle],
const pressableStyle = useCallback(
(): StyleProp<ViewStyle> => [
style,
styles.gutterInner,
styles.gutterLabel,
lineHeightStyle,
hasComments && styles.gutterLabelActive,
],
[hasComments, lineHeightStyle, style],
);
const actionIconStyle = useMemo<StyleProp<ViewStyle>>(
() => [
@@ -346,17 +349,14 @@ export function InlineReviewGutterCell({
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={pressableStyle}
testID={testID}
>
<View style={innerStyle}>
<View style={labelStyle}>
{children}
{showAction ? (
<View style={actionIconStyle} testID={actionTestID}>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</View>
) : null}
{children}
{showAction ? (
<View style={actionIconStyle} testID={actionTestID}>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</View>
</View>
) : null}
</Pressable>
);
}
+28 -14
View File
@@ -1,10 +1,9 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next";
import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { getProviderIcon } from "@/components/provider-icons";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isNative } from "@/constants/platform";
@@ -21,6 +20,8 @@ import {
} from "./track-presentation";
const ThemedArchive = withUnistyles(Archive);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedUnlink = withUnistyles(Unlink);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
@@ -69,11 +70,17 @@ export function SubagentsTrack({
[expanded],
);
const headerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
styles.headerToggle,
(hovered || pressed) && styles.headerActive,
],
[],
);
const headerContainerStyle = useMemo(
() => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed],
[expanded],
);
const headerAccessibilityState = useMemo(() => ({ expanded }), [expanded]);
if (rows.length === 0) {
return null;
@@ -87,19 +94,22 @@ export function SubagentsTrack({
<View style={styles.track}>
<View style={surfaceStyle}>
<View style={headerContainerStyle}>
<Button
variant="ghost"
size="xs"
<Pressable
accessibilityRole="button"
accessibilityLabel={headerLabel}
accessibilityState={headerAccessibilityState}
testID="subagents-track-header"
onPress={toggleExpanded}
leftIcon={expanded ? ChevronDown : ChevronRight}
style={styles.headerToggle}
textStyle={styles.headerLabel}
style={headerStyle}
>
{headerLabel}
</Button>
{expanded ? (
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.headerLabel} numberOfLines={1}>
{headerLabel}
</Text>
</Pressable>
{finishedCount > 0 && onArchiveFinished ? (
<View style={styles.headerAction}>
<SubagentActionButton
@@ -336,8 +346,9 @@ const styles = StyleSheet.create((theme) => ({
headerToggle: {
flex: 1,
minWidth: 0,
justifyContent: "flex-start",
borderRadius: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[1],
paddingVertical: theme.spacing[2],
@@ -348,6 +359,9 @@ const styles = StyleSheet.create((theme) => ({
headerCollapsed: {
paddingBottom: theme.spacing[4],
},
headerActive: {
backgroundColor: theme.colors.surface2,
},
headerDivider: {
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
@@ -0,0 +1,8 @@
declare module "@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth" {
export function boundRetainedRangeOnDataGrowth(
cellsAroundViewport: { first: number; last: number },
previousItemCount: number,
itemCount: number,
retainedCellLimit: number,
): { first: number; last: number };
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
compactHighlightTokens,
formatDiffContentText,
formatDiffGutterText,
hasVisibleDiffTokens,
@@ -28,4 +29,23 @@ describe("diff-rendering", () => {
expect(hasVisibleDiffTokens([{ text: "" }])).toBe(false);
expect(hasVisibleDiffTokens([{ text: "const value = 1;" }])).toBe(true);
});
it("compacts adjacent syntax tokens without changing their text or styles", () => {
expect(
compactHighlightTokens([
{ text: "const", style: "keyword" },
{ text: " ", style: null },
{ text: "value", style: null },
{ text: " ", style: null },
{ text: "=", style: "operator" },
{ text: "", style: "operator" },
{ text: " 1", style: "number" },
]),
).toEqual([
{ text: "const", style: "keyword" },
{ text: " value ", style: null },
{ text: "=", style: "operator" },
{ text: " 1", style: "number" },
]);
});
});
+22
View File
@@ -2,6 +2,28 @@ interface HighlightLikeToken {
text: string;
}
interface StyledHighlightToken extends HighlightLikeToken {
style: string | null;
}
export function compactHighlightTokens(
tokens: readonly StyledHighlightToken[],
): StyledHighlightToken[] {
const compacted: StyledHighlightToken[] = [];
for (const token of tokens) {
if (token.text.length === 0) {
continue;
}
const previous = compacted.at(-1);
if (previous?.style === token.style) {
previous.text += token.text;
continue;
}
compacted.push({ text: token.text, style: token.style });
}
return compacted;
}
// Preserve row height when a gutter or diff cell is intentionally blank.
// Non-breaking space because the gutter <Text> uses numberOfLines={1}, which
// collapses a plain ASCII space to zero height on web.
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.4.0-beta.1",
"@getpaseo/protocol": "0.4.0-beta.1",
"@getpaseo/server": "0.4.0-beta.1",
"@getpaseo/client": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/server": "0.3.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.4.0-beta.1",
"@getpaseo/relay": "0.4.0-beta.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -71,10 +71,10 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.4.0-beta.1",
"@getpaseo/highlight": "0.4.0-beta.1",
"@getpaseo/protocol": "0.4.0-beta.1",
"@getpaseo/relay": "0.4.0-beta.1",
"@getpaseo/client": "0.3.1",
"@getpaseo/highlight": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -2241,80 +2241,6 @@ describe("Codex app-server provider", () => {
});
});
test("keeps a settled child completed until Codex starts another child turn", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
const resultPromise = session.run("Delegate the investigation.");
await appServer.waitForTurnStart();
appServer.startsSubAgent({
callId: "call-settled-child",
threadId: "settled-child-thread",
agentPath: "/root/settled-child",
});
appServer.completeTurn({ threadId: "settled-child-thread" });
appServer.says({
threadId: "settled-child-thread",
itemId: "late-child-message",
text: "Late trailing output.",
});
const providerUpserts = events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
);
expect(providerUpserts.at(-1)).toMatchObject({
id: "settled-child-thread",
status: "completed",
});
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: {
callId: "call-settled-child",
status: "completed",
detail: { type: "sub_agent", log: "[Assistant] Late trailing output." },
},
});
appServer.completesSubAgentActivity({
callId: "late-child-interaction",
threadId: "settled-child-thread",
agentPath: "/root/settled-child",
kind: "interacted",
});
expect(
events.findLast(
(event) => event.type === "provider_subagent" && event.event.type === "upsert",
),
).toMatchObject({ event: { id: "settled-child-thread", status: "completed" } });
appServer.startsTurn({
threadId: "settled-child-thread",
turnId: "next-child-turn",
});
expect(
events.findLast(
(event) => event.type === "provider_subagent" && event.event.type === "upsert",
),
).toMatchObject({ event: { id: "settled-child-thread", status: "running" } });
appServer.completeTurn({ threadId: "settled-child-thread" });
appServer.completeTurn();
await resultPromise;
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("updates a registered child with its later native activity name", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
@@ -3527,10 +3453,6 @@ describe("Codex app-server provider", () => {
const liveEvents: AgentStreamEvent[] = [];
session.subscribe((event) => liveEvents.push(event));
asInternals(session).handleNotification("turn/started", {
threadId: "v2-child-thread",
turn: { id: "v2-child-turn-after-resume" },
});
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
@@ -3559,10 +3481,6 @@ describe("Codex app-server provider", () => {
});
liveEvents.length = 0;
asInternals(session).handleNotification("turn/started", {
threadId: "legacy-child-thread",
turn: { id: "legacy-child-turn-after-resume" },
});
asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "legacy-child-thread",
itemId: "legacy-child-message-after-resume",
@@ -1660,12 +1660,6 @@ interface CodexSubAgentActivity {
kind: "started" | "interacted" | "interrupted";
}
function isTerminalSubAgentStatus(
status: ToolCallTimelineItem["status"],
): status is "completed" | "failed" | "canceled" {
return status === "completed" || status === "failed" || status === "canceled";
}
function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null {
const record = toObjectRecord(item);
if (!record) {
@@ -4907,7 +4901,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
switch (parsed.kind) {
case "thread_started":
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
this.emitSubAgentActivityUpdate(callId, "running");
return;
case "turn_started":
case "turn_completed":
@@ -5180,13 +5174,10 @@ export class CodexAppServerAgentSession implements AgentSession {
},
};
}
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
if (activity.kind === "interrupted") {
nextStatus = "canceled";
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
nextStatus = undefined;
}
this.emitSubAgentActivityUpdate(callId, nextStatus);
this.emitSubAgentActivityUpdate(
callId,
activity.kind === "interrupted" ? "canceled" : "running",
);
return true;
}
@@ -5280,7 +5271,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private emitSubAgentActivityUpdate(
callId: string,
status?: ToolCallTimelineItem["status"],
options?: { reopen?: boolean },
): void {
const state = this.subAgentCallsByCallId.get(callId);
if (!state || state.toolCall.detail.type !== "sub_agent") {
@@ -5291,14 +5281,7 @@ export class CodexAppServerAgentSession implements AgentSession {
childTimeline.length > 0
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
: "";
let resolvedStatus = status ?? state.toolCall.status;
if (
status === "running" &&
!options?.reopen &&
isTerminalSubAgentStatus(state.toolCall.status)
) {
resolvedStatus = state.toolCall.status;
}
const resolvedStatus = status ?? state.toolCall.status;
for (const childThreadId of state.childThreadIds) {
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
}
@@ -5437,7 +5420,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.emitSubAgentActivityUpdate(callId);
this.emitSubAgentActivityUpdate(callId, "running");
}
private handleSubAgentContextCompactionItem(
@@ -5573,7 +5556,7 @@ export class CodexAppServerAgentSession implements AgentSession {
): void {
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) {
this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true });
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
return;
}
this.currentTurnId = parsed.turnId;
@@ -379,28 +379,6 @@ describe("codex tool-call mapper", () => {
});
});
it.each([
["shutdown", "canceled"],
["notFound", "failed"],
] as const)("maps terminal child state %s to %s", (childStatus, expectedStatus) => {
const item = mapCodexToolCallFromThreadItem({
type: "collabAgentToolCall",
id: `call-sub-agent-${childStatus}`,
tool: "closeAgent",
status: "completed",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: childStatus, message: null },
},
});
expect(item).toMatchObject({
type: "tool_call",
callId: `call-sub-agent-${childStatus}`,
status: expectedStatus,
});
});
it("maps mcp read_file completion with detail", () => {
const item = expectMapped(
mapCodexToolCallFromThreadItem(
@@ -569,17 +569,10 @@ function readStatus(value: unknown): string | undefined {
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
const normalized = status.trim().toLowerCase();
switch (normalized) {
case "error":
case "errored":
return "running";
case "shutdown":
return "canceled";
case "notfound":
return "failed";
default:
return normalizeToolCallStatus(status, null, null);
if (normalized === "error" || normalized === "errored") {
return "running";
}
return normalizeToolCallStatus(status, null, null);
}
function resolveCollabAgentStatus(
@@ -480,7 +480,6 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
models: {
"big-pickle": {
name: "Big Pickle",
variants: { max: { reasoningEffort: "max" } },
limit: {
context: 200_000,
},
@@ -530,11 +529,6 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
expect(catalog.models[0]).toMatchObject({
id: TEST_MODEL,
label: "Big Pickle",
thinkingOptions: [
{ id: "default", label: "Default", isDefault: true },
{ id: "max", label: "max" },
],
defaultThinkingOptionId: "default",
metadata: {
providerId: "opencode",
modelId: "big-pickle",
@@ -830,40 +824,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}, 180_000);
});
describe("OpenCode adapter normalization", () => {
test("omits OpenCode's implicit default variant from new and updated sessions", async () => {
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
openCode.sessionCreateResponse = { data: { id: "ses_default_variant" } };
openCode.sessionPromptAsyncEvents = [
{ type: "session.idle", properties: { sessionID: "ses_default_variant" } },
];
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/workspace/repo",
model: "catalog-provider/single-variant-model",
thinkingOptionId: "default",
});
await collectTurnEvents(streamSession(session, "Use the model default"));
await session.setThinkingOption?.("max");
await collectTurnEvents(streamSession(session, "Use max"));
await session.setThinkingOption?.("default");
await collectTurnEvents(streamSession(session, "Return to the model default"));
expect(openCode.calls.sessionPromptAsync).toEqual([
expect.not.objectContaining({ variant: expect.anything() }),
expect.objectContaining({ variant: "max" }),
expect.not.objectContaining({ variant: expect.anything() }),
]);
await session.close();
});
describe("OpenCode adapter context-window normalization", () => {
test("builds OpenCode file parts for image prompt blocks", () => {
expect(
__openCodeInternals.buildOpenCodePromptParts([
@@ -111,7 +111,6 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_DEFAULT_VARIANT_ID = "default";
const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept";
const OPENCODE_PERSISTED_SESSION_LIMIT = 200;
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
@@ -581,14 +580,6 @@ function normalizeOpenCodeModeId(modeId: string | null | undefined): string | nu
return trimmed;
}
function normalizeOpenCodeVariantId(variantId: string | null | undefined): string | null {
const trimmed = typeof variantId === "string" ? variantId.trim() : "";
if (!trimmed || trimmed === OPENCODE_DEFAULT_VARIANT_ID) {
return null;
}
return trimmed;
}
function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string | undefined {
const normalizedModeId = normalizeOpenCodeModeId(modeId);
if (normalizedModeId === null) {
@@ -600,19 +591,15 @@ function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): strin
}
function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConfig {
const normalized = {
...config,
thinkingOptionId: normalizeOpenCodeVariantId(config.thinkingOptionId) ?? undefined,
};
if (normalizeOpenCodeModeId(normalized.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
return normalized;
if (normalizeOpenCodeModeId(config.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
return { ...config };
}
return {
...normalized,
...config,
modeId: OPENCODE_BUILD_MODE_ID,
featureValues: {
...normalized.featureValues,
...config.featureValues,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
},
};
@@ -736,13 +723,11 @@ function buildOpenCodeModelDefinition(
},
): AgentModelDefinition {
const rawVariants = model.variants ? Object.keys(model.variants) : [];
// OpenCode lists only overrides; its base model behavior is selected by omitting `variant`.
const thinkingOptions = rawVariants.length
? [
{ id: OPENCODE_DEFAULT_VARIANT_ID, label: "Default", isDefault: true },
...rawVariants.map((id) => ({ id, label: id })),
]
: [];
const thinkingOptions = rawVariants.map((id, index) => ({
id,
label: id,
isDefault: index === 0,
}));
return {
provider: "opencode",
@@ -3210,7 +3195,10 @@ class OpenCodeAgentSession implements AgentSession {
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
const normalizedThinkingOptionId = normalizeOpenCodeVariantId(thinkingOptionId);
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.4.0-beta.1",
"version": "0.3.1",
"private": true,
"type": "module",
"scripts": {
@@ -0,0 +1,105 @@
diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js
index c92ac9d..6bdc84f 100644
--- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js
+++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js
@@ -47,6 +47,7 @@ import {
windowSizeOrDefault,
} from './VirtualizedListProps';
+import {boundRetainedRangeOnDataGrowth} from './boundRetainedRangeOnDataGrowth';
import {
computeWindowedRenderLimits,
keyExtractor as defaultKeyExtractor,
} from './VirtualizeUtils';
@@ -706,7 +706,8 @@ class VirtualizedList extends StateSafePureComponent<
// first and last could be stale (e.g. if a new, shorter items props is passed in), so we make
// sure we're rendering a reasonable range here.
const itemCount = newProps.getItemCount(newProps.data);
- if (itemCount === prevState.renderMask.numCells()) {
+ const previousItemCount = prevState.renderMask.numCells();
+ if (itemCount === previousItemCount) {
return prevState;
}
@@ -741,7 +742,7 @@ class VirtualizedList extends StateSafePureComponent<
}
}
- const constrainedCells = VirtualizedList._constrainToItemCount(
+ let constrainedCells = VirtualizedList._constrainToItemCount(
maintainVisibleContentPositionAdjustment != null
? {
first:
@@ -756,6 +758,20 @@ class VirtualizedList extends StateSafePureComponent<
newProps,
);
+ if (
+ newProps.unstable_boundedRangeOnDataChange === true &&
+ itemCount > previousItemCount
+ ) {
+ const retainedCellLimit =
+ maxToRenderPerBatchOrDefault(newProps.maxToRenderPerBatch) * 2;
+ constrainedCells = boundRetainedRangeOnDataGrowth(
+ constrainedCells,
+ previousItemCount,
+ itemCount,
+ retainedCellLimit,
+ );
+ }
+
return {
cellsAroundViewport: constrainedCells,
renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells),
diff --git a/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js b/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js
new file mode 100644
index 0000000..1e5b1f5
--- /dev/null
+++ b/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @format
+ */
+
+'use strict';
+
+export function boundRetainedRangeOnDataGrowth(
+ cellsAroundViewport,
+ previousItemCount,
+ itemCount,
+ retainedCellLimit,
+) {
+ if (itemCount <= previousItemCount) {
+ return cellsAroundViewport;
+ }
+ return {
+ first: cellsAroundViewport.first,
+ last: Math.min(
+ cellsAroundViewport.last,
+ cellsAroundViewport.first + retainedCellLimit - 1,
+ ),
+ };
+}
diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js
index 9d6d49e..54324a8 100644
--- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js
+++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js
@@ -264,8 +264,14 @@ type OptionalVirtualizedListProps = {
* Amount of time between low-pri item render batches, e.g. for rendering items quite a ways off
* screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`.
*/
updateCellsBatchingPeriod?: ?number,
+
+ /**
+ * Limits the synchronous render range retained when data grows. This is useful for lists that
+ * insert many bounded cells in the middle of an already-rendered range.
+ */
+ unstable_boundedRangeOnDataChange?: ?boolean,
/**
* See `ViewabilityHelper` for flow type and further documentation.
*/
viewabilityConfig?: ViewabilityConfig,
+4
View File
@@ -8,6 +8,10 @@ import { join, relative } from "node:path";
// workspace root live in their workspace's own node_modules, and patch-package resolves
// the patch's node_modules/... paths relative to its working directory.
const patchedPackages = [
{
nodeModulesPath: "node_modules/@react-native/virtualized-lists",
patchPrefix: "@react-native+virtualized-lists+",
},
{
nodeModulesPath: "node_modules/react-native-markdown-display",
patchPrefix: "react-native-markdown-display+",