mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 22:02:34 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a7301d9a0 | ||
|
|
781e07f4ea | ||
|
|
44b9980a70 | ||
|
|
ed0d4e6c5b | ||
|
|
1b887e534b | ||
|
|
4426a6b11f | ||
|
|
fc1cb82e02 | ||
|
|
0c6dbefa40 | ||
|
|
94106a30ab | ||
|
|
635a8be437 | ||
|
|
8e916fd2dc | ||
|
|
474b4e70b4 | ||
|
|
124e512b58 | ||
|
|
0ba7251280 | ||
|
|
b58ccf9269 |
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.0-beta.2 - 2026-08-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix old chat caches making the app refuse to start
|
||||
- Open delegated agents remain available when their parent is archived ([#3279](https://github.com/getpaseo/paseo/pull/3279))
|
||||
|
||||
## 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
|
||||
|
||||
+23
-10
@@ -44,9 +44,21 @@ Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Age
|
||||
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
|
||||
|
||||
Parent archive detaches a subagent instead of archiving it when either condition holds:
|
||||
|
||||
- The child belongs to another workspace.
|
||||
- The child is currently open in an agent tab.
|
||||
|
||||
All other children archive with the parent. After the workspace layout hydrates, the client marks
|
||||
every managed subagent present in its tabs with `paseo.open-agent-tab.<client-id>=true` through the
|
||||
generic agent metadata update. This includes background and restored tabs; navigation does not own
|
||||
the marker. Closing a tab sets that client's label to `false`. Any `true` client label keeps the child
|
||||
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
|
||||
normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
|
||||
@@ -74,7 +86,7 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
|
||||
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
|
||||
3. Notify subscribers
|
||||
4. Close the runtime (kills the process if still running)
|
||||
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
|
||||
5. **Resolve children** — detach cross-workspace and open-tab children; cascade-archive the rest recursively
|
||||
|
||||
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
||||
|
||||
@@ -109,7 +121,7 @@ These are two distinct concepts that used to be conflated:
|
||||
|
||||
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
|
||||
|
||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy.
|
||||
|
||||
The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached.
|
||||
|
||||
@@ -155,7 +167,7 @@ Archived Paseo subagents disappear from the track, by design. To remove one from
|
||||
|
||||
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
|
||||
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||
|
||||
## Why this shape
|
||||
|
||||
@@ -189,11 +201,12 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
| Field | Type | Meaning |
|
||||
| -------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
|
||||
| `labels["paseo.open-agent-tab.<client-id>"]` | `string?` | `"true"` protects an open tab on that client; detach clears every matching label |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-L73o8u6OsY3SsA5t2s5mWsMsMNc3qr5bCE4cd1NpbgI=
|
||||
sha256-zO0mNouwY+thgefl2zB/Lk8pIZGh0QowC8klXll2qOc=
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"@getpaseo/protocol": "0.4.0-beta.2",
|
||||
"@getpaseo/server": "0.4.0-beta.2",
|
||||
"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.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.3.1",
|
||||
"@getpaseo/relay": "0.3.1",
|
||||
"@getpaseo/protocol": "0.4.0-beta.2",
|
||||
"@getpaseo/relay": "0.4.0-beta.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37405,7 +37405,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38586,7 +38586,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"@getpaseo/highlight": "0.4.0-beta.2",
|
||||
"@getpaseo/protocol": "0.4.0-beta.2",
|
||||
"@getpaseo/relay": "0.4.0-beta.2",
|
||||
"@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.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"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",
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+378
-530
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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -614,6 +614,7 @@ export const ar: TranslationResources = {
|
||||
reloadingAgent: "وكيل إعادة التحميل...",
|
||||
reloadedAgent: "وكيل إعادة تحميل",
|
||||
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
|
||||
failedToCloseAgent: "فشل في إغلاق الوكيل",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "تغييرات غير محفوظة",
|
||||
|
||||
@@ -613,6 +613,7 @@ export const en = {
|
||||
reloadingAgent: "Reloading agent...",
|
||||
reloadedAgent: "Reloaded agent",
|
||||
failedToReloadAgent: "Failed to reload agent",
|
||||
failedToCloseAgent: "Failed to close agent",
|
||||
},
|
||||
confirmations: {
|
||||
close: "Close",
|
||||
|
||||
@@ -619,6 +619,7 @@ export const es: TranslationResources = {
|
||||
reloadingAgent: "Agente de recarga...",
|
||||
reloadedAgent: "Agente recargado",
|
||||
failedToReloadAgent: "No se pudo recargar el agente",
|
||||
failedToCloseAgent: "No se pudo cerrar el agente",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "Cambios sin guardar",
|
||||
|
||||
@@ -619,6 +619,7 @@ export const fr: TranslationResources = {
|
||||
reloadingAgent: "Agent de rechargement...",
|
||||
reloadedAgent: "Agent rechargé",
|
||||
failedToReloadAgent: "Échec du rechargement de l'agent",
|
||||
failedToCloseAgent: "Échec de la fermeture de l'agent",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "Modifications non enregistrées",
|
||||
|
||||
@@ -619,6 +619,7 @@ export const ja: TranslationResources = {
|
||||
reloadingAgent: "エージェントを再読み込み中...",
|
||||
reloadedAgent: "エージェントを再読み込みしました",
|
||||
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
|
||||
failedToCloseAgent: "エージェントを閉じられませんでした",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "未保存の変更",
|
||||
|
||||
@@ -616,6 +616,7 @@ export const ko: TranslationResources = {
|
||||
reloadingAgent: "에이전트 다시 로드 중...",
|
||||
reloadedAgent: "에이전트를 다시 로드했습니다",
|
||||
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
|
||||
failedToCloseAgent: "에이전트를 닫지 못했습니다",
|
||||
},
|
||||
confirmations: {
|
||||
close: "닫기",
|
||||
|
||||
@@ -618,6 +618,7 @@ export const ptBR: TranslationResources = {
|
||||
reloadingAgent: "Recarregando agente...",
|
||||
reloadedAgent: "Agente recarregado",
|
||||
failedToReloadAgent: "Falha ao recarregar agente",
|
||||
failedToCloseAgent: "Falha ao fechar agente",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "Alterações não salvas",
|
||||
|
||||
@@ -618,6 +618,7 @@ export const ru: TranslationResources = {
|
||||
reloadingAgent: "Перезагрузка агента...",
|
||||
reloadedAgent: "Перезагруженный агент",
|
||||
failedToReloadAgent: "Не удалось перезагрузить агент",
|
||||
failedToCloseAgent: "Не удалось закрыть агент",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "Несохранённые изменения",
|
||||
|
||||
@@ -614,6 +614,7 @@ export const zhCN: TranslationResources = {
|
||||
reloadingAgent: "正在重新加载 Agent...",
|
||||
reloadedAgent: "已重新加载 Agent",
|
||||
failedToReloadAgent: "重新加载 Agent 失败",
|
||||
failedToCloseAgent: "关闭 Agent 失败",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "未保存的更改",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ describe("ReplicaCache", () => {
|
||||
version: number;
|
||||
hosts: Array<{ timeline: Record<string, unknown> | null }>;
|
||||
};
|
||||
expect(persisted.version).toBe(3);
|
||||
expect(persisted.version).toBe(4);
|
||||
expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]);
|
||||
});
|
||||
|
||||
@@ -331,12 +331,12 @@ describe("ReplicaCache", () => {
|
||||
expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]);
|
||||
});
|
||||
|
||||
it("rejects version 1 cache data and overwrites it on flush", async () => {
|
||||
it("rejects version 3 cache data and overwrites it on flush", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.values.set(
|
||||
"@paseo:replica-cache",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
version: 3,
|
||||
hosts: [
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
@@ -361,7 +361,7 @@ describe("ReplicaCache", () => {
|
||||
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined();
|
||||
expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({
|
||||
version: 3,
|
||||
version: 4,
|
||||
hosts: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import { isUnreconciledLocalUserMessage, type StreamItem } from "@/types/stream"
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
|
||||
const STORAGE_KEY = "@paseo:replica-cache";
|
||||
const CACHE_VERSION = 3;
|
||||
const CACHE_VERSION = 4;
|
||||
const PERSIST_DELAY_MS = 750;
|
||||
const MAX_TIMELINE_ITEMS = 50;
|
||||
const MAX_CACHE_BYTES = 1024 * 1024;
|
||||
|
||||
@@ -42,7 +42,8 @@ describe("workspace bulk close helpers", () => {
|
||||
]);
|
||||
|
||||
expect(groups).toEqual({
|
||||
agentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
|
||||
archiveAgentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
|
||||
layoutOnlyAgentTabs: [],
|
||||
terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }],
|
||||
otherTabs: [
|
||||
{
|
||||
@@ -53,6 +54,21 @@ describe("workspace bulk close helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("separates subagent tabs from agents that archive on close", () => {
|
||||
const groups = classifyBulkClosableTabs(
|
||||
[makeAgentTab("root"), makeAgentTab("child")],
|
||||
(agentId) => (agentId === "child" ? "layout-only" : "archive"),
|
||||
);
|
||||
|
||||
expect(groups).toMatchObject({
|
||||
archiveAgentTabs: [{ tabId: "agent_root", agentId: "root" }],
|
||||
layoutOnlyAgentTabs: [{ tabId: "agent_child", agentId: "child" }],
|
||||
});
|
||||
expect(buildBulkCloseConfirmationMessage(groups)).toBe(
|
||||
"This will archive 1 agent(s) and close 1 tab(s).",
|
||||
);
|
||||
});
|
||||
|
||||
it("describes mixed destructive bulk close operations in the confirmation copy", () => {
|
||||
const message = buildBulkCloseConfirmationMessage(
|
||||
classifyBulkClosableTabs([
|
||||
@@ -106,6 +122,7 @@ describe("workspace bulk close helpers", () => {
|
||||
closeWorkspaceTabWithCleanup: (input) => {
|
||||
cleanupCalls.push(input);
|
||||
},
|
||||
closeLayoutOnlyAgent: async () => {},
|
||||
logLabel: "all tabs",
|
||||
});
|
||||
|
||||
@@ -152,6 +169,7 @@ describe("workspace bulk close helpers", () => {
|
||||
closeWorkspaceTabWithCleanup: (input) => {
|
||||
cleanupCalls.push(input);
|
||||
},
|
||||
closeLayoutOnlyAgent: async () => {},
|
||||
warn,
|
||||
logLabel: "others",
|
||||
});
|
||||
@@ -166,4 +184,31 @@ describe("workspace bulk close helpers", () => {
|
||||
{ tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes subagent tabs without sending them to closeItems", async () => {
|
||||
const groups = classifyBulkClosableTabs(
|
||||
[makeAgentTab("root"), makeAgentTab("child")],
|
||||
(agentId) => (agentId === "child" ? "layout-only" : "archive"),
|
||||
);
|
||||
const closeItems = vi.fn(async () => ({ agents: [], terminals: [], requestId: "req-1" }));
|
||||
const preparedSubagents: string[] = [];
|
||||
const cleanedTabs: string[] = [];
|
||||
|
||||
await closeBulkWorkspaceTabs({
|
||||
groups,
|
||||
client: { closeItems },
|
||||
closeTab: async (_tabId, action) => action(),
|
||||
closeLayoutOnlyAgent: async (agentId) => {
|
||||
preparedSubagents.push(agentId);
|
||||
},
|
||||
closeWorkspaceTabWithCleanup: ({ tabId }) => {
|
||||
cleanedTabs.push(tabId);
|
||||
},
|
||||
logLabel: "others",
|
||||
});
|
||||
|
||||
expect(closeItems).toHaveBeenCalledWith({ agentIds: ["root"], terminalIds: [] });
|
||||
expect(preparedSubagents).toEqual(["child"]);
|
||||
expect(cleanedTabs).toEqual(["agent_child", "agent_root"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export interface BulkClosableTabGroups {
|
||||
agentTabs: Array<{ tabId: string; agentId: string }>;
|
||||
archiveAgentTabs: Array<{ tabId: string; agentId: string }>;
|
||||
layoutOnlyAgentTabs: Array<{ tabId: string; agentId: string }>;
|
||||
terminalTabs: Array<{ tabId: string; terminalId: string }>;
|
||||
otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>;
|
||||
}
|
||||
@@ -43,20 +44,30 @@ interface CloseBulkWorkspaceTabsInput {
|
||||
groups: BulkClosableTabGroups;
|
||||
closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>;
|
||||
closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void;
|
||||
closeLayoutOnlyAgent: (agentId: string) => Promise<void>;
|
||||
logLabel: string;
|
||||
warn?: (message: string, payload: object) => void;
|
||||
}
|
||||
|
||||
export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups {
|
||||
export function classifyBulkClosableTabs(
|
||||
tabs: WorkspaceTabDescriptor[],
|
||||
resolveAgentCloseKind: (agentId: string) => "archive" | "layout-only" = () => "archive",
|
||||
): BulkClosableTabGroups {
|
||||
const groups: BulkClosableTabGroups = {
|
||||
agentTabs: [],
|
||||
archiveAgentTabs: [],
|
||||
layoutOnlyAgentTabs: [],
|
||||
terminalTabs: [],
|
||||
otherTabs: [],
|
||||
};
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (tab.target.kind === "agent") {
|
||||
groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.target.agentId });
|
||||
const agentTab = { tabId: tab.tabId, agentId: tab.target.agentId };
|
||||
if (resolveAgentCloseKind(tab.target.agentId) === "layout-only") {
|
||||
groups.layoutOnlyAgentTabs.push(agentTab);
|
||||
} else {
|
||||
groups.archiveAgentTabs.push(agentTab);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (tab.target.kind === "terminal") {
|
||||
@@ -73,49 +84,73 @@ export function buildBulkCloseConfirmationMessage(
|
||||
input: BulkClosableTabGroups,
|
||||
labels: BulkCloseConfirmationLabels = DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS,
|
||||
): string {
|
||||
const { agentTabs, terminalTabs, otherTabs } = input;
|
||||
if (agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0) {
|
||||
const { archiveAgentTabs, layoutOnlyAgentTabs, terminalTabs, otherTabs } = input;
|
||||
const tabCount = layoutOnlyAgentTabs.length + otherTabs.length;
|
||||
if (archiveAgentTabs.length > 0 && terminalTabs.length > 0 && tabCount > 0) {
|
||||
return labels.all({
|
||||
agents: agentTabs.length,
|
||||
agents: archiveAgentTabs.length,
|
||||
terminals: terminalTabs.length,
|
||||
tabs: otherTabs.length,
|
||||
tabs: tabCount,
|
||||
});
|
||||
}
|
||||
if (agentTabs.length > 0 && terminalTabs.length > 0) {
|
||||
if (archiveAgentTabs.length > 0 && terminalTabs.length > 0) {
|
||||
return labels.agentsAndTerminals({
|
||||
agents: agentTabs.length,
|
||||
agents: archiveAgentTabs.length,
|
||||
terminals: terminalTabs.length,
|
||||
});
|
||||
}
|
||||
if (terminalTabs.length > 0 && otherTabs.length > 0) {
|
||||
if (terminalTabs.length > 0 && tabCount > 0) {
|
||||
return labels.terminalsAndTabs({
|
||||
terminals: terminalTabs.length,
|
||||
tabs: otherTabs.length,
|
||||
tabs: tabCount,
|
||||
});
|
||||
}
|
||||
if (agentTabs.length > 0 && otherTabs.length > 0) {
|
||||
if (archiveAgentTabs.length > 0 && tabCount > 0) {
|
||||
return labels.agentsAndTabs({
|
||||
agents: agentTabs.length,
|
||||
tabs: otherTabs.length,
|
||||
agents: archiveAgentTabs.length,
|
||||
tabs: tabCount,
|
||||
});
|
||||
}
|
||||
if (terminalTabs.length > 0) {
|
||||
return labels.terminals({ terminals: terminalTabs.length });
|
||||
}
|
||||
if (otherTabs.length > 0) {
|
||||
return labels.tabs({ tabs: otherTabs.length });
|
||||
if (tabCount > 0) {
|
||||
return labels.tabs({ tabs: tabCount });
|
||||
}
|
||||
return labels.agents({ agents: agentTabs.length });
|
||||
return labels.agents({ agents: archiveAgentTabs.length });
|
||||
}
|
||||
|
||||
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
|
||||
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
|
||||
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
|
||||
const {
|
||||
client,
|
||||
groups,
|
||||
closeTab,
|
||||
closeWorkspaceTabWithCleanup,
|
||||
closeLayoutOnlyAgent,
|
||||
logLabel,
|
||||
warn,
|
||||
} = input;
|
||||
const hasDestructiveTabs = groups.archiveAgentTabs.length > 0 || groups.terminalTabs.length > 0;
|
||||
|
||||
for (const { tabId, agentId } of groups.layoutOnlyAgentTabs) {
|
||||
await closeTab(tabId, async () => {
|
||||
try {
|
||||
await closeLayoutOnlyAgent(agentId);
|
||||
} catch (error) {
|
||||
warn?.(`[WorkspaceScreen] Failed to close subagent tab ${logLabel}`, { error, agentId });
|
||||
return;
|
||||
}
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "agent", agentId },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (hasDestructiveTabs && client) {
|
||||
void client
|
||||
.closeItems({
|
||||
agentIds: groups.agentTabs.map((tab) => tab.agentId),
|
||||
agentIds: groups.archiveAgentTabs.map((tab) => tab.agentId),
|
||||
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -127,27 +162,27 @@ export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput)
|
||||
});
|
||||
}
|
||||
|
||||
for (const { tabId, agentId } of groups.agentTabs) {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "agent", agentId },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const { tabId, terminalId } of groups.terminalTabs) {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const { tabId, target } of groups.otherTabs) {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({ tabId, target });
|
||||
});
|
||||
}
|
||||
await Promise.all([
|
||||
...groups.archiveAgentTabs.map(({ tabId, agentId }) =>
|
||||
closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "agent", agentId },
|
||||
});
|
||||
}),
|
||||
),
|
||||
...groups.terminalTabs.map(({ tabId, terminalId }) =>
|
||||
closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
});
|
||||
}),
|
||||
),
|
||||
...groups.otherTabs.map(({ tabId, target }) =>
|
||||
closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({ tabId, target });
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -66,6 +67,7 @@ import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-
|
||||
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import { traceInstant } from "@/performance/native-trace";
|
||||
@@ -112,6 +114,7 @@ import { getDesktopHost } from "@/desktop/host";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
|
||||
import { useOpenAgentTabLabels } from "@/subagents/use-open-agent-tab-labels";
|
||||
import {
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
@@ -1970,6 +1973,12 @@ function WorkspaceScreenContent({
|
||||
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
|
||||
[workspaceLayout],
|
||||
);
|
||||
useOpenAgentTabLabels({
|
||||
client,
|
||||
serverId: normalizedServerId,
|
||||
tabs: uiTabs,
|
||||
enabled: hasHydratedWorkspaceLayoutStore,
|
||||
});
|
||||
useSyncWorkspaceActiveBrowser({
|
||||
workspaceLayout,
|
||||
isRouteFocused,
|
||||
@@ -2651,7 +2660,7 @@ function WorkspaceScreenContent({
|
||||
|
||||
const agent =
|
||||
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||
const closePolicy = resolveCloseAgentTabPolicy(agent);
|
||||
let closePolicy = resolveCloseAgentTabPolicy(agent);
|
||||
const isRunning = agent?.status === "running";
|
||||
|
||||
if (isRunning && closePolicy.kind === "archive-on-close") {
|
||||
@@ -2667,6 +2676,27 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
}
|
||||
|
||||
if (closePolicy.kind === "layout-only") {
|
||||
const sessionClient = useSessionStore.getState().sessions[normalizedServerId]?.client;
|
||||
if (!sessionClient) {
|
||||
toast.error(t("common.errors.daemonClientUnavailable"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const clientId = await getOrCreateClientId();
|
||||
await sessionClient.updateAgent(agentId, {
|
||||
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
|
||||
});
|
||||
const latestAgent =
|
||||
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||
closePolicy = resolveCloseAgentTabPolicy(latestAgent);
|
||||
} catch (error) {
|
||||
console.error("[WorkspaceScreen] Failed to close subagent tab", { error, agentId });
|
||||
toast.error(t("workspace.tabs.toasts.failedToCloseAgent"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
@@ -2683,7 +2713,15 @@ function WorkspaceScreenContent({
|
||||
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
|
||||
});
|
||||
},
|
||||
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t],
|
||||
[
|
||||
archiveAgent,
|
||||
closeTab,
|
||||
closeWorkspaceTabWithCleanup,
|
||||
normalizedServerId,
|
||||
persistenceKey,
|
||||
t,
|
||||
toast,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClosePassiveTab = useCallback(
|
||||
@@ -2899,7 +2937,10 @@ function WorkspaceScreenContent({
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = classifyBulkClosableTabs(tabsToClose);
|
||||
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
|
||||
const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId);
|
||||
return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive";
|
||||
});
|
||||
const modifiedCount = tabsToClose.filter(
|
||||
(tab) =>
|
||||
getPanelInstanceAttributes({
|
||||
@@ -2927,6 +2968,20 @@ function WorkspaceScreenContent({
|
||||
client,
|
||||
groups,
|
||||
closeTab,
|
||||
closeLayoutOnlyAgent: async (agentId) => {
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
const clientId = await getOrCreateClientId();
|
||||
await client.updateAgent(agentId, {
|
||||
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
|
||||
});
|
||||
const latestAgent =
|
||||
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||
if (resolveCloseAgentTabPolicy(latestAgent).kind === "archive-on-close") {
|
||||
await archiveAgent({ serverId: normalizedServerId, agentId });
|
||||
}
|
||||
},
|
||||
closeWorkspaceTabWithCleanup: (cleanupInput) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
@@ -2943,6 +2998,7 @@ function WorkspaceScreenContent({
|
||||
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||
},
|
||||
[
|
||||
archiveAgent,
|
||||
bulkCloseConfirmationLabels,
|
||||
client,
|
||||
closeTab,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
|
||||
|
||||
const label = "paseo.open-agent-tab.client-1";
|
||||
|
||||
function tab(agentId: string, createdAt: number): WorkspaceTab {
|
||||
return { tabId: `agent_${agentId}`, target: { kind: "agent", agentId }, createdAt };
|
||||
}
|
||||
|
||||
function agent(input: { id: string; parentAgentId?: string | null; open?: boolean }): Agent {
|
||||
return {
|
||||
id: input.id,
|
||||
parentAgentId: input.parentAgentId ?? null,
|
||||
labels: input.open ? { [label]: "true" } : {},
|
||||
} as Agent;
|
||||
}
|
||||
|
||||
describe("getAgentTabsNeedingOpenLabel", () => {
|
||||
it("marks every present parented tab, regardless of tab order or focus", () => {
|
||||
const agents = new Map([
|
||||
["background-child", agent({ id: "background-child", parentAgentId: "parent" })],
|
||||
["focused-child", agent({ id: "focused-child", parentAgentId: "parent" })],
|
||||
["root", agent({ id: "root" })],
|
||||
]);
|
||||
|
||||
expect(
|
||||
getAgentTabsNeedingOpenLabel({
|
||||
tabs: [tab("background-child", 1), tab("root", 2), tab("focused-child", 3)],
|
||||
getAgent: (agentId) => agents.get(agentId),
|
||||
label,
|
||||
pendingAgentIds: new Set(),
|
||||
}),
|
||||
).toEqual(["background-child", "focused-child"]);
|
||||
});
|
||||
|
||||
it("skips tabs already marked or currently being marked", () => {
|
||||
const agents = new Map([
|
||||
["marked", agent({ id: "marked", parentAgentId: "parent", open: true })],
|
||||
["pending", agent({ id: "pending", parentAgentId: "parent" })],
|
||||
]);
|
||||
|
||||
expect(
|
||||
getAgentTabsNeedingOpenLabel({
|
||||
tabs: [tab("marked", 1), tab("pending", 2)],
|
||||
getAgent: (agentId) => agents.get(agentId),
|
||||
label,
|
||||
pendingAgentIds: new Set(["pending"]),
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
|
||||
export function getAgentTabsNeedingOpenLabel(input: {
|
||||
tabs: WorkspaceTab[];
|
||||
getAgent: (agentId: string) => Agent | null | undefined;
|
||||
label: string;
|
||||
pendingAgentIds: ReadonlySet<string>;
|
||||
}): string[] {
|
||||
const agentIds = new Set<string>();
|
||||
for (const tab of input.tabs) {
|
||||
if (tab.target.kind !== "agent") {
|
||||
continue;
|
||||
}
|
||||
const agent = input.getAgent(tab.target.agentId);
|
||||
if (
|
||||
agent?.parentAgentId &&
|
||||
agent.labels[input.label] !== "true" &&
|
||||
!input.pendingAgentIds.has(agent.id)
|
||||
) {
|
||||
agentIds.add(agent.id);
|
||||
}
|
||||
}
|
||||
return [...agentIds];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getOrCreateClientId } from "@/utils/client-id";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
|
||||
|
||||
function increment(value: number): number {
|
||||
return value + 1;
|
||||
}
|
||||
|
||||
export function useOpenAgentTabLabels(input: {
|
||||
client: DaemonClient | null;
|
||||
serverId: string;
|
||||
tabs: WorkspaceTab[];
|
||||
enabled: boolean;
|
||||
}): void {
|
||||
const agents = useSessionStore((state) => state.sessions[input.serverId]?.agents ?? null);
|
||||
const agentDetails = useSessionStore(
|
||||
(state) => state.sessions[input.serverId]?.agentDetails ?? null,
|
||||
);
|
||||
const pendingAgentIdsRef = useRef(new Set<string>());
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [retryVersion, setRetryVersion] = useState(0);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const client = input.client;
|
||||
if (!client || !input.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openAgentIds = new Set(
|
||||
input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
|
||||
);
|
||||
for (const agentId of pendingAgentIdsRef.current) {
|
||||
if (!openAgentIds.has(agentId)) {
|
||||
pendingAgentIdsRef.current.delete(agentId);
|
||||
}
|
||||
}
|
||||
if (openAgentIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const clientId = await getOrCreateClientId();
|
||||
const label = getOpenAgentTabLabel(clientId);
|
||||
const agentIds = getAgentTabsNeedingOpenLabel({
|
||||
tabs: input.tabs,
|
||||
getAgent: (agentId) => agents?.get(agentId) ?? agentDetails?.get(agentId),
|
||||
label,
|
||||
pendingAgentIds: pendingAgentIdsRef.current,
|
||||
});
|
||||
for (const agentId of agentIds) {
|
||||
pendingAgentIdsRef.current.add(agentId);
|
||||
try {
|
||||
await client.updateAgent(agentId, { labels: { [label]: "true" } });
|
||||
} catch (error) {
|
||||
console.warn("[OpenAgentTabLabels] Failed to mark open subagent tab", {
|
||||
error,
|
||||
agentId,
|
||||
});
|
||||
retryTimerRef.current ??= setTimeout(() => {
|
||||
retryTimerRef.current = null;
|
||||
setRetryVersion(increment);
|
||||
}, 2_000);
|
||||
} finally {
|
||||
pendingAgentIdsRef.current.delete(agentId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[OpenAgentTabLabels] Failed to resolve client ID", { error });
|
||||
}
|
||||
})();
|
||||
}, [agentDetails, agents, input.client, input.enabled, input.tabs, retryVersion]);
|
||||
}
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"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.2",
|
||||
"@getpaseo/protocol": "0.4.0-beta.2",
|
||||
"@getpaseo/server": "0.4.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"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.2",
|
||||
"@getpaseo/relay": "0.4.0-beta.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
getParentAgentIdFromLabels,
|
||||
getOpenAgentTabLabel,
|
||||
hasOpenAgentTab,
|
||||
isDelegatedAgent,
|
||||
isOpenAgentTabLabel,
|
||||
PARENT_AGENT_ID_LABEL,
|
||||
} from "./agent-labels.js";
|
||||
|
||||
@@ -18,4 +21,19 @@ describe("agent label policy", () => {
|
||||
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: " " } })).toBe(false);
|
||||
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } })).toBe(false);
|
||||
});
|
||||
|
||||
test("treats any true client-scoped open-tab label as open", () => {
|
||||
const desktopLabel = getOpenAgentTabLabel("desktop-client");
|
||||
const mobileLabel = getOpenAgentTabLabel("mobile-client");
|
||||
|
||||
expect(hasOpenAgentTab({ [desktopLabel]: "false", [mobileLabel]: "true" })).toBe(true);
|
||||
expect(hasOpenAgentTab({ [desktopLabel]: "false", [mobileLabel]: "false" })).toBe(false);
|
||||
expect(hasOpenAgentTab({})).toBe(false);
|
||||
});
|
||||
|
||||
test("recognizes only client-scoped open-tab labels", () => {
|
||||
expect(isOpenAgentTabLabel(getOpenAgentTabLabel("client-a"))).toBe(true);
|
||||
expect(isOpenAgentTabLabel("paseo.open-agent-tab")).toBe(false);
|
||||
expect(isOpenAgentTabLabel("custom.open-agent-tab.client-a")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
|
||||
const OPEN_AGENT_TAB_LABEL_PREFIX = "paseo.open-agent-tab.";
|
||||
|
||||
export function getOpenAgentTabLabel(clientId: string): string {
|
||||
return `${OPEN_AGENT_TAB_LABEL_PREFIX}${clientId}`;
|
||||
}
|
||||
|
||||
export function isOpenAgentTabLabel(label: string): boolean {
|
||||
return label.startsWith(OPEN_AGENT_TAB_LABEL_PREFIX);
|
||||
}
|
||||
|
||||
export interface AgentLabelSource {
|
||||
labels?: Record<string, unknown> | null;
|
||||
@@ -14,3 +23,9 @@ export function getParentAgentIdFromLabels(labels: Record<string, unknown> | nul
|
||||
export function isDelegatedAgent(agent: AgentLabelSource): boolean {
|
||||
return getParentAgentIdFromLabels(agent.labels) !== null;
|
||||
}
|
||||
|
||||
export function hasOpenAgentTab(labels: Record<string, unknown> | null | undefined): boolean {
|
||||
return Object.entries(labels ?? {}).some(
|
||||
([label, value]) => isOpenAgentTabLabel(label) && value === "true",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"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.2",
|
||||
"@getpaseo/highlight": "0.4.0-beta.2",
|
||||
"@getpaseo/protocol": "0.4.0-beta.2",
|
||||
"@getpaseo/relay": "0.4.0-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js";
|
||||
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
@@ -41,6 +41,9 @@ import type {
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
|
||||
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
|
||||
const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client");
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
@@ -3590,7 +3593,7 @@ test("setLabels merges and persists labels", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("detachAgent removes only the parent label from a live agent and emits state", async () => {
|
||||
test("detachAgent removes relationship lifecycle labels from a live agent and emits state", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-live-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -3621,6 +3624,8 @@ test("detachAgent removes only the parent label from a live agent and emits stat
|
||||
{
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
|
||||
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
|
||||
team: "infra",
|
||||
},
|
||||
workspaceId: undefined,
|
||||
@@ -3648,7 +3653,7 @@ test("detachAgent removes only the parent label from a live agent and emits stat
|
||||
expect(emittedLabels).toContainEqual({ team: "infra" });
|
||||
});
|
||||
|
||||
test("detachAgent removes the parent label from a stored-only agent", async () => {
|
||||
test("detachAgent removes relationship lifecycle labels from a stored-only agent", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-stored-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -3679,6 +3684,8 @@ test("detachAgent removes the parent label from a stored-only agent", async () =
|
||||
{
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
|
||||
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
|
||||
role: "reviewer",
|
||||
},
|
||||
workspaceId: undefined,
|
||||
@@ -6724,6 +6731,182 @@ test("archiveAgent cascade archives in-memory children with the full archive con
|
||||
expect(storedUnrelated?.archivedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
test("archiveAgent detaches an open same-workspace child instead of cascading", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-child-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Parent" },
|
||||
undefined,
|
||||
{ workspaceId: "workspace-a" },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Open child" },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "workspace-a",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "false",
|
||||
[MOBILE_OPEN_AGENT_TAB_LABEL]: "true",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await manager.archiveAgent(parent.id);
|
||||
|
||||
const storedChild = await storage.get(child.id);
|
||||
expect(storedChild?.archivedAt).toBeUndefined();
|
||||
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
||||
expect(storedChild?.labels[DESKTOP_OPEN_AGENT_TAB_LABEL]).toBeUndefined();
|
||||
expect(storedChild?.labels[MOBILE_OPEN_AGENT_TAB_LABEL]).toBeUndefined();
|
||||
expect(manager.getAgent(child.id)?.id).toBe(child.id);
|
||||
});
|
||||
|
||||
test("archiveAgent detaches a cross-workspace child even when its tab is closed", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-cross-workspace-child-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Parent" },
|
||||
undefined,
|
||||
{ workspaceId: "workspace-a" },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Cross-workspace child" },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "workspace-b",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "false",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await manager.archiveAgent(parent.id);
|
||||
|
||||
const storedChild = await storage.get(child.id);
|
||||
expect(storedChild?.archivedAt).toBeUndefined();
|
||||
expect(storedChild?.workspaceId).toBe("workspace-b");
|
||||
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("archiveAgent re-reads a child before deciding whether to cascade", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-fresh-child-"));
|
||||
|
||||
class ChildOpensAfterCascadeListStorage extends AgentStorage {
|
||||
childId: string | null = null;
|
||||
|
||||
override async list(): Promise<StoredAgentRecord[]> {
|
||||
const records = await super.list();
|
||||
const parentIsArchived = records.some(
|
||||
(record) => record.id !== this.childId && Boolean(record.archivedAt),
|
||||
);
|
||||
const staleChild = records.find((record) => record.id === this.childId);
|
||||
if (parentIsArchived && staleChild) {
|
||||
await super.upsert({
|
||||
...staleChild,
|
||||
labels: { ...staleChild.labels, [MOBILE_OPEN_AGENT_TAB_LABEL]: "true" },
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new ChildOpensAfterCascadeListStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Parent" },
|
||||
undefined,
|
||||
{ workspaceId: "workspace-a" },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Late-open child" },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "workspace-a",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
|
||||
},
|
||||
},
|
||||
);
|
||||
storage.childId = child.id;
|
||||
|
||||
await manager.archiveAgent(parent.id);
|
||||
|
||||
const storedChild = await storage.get(child.id);
|
||||
expect(storedChild?.archivedAt).toBeUndefined();
|
||||
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("archiveAgent cannot overtake a received child open-tab update", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-race-"));
|
||||
const markerWriteStarted = deferred<void>();
|
||||
const releaseMarkerWrite = deferred<void>();
|
||||
|
||||
class BlockingOpenMarkerStorage extends AgentStorage {
|
||||
childId: string | null = null;
|
||||
|
||||
override async applySnapshot(
|
||||
agent: ManagedAgent,
|
||||
options?: { title?: string | null; internal?: boolean },
|
||||
): Promise<void> {
|
||||
if (agent.id === this.childId && agent.labels[MOBILE_OPEN_AGENT_TAB_LABEL] === "true") {
|
||||
markerWriteStarted.resolve();
|
||||
await releaseMarkerWrite.promise;
|
||||
}
|
||||
await super.applySnapshot(agent, options);
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new BlockingOpenMarkerStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Parent" },
|
||||
undefined,
|
||||
{ workspaceId: "workspace-a" },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Opening child" },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "workspace-a",
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
|
||||
},
|
||||
);
|
||||
storage.childId = child.id;
|
||||
|
||||
const markOpen = manager.updateAgentMetadata(child.id, {
|
||||
labels: { [MOBILE_OPEN_AGENT_TAB_LABEL]: "true" },
|
||||
});
|
||||
await markerWriteStarted.promise;
|
||||
const archiveParent = manager.archiveAgent(parent.id);
|
||||
releaseMarkerWrite.resolve();
|
||||
await Promise.all([markOpen, archiveParent]);
|
||||
|
||||
const storedChild = await storage.get(child.id);
|
||||
expect(storedChild?.archivedAt).toBeUndefined();
|
||||
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("archiveAgent cascade closes a running child runtime", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-running-child-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
} from "@getpaseo/protocol/agent-lifecycle";
|
||||
import {
|
||||
getParentAgentIdFromLabels,
|
||||
hasOpenAgentTab,
|
||||
isDelegatedAgent,
|
||||
isOpenAgentTabLabel,
|
||||
PARENT_AGENT_ID_LABEL,
|
||||
} from "@getpaseo/protocol/agent-labels";
|
||||
import type { Logger } from "pino";
|
||||
@@ -613,6 +615,27 @@ function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): str
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldDetachFromArchivedParent(
|
||||
parent: StoredAgentRecord,
|
||||
child: StoredAgentRecord,
|
||||
): boolean {
|
||||
const isCrossWorkspace =
|
||||
parent.workspaceId !== undefined &&
|
||||
child.workspaceId !== undefined &&
|
||||
parent.workspaceId !== child.workspaceId;
|
||||
return isCrossWorkspace || hasOpenAgentTab(child.labels);
|
||||
}
|
||||
|
||||
function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatch {
|
||||
const patch: AgentLabelPatch = { [PARENT_AGENT_ID_LABEL]: null };
|
||||
for (const label of Object.keys(labels)) {
|
||||
if (isOpenAgentTabLabel(label)) {
|
||||
patch[label] = null;
|
||||
}
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
export class AgentManager {
|
||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
||||
@@ -631,6 +654,7 @@ export class AgentManager {
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly agentRegistrationTasks = new Set<Promise<void>>();
|
||||
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
|
||||
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
private mcpBaseUrl: string | null;
|
||||
private readonly mcpAuthToken: string | null;
|
||||
@@ -1494,6 +1518,10 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
||||
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId));
|
||||
}
|
||||
|
||||
private async archiveAgentUnlocked(agentId: string): Promise<{ archivedAt: string }> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
if (!this.registry) {
|
||||
throw new Error("Agent storage is not configured");
|
||||
@@ -1527,6 +1555,10 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
const records = await registry.list();
|
||||
const parent = records.find((record) => record.id === parentAgentId);
|
||||
if (!parent) {
|
||||
throw new Error(`Archived parent ${parentAgentId} not found in storage`);
|
||||
}
|
||||
for (const record of records) {
|
||||
if (record.archivedAt) {
|
||||
continue;
|
||||
@@ -1534,11 +1566,27 @@ export class AgentManager {
|
||||
if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
||||
continue;
|
||||
}
|
||||
if (this.agents.has(record.id)) {
|
||||
await this.archiveAgent(record.id);
|
||||
} else {
|
||||
await this.archiveSnapshot(record.id, new Date().toISOString());
|
||||
const child = await registry.get(record.id);
|
||||
if (!child || child.archivedAt || child.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
||||
continue;
|
||||
}
|
||||
await this.runLifecycleMutation(child.id, async () => {
|
||||
const currentChild = await registry.get(child.id);
|
||||
if (
|
||||
!currentChild ||
|
||||
currentChild.archivedAt ||
|
||||
currentChild.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (shouldDetachFromArchivedParent(parent, currentChild)) {
|
||||
await this.detachAgentUnlocked(currentChild.id);
|
||||
} else if (this.agents.has(currentChild.id)) {
|
||||
await this.archiveAgentUnlocked(currentChild.id);
|
||||
} else {
|
||||
await this.archiveSnapshot(currentChild.id, new Date().toISOString());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1711,8 +1759,10 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
await this.writeLabels(agent.id, labels);
|
||||
await this.runLifecycleMutation(agentId, async () => {
|
||||
const agent = this.requireAgent(agentId);
|
||||
await this.writeLabels(agent.id, labels);
|
||||
});
|
||||
}
|
||||
|
||||
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
||||
@@ -1754,6 +1804,14 @@ export class AgentManager {
|
||||
record: StoredAgentRecord;
|
||||
live: boolean;
|
||||
previousParentAgentId: string | null;
|
||||
}> {
|
||||
return this.runLifecycleMutation(agentId, () => this.detachAgentUnlocked(agentId));
|
||||
}
|
||||
|
||||
private async detachAgentUnlocked(agentId: string): Promise<{
|
||||
record: StoredAgentRecord;
|
||||
live: boolean;
|
||||
previousParentAgentId: string | null;
|
||||
}> {
|
||||
const registry = this.requireRegistry();
|
||||
const liveAgent = this.agents.get(agentId);
|
||||
@@ -1768,7 +1826,7 @@ export class AgentManager {
|
||||
return { record, live: true, previousParentAgentId: null };
|
||||
}
|
||||
|
||||
const { record } = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
|
||||
const { record } = await this.writeLabels(agentId, detachedAgentLabelPatch(liveAgent.labels));
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
||||
}
|
||||
@@ -1784,7 +1842,7 @@ export class AgentManager {
|
||||
return { record, live: false, previousParentAgentId: null };
|
||||
}
|
||||
|
||||
const result = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
|
||||
const result = await this.writeLabels(agentId, detachedAgentLabelPatch(record.labels));
|
||||
if (!result.record) {
|
||||
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
||||
}
|
||||
@@ -1890,6 +1948,18 @@ export class AgentManager {
|
||||
title?: string;
|
||||
labels?: Record<string, string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.runLifecycleMutation(agentId, () =>
|
||||
this.updateAgentMetadataUnlocked(agentId, updates),
|
||||
);
|
||||
}
|
||||
|
||||
private async updateAgentMetadataUnlocked(
|
||||
agentId: string,
|
||||
updates: {
|
||||
title?: string;
|
||||
labels?: Record<string, string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const liveAgent = this.getAgent(agentId);
|
||||
if (liveAgent) {
|
||||
@@ -1905,6 +1975,24 @@ export class AgentManager {
|
||||
await this.writeStoredMetadata(agentId, updates);
|
||||
}
|
||||
|
||||
private async runLifecycleMutation<T>(agentId: string, mutation: () => Promise<T>): Promise<T> {
|
||||
// Parent cascade classifies a child inside the same lane used by open-tab
|
||||
// label writes, so a received ownership update cannot be overtaken.
|
||||
const previous = this.lifecycleMutationTails.get(agentId) ?? Promise.resolve();
|
||||
const result = previous.catch(() => undefined).then(mutation);
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.lifecycleMutationTails.set(agentId, tail);
|
||||
void tail.finally(() => {
|
||||
if (this.lifecycleMutationTails.get(agentId) === tail) {
|
||||
this.lifecycleMutationTails.delete(agentId);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async runAgent(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0-beta.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
@@ -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+",
|
||||
|
||||
Reference in New Issue
Block a user