mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +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
|
# 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
|
## 0.3.1 - 2026-08-09
|
||||||
|
|
||||||
### Added
|
### 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.
|
- **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.
|
- **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.
|
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.
|
`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.
|
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`
|
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
|
||||||
3. Notify subscribers
|
3. Notify subscribers
|
||||||
4. Close the runtime (kills the process if still running)
|
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.
|
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 **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.
|
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.
|
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
|
## 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:
|
Each agent is a single JSON file. Fields relevant to this doc:
|
||||||
|
|
||||||
| Field | Type | Meaning |
|
| Field | Type | Meaning |
|
||||||
| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
| -------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- |
|
||||||
| `id` | `string` | Stable identifier |
|
| `id` | `string` | Stable identifier |
|
||||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
| `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.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach |
|
||||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
| `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.
|
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 |
|
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||||
| `title` | `string?` | User-visible title |
|
| `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`. |
|
| `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 |
|
| `lastModeId` | `string?` | Last active mode ID |
|
||||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
| `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",
|
"name": "paseo",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -36091,7 +36091,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -37139,12 +37139,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.3.1",
|
"@getpaseo/client": "0.4.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.3.1",
|
"@getpaseo/protocol": "0.4.0-beta.2",
|
||||||
"@getpaseo/server": "0.3.1",
|
"@getpaseo/server": "0.4.0-beta.2",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37391,10 +37391,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.3.1",
|
"@getpaseo/protocol": "0.4.0-beta.2",
|
||||||
"@getpaseo/relay": "0.3.1",
|
"@getpaseo/relay": "0.4.0-beta.2",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37405,7 +37405,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -37648,7 +37648,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -38544,7 +38544,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -38573,7 +38573,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -38586,7 +38586,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38804,15 +38804,15 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.3.1",
|
"@getpaseo/client": "0.4.0-beta.2",
|
||||||
"@getpaseo/highlight": "0.3.1",
|
"@getpaseo/highlight": "0.4.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.3.1",
|
"@getpaseo/protocol": "0.4.0-beta.2",
|
||||||
"@getpaseo/relay": "0.3.1",
|
"@getpaseo/relay": "0.4.0-beta.2",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -39360,7 +39360,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
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 { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
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";
|
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";
|
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
interface UseMountedTabSetInput {
|
interface UseMountedTabSetInput {
|
||||||
@@ -469,12 +465,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
|
|||||||
await expect(visiblePanel).toBeVisible();
|
await expect(visiblePanel).toBeVisible();
|
||||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
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 expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
|
||||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
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 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");
|
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
|
||||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||||
await workingDiffLayoutToggle.click();
|
await workingDiffLayoutToggle.click();
|
||||||
@@ -490,12 +486,14 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
|
|||||||
);
|
);
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
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 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 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 expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||||
|
|
||||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
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 changesTabToggle.click();
|
||||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
|
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 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 }) => {
|
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",
|
"none",
|
||||||
);
|
);
|
||||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
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);
|
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
|
const folderLabelBounds = await page
|
||||||
.getByTestId("diff-folder-src")
|
.getByTestId("diff-folder-src")
|
||||||
.getByText("src", { exact: true })
|
.getByText("src", { exact: true })
|
||||||
@@ -563,7 +576,7 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
|||||||
.boundingBox();
|
.boundingBox();
|
||||||
expect(folderLabelBounds).not.toBeNull();
|
expect(folderLabelBounds).not.toBeNull();
|
||||||
expect(fileLabelBounds).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");
|
const folderToggle = page.getByTestId("diff-folder-src-toggle");
|
||||||
await folderToggle.click();
|
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 page.getByRole("button", { name: "Collapse all" }).click();
|
||||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "Expand all" }).click();
|
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 page.getByTestId("diff-folder-src-toggle").click();
|
||||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
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);
|
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> {
|
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ settingsKey, fontSize }) => {
|
({ 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> {
|
async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ preferencesKey }) => {
|
({ preferencesKey }) => {
|
||||||
@@ -770,22 +693,21 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
|||||||
codeLineHeight: number;
|
codeLineHeight: number;
|
||||||
}[];
|
}[];
|
||||||
}> {
|
}> {
|
||||||
return diffGroups(page).evaluateAll((groups) => {
|
return page.locator("body").evaluate(({ ownerDocument }) => {
|
||||||
if (groups.length === 0) {
|
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
|
||||||
|
if (!root) {
|
||||||
throw new Error("Expanded diff body is not mounted");
|
throw new Error("Expanded diff body is not mounted");
|
||||||
}
|
}
|
||||||
|
|
||||||
const readRows = (prefix: string, textPrefix: string) =>
|
const readRows = (prefix: string, textPrefix: string) =>
|
||||||
groups.flatMap((group) =>
|
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
|
||||||
Array.from(group.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
|
const testId = row.getAttribute("data-testid") ?? "";
|
||||||
const testId = row.getAttribute("data-testid") ?? "";
|
const index = Number(testId.slice(prefix.length));
|
||||||
const index = Number(testId.slice(prefix.length));
|
const rect = row.getBoundingClientRect();
|
||||||
const rect = row.getBoundingClientRect();
|
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
|
||||||
const text = row.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
|
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
|
||||||
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
|
return { index, top: rect.top, height: rect.height, lineHeight };
|
||||||
return { index, top: rect.top, height: rect.height, lineHeight };
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const gutters = new Map(
|
const gutters = new Map(
|
||||||
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
|
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> {
|
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({
|
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
@@ -928,9 +850,15 @@ async function returnToWorkspaceChanges(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
||||||
const groups = diffGroups(page);
|
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
|
||||||
await expect.poll(() => groups.count()).toBeGreaterThan(1);
|
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
|
||||||
const lastMountedGroup = groups.last();
|
if (rows.length === 0) {
|
||||||
await lastMountedGroup.scrollIntoViewIfNeeded();
|
throw new Error("No unwrapped code rows are mounted");
|
||||||
await expect(lastMountedGroup.locator('[data-testid^="diff-code-row-"]').last()).toBeVisible();
|
}
|
||||||
|
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 layoutToggle.click();
|
||||||
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||||
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
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 page.getByTestId(/^workspace-commit-diff-close-/).click();
|
||||||
await expect(panel).toHaveCount(0);
|
await expect(panel).toHaveCount(0);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
|
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
import { getServerId } from "../support/helpers/server-id";
|
||||||
|
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||||
|
|
||||||
const MINIMAL_PNG = Buffer.from(
|
const MINIMAL_PNG = Buffer.from(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
"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("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
const serverId = getServerId();
|
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 page.getByTestId("files-new-folder").click();
|
||||||
await expect(nameInput).toBeVisible();
|
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 nameInput.press("Tab");
|
||||||
await expect(nameInput).toBeHidden();
|
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(
|
const fileRow = entry("created.txt").locator(
|
||||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
"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);
|
await expect(fileRow.locator("svg")).toHaveCount(1);
|
||||||
const gitRow = entry(".git").locator(
|
const gitRow = entry(".git").locator(
|
||||||
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
|
||||||
);
|
);
|
||||||
const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox();
|
const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox();
|
||||||
const expandedChevronBounds = await folderRow.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(collapsedChevronBounds).not.toBeNull();
|
||||||
expect(expandedChevronBounds).not.toBeNull();
|
expect(expandedChevronBounds).not.toBeNull();
|
||||||
|
expect(collapsedChevronSlotBounds).not.toBeNull();
|
||||||
|
expect(expandedChevronSlotBounds).not.toBeNull();
|
||||||
const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2;
|
const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2;
|
||||||
const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2;
|
const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2;
|
||||||
expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0);
|
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 folderLabelBounds = await entry("folder").boundingBox();
|
||||||
const fileLabelBounds = await entry("created.txt").boundingBox();
|
const fileLabelBounds = await entry("created.txt").boundingBox();
|
||||||
expect(folderLabelBounds).not.toBeNull();
|
expect(folderLabelBounds).not.toBeNull();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -73,30 +73,32 @@ export function AgentProfileRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={rowStyle} testID={`agent-profile-row-${profile.id}`}>
|
<View style={rowStyle} testID={`agent-profile-row-${profile.id}`}>
|
||||||
<View style={styles.iconWrapper}>
|
<View style={styles.rowMain}>
|
||||||
<AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} />
|
<View style={styles.iconWrapper}>
|
||||||
</View>
|
<AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} />
|
||||||
<View style={settingsStyles.rowContent}>
|
|
||||||
<View style={styles.titleLine}>
|
|
||||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
|
||||||
{profile.name}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.summary} numberOfLines={1}>
|
|
||||||
{summary}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
{profile.notes ? (
|
<View style={settingsStyles.rowContent}>
|
||||||
<View style={styles.notes}>
|
<View style={styles.titleLine}>
|
||||||
<ThemedFileText size={ICON_SIZE.xs} uniProps={mutedColorMapping} />
|
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||||
<Text
|
{profile.name}
|
||||||
style={styles.notesText}
|
</Text>
|
||||||
numberOfLines={2}
|
<Text style={styles.summary} numberOfLines={1}>
|
||||||
testID={`agent-profile-notes-${profile.id}`}
|
{summary}
|
||||||
>
|
|
||||||
{profile.notes}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
||||||
<View style={styles.rowActions}>
|
<View style={styles.rowActions}>
|
||||||
<Button
|
<Button
|
||||||
@@ -145,12 +147,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingVertical: theme.spacing[3],
|
paddingVertical: theme.spacing[3],
|
||||||
},
|
},
|
||||||
|
rowMain: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
iconWrapper: {
|
iconWrapper: {
|
||||||
width: theme.iconSize.md,
|
width: theme.iconSize.md,
|
||||||
alignSelf: "flex-start",
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
marginTop: theme.spacing[1],
|
marginTop: 1,
|
||||||
},
|
},
|
||||||
titleLine: {
|
titleLine: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -1,75 +1,58 @@
|
|||||||
import { useState, useCallback, useEffect, useId } from "react";
|
import { useState, useCallback, useEffect, useId, useRef } from "react";
|
||||||
import { type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
|
||||||
import { ScrollView } from "react-native-gesture-handler";
|
|
||||||
import {
|
import {
|
||||||
createAnimatedComponent,
|
type LayoutChangeEvent,
|
||||||
useAnimatedScrollHandler,
|
type NativeSyntheticEvent,
|
||||||
useSharedValue,
|
type NativeScrollEvent,
|
||||||
type SharedValue,
|
type StyleProp,
|
||||||
} from "react-native-reanimated";
|
type ViewStyle,
|
||||||
import { scheduleOnRN } from "react-native-worklets";
|
} from "react-native";
|
||||||
|
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||||
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
||||||
|
|
||||||
const AnimatedScrollView = createAnimatedComponent(ScrollView);
|
|
||||||
|
|
||||||
interface DiffScrollProps {
|
interface DiffScrollProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
scrollViewWidth: number;
|
||||||
onScrollViewWidthChange: (width: number) => void;
|
onScrollViewWidthChange: (width: number) => void;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||||
offset: SharedValue<number>;
|
|
||||||
testID?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiffScroll({
|
export function DiffScroll({
|
||||||
children,
|
children,
|
||||||
|
scrollViewWidth: _scrollViewWidth,
|
||||||
onScrollViewWidthChange,
|
onScrollViewWidthChange,
|
||||||
style,
|
style,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
offset,
|
|
||||||
testID,
|
|
||||||
}: DiffScrollProps) {
|
}: DiffScrollProps) {
|
||||||
|
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
const scrollId = useId();
|
const scrollId = useId();
|
||||||
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||||
const isAtLeftEdgeValue = useSharedValue(true);
|
|
||||||
|
|
||||||
const closeGestureRef = useFileExplorerCloseGestureRef();
|
const closeGestureRef = useFileExplorerCloseGestureRef();
|
||||||
|
|
||||||
// Register/unregister scroll offset tracking
|
// Register/unregister scroll offset tracking
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!horizontalScroll) return;
|
if (!horizontalScroll) return;
|
||||||
|
// Start at 0 (not scrolled)
|
||||||
horizontalScroll.registerScrollOffset(scrollId, 0);
|
horizontalScroll.registerScrollOffset(scrollId, 0);
|
||||||
return () => {
|
return () => {
|
||||||
horizontalScroll.unregisterScrollOffset(scrollId);
|
horizontalScroll.unregisterScrollOffset(scrollId);
|
||||||
};
|
};
|
||||||
}, [horizontalScroll, scrollId]);
|
}, [horizontalScroll, scrollId]);
|
||||||
|
|
||||||
const recordScrollOffset = useCallback(
|
const handleScroll = useCallback(
|
||||||
(offsetX: number) => {
|
(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) {
|
if (horizontalScroll) {
|
||||||
horizontalScroll.registerScrollOffset(scrollId, offsetX);
|
horizontalScroll.registerScrollOffset(scrollId, offsetX);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[horizontalScroll, scrollId],
|
[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(
|
const handleLayout = useCallback(
|
||||||
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
|
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
|
||||||
@@ -77,7 +60,8 @@ export function DiffScroll({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedScrollView
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
@@ -87,7 +71,6 @@ export function DiffScroll({
|
|||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
onLayout={handleLayout}
|
onLayout={handleLayout}
|
||||||
testID={testID}
|
|
||||||
// When at left edge, wait for close gesture to fail before scrolling.
|
// When at left edge, wait for close gesture to fail before scrolling.
|
||||||
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
|
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
|
||||||
// so scrolling left works normally. On rightward swipes, close gesture
|
// so scrolling left works normally. On rightward swipes, close gesture
|
||||||
@@ -95,6 +78,6 @@ export function DiffScroll({
|
|||||||
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</AnimatedScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
import { useCallback, useId, useLayoutEffect, useState } from "react";
|
import { useCallback } from "react";
|
||||||
import {
|
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
||||||
type LayoutChangeEvent,
|
|
||||||
type NativeScrollEvent,
|
|
||||||
type NativeSyntheticEvent,
|
|
||||||
type StyleProp,
|
|
||||||
type ViewStyle,
|
|
||||||
} from "react-native";
|
|
||||||
import { ScrollView } from "react-native-gesture-handler";
|
|
||||||
import type { SharedValue } from "react-native-reanimated";
|
|
||||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
|
||||||
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
|
||||||
|
|
||||||
interface DiffScrollProps {
|
interface DiffScrollProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
scrollViewWidth: number;
|
||||||
onScrollViewWidthChange: (width: number) => void;
|
onScrollViewWidthChange: (width: number) => void;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||||
offset: SharedValue<number>;
|
|
||||||
testID?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiffScroll({
|
export function DiffScroll({
|
||||||
@@ -25,31 +14,9 @@ export function DiffScroll({
|
|||||||
onScrollViewWidthChange,
|
onScrollViewWidthChange,
|
||||||
style,
|
style,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
offset,
|
|
||||||
testID,
|
|
||||||
}: DiffScrollProps) {
|
}: 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(
|
const handleLayout = useCallback(
|
||||||
(event: LayoutChangeEvent) => onScrollViewWidthChange(event.nativeEvent.layout.width),
|
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
|
||||||
[onScrollViewWidthChange],
|
[onScrollViewWidthChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -58,14 +25,9 @@ export function DiffScroll({
|
|||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
bounces={false}
|
|
||||||
style={style}
|
style={style}
|
||||||
contentContainerStyle={contentContainerStyle}
|
contentContainerStyle={contentContainerStyle}
|
||||||
onScroll={handleScroll}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
onLayout={handleLayout}
|
onLayout={handleLayout}
|
||||||
testID={testID}
|
|
||||||
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -27,15 +27,7 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"
|
|||||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import {
|
import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native";
|
||||||
ChevronDown,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
FilePlus,
|
|
||||||
Folder,
|
|
||||||
FolderPlus,
|
|
||||||
RotateCw,
|
|
||||||
} from "lucide-react-native";
|
|
||||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||||
import {
|
import {
|
||||||
TreeChevron,
|
TreeChevron,
|
||||||
@@ -89,7 +81,6 @@ const SORT_OPTIONS: { value: SortOption }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||||
const ThemedFolder = withUnistyles(Folder);
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
});
|
||||||
@@ -213,12 +204,9 @@ function EntryNameInputRow({
|
|||||||
<View style={[styles.entryRow, { paddingLeft: treeRowPaddingLeft(depth) }]}>
|
<View style={[styles.entryRow, { paddingLeft: treeRowPaddingLeft(depth) }]}>
|
||||||
<TreeIndentGuides depth={depth} />
|
<TreeIndentGuides depth={depth} />
|
||||||
<View style={styles.entryInfo}>
|
<View style={styles.entryInfo}>
|
||||||
<View style={styles.entryChevron}>
|
|
||||||
{kind === "directory" ? <TreeChevron expanded={false} /> : null}
|
|
||||||
</View>
|
|
||||||
<View style={styles.entryIcon}>
|
<View style={styles.entryIcon}>
|
||||||
{kind === "directory" ? (
|
{kind === "directory" ? (
|
||||||
<ThemedFolder size={WORKSPACE_TREE_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
|
<TreeChevron expanded={false} />
|
||||||
) : (
|
) : (
|
||||||
<MaterialFileIcon fileName={name || "untitled"} size={WORKSPACE_TREE_ICON_SIZE} />
|
<MaterialFileIcon fileName={name || "untitled"} size={WORKSPACE_TREE_ICON_SIZE} />
|
||||||
)}
|
)}
|
||||||
@@ -237,6 +225,7 @@ function EntryNameInputRow({
|
|||||||
}
|
}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
|
placeholderTextColor={styles.draftPlaceholder.color}
|
||||||
style={styles.draftInput}
|
style={styles.draftInput}
|
||||||
selectTextOnFocus={Boolean(initialName)}
|
selectTextOnFocus={Boolean(initialName)}
|
||||||
testID="file-explorer-name-input"
|
testID="file-explorer-name-input"
|
||||||
@@ -381,15 +370,9 @@ function TreeRowItem({
|
|||||||
>
|
>
|
||||||
<TreeIndentGuides depth={depth} />
|
<TreeIndentGuides depth={depth} />
|
||||||
<View ref={dragSourceRef} style={styles.entryInfo}>
|
<View ref={dragSourceRef} style={styles.entryInfo}>
|
||||||
<View style={styles.entryChevron}>
|
|
||||||
{isDirectory ? <DirectoryChevronIcon loading={loading} expanded={isExpanded} /> : null}
|
|
||||||
</View>
|
|
||||||
<View style={styles.entryIcon}>
|
<View style={styles.entryIcon}>
|
||||||
{isDirectory ? (
|
{isDirectory ? (
|
||||||
<ThemedFolder
|
<DirectoryChevronIcon loading={loading} expanded={isExpanded} />
|
||||||
size={WORKSPACE_TREE_ICON_SIZE}
|
|
||||||
uniProps={foregroundMutedColorMapping}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} />
|
<MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} />
|
||||||
)}
|
)}
|
||||||
@@ -1772,13 +1755,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: WORKSPACE_TREE_ICON_LABEL_GAP,
|
gap: WORKSPACE_TREE_ICON_LABEL_GAP,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
entryChevron: {
|
|
||||||
width: WORKSPACE_TREE_ICON_SIZE,
|
|
||||||
height: WORKSPACE_TREE_ICON_SIZE,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
entryIcon: {
|
entryIcon: {
|
||||||
width: WORKSPACE_TREE_ICON_SIZE,
|
width: WORKSPACE_TREE_ICON_SIZE,
|
||||||
height: WORKSPACE_TREE_ICON_SIZE,
|
height: WORKSPACE_TREE_ICON_SIZE,
|
||||||
@@ -1799,6 +1775,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
paddingHorizontal: 0,
|
paddingHorizontal: 0,
|
||||||
},
|
},
|
||||||
|
draftPlaceholder: {
|
||||||
|
color: theme.colors.foregroundExtraMuted,
|
||||||
|
},
|
||||||
contextMetaBlock: {
|
contextMetaBlock: {
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
|
|||||||
accessibilityLabel={t("modelSelector.editProfilesLabel")}
|
accessibilityLabel={t("modelSelector.editProfilesLabel")}
|
||||||
testID="model-profiles-edit"
|
testID="model-profiles-edit"
|
||||||
>
|
>
|
||||||
<ThemedPencil size={ICON_SIZE.sm} uniProps={foregroundExtraMutedMapping} />
|
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
@@ -675,7 +675,11 @@ function AgentProfilesPickerSection({
|
|||||||
<View style={styles.profilesContainer}>
|
<View style={styles.profilesContainer}>
|
||||||
<View style={styles.sectionHeading}>
|
<View style={styles.sectionHeading}>
|
||||||
<Text style={styles.sectionHeadingText}>{t("modelSelector.profiles")}</Text>
|
<Text style={styles.sectionHeadingText}>{t("modelSelector.profiles")}</Text>
|
||||||
{onEditProfiles ? <AgentProfilesEditAction onPress={onEditProfiles} /> : null}
|
{onEditProfiles ? (
|
||||||
|
<View style={styles.sectionHeadingAction}>
|
||||||
|
<AgentProfilesEditAction onPress={onEditProfiles} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<AgentProfilePickerRowView key={row.id} row={row} onApply={handleApply} />
|
<AgentProfilePickerRowView key={row.id} row={row} onApply={handleApply} />
|
||||||
@@ -1217,6 +1221,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.border,
|
backgroundColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
sectionHeading: {
|
sectionHeading: {
|
||||||
|
position: "relative",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
@@ -1224,6 +1229,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingTop: theme.spacing[2],
|
paddingTop: theme.spacing[2],
|
||||||
paddingBottom: theme.spacing[1],
|
paddingBottom: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
sectionHeadingAction: {
|
||||||
|
position: "absolute",
|
||||||
|
top: theme.spacing[1],
|
||||||
|
right: isWeb ? theme.spacing[3] : theme.spacing[6],
|
||||||
|
},
|
||||||
sectionHeadingText: {
|
sectionHeadingText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.xs,
|
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
|
// 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
|
// indentation, guide lines, and chevron. Keep those here so the two trees can't
|
||||||
// drift apart.
|
// drift apart.
|
||||||
export const TREE_INDENT_PER_LEVEL = 14;
|
export const TREE_INDENT_PER_LEVEL = 16;
|
||||||
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1];
|
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
|
||||||
export const WORKSPACE_TREE_ICON_SIZE = 14;
|
export const WORKSPACE_TREE_ICON_SIZE = 16;
|
||||||
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 12;
|
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
|
||||||
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[1];
|
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
|
||||||
/**
|
/**
|
||||||
* Trailing glyph rail shared with the explorer X and Changes options chevron.
|
* 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
|
* The extra 2px is optical: text ink ends inside its layout box, while the
|
||||||
* header icons' strokes extend to theirs.
|
* 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
|
/** 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. */
|
* in the Changes tree so their indentation can't drift apart. */
|
||||||
export function treeRowPaddingLeft(depth: number): number {
|
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) => ({
|
const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
|
||||||
@@ -46,7 +46,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
|
|||||||
key: index,
|
key: index,
|
||||||
style: [
|
style: [
|
||||||
styles.indentGuide,
|
styles.indentGuide,
|
||||||
inlineUnistylesStyle({ left: SPACING[2] + index * TREE_INDENT_PER_LEVEL + 3 }),
|
inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
[depth],
|
[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 { useCallback, useMemo } from "react";
|
||||||
import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native";
|
import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Folder } from "lucide-react-native";
|
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
import {
|
||||||
TreeChevron,
|
TreeChevron,
|
||||||
@@ -18,9 +17,6 @@ import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
|||||||
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
|
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const ThemedFolder = withUnistyles(Folder);
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
|
||||||
|
|
||||||
interface DiffFolderRowProps {
|
interface DiffFolderRowProps {
|
||||||
/** full uncompressed directory path — the collapse identity */
|
/** full uncompressed directory path — the collapse identity */
|
||||||
dirPath: string;
|
dirPath: string;
|
||||||
@@ -145,15 +141,9 @@ export function DiffFolderRow({
|
|||||||
testID={testID ? `${testID}-toggle` : undefined}
|
testID={testID ? `${testID}-toggle` : undefined}
|
||||||
>
|
>
|
||||||
<View style={leftStyle}>
|
<View style={leftStyle}>
|
||||||
<View style={styles.chevronOpticalOffset}>
|
<View style={styles.chevronSlot}>
|
||||||
<TreeChevron expanded={!collapsed} />
|
<TreeChevron expanded={!collapsed} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.folderIcon}>
|
|
||||||
<ThemedFolder
|
|
||||||
size={WORKSPACE_TREE_ICON_SIZE}
|
|
||||||
uniProps={foregroundMutedColorMapping}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.folderName} numberOfLines={1}>
|
<Text style={styles.folderName} numberOfLines={1}>
|
||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -204,13 +194,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
chevronOpticalOffset: {
|
chevronSlot: {
|
||||||
width: WORKSPACE_TREE_ICON_SIZE,
|
|
||||||
height: WORKSPACE_TREE_ICON_SIZE,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
folderIcon: {
|
|
||||||
width: WORKSPACE_TREE_ICON_SIZE,
|
width: WORKSPACE_TREE_ICON_SIZE,
|
||||||
height: WORKSPACE_TREE_ICON_SIZE,
|
height: WORKSPACE_TREE_ICON_SIZE,
|
||||||
alignItems: "center",
|
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: "وكيل إعادة التحميل...",
|
reloadingAgent: "وكيل إعادة التحميل...",
|
||||||
reloadedAgent: "وكيل إعادة تحميل",
|
reloadedAgent: "وكيل إعادة تحميل",
|
||||||
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
|
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
|
||||||
|
failedToCloseAgent: "فشل في إغلاق الوكيل",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
unsavedTitle: "تغييرات غير محفوظة",
|
unsavedTitle: "تغييرات غير محفوظة",
|
||||||
|
|||||||
@@ -613,6 +613,7 @@ export const en = {
|
|||||||
reloadingAgent: "Reloading agent...",
|
reloadingAgent: "Reloading agent...",
|
||||||
reloadedAgent: "Reloaded agent",
|
reloadedAgent: "Reloaded agent",
|
||||||
failedToReloadAgent: "Failed to reload agent",
|
failedToReloadAgent: "Failed to reload agent",
|
||||||
|
failedToCloseAgent: "Failed to close agent",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
close: "Close",
|
close: "Close",
|
||||||
|
|||||||
@@ -619,6 +619,7 @@ export const es: TranslationResources = {
|
|||||||
reloadingAgent: "Agente de recarga...",
|
reloadingAgent: "Agente de recarga...",
|
||||||
reloadedAgent: "Agente recargado",
|
reloadedAgent: "Agente recargado",
|
||||||
failedToReloadAgent: "No se pudo recargar el agente",
|
failedToReloadAgent: "No se pudo recargar el agente",
|
||||||
|
failedToCloseAgent: "No se pudo cerrar el agente",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
unsavedTitle: "Cambios sin guardar",
|
unsavedTitle: "Cambios sin guardar",
|
||||||
|
|||||||
@@ -619,6 +619,7 @@ export const fr: TranslationResources = {
|
|||||||
reloadingAgent: "Agent de rechargement...",
|
reloadingAgent: "Agent de rechargement...",
|
||||||
reloadedAgent: "Agent rechargé",
|
reloadedAgent: "Agent rechargé",
|
||||||
failedToReloadAgent: "Échec du rechargement de l'agent",
|
failedToReloadAgent: "Échec du rechargement de l'agent",
|
||||||
|
failedToCloseAgent: "Échec de la fermeture de l'agent",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
unsavedTitle: "Modifications non enregistrées",
|
unsavedTitle: "Modifications non enregistrées",
|
||||||
|
|||||||
@@ -619,6 +619,7 @@ export const ja: TranslationResources = {
|
|||||||
reloadingAgent: "エージェントを再読み込み中...",
|
reloadingAgent: "エージェントを再読み込み中...",
|
||||||
reloadedAgent: "エージェントを再読み込みしました",
|
reloadedAgent: "エージェントを再読み込みしました",
|
||||||
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
|
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
|
||||||
|
failedToCloseAgent: "エージェントを閉じられませんでした",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
unsavedTitle: "未保存の変更",
|
unsavedTitle: "未保存の変更",
|
||||||
|
|||||||
@@ -616,6 +616,7 @@ export const ko: TranslationResources = {
|
|||||||
reloadingAgent: "에이전트 다시 로드 중...",
|
reloadingAgent: "에이전트 다시 로드 중...",
|
||||||
reloadedAgent: "에이전트를 다시 로드했습니다",
|
reloadedAgent: "에이전트를 다시 로드했습니다",
|
||||||
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
|
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
|
||||||
|
failedToCloseAgent: "에이전트를 닫지 못했습니다",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
close: "닫기",
|
close: "닫기",
|
||||||
|
|||||||
@@ -618,6 +618,7 @@ export const ptBR: TranslationResources = {
|
|||||||
reloadingAgent: "Recarregando agente...",
|
reloadingAgent: "Recarregando agente...",
|
||||||
reloadedAgent: "Agente recarregado",
|
reloadedAgent: "Agente recarregado",
|
||||||
failedToReloadAgent: "Falha ao recarregar agente",
|
failedToReloadAgent: "Falha ao recarregar agente",
|
||||||
|
failedToCloseAgent: "Falha ao fechar agente",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
unsavedTitle: "Alterações não salvas",
|
unsavedTitle: "Alterações não salvas",
|
||||||
|
|||||||
@@ -618,6 +618,7 @@ export const ru: TranslationResources = {
|
|||||||
reloadingAgent: "Перезагрузка агента...",
|
reloadingAgent: "Перезагрузка агента...",
|
||||||
reloadedAgent: "Перезагруженный агент",
|
reloadedAgent: "Перезагруженный агент",
|
||||||
failedToReloadAgent: "Не удалось перезагрузить агент",
|
failedToReloadAgent: "Не удалось перезагрузить агент",
|
||||||
|
failedToCloseAgent: "Не удалось закрыть агент",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
unsavedTitle: "Несохранённые изменения",
|
unsavedTitle: "Несохранённые изменения",
|
||||||
|
|||||||
@@ -614,6 +614,7 @@ export const zhCN: TranslationResources = {
|
|||||||
reloadingAgent: "正在重新加载 Agent...",
|
reloadingAgent: "正在重新加载 Agent...",
|
||||||
reloadedAgent: "已重新加载 Agent",
|
reloadedAgent: "已重新加载 Agent",
|
||||||
failedToReloadAgent: "重新加载 Agent 失败",
|
failedToReloadAgent: "重新加载 Agent 失败",
|
||||||
|
failedToCloseAgent: "关闭 Agent 失败",
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
unsavedTitle: "未保存的更改",
|
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[] = [
|
const nonMatchingCases: NonMatchingShortcutCase[] = [
|
||||||
{
|
{
|
||||||
name: "does not keep old Mod+Alt+N binding",
|
name: "does not keep old Mod+Alt+N binding",
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ interface ShortcutWhen {
|
|||||||
terminal?: false;
|
terminal?: false;
|
||||||
/** false = disabled when command center is open */
|
/** false = disabled when command center is open */
|
||||||
commandCenter?: false;
|
commandCenter?: false;
|
||||||
/** Exact focus scope match */
|
/** Allowed focus scope or scopes */
|
||||||
focusScope?: KeyboardFocusScope;
|
focusScope?: KeyboardFocusScope | readonly KeyboardFocusScope[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShortcutPayloadDef =
|
type ShortcutPayloadDef =
|
||||||
@@ -1042,7 +1042,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
|||||||
id: "agent-interrupt",
|
id: "agent-interrupt",
|
||||||
action: "agent.interrupt",
|
action: "agent.interrupt",
|
||||||
combo: "Escape",
|
combo: "Escape",
|
||||||
when: { commandCenter: false, terminal: false },
|
when: { commandCenter: false, focusScope: ["message-input", "other"] },
|
||||||
preventDefault: false,
|
preventDefault: false,
|
||||||
stopPropagation: false,
|
stopPropagation: false,
|
||||||
help: {
|
help: {
|
||||||
@@ -1215,7 +1215,14 @@ export function matchesKeyboardShortcutContext(
|
|||||||
}
|
}
|
||||||
if (when.terminal === false && context.focusScope === "terminal") return false;
|
if (when.terminal === false && context.focusScope === "terminal") return false;
|
||||||
if (when.commandCenter === false && context.commandCenterOpen) 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -260,7 +260,6 @@ export function InlineReviewGutterCell({
|
|||||||
onStartComment,
|
onStartComment,
|
||||||
style,
|
style,
|
||||||
actionTestID,
|
actionTestID,
|
||||||
testID,
|
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
reviewTarget: ReviewableDiffTarget | null | undefined;
|
reviewTarget: ReviewableDiffTarget | null | undefined;
|
||||||
@@ -271,7 +270,6 @@ export function InlineReviewGutterCell({
|
|||||||
onStartComment: (target: ReviewableDiffTarget) => void;
|
onStartComment: (target: ReviewableDiffTarget) => void;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
actionTestID?: string;
|
actionTestID?: string;
|
||||||
testID?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const canComment = Boolean(reviewTarget);
|
const canComment = Boolean(reviewTarget);
|
||||||
@@ -311,6 +309,7 @@ export function InlineReviewGutterCell({
|
|||||||
}
|
}
|
||||||
}, [isInteractionActive]);
|
}, [isInteractionActive]);
|
||||||
|
|
||||||
|
const pressableStyle = useCallback((): StyleProp<ViewStyle> => style, [style]);
|
||||||
const lineHeightStyle = useMemo<StyleProp<ViewStyle>>(
|
const lineHeightStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
() =>
|
() =>
|
||||||
lineHeight !== undefined
|
lineHeight !== undefined
|
||||||
@@ -319,15 +318,13 @@ export function InlineReviewGutterCell({
|
|||||||
[lineHeight],
|
[lineHeight],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pressableStyle = useCallback(
|
const labelStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
(): StyleProp<ViewStyle> => [
|
() => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive],
|
||||||
style,
|
[hasComments, lineHeightStyle],
|
||||||
styles.gutterInner,
|
);
|
||||||
styles.gutterLabel,
|
const innerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
lineHeightStyle,
|
() => [styles.gutterInner, lineHeightStyle],
|
||||||
hasComments && styles.gutterLabelActive,
|
[lineHeightStyle],
|
||||||
],
|
|
||||||
[hasComments, lineHeightStyle, style],
|
|
||||||
);
|
);
|
||||||
const actionIconStyle = useMemo<StyleProp<ViewStyle>>(
|
const actionIconStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
() => [
|
() => [
|
||||||
@@ -349,14 +346,17 @@ export function InlineReviewGutterCell({
|
|||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPressOut={handlePressOut}
|
onPressOut={handlePressOut}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
testID={testID}
|
|
||||||
>
|
>
|
||||||
{children}
|
<View style={innerStyle}>
|
||||||
{showAction ? (
|
<View style={labelStyle}>
|
||||||
<View style={actionIconStyle} testID={actionTestID}>
|
{children}
|
||||||
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
|
{showAction ? (
|
||||||
|
<View style={actionIconStyle} testID={actionTestID}>
|
||||||
|
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ describe("ReplicaCache", () => {
|
|||||||
version: number;
|
version: number;
|
||||||
hosts: Array<{ timeline: Record<string, unknown> | null }>;
|
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"]);
|
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"]);
|
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();
|
const storage = new MemoryStorage();
|
||||||
storage.values.set(
|
storage.values.set(
|
||||||
"@paseo:replica-cache",
|
"@paseo:replica-cache",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 1,
|
version: 3,
|
||||||
hosts: [
|
hosts: [
|
||||||
{
|
{
|
||||||
serverId: SERVER_ID,
|
serverId: SERVER_ID,
|
||||||
@@ -361,7 +361,7 @@ describe("ReplicaCache", () => {
|
|||||||
|
|
||||||
expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined();
|
expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined();
|
||||||
expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({
|
expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({
|
||||||
version: 3,
|
version: 4,
|
||||||
hosts: [],
|
hosts: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { isUnreconciledLocalUserMessage, type StreamItem } from "@/types/stream"
|
|||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
|
|
||||||
const STORAGE_KEY = "@paseo:replica-cache";
|
const STORAGE_KEY = "@paseo:replica-cache";
|
||||||
const CACHE_VERSION = 3;
|
const CACHE_VERSION = 4;
|
||||||
const PERSIST_DELAY_MS = 750;
|
const PERSIST_DELAY_MS = 750;
|
||||||
const MAX_TIMELINE_ITEMS = 50;
|
const MAX_TIMELINE_ITEMS = 50;
|
||||||
const MAX_CACHE_BYTES = 1024 * 1024;
|
const MAX_CACHE_BYTES = 1024 * 1024;
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ describe("workspace bulk close helpers", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(groups).toEqual({
|
expect(groups).toEqual({
|
||||||
agentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
|
archiveAgentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
|
||||||
|
layoutOnlyAgentTabs: [],
|
||||||
terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }],
|
terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }],
|
||||||
otherTabs: [
|
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", () => {
|
it("describes mixed destructive bulk close operations in the confirmation copy", () => {
|
||||||
const message = buildBulkCloseConfirmationMessage(
|
const message = buildBulkCloseConfirmationMessage(
|
||||||
classifyBulkClosableTabs([
|
classifyBulkClosableTabs([
|
||||||
@@ -106,6 +122,7 @@ describe("workspace bulk close helpers", () => {
|
|||||||
closeWorkspaceTabWithCleanup: (input) => {
|
closeWorkspaceTabWithCleanup: (input) => {
|
||||||
cleanupCalls.push(input);
|
cleanupCalls.push(input);
|
||||||
},
|
},
|
||||||
|
closeLayoutOnlyAgent: async () => {},
|
||||||
logLabel: "all tabs",
|
logLabel: "all tabs",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,6 +169,7 @@ describe("workspace bulk close helpers", () => {
|
|||||||
closeWorkspaceTabWithCleanup: (input) => {
|
closeWorkspaceTabWithCleanup: (input) => {
|
||||||
cleanupCalls.push(input);
|
cleanupCalls.push(input);
|
||||||
},
|
},
|
||||||
|
closeLayoutOnlyAgent: async () => {},
|
||||||
warn,
|
warn,
|
||||||
logLabel: "others",
|
logLabel: "others",
|
||||||
});
|
});
|
||||||
@@ -166,4 +184,31 @@ describe("workspace bulk close helpers", () => {
|
|||||||
{ tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } },
|
{ 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";
|
import { i18n } from "@/i18n/i18next";
|
||||||
|
|
||||||
export interface BulkClosableTabGroups {
|
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 }>;
|
terminalTabs: Array<{ tabId: string; terminalId: string }>;
|
||||||
otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>;
|
otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>;
|
||||||
}
|
}
|
||||||
@@ -43,20 +44,30 @@ interface CloseBulkWorkspaceTabsInput {
|
|||||||
groups: BulkClosableTabGroups;
|
groups: BulkClosableTabGroups;
|
||||||
closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>;
|
closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>;
|
||||||
closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void;
|
closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void;
|
||||||
|
closeLayoutOnlyAgent: (agentId: string) => Promise<void>;
|
||||||
logLabel: string;
|
logLabel: string;
|
||||||
warn?: (message: string, payload: object) => void;
|
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 = {
|
const groups: BulkClosableTabGroups = {
|
||||||
agentTabs: [],
|
archiveAgentTabs: [],
|
||||||
|
layoutOnlyAgentTabs: [],
|
||||||
terminalTabs: [],
|
terminalTabs: [],
|
||||||
otherTabs: [],
|
otherTabs: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
if (tab.target.kind === "agent") {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (tab.target.kind === "terminal") {
|
if (tab.target.kind === "terminal") {
|
||||||
@@ -73,49 +84,73 @@ export function buildBulkCloseConfirmationMessage(
|
|||||||
input: BulkClosableTabGroups,
|
input: BulkClosableTabGroups,
|
||||||
labels: BulkCloseConfirmationLabels = DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS,
|
labels: BulkCloseConfirmationLabels = DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS,
|
||||||
): string {
|
): string {
|
||||||
const { agentTabs, terminalTabs, otherTabs } = input;
|
const { archiveAgentTabs, layoutOnlyAgentTabs, terminalTabs, otherTabs } = input;
|
||||||
if (agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0) {
|
const tabCount = layoutOnlyAgentTabs.length + otherTabs.length;
|
||||||
|
if (archiveAgentTabs.length > 0 && terminalTabs.length > 0 && tabCount > 0) {
|
||||||
return labels.all({
|
return labels.all({
|
||||||
agents: agentTabs.length,
|
agents: archiveAgentTabs.length,
|
||||||
terminals: terminalTabs.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({
|
return labels.agentsAndTerminals({
|
||||||
agents: agentTabs.length,
|
agents: archiveAgentTabs.length,
|
||||||
terminals: terminalTabs.length,
|
terminals: terminalTabs.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (terminalTabs.length > 0 && otherTabs.length > 0) {
|
if (terminalTabs.length > 0 && tabCount > 0) {
|
||||||
return labels.terminalsAndTabs({
|
return labels.terminalsAndTabs({
|
||||||
terminals: terminalTabs.length,
|
terminals: terminalTabs.length,
|
||||||
tabs: otherTabs.length,
|
tabs: tabCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (agentTabs.length > 0 && otherTabs.length > 0) {
|
if (archiveAgentTabs.length > 0 && tabCount > 0) {
|
||||||
return labels.agentsAndTabs({
|
return labels.agentsAndTabs({
|
||||||
agents: agentTabs.length,
|
agents: archiveAgentTabs.length,
|
||||||
tabs: otherTabs.length,
|
tabs: tabCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (terminalTabs.length > 0) {
|
if (terminalTabs.length > 0) {
|
||||||
return labels.terminals({ terminals: terminalTabs.length });
|
return labels.terminals({ terminals: terminalTabs.length });
|
||||||
}
|
}
|
||||||
if (otherTabs.length > 0) {
|
if (tabCount > 0) {
|
||||||
return labels.tabs({ tabs: otherTabs.length });
|
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> {
|
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
|
||||||
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
|
const {
|
||||||
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
|
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) {
|
if (hasDestructiveTabs && client) {
|
||||||
void client
|
void client
|
||||||
.closeItems({
|
.closeItems({
|
||||||
agentIds: groups.agentTabs.map((tab) => tab.agentId),
|
agentIds: groups.archiveAgentTabs.map((tab) => tab.agentId),
|
||||||
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -127,27 +162,27 @@ export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { tabId, agentId } of groups.agentTabs) {
|
await Promise.all([
|
||||||
void closeTab(tabId, async () => {
|
...groups.archiveAgentTabs.map(({ tabId, agentId }) =>
|
||||||
closeWorkspaceTabWithCleanup({
|
closeTab(tabId, async () => {
|
||||||
tabId,
|
closeWorkspaceTabWithCleanup({
|
||||||
target: { kind: "agent", agentId },
|
tabId,
|
||||||
});
|
target: { kind: "agent", agentId },
|
||||||
});
|
});
|
||||||
}
|
}),
|
||||||
|
),
|
||||||
for (const { tabId, terminalId } of groups.terminalTabs) {
|
...groups.terminalTabs.map(({ tabId, terminalId }) =>
|
||||||
void closeTab(tabId, async () => {
|
closeTab(tabId, async () => {
|
||||||
closeWorkspaceTabWithCleanup({
|
closeWorkspaceTabWithCleanup({
|
||||||
tabId,
|
tabId,
|
||||||
target: { kind: "terminal", terminalId },
|
target: { kind: "terminal", terminalId },
|
||||||
});
|
});
|
||||||
});
|
}),
|
||||||
}
|
),
|
||||||
|
...groups.otherTabs.map(({ tabId, target }) =>
|
||||||
for (const { tabId, target } of groups.otherTabs) {
|
closeTab(tabId, async () => {
|
||||||
void closeTab(tabId, async () => {
|
closeWorkspaceTabWithCleanup({ tabId, target });
|
||||||
closeWorkspaceTabWithCleanup({ tabId, target });
|
}),
|
||||||
});
|
),
|
||||||
}
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -66,6 +67,7 @@ import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-
|
|||||||
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
||||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import { getOrCreateClientId } from "@/utils/client-id";
|
||||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||||
import { traceInstant } from "@/performance/native-trace";
|
import { traceInstant } from "@/performance/native-trace";
|
||||||
@@ -112,6 +114,7 @@ import { getDesktopHost } from "@/desktop/host";
|
|||||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
|
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
|
||||||
|
import { useOpenAgentTabLabels } from "@/subagents/use-open-agent-tab-labels";
|
||||||
import {
|
import {
|
||||||
WorkspaceTabPresentationResolver,
|
WorkspaceTabPresentationResolver,
|
||||||
WorkspaceTabIcon,
|
WorkspaceTabIcon,
|
||||||
@@ -1970,6 +1973,12 @@ function WorkspaceScreenContent({
|
|||||||
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
|
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
|
||||||
[workspaceLayout],
|
[workspaceLayout],
|
||||||
);
|
);
|
||||||
|
useOpenAgentTabLabels({
|
||||||
|
client,
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
tabs: uiTabs,
|
||||||
|
enabled: hasHydratedWorkspaceLayoutStore,
|
||||||
|
});
|
||||||
useSyncWorkspaceActiveBrowser({
|
useSyncWorkspaceActiveBrowser({
|
||||||
workspaceLayout,
|
workspaceLayout,
|
||||||
isRouteFocused,
|
isRouteFocused,
|
||||||
@@ -2651,7 +2660,7 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
const agent =
|
const agent =
|
||||||
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||||
const closePolicy = resolveCloseAgentTabPolicy(agent);
|
let closePolicy = resolveCloseAgentTabPolicy(agent);
|
||||||
const isRunning = agent?.status === "running";
|
const isRunning = agent?.status === "running";
|
||||||
|
|
||||||
if (isRunning && closePolicy.kind === "archive-on-close") {
|
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));
|
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||||
if (persistenceKey) {
|
if (persistenceKey) {
|
||||||
closeWorkspaceTabWithCleanup({
|
closeWorkspaceTabWithCleanup({
|
||||||
@@ -2683,7 +2713,15 @@ function WorkspaceScreenContent({
|
|||||||
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
|
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t],
|
[
|
||||||
|
archiveAgent,
|
||||||
|
closeTab,
|
||||||
|
closeWorkspaceTabWithCleanup,
|
||||||
|
normalizedServerId,
|
||||||
|
persistenceKey,
|
||||||
|
t,
|
||||||
|
toast,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClosePassiveTab = useCallback(
|
const handleClosePassiveTab = useCallback(
|
||||||
@@ -2899,7 +2937,10 @@ function WorkspaceScreenContent({
|
|||||||
return;
|
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(
|
const modifiedCount = tabsToClose.filter(
|
||||||
(tab) =>
|
(tab) =>
|
||||||
getPanelInstanceAttributes({
|
getPanelInstanceAttributes({
|
||||||
@@ -2927,6 +2968,20 @@ function WorkspaceScreenContent({
|
|||||||
client,
|
client,
|
||||||
groups,
|
groups,
|
||||||
closeTab,
|
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) => {
|
closeWorkspaceTabWithCleanup: (cleanupInput) => {
|
||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
@@ -2943,6 +2998,7 @@ function WorkspaceScreenContent({
|
|||||||
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
archiveAgent,
|
||||||
bulkCloseConfirmationLabels,
|
bulkCloseConfirmationLabels,
|
||||||
client,
|
client,
|
||||||
closeTab,
|
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 { 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 { useTranslation } from "react-i18next";
|
||||||
import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native";
|
import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useIsCompactFormFactor, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { useIsCompactFormFactor, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
@@ -20,8 +21,6 @@ import {
|
|||||||
} from "./track-presentation";
|
} from "./track-presentation";
|
||||||
|
|
||||||
const ThemedArchive = withUnistyles(Archive);
|
const ThemedArchive = withUnistyles(Archive);
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
|
||||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
|
||||||
const ThemedUnlink = withUnistyles(Unlink);
|
const ThemedUnlink = withUnistyles(Unlink);
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
@@ -70,17 +69,11 @@ export function SubagentsTrack({
|
|||||||
[expanded],
|
[expanded],
|
||||||
);
|
);
|
||||||
|
|
||||||
const headerStyle = useCallback(
|
|
||||||
({ hovered, pressed }: PressableStateCallbackType) => [
|
|
||||||
styles.headerToggle,
|
|
||||||
(hovered || pressed) && styles.headerActive,
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const headerContainerStyle = useMemo(
|
const headerContainerStyle = useMemo(
|
||||||
() => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed],
|
() => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed],
|
||||||
[expanded],
|
[expanded],
|
||||||
);
|
);
|
||||||
|
const headerAccessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -94,22 +87,19 @@ export function SubagentsTrack({
|
|||||||
<View style={styles.track}>
|
<View style={styles.track}>
|
||||||
<View style={surfaceStyle}>
|
<View style={surfaceStyle}>
|
||||||
<View style={headerContainerStyle}>
|
<View style={headerContainerStyle}>
|
||||||
<Pressable
|
<Button
|
||||||
accessibilityRole="button"
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
accessibilityLabel={headerLabel}
|
accessibilityLabel={headerLabel}
|
||||||
|
accessibilityState={headerAccessibilityState}
|
||||||
testID="subagents-track-header"
|
testID="subagents-track-header"
|
||||||
onPress={toggleExpanded}
|
onPress={toggleExpanded}
|
||||||
style={headerStyle}
|
leftIcon={expanded ? ChevronDown : ChevronRight}
|
||||||
|
style={styles.headerToggle}
|
||||||
|
textStyle={styles.headerLabel}
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{headerLabel}
|
||||||
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
|
</Button>
|
||||||
) : (
|
|
||||||
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
|
|
||||||
)}
|
|
||||||
<Text style={styles.headerLabel} numberOfLines={1}>
|
|
||||||
{headerLabel}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
{finishedCount > 0 && onArchiveFinished ? (
|
{finishedCount > 0 && onArchiveFinished ? (
|
||||||
<View style={styles.headerAction}>
|
<View style={styles.headerAction}>
|
||||||
<SubagentActionButton
|
<SubagentActionButton
|
||||||
@@ -346,9 +336,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
headerToggle: {
|
headerToggle: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
flexDirection: "row",
|
justifyContent: "flex-start",
|
||||||
alignItems: "center",
|
borderRadius: 0,
|
||||||
gap: theme.spacing[2],
|
|
||||||
paddingLeft: theme.spacing[3],
|
paddingLeft: theme.spacing[3],
|
||||||
paddingRight: theme.spacing[1],
|
paddingRight: theme.spacing[1],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
@@ -359,9 +348,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
headerCollapsed: {
|
headerCollapsed: {
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
headerActive: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
headerDivider: {
|
headerDivider: {
|
||||||
borderBottomWidth: theme.borderWidth[1],
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
borderBottomColor: theme.colors.border,
|
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 { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
compactHighlightTokens,
|
|
||||||
formatDiffContentText,
|
formatDiffContentText,
|
||||||
formatDiffGutterText,
|
formatDiffGutterText,
|
||||||
hasVisibleDiffTokens,
|
hasVisibleDiffTokens,
|
||||||
@@ -29,23 +28,4 @@ describe("diff-rendering", () => {
|
|||||||
expect(hasVisibleDiffTokens([{ text: "" }])).toBe(false);
|
expect(hasVisibleDiffTokens([{ text: "" }])).toBe(false);
|
||||||
expect(hasVisibleDiffTokens([{ text: "const value = 1;" }])).toBe(true);
|
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;
|
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.
|
// Preserve row height when a gutter or diff cell is intentionally blank.
|
||||||
// Non-breaking space because the gutter <Text> uses numberOfLines={1}, which
|
// Non-breaking space because the gutter <Text> uses numberOfLines={1}, which
|
||||||
// collapses a plain ASCII space to zero height on web.
|
// collapses a plain ASCII space to zero height on web.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"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",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.3.1",
|
"@getpaseo/client": "0.4.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.3.1",
|
"@getpaseo/protocol": "0.4.0-beta.2",
|
||||||
"@getpaseo/server": "0.3.1",
|
"@getpaseo/server": "0.4.0-beta.2",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"description": "Paseo client SDK package",
|
"description": "Paseo client SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.3.1",
|
"@getpaseo/protocol": "0.4.0-beta.2",
|
||||||
"@getpaseo/relay": "0.3.1",
|
"@getpaseo/relay": "0.4.0-beta.2",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"homepage": "https://paseo.sh",
|
"homepage": "https://paseo.sh",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ExpoTwoWayAudio",
|
"ExpoTwoWayAudio",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"!dist/**/*.map"
|
"!dist/**/*.map"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"description": "Paseo shared protocol schemas and wire types",
|
"description": "Paseo shared protocol schemas and wire types",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
getParentAgentIdFromLabels,
|
getParentAgentIdFromLabels,
|
||||||
|
getOpenAgentTabLabel,
|
||||||
|
hasOpenAgentTab,
|
||||||
isDelegatedAgent,
|
isDelegatedAgent,
|
||||||
|
isOpenAgentTabLabel,
|
||||||
PARENT_AGENT_ID_LABEL,
|
PARENT_AGENT_ID_LABEL,
|
||||||
} from "./agent-labels.js";
|
} 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]: " " } })).toBe(false);
|
||||||
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } })).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";
|
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 {
|
export interface AgentLabelSource {
|
||||||
labels?: Record<string, unknown> | null;
|
labels?: Record<string, unknown> | null;
|
||||||
@@ -14,3 +23,9 @@ export function getParentAgentIdFromLabels(labels: Record<string, unknown> | nul
|
|||||||
export function isDelegatedAgent(agent: AgentLabelSource): boolean {
|
export function isDelegatedAgent(agent: AgentLabelSource): boolean {
|
||||||
return getParentAgentIdFromLabels(agent.labels) !== null;
|
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",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/server",
|
"dist/server",
|
||||||
@@ -71,10 +71,10 @@
|
|||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.3.1",
|
"@getpaseo/client": "0.4.0-beta.2",
|
||||||
"@getpaseo/highlight": "0.3.1",
|
"@getpaseo/highlight": "0.4.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.3.1",
|
"@getpaseo/protocol": "0.4.0-beta.2",
|
||||||
"@getpaseo/relay": "0.3.1",
|
"@getpaseo/relay": "0.4.0-beta.2",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "./agent-manager.js";
|
} from "./agent-manager.js";
|
||||||
import { AgentStorage } from "./agent-storage.js";
|
import { AgentStorage } from "./agent-storage.js";
|
||||||
import { toAgentPayload } from "./agent-projections.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 { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js";
|
||||||
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
|
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
|
||||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||||
@@ -41,6 +41,9 @@ import type {
|
|||||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||||
import type { ProviderDefinition } from "./provider-registry.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> {
|
interface Deferred<T> {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
resolve: (value: T) => void;
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-live-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
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: {
|
labels: {
|
||||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||||
|
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
|
||||||
|
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
|
||||||
team: "infra",
|
team: "infra",
|
||||||
},
|
},
|
||||||
workspaceId: undefined,
|
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" });
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-stored-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
@@ -3679,6 +3684,8 @@ test("detachAgent removes the parent label from a stored-only agent", async () =
|
|||||||
{
|
{
|
||||||
labels: {
|
labels: {
|
||||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||||
|
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
|
||||||
|
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
|
||||||
role: "reviewer",
|
role: "reviewer",
|
||||||
},
|
},
|
||||||
workspaceId: undefined,
|
workspaceId: undefined,
|
||||||
@@ -6724,6 +6731,182 @@ test("archiveAgent cascade archives in-memory children with the full archive con
|
|||||||
expect(storedUnrelated?.archivedAt).toBeUndefined();
|
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 () => {
|
test("archiveAgent cascade closes a running child runtime", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-running-child-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-running-child-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
} from "@getpaseo/protocol/agent-lifecycle";
|
} from "@getpaseo/protocol/agent-lifecycle";
|
||||||
import {
|
import {
|
||||||
getParentAgentIdFromLabels,
|
getParentAgentIdFromLabels,
|
||||||
|
hasOpenAgentTab,
|
||||||
isDelegatedAgent,
|
isDelegatedAgent,
|
||||||
|
isOpenAgentTabLabel,
|
||||||
PARENT_AGENT_ID_LABEL,
|
PARENT_AGENT_ID_LABEL,
|
||||||
} from "@getpaseo/protocol/agent-labels";
|
} from "@getpaseo/protocol/agent-labels";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
@@ -613,6 +615,27 @@ function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): str
|
|||||||
return null;
|
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 {
|
export class AgentManager {
|
||||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||||
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
||||||
@@ -631,6 +654,7 @@ export class AgentManager {
|
|||||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||||
private readonly agentRegistrationTasks = new Set<Promise<void>>();
|
private readonly agentRegistrationTasks = new Set<Promise<void>>();
|
||||||
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
|
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
|
||||||
|
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
|
||||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||||
private mcpBaseUrl: string | null;
|
private mcpBaseUrl: string | null;
|
||||||
private readonly mcpAuthToken: string | null;
|
private readonly mcpAuthToken: string | null;
|
||||||
@@ -1494,6 +1518,10 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
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);
|
const agent = this.requireAgent(agentId);
|
||||||
if (!this.registry) {
|
if (!this.registry) {
|
||||||
throw new Error("Agent storage is not configured");
|
throw new Error("Agent storage is not configured");
|
||||||
@@ -1527,6 +1555,10 @@ export class AgentManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const records = await registry.list();
|
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) {
|
for (const record of records) {
|
||||||
if (record.archivedAt) {
|
if (record.archivedAt) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1534,11 +1566,27 @@ export class AgentManager {
|
|||||||
if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (this.agents.has(record.id)) {
|
const child = await registry.get(record.id);
|
||||||
await this.archiveAgent(record.id);
|
if (!child || child.archivedAt || child.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
||||||
} else {
|
continue;
|
||||||
await this.archiveSnapshot(record.id, new Date().toISOString());
|
|
||||||
}
|
}
|
||||||
|
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> {
|
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
await this.runLifecycleMutation(agentId, async () => {
|
||||||
await this.writeLabels(agent.id, labels);
|
const agent = this.requireAgent(agentId);
|
||||||
|
await this.writeLabels(agent.id, labels);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
||||||
@@ -1754,6 +1804,14 @@ export class AgentManager {
|
|||||||
record: StoredAgentRecord;
|
record: StoredAgentRecord;
|
||||||
live: boolean;
|
live: boolean;
|
||||||
previousParentAgentId: string | null;
|
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 registry = this.requireRegistry();
|
||||||
const liveAgent = this.agents.get(agentId);
|
const liveAgent = this.agents.get(agentId);
|
||||||
@@ -1768,7 +1826,7 @@ export class AgentManager {
|
|||||||
return { record, live: true, previousParentAgentId: null };
|
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) {
|
if (!record) {
|
||||||
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
||||||
}
|
}
|
||||||
@@ -1784,7 +1842,7 @@ export class AgentManager {
|
|||||||
return { record, live: false, previousParentAgentId: null };
|
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) {
|
if (!result.record) {
|
||||||
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
||||||
}
|
}
|
||||||
@@ -1890,6 +1948,18 @@ export class AgentManager {
|
|||||||
title?: string;
|
title?: string;
|
||||||
labels?: Record<string, 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> {
|
): Promise<void> {
|
||||||
const liveAgent = this.getAgent(agentId);
|
const liveAgent = this.getAgent(agentId);
|
||||||
if (liveAgent) {
|
if (liveAgent) {
|
||||||
@@ -1905,6 +1975,24 @@ export class AgentManager {
|
|||||||
await this.writeStoredMetadata(agentId, updates);
|
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(
|
async runAgent(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
prompt: AgentPromptInput,
|
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", () => {
|
test("updates a registered child with its later native activity name", () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
@@ -3453,6 +3527,10 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
const liveEvents: AgentStreamEvent[] = [];
|
const liveEvents: AgentStreamEvent[] = [];
|
||||||
session.subscribe((event) => liveEvents.push(event));
|
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", {
|
asInternals(session).handleNotification("item/completed", {
|
||||||
threadId: "test-thread",
|
threadId: "test-thread",
|
||||||
item: {
|
item: {
|
||||||
@@ -3481,6 +3559,10 @@ describe("Codex app-server provider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
liveEvents.length = 0;
|
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", {
|
asInternals(session).handleNotification("item/agentMessage/delta", {
|
||||||
threadId: "legacy-child-thread",
|
threadId: "legacy-child-thread",
|
||||||
itemId: "legacy-child-message-after-resume",
|
itemId: "legacy-child-message-after-resume",
|
||||||
|
|||||||
@@ -1660,6 +1660,12 @@ interface CodexSubAgentActivity {
|
|||||||
kind: "started" | "interacted" | "interrupted";
|
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 {
|
function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null {
|
||||||
const record = toObjectRecord(item);
|
const record = toObjectRecord(item);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
@@ -4901,7 +4907,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
|
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
|
||||||
switch (parsed.kind) {
|
switch (parsed.kind) {
|
||||||
case "thread_started":
|
case "thread_started":
|
||||||
this.emitSubAgentActivityUpdate(callId, "running");
|
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
|
||||||
return;
|
return;
|
||||||
case "turn_started":
|
case "turn_started":
|
||||||
case "turn_completed":
|
case "turn_completed":
|
||||||
@@ -5174,10 +5180,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
this.emitSubAgentActivityUpdate(
|
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
|
||||||
callId,
|
if (activity.kind === "interrupted") {
|
||||||
activity.kind === "interrupted" ? "canceled" : "running",
|
nextStatus = "canceled";
|
||||||
);
|
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
|
||||||
|
nextStatus = undefined;
|
||||||
|
}
|
||||||
|
this.emitSubAgentActivityUpdate(callId, nextStatus);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5271,6 +5280,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private emitSubAgentActivityUpdate(
|
private emitSubAgentActivityUpdate(
|
||||||
callId: string,
|
callId: string,
|
||||||
status?: ToolCallTimelineItem["status"],
|
status?: ToolCallTimelineItem["status"],
|
||||||
|
options?: { reopen?: boolean },
|
||||||
): void {
|
): void {
|
||||||
const state = this.subAgentCallsByCallId.get(callId);
|
const state = this.subAgentCallsByCallId.get(callId);
|
||||||
if (!state || state.toolCall.detail.type !== "sub_agent") {
|
if (!state || state.toolCall.detail.type !== "sub_agent") {
|
||||||
@@ -5281,7 +5291,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
childTimeline.length > 0
|
childTimeline.length > 0
|
||||||
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
|
? 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) {
|
for (const childThreadId of state.childThreadIds) {
|
||||||
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
|
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
|
||||||
}
|
}
|
||||||
@@ -5420,7 +5437,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.pendingCommandOutputDeltas.delete(itemId);
|
this.pendingCommandOutputDeltas.delete(itemId);
|
||||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||||
}
|
}
|
||||||
this.emitSubAgentActivityUpdate(callId, "running");
|
this.emitSubAgentActivityUpdate(callId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSubAgentContextCompactionItem(
|
private handleSubAgentContextCompactionItem(
|
||||||
@@ -5556,7 +5573,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
): void {
|
): void {
|
||||||
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||||
if (subAgentCallId) {
|
if (subAgentCallId) {
|
||||||
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
|
this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.currentTurnId = parsed.turnId;
|
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", () => {
|
it("maps mcp read_file completion with detail", () => {
|
||||||
const item = expectMapped(
|
const item = expectMapped(
|
||||||
mapCodexToolCallFromThreadItem(
|
mapCodexToolCallFromThreadItem(
|
||||||
|
|||||||
@@ -569,10 +569,17 @@ function readStatus(value: unknown): string | undefined {
|
|||||||
|
|
||||||
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
|
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
|
||||||
const normalized = status.trim().toLowerCase();
|
const normalized = status.trim().toLowerCase();
|
||||||
if (normalized === "error" || normalized === "errored") {
|
switch (normalized) {
|
||||||
return "running";
|
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(
|
function resolveCollabAgentStatus(
|
||||||
|
|||||||
@@ -480,6 +480,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
|||||||
models: {
|
models: {
|
||||||
"big-pickle": {
|
"big-pickle": {
|
||||||
name: "Big Pickle",
|
name: "Big Pickle",
|
||||||
|
variants: { max: { reasoningEffort: "max" } },
|
||||||
limit: {
|
limit: {
|
||||||
context: 200_000,
|
context: 200_000,
|
||||||
},
|
},
|
||||||
@@ -529,6 +530,11 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
|||||||
expect(catalog.models[0]).toMatchObject({
|
expect(catalog.models[0]).toMatchObject({
|
||||||
id: TEST_MODEL,
|
id: TEST_MODEL,
|
||||||
label: "Big Pickle",
|
label: "Big Pickle",
|
||||||
|
thinkingOptions: [
|
||||||
|
{ id: "default", label: "Default", isDefault: true },
|
||||||
|
{ id: "max", label: "max" },
|
||||||
|
],
|
||||||
|
defaultThinkingOptionId: "default",
|
||||||
metadata: {
|
metadata: {
|
||||||
providerId: "opencode",
|
providerId: "opencode",
|
||||||
modelId: "big-pickle",
|
modelId: "big-pickle",
|
||||||
@@ -824,7 +830,40 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
|||||||
}, 180_000);
|
}, 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", () => {
|
test("builds OpenCode file parts for image prompt blocks", () => {
|
||||||
expect(
|
expect(
|
||||||
__openCodeInternals.buildOpenCodePromptParts([
|
__openCodeInternals.buildOpenCodePromptParts([
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
|
|
||||||
const OPENCODE_BUILD_MODE_ID = "build";
|
const OPENCODE_BUILD_MODE_ID = "build";
|
||||||
const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access";
|
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_AUTO_ACCEPT_FEATURE_ID = "auto_accept";
|
||||||
const OPENCODE_PERSISTED_SESSION_LIMIT = 200;
|
const OPENCODE_PERSISTED_SESSION_LIMIT = 200;
|
||||||
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
|
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
|
||||||
@@ -580,6 +581,14 @@ function normalizeOpenCodeModeId(modeId: string | null | undefined): string | nu
|
|||||||
return trimmed;
|
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 {
|
function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string | undefined {
|
||||||
const normalizedModeId = normalizeOpenCodeModeId(modeId);
|
const normalizedModeId = normalizeOpenCodeModeId(modeId);
|
||||||
if (normalizedModeId === null) {
|
if (normalizedModeId === null) {
|
||||||
@@ -591,15 +600,19 @@ function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConfig {
|
function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConfig {
|
||||||
if (normalizeOpenCodeModeId(config.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
|
const normalized = {
|
||||||
return { ...config };
|
...config,
|
||||||
|
thinkingOptionId: normalizeOpenCodeVariantId(config.thinkingOptionId) ?? undefined,
|
||||||
|
};
|
||||||
|
if (normalizeOpenCodeModeId(normalized.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...normalized,
|
||||||
modeId: OPENCODE_BUILD_MODE_ID,
|
modeId: OPENCODE_BUILD_MODE_ID,
|
||||||
featureValues: {
|
featureValues: {
|
||||||
...config.featureValues,
|
...normalized.featureValues,
|
||||||
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
|
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -723,11 +736,13 @@ function buildOpenCodeModelDefinition(
|
|||||||
},
|
},
|
||||||
): AgentModelDefinition {
|
): AgentModelDefinition {
|
||||||
const rawVariants = model.variants ? Object.keys(model.variants) : [];
|
const rawVariants = model.variants ? Object.keys(model.variants) : [];
|
||||||
const thinkingOptions = rawVariants.map((id, index) => ({
|
// OpenCode lists only overrides; its base model behavior is selected by omitting `variant`.
|
||||||
id,
|
const thinkingOptions = rawVariants.length
|
||||||
label: id,
|
? [
|
||||||
isDefault: index === 0,
|
{ id: OPENCODE_DEFAULT_VARIANT_ID, label: "Default", isDefault: true },
|
||||||
}));
|
...rawVariants.map((id) => ({ id, label: id })),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
@@ -3195,10 +3210,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||||
const normalizedThinkingOptionId =
|
const normalizedThinkingOptionId = normalizeOpenCodeVariantId(thinkingOptionId);
|
||||||
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
|
|
||||||
? thinkingOptionId
|
|
||||||
: null;
|
|
||||||
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
|
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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
|
// 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.
|
// the patch's node_modules/... paths relative to its working directory.
|
||||||
const patchedPackages = [
|
const patchedPackages = [
|
||||||
{
|
|
||||||
nodeModulesPath: "node_modules/@react-native/virtualized-lists",
|
|
||||||
patchPrefix: "@react-native+virtualized-lists+",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
nodeModulesPath: "node_modules/react-native-markdown-display",
|
nodeModulesPath: "node_modules/react-native-markdown-display",
|
||||||
patchPrefix: "react-native-markdown-display+",
|
patchPrefix: "react-native-markdown-display+",
|
||||||
|
|||||||
Reference in New Issue
Block a user