Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 0c6dbefa40 chore(release): cut 0.4.0-beta.1 2026-08-12 20:39:35 +02:00
Mohamed Boudra 94106a30ab docs(changelog): add 0.4.0-beta.1 2026-08-12 20:34:16 +02:00
Mohamed Boudra 635a8be437 Restore model-default variant selection (#3281)
* fix(providers): restore implicit model variant selection

Provider catalogs list only explicit overrides, so treating the first override as the default made base model behavior unreachable. Represent the base choice in the catalog and translate it back to an omitted variant at the provider boundary.

* test(providers): exercise variant catalog boundary

The regression concerns the model catalog callers receive, so verify normalization through fetchCatalog instead of coupling the test to its internal builder.
2026-08-12 19:54:06 +02:00
Mohamed Boudra 8e916fd2dc fix(app): align agent profile controls
Keep trailing edit controls from changing section heading rhythm, and center each profile's icon with its own text block so one-line and noted rows share the same visual alignment.
2026-08-12 17:27:08 +02:00
Mohamed Boudra 474b4e70b4 fix(app): restore file tree layout
Restore the established tree rails while retaining file actions. Keep Escape local to generic editable fields so cancelling a file draft cannot interrupt an agent.
2026-08-12 15:54:11 +02:00
Mohamed Boudra 124e512b58 fix(app): align track header hover states
The subagent track used a custom hover path that left its label and chevron muted. Reuse the ghost button interaction so track headers promote their content consistently.
2026-08-12 15:53:00 +02:00
Mohamed Boudra 0ba7251280 Revert "Make large diffs open quickly and scroll as one surface (#3212)"
This reverts commit b599d38a77.
2026-08-12 15:29:21 +02:00
James Strain b58ccf9269 fix: stop completed Codex subagents appearing active (#3188) 2026-08-12 14:05:45 +02:00
Mohamed Boudra 2f6b585a3a fix(desktop): shut down cleanly with workspace terminals (#3268)
Workspace terminal shutdown only signaled an npm wrapper, leaving Electron alive with broken output pipes. Make the signal-handling runner the terminal-owned process and route external Electron termination through the existing quit lifecycle.
2026-08-12 13:00:55 +02:00
2c5c629db8 fix(usage): read Cursor token from modern state.vscdb key via node:sqlite (#2704)
* fix(usage): read Cursor token from modern state.vscdb key via node:sqlite

Settings → Usage showed Cursor as unavailable for a normal Cursor desktop
login. The quota fetcher read the access token from the legacy `cursorAuthStatus`
key, but current Cursor builds store a plain JWT under `cursorAuth/accessToken`
with no such legacy row, so the lookup returned null.

Read the token with built-in node:sqlite instead of shelling out to a `sqlite3`
CLI (absent by default on Windows) — a second, independent cause of the same
"token not found → unavailable" failure. Prefer the modern key, fall back to the
legacy `cursorAuthStatus` blob for older builds, and log read failures at debug
so an unavailable card stays diagnosable.

Fixes #2586

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(usage): cover Cursor sqlite text and blob auth

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-12 12:25:04 +02:00
Mohamed Boudra 6ed364ffe2 Document per-execution Hub worktree branch names (#3267)
* docs(hub): document per-execution worktree branch names

Hub now materializes `${{ paseo.execution.id }}` inside a daemon
environment's `worktree.newBranch` before it persists or dispatches the
launch, so a reusable branch-off environment can get a stable branch per
execution instead of every trigger contending for one literal name.

The reference states the constraint precisely because it is the part the
YAML cannot show: `newBranch` accepts that one execution-scoped path and
nothing else, and any other expression fails bundle activation with the
authored file and field in the error. Literal branch names stay valid.

* docs(hub): reshape the per-execution worktree branch docs

Split the two pages by the job each reader is doing. The daemon page is a
task guide, so it now carries the worktree YAML and only the two facts
that reader needs to choose a branch name, then links out. The reference
owns the exhaustive contract at the `newBranch` field: a table contrasting
a literal with the template, then the accepted expression scope and what
activation rejects.

Dropped the render-before-dispatch ordering. Readers cannot act on where
Hub materializes the value; the reachable behavior is that a retried or
recovered execution keeps its branch, which is what the table says.

* docs(hub): make the per-execution worktree branch the only guidance

A branch-off Hub environment that aims every execution at one branch name
is not a configuration worth teaching, so the docs no longer present it as
an alternative. Both pages now show `newBranch: trigger-${{ paseo.execution.id }}`
and say what it does, with no literal-branch example and no comparison.

The reference keeps the field's type as a bare syntax fact, along with the
accepted expression, what activation rejects, and the authored field the
failure names.
2026-08-12 18:20:08 +08:00
61 changed files with 1492 additions and 2058 deletions
+44
View File
@@ -1,5 +1,49 @@
# 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
+5 -3
View File
@@ -131,9 +131,11 @@ desktop-only environment inherited by terminals opened inside Paseo from couplin
a new worktree instance to the parent desktop instance's profile or single-instance
lock.
Electron remains in the desktop dev runner's process group. Closing or stopping the
workspace-script terminal must terminate Electron with Metro; detaching Electron
leaves an orphan holding the worktree's single-instance lock and broken output pipes.
The desktop workspace script `exec`s the dev runner so the terminal owns the runner
PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or
detach Electron: either change leaves an orphan holding the worktree's single-instance
lock and broken output pipes.
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
transition, and 751-pixel settings split with:
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36091,7 +36091,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37139,12 +37139,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/server": "0.3.1",
"@getpaseo/client": "0.4.0-beta.1",
"@getpaseo/protocol": "0.4.0-beta.1",
"@getpaseo/server": "0.4.0-beta.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.3.1",
"version": "0.4.0-beta.1",
"dependencies": {
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"@getpaseo/protocol": "0.4.0-beta.1",
"@getpaseo/relay": "0.4.0-beta.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37405,7 +37405,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.3.1",
"version": "0.4.0-beta.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.3.1",
"version": "0.4.0-beta.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38544,7 +38544,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38573,7 +38573,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38586,7 +38586,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38804,15 +38804,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.3.1",
"@getpaseo/highlight": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"@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",
"@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.3.1",
"version": "0.4.0-beta.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.3.1",
"version": "0.4.0-beta.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+52 -124
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 Locator, type Page } from "@playwright/test";
import { 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,10 +63,6 @@ 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 {
@@ -469,12 +465,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(diffGroups(visiblePanel).first()).toBeVisible();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(diffGroups(visiblePanel)).toHaveCount(0);
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click();
@@ -490,12 +486,14 @@ 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+-group-\d+$/)).toHaveCount(0);
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).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);
@@ -518,9 +516,13 @@ 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(diffGroups(page.getByTestId("explorer-content-area")).first()).toBeVisible();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
@@ -551,8 +553,19 @@ 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(2);
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
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 })
@@ -563,7 +576,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(14, 0);
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(16, 0);
const folderToggle = page.getByTestId("diff-folder-src-toggle");
await folderToggle.click();
@@ -583,7 +596,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(diffGroups(page).first()).toBeVisible();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -607,90 +620,6 @@ 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 }) => {
@@ -716,12 +645,6 @@ 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 }) => {
@@ -770,22 +693,21 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
codeLineHeight: number;
}[];
}> {
return diffGroups(page).evaluateAll((groups) => {
if (groups.length === 0) {
return page.locator("body").evaluate(({ ownerDocument }) => {
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
if (!root) {
throw new Error("Expanded diff body is not mounted");
}
const readRows = (prefix: string, textPrefix: string) =>
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 };
}),
);
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 };
});
const gutters = new Map(
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
@@ -889,7 +811,7 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(diffGroups(page).first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
timeout: 30_000,
});
@@ -928,9 +850,15 @@ async function returnToWorkspaceChanges(page: Page): Promise<void> {
}
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
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();
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();
}
@@ -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-group-\d+$/).first()).toBeVisible();
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
@@ -37,6 +37,7 @@ 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==",
@@ -294,6 +295,28 @@ 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,6 +124,14 @@ 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();
@@ -189,18 +197,38 @@ 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(2);
await expect(folderRow.locator("svg")).toHaveCount(1);
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.3.1",
"version": "0.4.0-beta.1",
"private": true,
"main": "index.ts",
"scripts": {
@@ -73,30 +73,32 @@ export function AgentProfileRow({
return (
<View style={rowStyle} testID={`agent-profile-row-${profile.id}`}>
<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 style={styles.rowMain}>
<View style={styles.iconWrapper}>
<AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} />
</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}
<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>
) : null}
{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>
</View>
<View style={styles.rowActions}>
<Button
@@ -145,12 +147,17 @@ 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: theme.spacing[1],
marginTop: 1,
},
titleLine: {
flexDirection: "row",
+21 -38
View File
@@ -1,75 +1,58 @@
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 { useState, useCallback, useEffect, useId, useRef } from "react";
import {
createAnimatedComponent,
useAnimatedScrollHandler,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
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 [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const isAtLeftEdgeValue = useSharedValue(true);
const scrollViewRef = useRef<ScrollViewType>(null);
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 recordScrollOffset = useCallback(
(offsetX: number) => {
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);
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),
@@ -77,7 +60,8 @@ export function DiffScroll({
);
return (
<AnimatedScrollView
<ScrollView
ref={scrollViewRef}
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
@@ -87,7 +71,6 @@ 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
@@ -95,6 +78,6 @@ export function DiffScroll({
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
{children}
</AnimatedScrollView>
</ScrollView>
);
}
@@ -1,23 +1,12 @@
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";
import { useCallback } from "react";
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
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({
@@ -25,31 +14,9 @@ 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(
(event: LayoutChangeEvent) => onScrollViewWidthChange(event.nativeEvent.layout.width),
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
[onScrollViewWidthChange],
);
@@ -58,14 +25,9 @@ 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,15 +27,7 @@ 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,
Folder,
FolderPlus,
RotateCw,
} from "lucide-react-native";
import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import {
TreeChevron,
@@ -89,7 +81,6 @@ const SORT_OPTIONS: { value: SortOption }[] = [
];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedFolder = withUnistyles(Folder);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -213,12 +204,9 @@ 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" ? (
<ThemedFolder size={WORKSPACE_TREE_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
<TreeChevron expanded={false} />
) : (
<MaterialFileIcon fileName={name || "untitled"} size={WORKSPACE_TREE_ICON_SIZE} />
)}
@@ -237,6 +225,7 @@ function EntryNameInputRow({
}
autoCapitalize="none"
autoCorrect={false}
placeholderTextColor={styles.draftPlaceholder.color}
style={styles.draftInput}
selectTextOnFocus={Boolean(initialName)}
testID="file-explorer-name-input"
@@ -381,15 +370,9 @@ 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 ? (
<ThemedFolder
size={WORKSPACE_TREE_ICON_SIZE}
uniProps={foregroundMutedColorMapping}
/>
<DirectoryChevronIcon loading={loading} expanded={isExpanded} />
) : (
<MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} />
)}
@@ -1772,13 +1755,6 @@ 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,
@@ -1799,6 +1775,9 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: 0,
paddingHorizontal: 0,
},
draftPlaceholder: {
color: theme.colors.foregroundExtraMuted,
},
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},
+12 -2
View File
@@ -114,7 +114,7 @@ function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
accessibilityLabel={t("modelSelector.editProfilesLabel")}
testID="model-profiles-edit"
>
<ThemedPencil size={ICON_SIZE.sm} uniProps={foregroundExtraMutedMapping} />
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -675,7 +675,11 @@ function AgentProfilesPickerSection({
<View style={styles.profilesContainer}>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>{t("modelSelector.profiles")}</Text>
{onEditProfiles ? <AgentProfilesEditAction onPress={onEditProfiles} /> : null}
{onEditProfiles ? (
<View style={styles.sectionHeadingAction}>
<AgentProfilesEditAction onPress={onEditProfiles} />
</View>
) : null}
</View>
{rows.map((row) => (
<AgentProfilePickerRowView key={row.id} row={row} onApply={handleApply} />
@@ -1217,6 +1221,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.border,
},
sectionHeading: {
position: "relative",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
@@ -1224,6 +1229,11 @@ 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 = 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];
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];
/**
* 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[3] + 2;
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 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[2] + depth * TREE_INDENT_PER_LEVEL;
return SPACING[3] + 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[2] + index * TREE_INDENT_PER_LEVEL + 3 }),
inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
],
})),
[depth],
@@ -0,0 +1,134 @@
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
@@ -0,0 +1,121 @@
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);
}
+3 -19
View File
@@ -1,7 +1,6 @@
import { useCallback, useMemo } from "react";
import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Folder } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { DiffStat } from "@/components/diff-stat";
import {
TreeChevron,
@@ -18,9 +17,6 @@ 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;
@@ -145,15 +141,9 @@ export function DiffFolderRow({
testID={testID ? `${testID}-toggle` : undefined}
>
<View style={leftStyle}>
<View style={styles.chevronOpticalOffset}>
<View style={styles.chevronSlot}>
<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>
@@ -204,13 +194,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
flex: 1,
minWidth: 0,
},
chevronOpticalOffset: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
},
folderIcon: {
chevronSlot: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
@@ -1,80 +0,0 @@
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
@@ -1,286 +0,0 @@
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
@@ -1,422 +0,0 @@
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];
},
};
}
@@ -1,24 +0,0 @@
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,6 +404,13 @@ 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;
/** Exact focus scope match */
focusScope?: KeyboardFocusScope;
/** Allowed focus scope or scopes */
focusScope?: KeyboardFocusScope | readonly KeyboardFocusScope[];
}
type ShortcutPayloadDef =
@@ -1042,7 +1042,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "agent-interrupt",
action: "agent.interrupt",
combo: "Escape",
when: { commandCenter: false, terminal: false },
when: { commandCenter: false, focusScope: ["message-input", "other"] },
preventDefault: false,
stopPropagation: false,
help: {
@@ -1215,7 +1215,14 @@ export function matchesKeyboardShortcutContext(
}
if (when.terminal === false && context.focusScope === "terminal") return false;
if (when.commandCenter === false && context.commandCenterOpen) return false;
if (when.focusScope !== undefined && context.focusScope !== when.focusScope) return false;
if (
when.focusScope !== undefined &&
!(typeof when.focusScope === "string"
? context.focusScope === when.focusScope
: when.focusScope.includes(context.focusScope))
) {
return false;
}
return true;
}
+17 -17
View File
@@ -260,7 +260,6 @@ export function InlineReviewGutterCell({
onStartComment,
style,
actionTestID,
testID,
}: {
children: ReactNode;
reviewTarget: ReviewableDiffTarget | null | undefined;
@@ -271,7 +270,6 @@ export function InlineReviewGutterCell({
onStartComment: (target: ReviewableDiffTarget) => void;
style?: StyleProp<ViewStyle>;
actionTestID?: string;
testID?: string;
}) {
const { t } = useTranslation();
const canComment = Boolean(reviewTarget);
@@ -311,6 +309,7 @@ export function InlineReviewGutterCell({
}
}, [isInteractionActive]);
const pressableStyle = useCallback((): StyleProp<ViewStyle> => style, [style]);
const lineHeightStyle = useMemo<StyleProp<ViewStyle>>(
() =>
lineHeight !== undefined
@@ -319,15 +318,13 @@ export function InlineReviewGutterCell({
[lineHeight],
);
const pressableStyle = useCallback(
(): StyleProp<ViewStyle> => [
style,
styles.gutterInner,
styles.gutterLabel,
lineHeightStyle,
hasComments && styles.gutterLabelActive,
],
[hasComments, lineHeightStyle, style],
const labelStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive],
[hasComments, lineHeightStyle],
);
const innerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.gutterInner, lineHeightStyle],
[lineHeightStyle],
);
const actionIconStyle = useMemo<StyleProp<ViewStyle>>(
() => [
@@ -349,14 +346,17 @@ export function InlineReviewGutterCell({
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={pressableStyle}
testID={testID}
>
{children}
{showAction ? (
<View style={actionIconStyle} testID={actionTestID}>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
<View style={innerStyle}>
<View style={labelStyle}>
{children}
{showAction ? (
<View style={actionIconStyle} testID={actionTestID}>
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
</View>
) : null}
</View>
) : null}
</View>
</Pressable>
);
}
+14 -28
View File
@@ -1,9 +1,10 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { Pressable, ScrollView, Text, View } 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";
@@ -20,8 +21,6 @@ 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 });
@@ -70,17 +69,11 @@ 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;
@@ -94,22 +87,19 @@ export function SubagentsTrack({
<View style={styles.track}>
<View style={surfaceStyle}>
<View style={headerContainerStyle}>
<Pressable
accessibilityRole="button"
<Button
variant="ghost"
size="xs"
accessibilityLabel={headerLabel}
accessibilityState={headerAccessibilityState}
testID="subagents-track-header"
onPress={toggleExpanded}
style={headerStyle}
leftIcon={expanded ? ChevronDown : ChevronRight}
style={styles.headerToggle}
textStyle={styles.headerLabel}
>
{expanded ? (
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.headerLabel} numberOfLines={1}>
{headerLabel}
</Text>
</Pressable>
{headerLabel}
</Button>
{finishedCount > 0 && onArchiveFinished ? (
<View style={styles.headerAction}>
<SubagentActionButton
@@ -346,9 +336,8 @@ const styles = StyleSheet.create((theme) => ({
headerToggle: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
justifyContent: "flex-start",
borderRadius: 0,
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[1],
paddingVertical: theme.spacing[2],
@@ -359,9 +348,6 @@ const styles = StyleSheet.create((theme) => ({
headerCollapsed: {
paddingBottom: theme.spacing[4],
},
headerActive: {
backgroundColor: theme.colors.surface2,
},
headerDivider: {
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
@@ -1,8 +0,0 @@
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,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
compactHighlightTokens,
formatDiffContentText,
formatDiffGutterText,
hasVisibleDiffTokens,
@@ -29,23 +28,4 @@ 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,28 +2,6 @@ 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.3.1",
"version": "0.4.0-beta.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.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/server": "0.3.1",
"@getpaseo/client": "0.4.0-beta.1",
"@getpaseo/protocol": "0.4.0-beta.1",
"@getpaseo/server": "0.4.0-beta.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.3.1",
"version": "0.4.0-beta.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"@getpaseo/protocol": "0.4.0-beta.1",
"@getpaseo/relay": "0.4.0-beta.1",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -15,3 +15,9 @@ export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl }) {
export function resolveChildKillTarget(pid, detached) {
return detached ? -pid : pid;
}
export function registerDevRunnerShutdownSignals({ signalSource, stop }) {
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
signalSource.on(signal, () => stop("SIGTERM"));
}
}
@@ -1,7 +1,23 @@
import { describe, expect, test } from "vitest";
import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs";
import { readFileSync } from "node:fs";
import {
createElectronSpawnOptions,
registerDevRunnerShutdownSignals,
resolveChildKillTarget,
} from "./dev-runner-config.mjs";
describe("desktop dev process ownership", () => {
test("makes the signal-handling runner the workspace terminal process", () => {
const paseoConfig = JSON.parse(
readFileSync(new URL("../../../paseo.json", import.meta.url), "utf8"),
);
const devScript = readFileSync(new URL("./dev.sh", import.meta.url), "utf8");
expect(paseoConfig.scripts.desktop.command).toContain("exec ./packages/desktop/scripts/dev.sh");
expect(devScript).toContain('npm --prefix "$DESKTOP_DIR" run build:main');
expect(devScript).toContain('exec node "$SCRIPT_DIR/dev-runner.mjs"');
});
test("keeps Electron in the runner process group", () => {
const options = createElectronSpawnOptions({
env: { PATH: "/usr/bin" },
@@ -23,4 +39,24 @@ describe("desktop dev process ownership", () => {
expect(resolveChildKillTarget(42, true)).toBe(-42);
expect(resolveChildKillTarget(42, false)).toBe(42);
});
test("stops children when the owning terminal hangs up", () => {
const listeners = new Map();
const receivedSignals = [];
registerDevRunnerShutdownSignals({
signalSource: {
on(signal, listener) {
listeners.set(signal, listener);
},
},
stop(signal) {
receivedSignals.push(signal);
},
});
expect(Array.from(listeners.keys())).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]);
listeners.get("SIGHUP")();
expect(receivedSignals).toEqual(["SIGTERM"]);
});
});
+6 -3
View File
@@ -4,7 +4,11 @@ import path from "node:path";
import { execFileSync, spawn } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs";
import {
createElectronSpawnOptions,
registerDevRunnerShutdownSignals,
resolveChildKillTarget,
} from "./dev-runner-config.mjs";
import { resolveDevElectronArgs } from "./dev-runner-args.mjs";
@@ -159,8 +163,7 @@ function canConnect(port, host) {
});
}
process.on("SIGINT", () => stopAll("SIGTERM"));
process.on("SIGTERM", () => stopAll("SIGTERM"));
registerDevRunnerShutdownSignals({ signalSource: process, stop: stopAll });
spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], {
cwd: appDir,
+1 -1
View File
@@ -31,7 +31,7 @@ export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT"
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
npm run build:main
npm --prefix "$DESKTOP_DIR" run build:main
echo "══════════════════════════════════════════════════════"
echo " Paseo Desktop Dev"
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
import {
createQuitLifecycle,
registerExternalQuitSignals,
shouldStopDesktopManagedDaemonOnQuit,
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./quit-lifecycle";
@@ -29,6 +30,25 @@ function waitForQuitLifecycle(): Promise<void> {
}
describe("quit-lifecycle", () => {
it("turns external termination signals into one Electron quit", () => {
const listeners = new Map<NodeJS.Signals, () => void>();
const quits: string[] = [];
registerExternalQuitSignals({
signals: {
on: (signal, listener) => {
listeners.set(signal, listener);
},
},
quit: () => quits.push("quit"),
});
expect(Array.from(listeners.keys())).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]);
listeners.get("SIGTERM")?.();
listeners.get("SIGHUP")?.();
expect(quits).toEqual(["quit"]);
});
it("stops by default and only keeps running when keepRunningAfterQuit is enabled", () => {
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true);
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false);
@@ -14,6 +14,10 @@ interface BeforeQuitApp {
exit(code: number): void;
}
interface ExternalQuitSignalSource {
on(signal: NodeJS.Signals, listener: () => void): unknown;
}
interface QuitLifecycle {
handleBeforeQuit(event: BeforeQuitEvent): void;
handleBeforeQuitForUpdate(): void;
@@ -31,6 +35,23 @@ export interface StopOnQuitDeps {
showShutdownFeedback: () => void;
}
export function registerExternalQuitSignals({
signals,
quit,
}: {
signals: ExternalQuitSignalSource;
quit: () => void;
}): void {
let quitRequested = false;
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] satisfies NodeJS.Signals[]) {
signals.on(signal, () => {
if (quitRequested) return;
quitRequested = true;
quit();
});
}
}
export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean {
return !settings.daemon.keepRunningAfterQuit;
}
+2
View File
@@ -83,6 +83,7 @@ import {
} from "./daemon/daemon-manager.js";
import {
createQuitLifecycle,
registerExternalQuitSignals,
stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js";
import { runDesktopStartup } from "./desktop-startup.js";
@@ -1081,6 +1082,7 @@ const quitLifecycle = createQuitLifecycle({
// electron-updater forwards this event through Electron's built-in autoUpdater.
electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate);
app.on("before-quit", quitLifecycle.handleBeforeQuit);
registerExternalQuitSignals({ signals: process, quit: () => app.quit() });
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"files": [
"dist",
"!dist/**/*.map"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.3.1",
"version": "0.4.0-beta.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.3.1",
"version": "0.4.0-beta.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.3.1",
"@getpaseo/highlight": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"@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",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -2241,6 +2241,80 @@ 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[] = [];
@@ -3453,6 +3527,10 @@ 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: {
@@ -3481,6 +3559,10 @@ 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,6 +1660,12 @@ 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) {
@@ -4901,7 +4907,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
switch (parsed.kind) {
case "thread_started":
this.emitSubAgentActivityUpdate(callId, "running");
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
return;
case "turn_started":
case "turn_completed":
@@ -5174,10 +5180,13 @@ export class CodexAppServerAgentSession implements AgentSession {
},
};
}
this.emitSubAgentActivityUpdate(
callId,
activity.kind === "interrupted" ? "canceled" : "running",
);
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
if (activity.kind === "interrupted") {
nextStatus = "canceled";
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
nextStatus = undefined;
}
this.emitSubAgentActivityUpdate(callId, nextStatus);
return true;
}
@@ -5271,6 +5280,7 @@ 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") {
@@ -5281,7 +5291,14 @@ export class CodexAppServerAgentSession implements AgentSession {
childTimeline.length > 0
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
: "";
const resolvedStatus = status ?? state.toolCall.status;
let resolvedStatus = status ?? state.toolCall.status;
if (
status === "running" &&
!options?.reopen &&
isTerminalSubAgentStatus(state.toolCall.status)
) {
resolvedStatus = state.toolCall.status;
}
for (const childThreadId of state.childThreadIds) {
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
}
@@ -5420,7 +5437,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.emitSubAgentActivityUpdate(callId, "running");
this.emitSubAgentActivityUpdate(callId);
}
private handleSubAgentContextCompactionItem(
@@ -5556,7 +5573,7 @@ export class CodexAppServerAgentSession implements AgentSession {
): void {
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) {
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true });
return;
}
this.currentTurnId = parsed.turnId;
@@ -379,6 +379,28 @@ 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,10 +569,17 @@ function readStatus(value: unknown): string | undefined {
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
const normalized = status.trim().toLowerCase();
if (normalized === "error" || normalized === "errored") {
return "running";
switch (normalized) {
case "error":
case "errored":
return "running";
case "shutdown":
return "canceled";
case "notfound":
return "failed";
default:
return normalizeToolCallStatus(status, null, null);
}
return normalizeToolCallStatus(status, null, null);
}
function resolveCollabAgentStatus(
@@ -480,6 +480,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
models: {
"big-pickle": {
name: "Big Pickle",
variants: { max: { reasoningEffort: "max" } },
limit: {
context: 200_000,
},
@@ -529,6 +530,11 @@ 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",
@@ -824,7 +830,40 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}, 180_000);
});
describe("OpenCode adapter context-window normalization", () => {
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();
});
test("builds OpenCode file parts for image prompt blocks", () => {
expect(
__openCodeInternals.buildOpenCodePromptParts([
@@ -111,6 +111,7 @@ 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;
@@ -580,6 +581,14 @@ 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) {
@@ -591,15 +600,19 @@ function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): strin
}
function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConfig {
if (normalizeOpenCodeModeId(config.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
return { ...config };
const normalized = {
...config,
thinkingOptionId: normalizeOpenCodeVariantId(config.thinkingOptionId) ?? undefined,
};
if (normalizeOpenCodeModeId(normalized.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
return normalized;
}
return {
...config,
...normalized,
modeId: OPENCODE_BUILD_MODE_ID,
featureValues: {
...config.featureValues,
...normalized.featureValues,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
},
};
@@ -723,11 +736,13 @@ function buildOpenCodeModelDefinition(
},
): AgentModelDefinition {
const rawVariants = model.variants ? Object.keys(model.variants) : [];
const thinkingOptions = rawVariants.map((id, index) => ({
id,
label: id,
isDefault: index === 0,
}));
// 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 })),
]
: [];
return {
provider: "opencode",
@@ -3195,10 +3210,7 @@ class OpenCodeAgentSession implements AgentSession {
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
const normalizedThinkingOptionId = normalizeOpenCodeVariantId(thinkingOptionId);
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
}
+35 -88
View File
@@ -366,10 +366,6 @@ type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"];
type AgentUpdatesFilter = FetchAgentsRequestFilter;
type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_agent_request" }>;
interface CreatedAgentRequest {
agent: AgentSnapshotPayload;
}
interface ResolvedSessionCreateAgentIntent {
config: AgentSessionConfig;
intent: CreateAgentIntent;
@@ -680,10 +676,6 @@ export class Session {
private readonly hubExecutionController: HubExecutionController | null;
private readonly workspaceScripts: WorkspaceScriptsService;
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
private readonly inFlightAgentCreationsByClientMessageId = new Map<
string,
Promise<CreatedAgentRequest>
>();
constructor(options: SessionOptions) {
const {
@@ -3110,86 +3102,10 @@ export class Session {
* Handle create agent request
*/
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
const clientMessageId = msg.clientMessageId?.trim();
const existingCreation = clientMessageId
? this.inFlightAgentCreationsByClientMessageId.get(clientMessageId)
: undefined;
if (existingCreation) {
try {
const created = await existingCreation;
this.emitCreatedAgentResponse(msg.requestId, created.agent);
} catch (error) {
this.emitFailedAgentCreateResponse(msg.requestId, error);
}
return;
}
const creation = this.createAgentForRequest(msg);
if (clientMessageId) {
this.inFlightAgentCreationsByClientMessageId.set(clientMessageId, creation);
}
try {
const created = await creation;
this.emitCreatedAgentResponse(msg.requestId, created.agent);
} catch (error) {
const wireError = toWorktreeWireError(error);
this.sessionLogger.error({ err: error }, "Failed to create agent");
this.emitFailedAgentCreateResponse(msg.requestId, wireError);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to create agent: ${wireError.message}`,
},
});
} finally {
if (
clientMessageId &&
this.inFlightAgentCreationsByClientMessageId.get(clientMessageId) === creation
) {
this.inFlightAgentCreationsByClientMessageId.delete(clientMessageId);
}
}
}
private emitCreatedAgentResponse(
requestId: string | undefined,
agent: AgentSnapshotPayload,
): void {
if (!requestId) return;
this.emit({
type: "status",
payload: {
status: "agent_created",
agentId: agent.id,
requestId,
agent,
},
});
}
private emitFailedAgentCreateResponse(requestId: string | undefined, error: unknown): void {
if (!requestId) return;
const wireError = toWorktreeWireError(error);
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId,
error: wireError.message,
errorCode: wireError.code,
},
});
}
private async createAgentForRequest(
msg: CreateAgentRequestMessage,
): Promise<CreatedAgentRequest> {
const {
config,
worktreeName,
requestId,
initialPrompt,
clientMessageId,
outputSchema,
@@ -3289,19 +3205,50 @@ export class Session {
agentId: snapshot.id,
createdWorktree,
});
const agentPayload = await this.buildAgentPayload(liveSnapshot);
if (requestId) {
const agentPayload = await this.buildAgentPayload(liveSnapshot);
this.emit({
type: "status",
payload: {
status: "agent_created",
agentId: liveSnapshot.id,
requestId,
agent: agentPayload,
},
});
}
this.sessionLogger.info(
{ agentId: snapshot.id, provider: snapshot.provider },
`Created agent ${snapshot.id} (${snapshot.provider})`,
);
return { agent: agentPayload };
} catch (error) {
await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({
createdWorktree: createdWorktreeForCleanup,
createdAgentId,
});
throw error;
const wireError = toWorktreeWireError(error);
this.sessionLogger.error({ err: error }, "Failed to create agent");
if (requestId) {
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId,
error: wireError.message,
errorCode: wireError.code,
},
});
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to create agent: ${wireError.message}`,
},
});
}
}
@@ -872,7 +872,7 @@ test("client heartbeat clears attention for the focused terminal", async () => {
});
});
test("create_agent_request deduplicates concurrent draft submission retries", async () => {
test("create_agent_request keeps requested child cwd when grouped under an existing parent workspace", async () => {
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-cwd-"));
try {
const parent = path.join(workdir, "parent");
@@ -981,26 +981,14 @@ test("create_agent_request deduplicates concurrent draft submission retries", as
terminalManager: null,
}),
);
await Promise.all([
session.handleMessage({
type: "create_agent_request",
requestId: "req-create-child-1",
clientMessageId: "draft-submission-1",
config: { provider: "codex", cwd: child },
attachments: [],
}),
session.handleMessage({
type: "create_agent_request",
requestId: "req-create-child-2",
clientMessageId: "draft-submission-1",
config: { provider: "codex", cwd: child },
attachments: [],
}),
]);
await session.handleMessage({
type: "create_agent_request",
requestId: "req-create-child",
config: { provider: "codex", cwd: child },
attachments: [],
});
const createdAgents = agentManager.listAgents();
expect(createdAgents).toHaveLength(1);
const [createdAgent] = createdAgents;
const [createdAgent] = agentManager.listAgents();
expect(createdAgent?.cwd).toBe(child);
const createdWorkspace = await workspaceRegistry.get(createdAgent!.workspaceId!);
expect(createdWorkspace).not.toBeNull();
@@ -1019,22 +1007,10 @@ test("create_agent_request deduplicates concurrent draft submission retries", as
projectKey: createdWorkspace!.projectId,
checkout: { cwd: child },
});
expect(
emitted.filter((message) => message.type === "status").map((message) => message.payload),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
status: "agent_created",
requestId: "req-create-child-1",
agent: expect.objectContaining({ cwd: child }),
}),
expect.objectContaining({
status: "agent_created",
requestId: "req-create-child-2",
agent: expect.objectContaining({ cwd: child }),
}),
]),
);
expect(findByType(emitted, "status")?.payload).toMatchObject({
status: "agent_created",
agent: { cwd: child },
});
} finally {
rmSync(workdir, { recursive: true, force: true });
}
@@ -1,8 +1,6 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { Logger } from "pino";
import { z } from "zod";
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
@@ -16,8 +14,25 @@ import {
unavailableUsage,
} from "../usage.js";
const execFileAsync = promisify(execFile);
const CURSOR_SQLITE_TIMEOUT_MS = 2_000;
// Cursor stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep the
// access token as a plain JWT string under `cursorAuth/accessToken`; older builds
// kept a JSON blob under `cursorAuthStatus`. Read it with node:sqlite so we don't
// depend on a `sqlite3` CLI, which isn't installed by default on Windows (or on many
// Linux hosts) — a missing binary silently rendered Cursor usage unavailable.
const CURSOR_ACCESS_TOKEN_KEY = "cursorAuth/accessToken";
const CURSOR_LEGACY_AUTH_KEY = "cursorAuthStatus";
// @types/node@20 predates the node:sqlite typings; declare the slice we use.
interface CursorStateStatement {
get(...params: unknown[]): Record<string, unknown> | undefined;
}
interface CursorStateDatabase {
prepare(sql: string): CursorStateStatement;
close(): void;
}
interface NodeSqliteModule {
DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => CursorStateDatabase;
}
const CursorBillingCycleTimestampSchema = z.preprocess(
(value) => (typeof value === "string" || typeof value === "number" ? value : null),
@@ -47,6 +62,7 @@ type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>;
interface CursorQuotaProviderOptions {
logger: Logger;
fetch?: ProviderApiFetch;
homeDir?: string;
}
function parseCursorBillingCycleTimestamp(
@@ -70,14 +86,38 @@ function centsToDollars(value: number | null): number | null {
return value === null ? null : value / 100;
}
async function readCursorTokenFromSqlite(): Promise<string | null> {
function readItemTableValue(db: CursorStateDatabase, key: string): string | null {
const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key);
const value = row?.["value"];
if (typeof value === "string") return value;
if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8");
return null;
}
function cursorTokenFromDb(db: CursorStateDatabase): string | null {
const modern = readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY)?.trim();
if (modern) return modern;
const legacy = readItemTableValue(db, CURSOR_LEGACY_AUTH_KEY);
if (legacy) {
try {
const parsed = CursorAuthStatusSchema.parse(JSON.parse(legacy));
if (parsed.accessToken) return parsed.accessToken;
} catch {
// ignore a malformed legacy blob
}
}
return null;
}
async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promise<string | null> {
const dbPaths: string[] = [];
if (process.env["APPDATA"]) {
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
}
dbPaths.push(
join(
homedir(),
homeDir,
"Library",
"Application Support",
"Cursor",
@@ -86,22 +126,32 @@ async function readCursorTokenFromSqlite(): Promise<string | null> {
"state.vscdb",
),
);
dbPaths.push(join(homedir(), ".config", "Cursor", "User", "globalStorage", "state.vscdb"));
dbPaths.push(join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb"));
// Held in a variable so TypeScript skips module resolution: @types/node@20 has no
// node:sqlite typings yet, while the runtime (Node 22+ / Electron) provides it.
const sqliteSpecifier: string = "node:sqlite";
let sqlite: NodeSqliteModule;
try {
sqlite = (await import(sqliteSpecifier)) as unknown as NodeSqliteModule;
} catch (err) {
logger.debug({ err }, "node:sqlite unavailable; cannot read Cursor state.vscdb");
return null; // runtime without node:sqlite
}
for (const path of dbPaths) {
if (!existsSync(path)) continue;
let db: CursorStateDatabase | undefined;
try {
const { stdout } = await execFileAsync(
"sqlite3",
[path, "SELECT value FROM ItemTable WHERE key = 'cursorAuthStatus'"],
{ timeout: CURSOR_SQLITE_TIMEOUT_MS },
);
if (stdout) {
const parsed = CursorAuthStatusSchema.parse(JSON.parse(stdout.trim()));
if (parsed.accessToken) return parsed.accessToken;
}
} catch {
continue;
db = new sqlite.DatabaseSync(path, { readOnly: true });
const token = cursorTokenFromDb(db);
if (token) return token;
} catch (err) {
// Locked/permission/corrupt/schema failures all land here; log so an
// unavailable Cursor card is diagnosable, then try the next candidate.
logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb");
} finally {
db?.close();
}
}
return null;
@@ -113,17 +163,19 @@ export class CursorQuotaProvider implements ProviderUsageFetcher {
private readonly logger: Logger;
private readonly fetchApi: ProviderApiFetch;
private readonly homeDir: string;
constructor(options: CursorQuotaProviderOptions) {
this.logger = options.logger;
this.fetchApi = options.fetch ?? fetch;
this.homeDir = options.homeDir ?? homedir();
}
async fetchUsage(): Promise<ProviderUsage> {
const token =
process.env["CURSOR_ACCESS_TOKEN"] ||
process.env["CURSOR_TOKEN"] ||
(await readCursorTokenFromSqlite());
(await readCursorTokenFromSqlite(this.homeDir, this.logger));
if (!token) return unavailableUsage(this);
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { promises as fsPromises } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -58,6 +59,31 @@ function writeKimiCredentials(dir: string, accessToken: string, overrides: objec
);
}
// node:sqlite has no @types/node@20 typings; require it with a narrow local type.
const testRequire = createRequire(import.meta.url);
interface TestSqliteDb {
exec(sql: string): void;
prepare(sql: string): { run(...params: unknown[]): void };
close(): void;
}
// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the
// real layouts: a plain modern token or the legacy JSON object.
function writeCursorStateDb(homeDir: string, rows: Record<string, string | Uint8Array>): void {
const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage");
mkdirSync(dir, { recursive: true });
const { DatabaseSync } = testRequire("node:sqlite") as {
DatabaseSync: new (path: string) => TestSqliteDb;
};
const db = new DatabaseSync(join(dir, "state.vscdb"));
db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)");
const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)");
for (const [key, value] of Object.entries(rows)) {
insert.run(key, value);
}
db.close();
}
function writeGrokAuth(home: string, auth: Record<string, unknown>): void {
mkdirSync(join(home, ".grok"), { recursive: true });
writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth));
@@ -372,6 +398,7 @@ describe("real provider usage fetchers", () => {
platform?: typeof process.platform;
keychain?: () => Promise<unknown | null>;
kimiHomeDir?: string;
cursorHomeDir?: string;
miniMaxConfigPath?: string;
miniMaxCredentialsPath?: string;
} = {},
@@ -392,7 +419,11 @@ describe("real provider usage fetchers", () => {
}),
new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }),
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new CursorQuotaProvider({
logger,
fetch: fetchThroughTestDouble,
homeDir: options.cursorHomeDir,
}),
new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new GrokQuotaProvider({
logger,
@@ -702,6 +733,75 @@ describe("real provider usage fetchers", () => {
});
});
it("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => {
writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" });
let authorization: string | null = null;
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
return jsonResponse({
planUsage: {
totalSpend: "1500",
includedSpend: "1000",
bonusSpend: "500",
remaining: "2500",
limit: "4000",
},
billingCycleStart: "2026-01-14T12:42:14.000Z",
billingCycleEnd: "2026-02-14T12:42:14.000Z",
});
}) as unknown as typeof fetch;
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
expect(authorization).toBe("Bearer cursor_state_jwt");
expect(cursor).toMatchObject({
status: "available",
balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })],
});
});
it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => {
writeCursorStateDb(homeDir, {
cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"),
});
let authorization: string | null = null;
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
return jsonResponse({
planUsage: { totalSpend: "0", remaining: "100", limit: "100" },
billingCycleStart: null,
billingCycleEnd: null,
});
}) as unknown as typeof fetch;
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
expect(authorization).toBe("Bearer cursor_legacy_jwt");
expect(cursor.status).toBe("available");
});
it("logs a debug diagnostic and stays unavailable when state.vscdb is unreadable", async () => {
const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "state.vscdb"), "not a sqlite database");
const logger = createLogger();
const provider = new CursorQuotaProvider({
logger,
fetch: (() => {
throw new Error("usage API should not be called without a token");
}) as unknown as typeof fetch,
homeDir,
});
const usage = await provider.fetchUsage();
expect(usage.status).toBe("unavailable");
expect((logger as unknown as { debug: ReturnType<typeof vi.fn> }).debug).toHaveBeenCalledWith(
expect.objectContaining({ path: expect.stringContaining("state.vscdb") }),
expect.stringContaining("Failed to read Cursor token"),
);
});
it("fetches Z.ai usage from ZAI_API_KEY", async () => {
process.env["ZAI_API_KEY"] = "zai_test_token";
fetchApi = mockFetch(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.3.1",
"version": "0.4.0-beta.1",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -19,7 +19,7 @@
},
"desktop": {
"type": "service",
"command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} npm run dev --workspace=@getpaseo/desktop"
"command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} exec ./packages/desktop/scripts/dev.sh"
},
"ios-simulator": {
"type": "service",
@@ -1,105 +0,0 @@
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,
+9 -1
View File
@@ -72,10 +72,18 @@ environments:
cwd: /workspace/project
worktree:
mode: branch-off
newBranch: paseo/review
newBranch: trigger-${{ paseo.execution.id }}
base: origin/main
```
`newBranch` is a branch-name string. Embed `${{ paseo.execution.id }}`, which renders the execution's UUID, so every execution branches off `base` on its own branch and keeps it when Hub retries or recovers that execution.
One execution is one step run, so two steps selecting the same environment get separate branches.
`${{ paseo.execution.id }}` is the only expression `newBranch` accepts. `paseo.prompt`, `paseo.context`, `paseo.inputs.*`, `values.*`, `steps.<id>.outputs.*`, and provider event fields are unavailable here, and each one fails bundle activation at the authored field, such as `.paseo/hub.yml.environments.review.worktree.newBranch`.
`${{ paseo.execution.id }}` fails activation the same way anywhere else in a bundle. `branch` and `prNumber` take literal values.
An environment is a complete named object. A step selects its name; objects are not inherited, merged, or partially overridden.
### Named agents
+4 -2
View File
@@ -80,11 +80,13 @@ To keep executions off your working tree, add a worktree:
```yaml
worktree:
mode: branch-off
newBranch: hub/investigation
newBranch: trigger-${{ paseo.execution.id }}
base: origin/main
```
See [Git worktrees](/docs/worktrees) for setup hooks and scripts.
`${{ paseo.execution.id }}` renders the execution's UUID, so every execution gets its own branch off `origin/main`.
[Environment fields](/docs/hub/configuration/hub-yml#environments) lists what `newBranch` accepts. See [Git worktrees](/docs/worktrees) for setup hooks and scripts.
## What Hub owns
-4
View File
@@ -8,10 +8,6 @@ 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+",