mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d08ddba94 |
@@ -1,56 +1,5 @@
|
|||||||
# 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
|
||||||
|
|||||||
+5
-18
@@ -44,21 +44,9 @@ 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 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.
|
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.
|
||||||
|
|
||||||
`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.
|
||||||
@@ -86,7 +74,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. **Resolve children** — detach cross-workspace and open-tab children; cascade-archive the rest recursively
|
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
|
||||||
|
|
||||||
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
||||||
|
|
||||||
@@ -121,7 +109,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 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.
|
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`).
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -167,7 +155,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 relationship lifecycle labels, 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 parent label, 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
|
||||||
|
|
||||||
@@ -202,11 +190,10 @@ $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 |
|
||||||
| `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` |
|
| `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 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) |
|
| `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) |
|
||||||
| `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-zO0mNouwY+thgefl2zB/Lk8pIZGh0QowC8klXll2qOc=
|
sha256-L73o8u6OsY3SsA5t2s5mWsMsMNc3qr5bCE4cd1NpbgI=
|
||||||
|
|||||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.4.0-beta.2",
|
"@getpaseo/client": "0.3.1",
|
||||||
"@getpaseo/protocol": "0.4.0-beta.2",
|
"@getpaseo/protocol": "0.3.1",
|
||||||
"@getpaseo/server": "0.4.0-beta.2",
|
"@getpaseo/server": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.4.0-beta.2",
|
"@getpaseo/protocol": "0.3.1",
|
||||||
"@getpaseo/relay": "0.4.0-beta.2",
|
"@getpaseo/relay": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"@getpaseo/client": "0.3.1",
|
||||||
"@getpaseo/highlight": "0.4.0-beta.2",
|
"@getpaseo/highlight": "0.3.1",
|
||||||
"@getpaseo/protocol": "0.4.0-beta.2",
|
"@getpaseo/protocol": "0.3.1",
|
||||||
"@getpaseo/relay": "0.4.0-beta.2",
|
"@getpaseo/relay": "0.3.1",
|
||||||
"@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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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 Page } from "@playwright/test";
|
import { type Locator, 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,6 +63,10 @@ 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 {
|
||||||
@@ -465,12 +469,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(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
await expect(diffGroups(visiblePanel).first()).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(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
|
await expect(diffGroups(visiblePanel)).toHaveCount(0);
|
||||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
await expect(diffGroups(visiblePanel).first()).toBeVisible();
|
||||||
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
|
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();
|
||||||
@@ -486,14 +490,12 @@ 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+-body$/)).toHaveCount(0);
|
await expect(visiblePanel.getByTestId(/^diff-file-\d+-group-\d+$/)).toHaveCount(0);
|
||||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
await expect(diffGroups(visiblePanel).first()).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||||
await expect(
|
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
|
||||||
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);
|
||||||
@@ -516,13 +518,9 @@ 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(
|
await expect(diffGroups(page.getByTestId("explorer-content-area")).first()).toBeVisible();
|
||||||
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(
|
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
|
||||||
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 }) => {
|
||||||
@@ -553,19 +551,8 @@ 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(1);
|
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(2);
|
||||||
await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
|
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 })
|
||||||
@@ -576,7 +563,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(16, 0);
|
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(14, 0);
|
||||||
|
|
||||||
const folderToggle = page.getByTestId("diff-folder-src-toggle");
|
const folderToggle = page.getByTestId("diff-folder-src-toggle");
|
||||||
await folderToggle.click();
|
await folderToggle.click();
|
||||||
@@ -596,7 +583,7 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
|||||||
await page.getByRole("button", { name: "Collapse all" }).click();
|
await 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(page.getByTestId("diff-file-0-body")).toBeVisible();
|
await expect(diffGroups(page).first()).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);
|
||||||
@@ -620,6 +607,90 @@ 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 }) => {
|
||||||
@@ -645,6 +716,12 @@ async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readMountedDiffGroupIds(page: Page): Promise<(string | null)[]> {
|
||||||
|
return diffGroups(page).evaluateAll((groups) =>
|
||||||
|
groups.map((group) => group.getAttribute("data-testid")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ preferencesKey }) => {
|
({ preferencesKey }) => {
|
||||||
@@ -693,21 +770,22 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
|||||||
codeLineHeight: number;
|
codeLineHeight: number;
|
||||||
}[];
|
}[];
|
||||||
}> {
|
}> {
|
||||||
return page.locator("body").evaluate(({ ownerDocument }) => {
|
return diffGroups(page).evaluateAll((groups) => {
|
||||||
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
|
if (groups.length === 0) {
|
||||||
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) =>
|
||||||
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
|
groups.flatMap((group) =>
|
||||||
|
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]),
|
||||||
@@ -811,7 +889,7 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
await expect(diffGroups(page).first()).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
@@ -850,15 +928,9 @@ async function returnToWorkspaceChanges(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
||||||
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
|
const groups = diffGroups(page);
|
||||||
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
|
await expect.poll(() => groups.count()).toBeGreaterThan(1);
|
||||||
if (rows.length === 0) {
|
const lastMountedGroup = groups.last();
|
||||||
throw new Error("No unwrapped code rows are mounted");
|
await lastMountedGroup.scrollIntoViewIfNeeded();
|
||||||
}
|
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-body")).toBeVisible();
|
await expect(panel.getByTestId(/^diff-file-0-group-\d+$/).first()).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,7 +37,6 @@ 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==",
|
||||||
@@ -295,28 +294,6 @@ test.describe("Composer attachments", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Escape cancels file creation without interrupting the running agent", async ({ page }) => {
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
const agent = await startRunningMockAgent(page, {
|
|
||||||
prefix: "file-create-escape-",
|
|
||||||
model: "one-minute-stream",
|
|
||||||
prompt: "Stay running while a file draft is cancelled.",
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await openFileExplorer(page);
|
|
||||||
await page.getByTestId("files-new-file").click();
|
|
||||||
const nameInput = page.getByTestId("file-explorer-name-input");
|
|
||||||
await expect(nameInput).toBeVisible();
|
|
||||||
|
|
||||||
await nameInput.press("Escape");
|
|
||||||
|
|
||||||
await expect(nameInput).toBeHidden();
|
|
||||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
|
||||||
} finally {
|
|
||||||
await agent.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
test("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,14 +124,6 @@ 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();
|
||||||
|
|
||||||
@@ -197,38 +189,18 @@ 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(1);
|
await expect(folderRow.locator("svg")).toHaveCount(2);
|
||||||
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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ 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.rowMain}>
|
|
||||||
<View style={styles.iconWrapper}>
|
<View style={styles.iconWrapper}>
|
||||||
<AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} />
|
<AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} />
|
||||||
</View>
|
</View>
|
||||||
@@ -99,7 +98,6 @@ export function AgentProfileRow({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
<View style={styles.rowActions}>
|
<View style={styles.rowActions}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -147,17 +145,12 @@ 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: 1,
|
marginTop: theme.spacing[1],
|
||||||
},
|
},
|
||||||
titleLine: {
|
titleLine: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
|||||||
@@ -1,58 +1,75 @@
|
|||||||
import { useState, useCallback, useEffect, useId, useRef } from "react";
|
import { useState, useCallback, useEffect, useId } from "react";
|
||||||
|
import { type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
import { ScrollView } from "react-native-gesture-handler";
|
||||||
import {
|
import {
|
||||||
type LayoutChangeEvent,
|
createAnimatedComponent,
|
||||||
type NativeSyntheticEvent,
|
useAnimatedScrollHandler,
|
||||||
type NativeScrollEvent,
|
useSharedValue,
|
||||||
type StyleProp,
|
type SharedValue,
|
||||||
type ViewStyle,
|
} from "react-native-reanimated";
|
||||||
} from "react-native";
|
import { scheduleOnRN } from "react-native-worklets";
|
||||||
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 scrollViewRef = useRef<ScrollViewType>(null);
|
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||||
|
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 handleScroll = useCallback(
|
const recordScrollOffset = useCallback(
|
||||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
(offsetX: number) => {
|
||||||
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),
|
||||||
@@ -60,8 +77,7 @@ export function DiffScroll({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<AnimatedScrollView
|
||||||
ref={scrollViewRef}
|
|
||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
@@ -71,6 +87,7 @@ 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
|
||||||
@@ -78,6 +95,6 @@ export function DiffScroll({
|
|||||||
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollView>
|
</AnimatedScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useId, useLayoutEffect, useState } from "react";
|
||||||
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
import {
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
type NativeScrollEvent,
|
||||||
|
type NativeSyntheticEvent,
|
||||||
|
type StyleProp,
|
||||||
|
type ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
import { ScrollView } from "react-native-gesture-handler";
|
||||||
|
import type { SharedValue } from "react-native-reanimated";
|
||||||
|
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||||
|
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
|
||||||
|
|
||||||
interface DiffScrollProps {
|
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({
|
||||||
@@ -14,9 +25,31 @@ 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(
|
||||||
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
|
(event: LayoutChangeEvent) => onScrollViewWidthChange(event.nativeEvent.layout.width),
|
||||||
[onScrollViewWidthChange],
|
[onScrollViewWidthChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -25,9 +58,14 @@ 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,7 +27,15 @@ 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 { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native";
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
FilePlus,
|
||||||
|
Folder,
|
||||||
|
FolderPlus,
|
||||||
|
RotateCw,
|
||||||
|
} from "lucide-react-native";
|
||||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||||
import {
|
import {
|
||||||
TreeChevron,
|
TreeChevron,
|
||||||
@@ -81,6 +89,7 @@ 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,
|
||||||
});
|
});
|
||||||
@@ -204,9 +213,12 @@ 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" ? (
|
||||||
<TreeChevron expanded={false} />
|
<ThemedFolder size={WORKSPACE_TREE_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
|
||||||
) : (
|
) : (
|
||||||
<MaterialFileIcon fileName={name || "untitled"} size={WORKSPACE_TREE_ICON_SIZE} />
|
<MaterialFileIcon fileName={name || "untitled"} size={WORKSPACE_TREE_ICON_SIZE} />
|
||||||
)}
|
)}
|
||||||
@@ -225,7 +237,6 @@ 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"
|
||||||
@@ -370,9 +381,15 @@ 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 ? (
|
||||||
<DirectoryChevronIcon loading={loading} expanded={isExpanded} />
|
<ThemedFolder
|
||||||
|
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} />
|
||||||
)}
|
)}
|
||||||
@@ -1755,6 +1772,13 @@ 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,
|
||||||
@@ -1775,9 +1799,6 @@ 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.xs} uniProps={foregroundExtraMutedMapping} />
|
<ThemedPencil size={ICON_SIZE.sm} uniProps={foregroundExtraMutedMapping} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
@@ -675,11 +675,7 @@ 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 ? (
|
{onEditProfiles ? <AgentProfilesEditAction onPress={onEditProfiles} /> : null}
|
||||||
<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} />
|
||||||
@@ -1221,7 +1217,6 @@ 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],
|
||||||
@@ -1229,11 +1224,6 @@ 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 = 16;
|
export const TREE_INDENT_PER_LEVEL = 14;
|
||||||
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
|
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1];
|
||||||
export const WORKSPACE_TREE_ICON_SIZE = 16;
|
export const WORKSPACE_TREE_ICON_SIZE = 14;
|
||||||
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
|
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 12;
|
||||||
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
|
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[1];
|
||||||
/**
|
/**
|
||||||
* 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[4] + 2;
|
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[3] + 2;
|
||||||
|
|
||||||
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
|
/** 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[3] + depth * TREE_INDENT_PER_LEVEL;
|
return SPACING[2] + 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[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
|
inlineUnistylesStyle({ left: SPACING[2] + index * TREE_INDENT_PER_LEVEL + 3 }),
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
[depth],
|
[depth],
|
||||||
|
|||||||
@@ -88,6 +88,43 @@ describe("useDraftAgentCreateFlow", () => {
|
|||||||
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts one create request when submit is invoked twice before React rerenders", async () => {
|
||||||
|
let resolveCreate: ((value: { agentId: string; result: { id: string } }) => void) | undefined;
|
||||||
|
const createRequest = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<{ agentId: string; result: { id: string } }>((resolve) => {
|
||||||
|
resolveCreate = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const onCreateSuccess = vi.fn();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useDraftAgentCreateFlow({
|
||||||
|
draftId: "draft-1",
|
||||||
|
getPendingServerId: () => "server-1",
|
||||||
|
buildDraftAgent: (attempt) => ({ attempt }),
|
||||||
|
createRequest,
|
||||||
|
onCreateSuccess,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const submission = { text: "build this", attachments: [], cwd: "/repo" };
|
||||||
|
|
||||||
|
let first: Promise<void>;
|
||||||
|
let duplicate: Promise<void>;
|
||||||
|
act(() => {
|
||||||
|
first = result.current.handleCreateFromInput(submission);
|
||||||
|
duplicate = result.current.handleCreateFromInput(submission);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(duplicate!).rejects.toThrow();
|
||||||
|
expect(createRequest).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveCreate?.({ agentId: "agent-1", result: { id: "agent-1" } });
|
||||||
|
await first!;
|
||||||
|
});
|
||||||
|
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("allows retrying an empty prompt when the draft still has context attachments", async () => {
|
it("allows retrying an empty prompt when the draft still has context attachments", async () => {
|
||||||
const attachment = {
|
const attachment = {
|
||||||
kind: "chat_history",
|
kind: "chat_history",
|
||||||
|
|||||||
@@ -84,6 +84,17 @@ interface CreateRequestContext {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertCreateAvailable(input: {
|
||||||
|
draftId: string;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
alreadyLoadingMessage: string;
|
||||||
|
}): void {
|
||||||
|
const pending = useCreateFlowStore.getState().pendingByDraftId[input.draftId];
|
||||||
|
if (input.isSubmitting || pending?.lifecycle === "active") {
|
||||||
|
throw new Error(input.alreadyLoadingMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
||||||
draftId: string;
|
draftId: string;
|
||||||
getPendingServerId: () => string | null;
|
getPendingServerId: () => string | null;
|
||||||
@@ -239,9 +250,11 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
|
|
||||||
const handleCreateFromInput = useCallback(
|
const handleCreateFromInput = useCallback(
|
||||||
async ({ text, attachments, cwd }: SubmitContext) => {
|
async ({ text, attachments, cwd }: SubmitContext) => {
|
||||||
if (isSubmitting) {
|
assertCreateAvailable({
|
||||||
throw new Error(t("composer.errors.alreadyLoading"));
|
draftId,
|
||||||
}
|
isSubmitting,
|
||||||
|
alreadyLoadingMessage: t("composer.errors.alreadyLoading"),
|
||||||
|
});
|
||||||
|
|
||||||
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
||||||
const trimmedPrompt = text.trim();
|
const trimmedPrompt = text.trim();
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "./diff-flat-items";
|
|
||||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
|
||||||
|
|
||||||
function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
|
|
||||||
return { path, isNew: false, isDeleted: false, additions, deletions, hunks: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarize(items: DiffFlatItem[]): string[] {
|
|
||||||
return items.map((item) => {
|
|
||||||
if (item.type === "folder") {
|
|
||||||
return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
|
|
||||||
}
|
|
||||||
const base = item.file.path.split("/").pop();
|
|
||||||
return `${" ".repeat(item.depth)}${item.type === "body" ? "body:" : ""}${base}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("buildDiffFlatItems", () => {
|
|
||||||
const files = [createFile("src/app/a.ts"), createFile("src/app/nested/b.ts")];
|
|
||||||
|
|
||||||
it("emits a body and a sticky index for each expanded file", () => {
|
|
||||||
const { items, stickyHeaderIndices } = buildDiffFlatItems({
|
|
||||||
files: [createFile("a.ts"), createFile("b.ts")],
|
|
||||||
viewMode: "tree",
|
|
||||||
collapsedFolders: new Set(),
|
|
||||||
expandedPaths: new Set(["a.ts"]),
|
|
||||||
});
|
|
||||||
// Root-level files (no folder): header, body (for expanded a.ts), header
|
|
||||||
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
|
|
||||||
// sticky points at the header (index 0), not the body
|
|
||||||
expect(stickyHeaderIndices).toEqual([0]);
|
|
||||||
expect(items[0].type).toBe("header");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits the old flat list without folder rows or indentation", () => {
|
|
||||||
const { items, stickyHeaderIndices } = buildDiffFlatItems({
|
|
||||||
files,
|
|
||||||
viewMode: "flat",
|
|
||||||
collapsedFolders: new Set(),
|
|
||||||
expandedPaths: new Set(["src/app/a.ts"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
|
|
||||||
expect(stickyHeaderIndices).toEqual([0]);
|
|
||||||
expect(items.every((item) => item.type !== "folder")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("groups files under compressed folder rows, all expanded by default", () => {
|
|
||||||
const { items } = buildDiffFlatItems({
|
|
||||||
files,
|
|
||||||
viewMode: "tree",
|
|
||||||
collapsedFolders: new Set(),
|
|
||||||
expandedPaths: new Set(),
|
|
||||||
});
|
|
||||||
// dirs sort before files within a level: [nested] precedes a.ts
|
|
||||||
expect(summarize(items)).toEqual(["[src/app]", " [nested]", " b.ts", " a.ts"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapsing a folder hides its descendants but keeps the row", () => {
|
|
||||||
const { items } = buildDiffFlatItems({
|
|
||||||
files,
|
|
||||||
viewMode: "tree",
|
|
||||||
collapsedFolders: new Set(["src/app/nested"]),
|
|
||||||
expandedPaths: new Set(),
|
|
||||||
});
|
|
||||||
expect(summarize(items)).toEqual(["[src/app]", " [nested] (collapsed)", " a.ts"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapsing an ancestor hides everything below it", () => {
|
|
||||||
const { items } = buildDiffFlatItems({
|
|
||||||
files,
|
|
||||||
viewMode: "tree",
|
|
||||||
collapsedFolders: new Set(["src/app"]),
|
|
||||||
expandedPaths: new Set(),
|
|
||||||
});
|
|
||||||
expect(summarize(items)).toEqual(["[src/app] (collapsed)"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives sticky indices file-headers-only from the post-collapse list", () => {
|
|
||||||
// dirs-first: [src/app], [nested], b.ts, a.ts. Expanding a.ts puts its
|
|
||||||
// header at index 3, with the body right after.
|
|
||||||
const { items, stickyHeaderIndices } = buildDiffFlatItems({
|
|
||||||
files,
|
|
||||||
viewMode: "tree",
|
|
||||||
collapsedFolders: new Set(),
|
|
||||||
expandedPaths: new Set(["src/app/a.ts"]),
|
|
||||||
});
|
|
||||||
expect(summarize(items)).toEqual([
|
|
||||||
"[src/app]",
|
|
||||||
" [nested]",
|
|
||||||
" b.ts",
|
|
||||||
" a.ts",
|
|
||||||
" body:a.ts",
|
|
||||||
]);
|
|
||||||
expect(stickyHeaderIndices).toEqual([3]);
|
|
||||||
for (const idx of stickyHeaderIndices) {
|
|
||||||
expect(items[idx].type).toBe("header");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps tree file rows back to their original files array index", () => {
|
|
||||||
const { items } = buildDiffFlatItems({
|
|
||||||
files,
|
|
||||||
viewMode: "tree",
|
|
||||||
collapsedFolders: new Set(),
|
|
||||||
expandedPaths: new Set(),
|
|
||||||
});
|
|
||||||
// Tree order is b.ts (fileIndex 1) then a.ts (fileIndex 0)
|
|
||||||
const headers = items.filter((i) => i.type === "header");
|
|
||||||
expect(headers.map((h) => (h.type === "header" ? h.fileIndex : -1))).toEqual([1, 0]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sumHeightsBefore", () => {
|
|
||||||
const items = buildDiffFlatItems({
|
|
||||||
files: [createFile("src/a.ts"), createFile("src/b.ts")],
|
|
||||||
viewMode: "tree",
|
|
||||||
collapsedFolders: new Set(),
|
|
||||||
expandedPaths: new Set(),
|
|
||||||
}).items; // [folder, a.ts, b.ts]
|
|
||||||
|
|
||||||
const heightFor = (item: DiffFlatItem) => (item.type === "folder" ? 10 : 20);
|
|
||||||
|
|
||||||
it("sums the heights of items before the index, counting folder rows", () => {
|
|
||||||
expect(sumHeightsBefore(items, 0, heightFor)).toBe(0);
|
|
||||||
expect(sumHeightsBefore(items, 1, heightFor)).toBe(10); // folder above a.ts
|
|
||||||
expect(sumHeightsBefore(items, 2, heightFor)).toBe(30); // folder + a.ts above b.ts
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps an out-of-range index to the list length", () => {
|
|
||||||
expect(sumHeightsBefore(items, 999, heightFor)).toBe(50);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
|
||||||
import {
|
|
||||||
buildDiffTree,
|
|
||||||
compressSingleChildChains,
|
|
||||||
flattenDiffTree,
|
|
||||||
type DiffTreeDirNode,
|
|
||||||
} from "@/git/diff-tree";
|
|
||||||
|
|
||||||
// The row model for the Changes FlatList. `fileIndex` is the file's index within
|
|
||||||
// the (path-sorted) `files` array — a stable identity for testIDs/keys that is
|
|
||||||
// independent of folder rows and collapse state. `depth` drives indentation.
|
|
||||||
export type DiffFlatItem =
|
|
||||||
| {
|
|
||||||
type: "folder";
|
|
||||||
dirPath: string;
|
|
||||||
displayName: string;
|
|
||||||
depth: number;
|
|
||||||
collapsed: boolean;
|
|
||||||
additions: number;
|
|
||||||
deletions: number;
|
|
||||||
}
|
|
||||||
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean; depth: number }
|
|
||||||
| { type: "body"; file: ParsedDiffFile; fileIndex: number; depth: number };
|
|
||||||
|
|
||||||
export interface DiffFlatItemsResult {
|
|
||||||
items: DiffFlatItem[];
|
|
||||||
/** Indices into `items` of expanded file headers — folder rows are never sticky. */
|
|
||||||
stickyHeaderIndices: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BuildDiffFlatItemsInput {
|
|
||||||
files: ParsedDiffFile[];
|
|
||||||
viewMode: "flat" | "tree";
|
|
||||||
/** Full uncompressed directory paths that are collapsed (empty = all expanded). */
|
|
||||||
collapsedFolders: ReadonlySet<string>;
|
|
||||||
/** File paths whose diff body is expanded. */
|
|
||||||
expandedPaths: ReadonlySet<string>;
|
|
||||||
/**
|
|
||||||
* Pre-built compressed tree (from the same `files`). Pass it to avoid rebuilding
|
|
||||||
* the tree on every collapse toggle; omitted callers (tests) build it internally.
|
|
||||||
*/
|
|
||||||
tree?: DiffTreeDirNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the FlatList item model plus its sticky-header indices: files grouped
|
|
||||||
* under directory rows, single-child chains compressed, descendants of collapsed
|
|
||||||
* folders omitted.
|
|
||||||
*
|
|
||||||
* Sticky indices are derived from the FINAL (post-collapse) item list and only
|
|
||||||
* ever point at expanded FILE headers, so folder rows can't stack or collide in
|
|
||||||
* the sticky header stack.
|
|
||||||
*/
|
|
||||||
export function buildDiffFlatItems({
|
|
||||||
files,
|
|
||||||
viewMode,
|
|
||||||
collapsedFolders,
|
|
||||||
expandedPaths,
|
|
||||||
tree,
|
|
||||||
}: BuildDiffFlatItemsInput): DiffFlatItemsResult {
|
|
||||||
const items: DiffFlatItem[] = [];
|
|
||||||
const stickyHeaderIndices: number[] = [];
|
|
||||||
|
|
||||||
const pushFile = (file: ParsedDiffFile, fileIndex: number, depth: number): void => {
|
|
||||||
const isExpanded = expandedPaths.has(file.path);
|
|
||||||
items.push({ type: "header", file, fileIndex, isExpanded, depth });
|
|
||||||
if (isExpanded) {
|
|
||||||
stickyHeaderIndices.push(items.length - 1);
|
|
||||||
items.push({ type: "body", file, fileIndex, depth });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (viewMode === "flat") {
|
|
||||||
for (const [fileIndex, file] of files.entries()) {
|
|
||||||
pushFile(file, fileIndex, 0);
|
|
||||||
}
|
|
||||||
return { items, stickyHeaderIndices };
|
|
||||||
}
|
|
||||||
|
|
||||||
const indexByPath = new Map(files.map((file, index) => [file.path, index]));
|
|
||||||
const compressedTree = tree ?? compressSingleChildChains(buildDiffTree(files));
|
|
||||||
const rows = flattenDiffTree(compressedTree, collapsedFolders);
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
if (row.kind === "folder") {
|
|
||||||
items.push({
|
|
||||||
type: "folder",
|
|
||||||
dirPath: row.dirPath,
|
|
||||||
displayName: row.displayName,
|
|
||||||
depth: row.depth,
|
|
||||||
collapsed: collapsedFolders.has(row.dirPath),
|
|
||||||
additions: row.additions,
|
|
||||||
deletions: row.deletions,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const fileIndex = indexByPath.get(row.file.path);
|
|
||||||
if (fileIndex === undefined) {
|
|
||||||
// Should never happen: the tree is built from the same `files` array.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
pushFile(row.file, fileIndex, row.depth);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items, stickyHeaderIndices };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cumulative height of every item before `index`. Single source of truth for
|
|
||||||
* FlatList's getItemLayout AND the collapse scroll-anchor math. */
|
|
||||||
export function sumHeightsBefore(
|
|
||||||
items: DiffFlatItem[],
|
|
||||||
index: number,
|
|
||||||
heightFor: (item: DiffFlatItem) => number,
|
|
||||||
): number {
|
|
||||||
let offset = 0;
|
|
||||||
const end = Math.min(index, items.length);
|
|
||||||
for (let i = 0; i < end; i++) {
|
|
||||||
offset += heightFor(items[i]);
|
|
||||||
}
|
|
||||||
return Math.max(0, offset);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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 } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } 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,
|
||||||
@@ -17,6 +18,9 @@ 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;
|
||||||
@@ -141,9 +145,15 @@ export function DiffFolderRow({
|
|||||||
testID={testID ? `${testID}-toggle` : undefined}
|
testID={testID ? `${testID}-toggle` : undefined}
|
||||||
>
|
>
|
||||||
<View style={leftStyle}>
|
<View style={leftStyle}>
|
||||||
<View style={styles.chevronSlot}>
|
<View style={styles.chevronOpticalOffset}>
|
||||||
<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>
|
||||||
@@ -194,7 +204,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
chevronSlot: {
|
chevronOpticalOffset: {
|
||||||
|
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",
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||||
|
import { type LayoutChangeEvent, type StyleProp, type ViewStyle, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
type SharedValue,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { DiffScroll } from "@/components/diff-scroll";
|
||||||
|
|
||||||
|
const FILL: ViewStyle = { flex: 1 };
|
||||||
|
const CLIP: ViewStyle = { flex: 1, overflow: "hidden" };
|
||||||
|
|
||||||
|
const DiffHorizontalOffsetContext = createContext<SharedValue<number> | null>(null);
|
||||||
|
|
||||||
|
export function DiffHorizontalSurface({
|
||||||
|
children,
|
||||||
|
contentWidth,
|
||||||
|
viewportWidth,
|
||||||
|
onViewportWidthChange,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
contentWidth: number;
|
||||||
|
viewportWidth: number;
|
||||||
|
onViewportWidthChange: (width: number) => void;
|
||||||
|
}) {
|
||||||
|
const offset = useSharedValue(0);
|
||||||
|
const pinnedViewportStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: offset.value }],
|
||||||
|
}));
|
||||||
|
const contentContainerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
|
() => [{ flexGrow: 1 }, { minWidth: Math.max(viewportWidth, contentWidth) }],
|
||||||
|
[contentWidth, viewportWidth],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<DiffHorizontalOffsetContext.Provider value={offset}>
|
||||||
|
<DiffScroll
|
||||||
|
offset={offset}
|
||||||
|
onScrollViewWidthChange={onViewportWidthChange}
|
||||||
|
style={FILL}
|
||||||
|
contentContainerStyle={contentContainerStyle}
|
||||||
|
testID="diff-horizontal-scroll"
|
||||||
|
>
|
||||||
|
<Animated.View style={[FILL, { width: viewportWidth }, pinnedViewportStyle]}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</DiffScroll>
|
||||||
|
</DiffHorizontalOffsetContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffHorizontalContent({
|
||||||
|
children,
|
||||||
|
viewportStyle,
|
||||||
|
contentStyle,
|
||||||
|
onContentLayout,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
viewportStyle?: StyleProp<ViewStyle>;
|
||||||
|
contentStyle?: StyleProp<ViewStyle>;
|
||||||
|
onContentLayout?: (event: LayoutChangeEvent) => void;
|
||||||
|
testID?: string;
|
||||||
|
}) {
|
||||||
|
const offset = useContext(DiffHorizontalOffsetContext);
|
||||||
|
const translatedContentStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: -(offset?.value ?? 0) }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[CLIP, viewportStyle]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[contentStyle, translatedContentStyle]}
|
||||||
|
onLayout={onContentLayout}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
+416
-264
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||||
|
import {
|
||||||
|
buildDiffRowIndex,
|
||||||
|
createDiffRowCache,
|
||||||
|
DIFF_VIRTUALIZATION_CONFIG,
|
||||||
|
type DiffRowCache,
|
||||||
|
} from "./model";
|
||||||
|
|
||||||
|
function createFile(path: string, lineCount: number): ParsedDiffFile {
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
isNew: false,
|
||||||
|
isDeleted: false,
|
||||||
|
additions: lineCount,
|
||||||
|
deletions: 0,
|
||||||
|
hunks: [
|
||||||
|
{
|
||||||
|
oldStart: 1,
|
||||||
|
oldCount: 0,
|
||||||
|
newStart: 1,
|
||||||
|
newCount: lineCount,
|
||||||
|
lines: [
|
||||||
|
{ type: "header", content: `@@ -1,0 +1,${lineCount} @@` },
|
||||||
|
...Array.from({ length: lineCount }, (_, index) => ({
|
||||||
|
type: "add" as const,
|
||||||
|
content: `line ${index + 1}`,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIndex(files: ParsedDiffFile[], expandedPaths: string[]) {
|
||||||
|
return buildDiffRowIndex({
|
||||||
|
files,
|
||||||
|
viewMode: "flat",
|
||||||
|
collapsedFolders: new Set(),
|
||||||
|
expandedPaths: new Set(expandedPaths),
|
||||||
|
layout: "unified",
|
||||||
|
wrapLines: false,
|
||||||
|
lineHeight: 18,
|
||||||
|
viewportWidth: 400,
|
||||||
|
codeFontSize: 12,
|
||||||
|
headerHeightFor: () => 44,
|
||||||
|
folderHeight: 44,
|
||||||
|
statusHeight: 64,
|
||||||
|
measuredGroupHeights: new Map(),
|
||||||
|
geometryKey: "test",
|
||||||
|
reviewHeightFor: () => 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsReviewedLine(group: {
|
||||||
|
lines: Array<{ reviewTarget?: { lineNumber?: number } | null }>;
|
||||||
|
}): boolean {
|
||||||
|
return group.lines.some((line) => line.reviewTarget?.lineNumber === 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeTree(items: ReturnType<typeof buildIndex>["items"]): string[] {
|
||||||
|
return items.map((item) => {
|
||||||
|
if (item.type === "folder") {
|
||||||
|
return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
|
||||||
|
}
|
||||||
|
const base = item.file.path.split("/").pop();
|
||||||
|
const isGroup = item.type === "unified_group" || item.type === "split_group";
|
||||||
|
return `${" ".repeat(item.depth)}${isGroup ? "group:" : ""}${base}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildDiffRowIndex", () => {
|
||||||
|
it("turns a large expanded file into bounded native-list cells", () => {
|
||||||
|
const index = buildIndex([createFile("large.ts", 1_200)], ["large.ts"]);
|
||||||
|
const groups = index.items.filter((item) => item.type === "unified_group");
|
||||||
|
|
||||||
|
expect(groups.length).toBeGreaterThan(1);
|
||||||
|
expect(Math.max(...groups.map((group) => group.lines.length))).toBeLessThanOrEqual(
|
||||||
|
DIFF_VIRTUALIZATION_CONFIG.groupRowLimit,
|
||||||
|
);
|
||||||
|
expect(groups.reduce((count, group) => count + group.lines.length, 0)).toBe(1_201);
|
||||||
|
expect(index.stickyHeaderIndices).toEqual([0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts wide glyphs and tabs conservatively for horizontal scroll extent", () => {
|
||||||
|
for (const [path, content] of [
|
||||||
|
["cjk.ts", "界".repeat(40)],
|
||||||
|
["emoji.ts", "😀".repeat(40)],
|
||||||
|
["tabs.ts", "\t".repeat(10)],
|
||||||
|
]) {
|
||||||
|
const file = createFile(path, 1);
|
||||||
|
file.hunks[0].lines[1].content = content;
|
||||||
|
|
||||||
|
const index = buildIndex([file], [file.path]);
|
||||||
|
const group = index.items.find((item) => item.type === "unified_group");
|
||||||
|
|
||||||
|
expect(group?.contentColumns).toBe(80);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("precomputes exact offsets and preserves total file height", () => {
|
||||||
|
const index = buildIndex(
|
||||||
|
[createFile("first.ts", 65), createFile("second.ts", 1)],
|
||||||
|
["first.ts"],
|
||||||
|
);
|
||||||
|
const firstGroups = index.items.filter(
|
||||||
|
(item) => item.type === "unified_group" && item.file.path === "first.ts",
|
||||||
|
);
|
||||||
|
const secondHeaderIndex = index.items.findIndex(
|
||||||
|
(item) => item.type === "header" && item.file.path === "second.ts",
|
||||||
|
);
|
||||||
|
const expectedBodyHeight = 66 * 18 + 2;
|
||||||
|
|
||||||
|
expect(firstGroups).toHaveLength(5);
|
||||||
|
expect(index.offsetAt(secondHeaderIndex)).toBe(44 + expectedBodyHeight);
|
||||||
|
expect(index.offsetOfFile("second.ts")).toBe(44 + expectedBodyHeight);
|
||||||
|
expect(index.totalHeight).toBe(44 + expectedBodyHeight + 44);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a review row whole and shifts every later offset by its height", () => {
|
||||||
|
const file = createFile("review.ts", 40);
|
||||||
|
const baseline = buildIndex([file], [file.path]);
|
||||||
|
const reviewed = buildDiffRowIndex({
|
||||||
|
files: [file],
|
||||||
|
viewMode: "flat",
|
||||||
|
collapsedFolders: new Set(),
|
||||||
|
expandedPaths: new Set([file.path]),
|
||||||
|
layout: "unified",
|
||||||
|
wrapLines: false,
|
||||||
|
lineHeight: 18,
|
||||||
|
viewportWidth: 400,
|
||||||
|
codeFontSize: 12,
|
||||||
|
headerHeightFor: () => 44,
|
||||||
|
folderHeight: 44,
|
||||||
|
statusHeight: 64,
|
||||||
|
measuredGroupHeights: new Map(),
|
||||||
|
geometryKey: "test",
|
||||||
|
reviewHeightFor: (target) => (target?.lineNumber === 10 ? 132 : 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reviewed.totalHeight - baseline.totalHeight).toBe(132);
|
||||||
|
const reviewGroup = reviewed.items
|
||||||
|
.filter((item) => item.type === "unified_group")
|
||||||
|
.find(containsReviewedLine);
|
||||||
|
expect(reviewGroup).toBeDefined();
|
||||||
|
expect(reviewed.heightAt(reviewed.items.indexOf(reviewGroup!))).toBeGreaterThanOrEqual(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves compressed tree order, indentation, and collapse state", () => {
|
||||||
|
const files = [createFile("src/app/a.ts", 1), createFile("src/app/nested/b.ts", 1)];
|
||||||
|
const index = buildDiffRowIndex({
|
||||||
|
files,
|
||||||
|
viewMode: "tree",
|
||||||
|
collapsedFolders: new Set(["src/app/nested"]),
|
||||||
|
expandedPaths: new Set(["src/app/a.ts"]),
|
||||||
|
layout: "unified",
|
||||||
|
wrapLines: false,
|
||||||
|
lineHeight: 18,
|
||||||
|
viewportWidth: 400,
|
||||||
|
codeFontSize: 12,
|
||||||
|
headerHeightFor: () => 44,
|
||||||
|
folderHeight: 44,
|
||||||
|
statusHeight: 64,
|
||||||
|
measuredGroupHeights: new Map(),
|
||||||
|
geometryKey: "tree",
|
||||||
|
reviewHeightFor: () => 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summarizeTree(index.items)).toEqual([
|
||||||
|
"[src/app]",
|
||||||
|
" [nested] (collapsed)",
|
||||||
|
" a.ts",
|
||||||
|
" group:a.ts",
|
||||||
|
]);
|
||||||
|
expect(index.stickyHeaderIndices).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps split groups bounded and sticky indices on headers", () => {
|
||||||
|
const file = createFile("split.ts", 100);
|
||||||
|
const index = buildDiffRowIndex({
|
||||||
|
files: [file],
|
||||||
|
viewMode: "flat",
|
||||||
|
collapsedFolders: new Set(),
|
||||||
|
expandedPaths: new Set([file.path]),
|
||||||
|
layout: "split",
|
||||||
|
wrapLines: false,
|
||||||
|
lineHeight: 18,
|
||||||
|
viewportWidth: 800,
|
||||||
|
codeFontSize: 12,
|
||||||
|
headerHeightFor: () => 44,
|
||||||
|
folderHeight: 44,
|
||||||
|
statusHeight: 64,
|
||||||
|
measuredGroupHeights: new Map(),
|
||||||
|
geometryKey: "split",
|
||||||
|
reviewHeightFor: () => 0,
|
||||||
|
});
|
||||||
|
const groups = index.items.filter((item) => item.type === "split_group");
|
||||||
|
|
||||||
|
expect(Math.max(...groups.map((group) => group.rows.length))).toBeLessThanOrEqual(
|
||||||
|
DIFF_VIRTUALIZATION_CONFIG.groupRowLimit,
|
||||||
|
);
|
||||||
|
expect(index.items[index.stickyHeaderIndices[0]].type).toBe("header");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates measured group geometry without deriving any file rows again", () => {
|
||||||
|
const delegate = createDiffRowCache();
|
||||||
|
let derivations = 0;
|
||||||
|
const rowCache: DiffRowCache = {
|
||||||
|
unifiedFor(file) {
|
||||||
|
derivations += 1;
|
||||||
|
return delegate.unifiedFor(file);
|
||||||
|
},
|
||||||
|
splitFor(file) {
|
||||||
|
derivations += 1;
|
||||||
|
return delegate.splitFor(file);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const files = [createFile("first.ts", 100), createFile("second.ts", 100)];
|
||||||
|
const index = buildDiffRowIndex({
|
||||||
|
files,
|
||||||
|
viewMode: "flat",
|
||||||
|
collapsedFolders: new Set(),
|
||||||
|
expandedPaths: new Set(files.map((file) => file.path)),
|
||||||
|
layout: "unified",
|
||||||
|
wrapLines: true,
|
||||||
|
lineHeight: 18,
|
||||||
|
viewportWidth: 400,
|
||||||
|
codeFontSize: 12,
|
||||||
|
headerHeightFor: () => 44,
|
||||||
|
folderHeight: 44,
|
||||||
|
statusHeight: 64,
|
||||||
|
measuredGroupHeights: new Map(),
|
||||||
|
geometryKey: "wrap",
|
||||||
|
reviewHeightFor: () => 0,
|
||||||
|
rowCache,
|
||||||
|
});
|
||||||
|
const group = index.items.find((item) => item.type === "unified_group");
|
||||||
|
expect(group).toBeDefined();
|
||||||
|
expect(derivations).toBe(2);
|
||||||
|
|
||||||
|
const groupIndex = index.indexOfGroup(group!.key);
|
||||||
|
const laterOffset = index.offsetAt(groupIndex + 1);
|
||||||
|
const update = index.updateHeight(group!.key, index.heightAt(groupIndex) + 20);
|
||||||
|
|
||||||
|
expect(update?.delta).toBe(20);
|
||||||
|
expect(index.offsetAt(groupIndex + 1)).toBe(laterOffset + 20);
|
||||||
|
expect(derivations).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the key's rounded viewport width for wrapped group membership", () => {
|
||||||
|
const file = createFile("wrapped.ts", 40);
|
||||||
|
for (const line of file.hunks[0].lines) {
|
||||||
|
if (line.type !== "header") {
|
||||||
|
line.content = "x".repeat(88);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const buildWrapped = (viewportWidth: number) =>
|
||||||
|
buildDiffRowIndex({
|
||||||
|
files: [file],
|
||||||
|
viewMode: "flat",
|
||||||
|
collapsedFolders: new Set(),
|
||||||
|
expandedPaths: new Set([file.path]),
|
||||||
|
layout: "unified",
|
||||||
|
wrapLines: true,
|
||||||
|
lineHeight: 18,
|
||||||
|
viewportWidth,
|
||||||
|
codeFontSize: 12,
|
||||||
|
headerHeightFor: () => 44,
|
||||||
|
folderHeight: 44,
|
||||||
|
statusHeight: 64,
|
||||||
|
measuredGroupHeights: new Map(),
|
||||||
|
geometryKey: `wrap:${Math.round(viewportWidth)}`,
|
||||||
|
reviewHeightFor: () => 0,
|
||||||
|
});
|
||||||
|
const summarizeGroups = (viewportWidth: number) =>
|
||||||
|
buildWrapped(viewportWidth)
|
||||||
|
.items.filter((item) => item.type === "unified_group")
|
||||||
|
.map((group) => ({
|
||||||
|
key: group.key,
|
||||||
|
startRowIndex: group.startRowIndex,
|
||||||
|
lineCount: group.lines.length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(summarizeGroups(399.2)).toEqual(summarizeGroups(399.49));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
||||||
|
import {
|
||||||
|
buildDiffTree,
|
||||||
|
compressSingleChildChains,
|
||||||
|
flattenDiffTree,
|
||||||
|
type DiffTreeDirNode,
|
||||||
|
} from "@/git/diff-tree";
|
||||||
|
import {
|
||||||
|
buildSplitDiffRows,
|
||||||
|
buildUnifiedDiffLines,
|
||||||
|
type ReviewableDiffTarget,
|
||||||
|
type SplitDiffRow,
|
||||||
|
type UnifiedDiffDisplayLine,
|
||||||
|
} from "@/utils/diff-layout";
|
||||||
|
|
||||||
|
// Tune cell size and the outer list window together. On the 1,200-row fixture, the exact
|
||||||
|
// three-fling repro filled immediately with both 32/720 + window 21 and this lower-cost pair
|
||||||
|
// on an iPhone 17 Pro simulator. Simulator frame percentiles are unavailable in xctrace.
|
||||||
|
export const DIFF_VIRTUALIZATION_CONFIG = {
|
||||||
|
groupRowLimit: 16,
|
||||||
|
groupHeightLimit: 360,
|
||||||
|
initialNumToRender: 8,
|
||||||
|
maxToRenderPerBatch: 8,
|
||||||
|
updateCellsBatchingPeriod: 16,
|
||||||
|
windowSize: 10,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface FolderItem {
|
||||||
|
type: "folder";
|
||||||
|
dirPath: string;
|
||||||
|
displayName: string;
|
||||||
|
depth: number;
|
||||||
|
collapsed: boolean;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderItem {
|
||||||
|
type: "header";
|
||||||
|
file: ParsedDiffFile;
|
||||||
|
fileIndex: number;
|
||||||
|
isExpanded: boolean;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusItem {
|
||||||
|
type: "status";
|
||||||
|
file: ParsedDiffFile;
|
||||||
|
fileIndex: number;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowGroupBase {
|
||||||
|
file: ParsedDiffFile;
|
||||||
|
fileIndex: number;
|
||||||
|
depth: number;
|
||||||
|
groupIndex: number;
|
||||||
|
startRowIndex: number;
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
contentColumns: number;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedRowGroupItem extends RowGroupBase {
|
||||||
|
type: "unified_group";
|
||||||
|
lines: UnifiedDiffDisplayLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SplitRowGroupItem extends RowGroupBase {
|
||||||
|
type: "split_group";
|
||||||
|
rows: SplitDiffRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiffListItem =
|
||||||
|
| FolderItem
|
||||||
|
| HeaderItem
|
||||||
|
| StatusItem
|
||||||
|
| UnifiedRowGroupItem
|
||||||
|
| SplitRowGroupItem;
|
||||||
|
|
||||||
|
export interface DiffRowIndex {
|
||||||
|
items: DiffListItem[];
|
||||||
|
stickyHeaderIndices: number[];
|
||||||
|
heightAt: (index: number) => number;
|
||||||
|
offsetAt: (index: number) => number;
|
||||||
|
offsetOfFile: (path: string) => number;
|
||||||
|
indexOfFile: (path: string) => number;
|
||||||
|
indexOfGroup: (key: string) => number;
|
||||||
|
updateHeight: (
|
||||||
|
key: string,
|
||||||
|
height: number,
|
||||||
|
) => { delta: number; index: number; previousHeight: number } | null;
|
||||||
|
totalHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiffRowCache {
|
||||||
|
unifiedFor: (file: ParsedDiffFile) => UnifiedDiffDisplayLine[];
|
||||||
|
splitFor: (file: ParsedDiffFile) => SplitDiffRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDiffRowCache(): DiffRowCache {
|
||||||
|
const unified = new WeakMap<ParsedDiffFile, UnifiedDiffDisplayLine[]>();
|
||||||
|
const split = new WeakMap<ParsedDiffFile, SplitDiffRow[]>();
|
||||||
|
return {
|
||||||
|
unifiedFor(file) {
|
||||||
|
const cached = unified.get(file);
|
||||||
|
if (cached) return cached;
|
||||||
|
const lines = buildUnifiedDiffLines(file);
|
||||||
|
unified.set(file, lines);
|
||||||
|
return lines;
|
||||||
|
},
|
||||||
|
splitFor(file) {
|
||||||
|
const cached = split.get(file);
|
||||||
|
if (cached) return cached;
|
||||||
|
const rows = buildSplitDiffRows(file);
|
||||||
|
split.set(file, rows);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRowCache = createDiffRowCache();
|
||||||
|
|
||||||
|
export interface BuildDiffRowIndexInput {
|
||||||
|
files: ParsedDiffFile[];
|
||||||
|
viewMode: "flat" | "tree";
|
||||||
|
collapsedFolders: ReadonlySet<string>;
|
||||||
|
expandedPaths: ReadonlySet<string>;
|
||||||
|
layout: "unified" | "split";
|
||||||
|
wrapLines: boolean;
|
||||||
|
lineHeight: number;
|
||||||
|
viewportWidth: number;
|
||||||
|
codeFontSize: number;
|
||||||
|
headerHeightFor: (file: ParsedDiffFile) => number;
|
||||||
|
folderHeight: number;
|
||||||
|
statusHeight: number;
|
||||||
|
measuredGroupHeights: ReadonlyMap<string, number>;
|
||||||
|
geometryKey: string;
|
||||||
|
reviewHeightFor: (target: ReviewableDiffTarget | null | undefined) => number;
|
||||||
|
rowCache?: DiffRowCache;
|
||||||
|
tree?: DiffTreeDirNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingGroup<Row> {
|
||||||
|
rows: Row[];
|
||||||
|
startRowIndex: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FULL_WIDTH_CODE_POINT_RANGES = [
|
||||||
|
[0x1100, 0x115f],
|
||||||
|
[0x2329, 0x232a],
|
||||||
|
[0x2e80, 0x303e],
|
||||||
|
[0x3040, 0x3247],
|
||||||
|
[0x3250, 0x4dbf],
|
||||||
|
[0x4e00, 0xa4c6],
|
||||||
|
[0xa960, 0xa97c],
|
||||||
|
[0xac00, 0xd7a3],
|
||||||
|
[0xf900, 0xfaff],
|
||||||
|
[0xfe10, 0xfe19],
|
||||||
|
[0xfe30, 0xfe6b],
|
||||||
|
[0xff01, 0xff60],
|
||||||
|
[0xffe0, 0xffe6],
|
||||||
|
[0x1b000, 0x1b001],
|
||||||
|
[0x1f200, 0x1f251],
|
||||||
|
[0x1f300, 0x1faff],
|
||||||
|
[0x20000, 0x3fffd],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isFullWidthCodePoint(codePoint: number): boolean {
|
||||||
|
return FULL_WIDTH_CODE_POINT_RANGES.some(
|
||||||
|
([start, end]) => codePoint >= start && codePoint <= end,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayColumns(content: string): number {
|
||||||
|
let columns = 0;
|
||||||
|
for (const character of content) {
|
||||||
|
if (character === "\t") {
|
||||||
|
columns += 8 - (columns % 8);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
columns += isFullWidthCodePoint(character.codePointAt(0) ?? 0) ? 2 : 1;
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimatedVisualLines(content: string, columns: number): number {
|
||||||
|
return Math.max(1, Math.ceil(displayColumns(content) / columns));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupRows<Row>(rows: Row[], heightFor: (row: Row) => number): PendingGroup<Row>[] {
|
||||||
|
const groups: PendingGroup<Row>[] = [];
|
||||||
|
let current: PendingGroup<Row> = { rows: [], startRowIndex: 0, height: 0 };
|
||||||
|
|
||||||
|
for (const [rowIndex, row] of rows.entries()) {
|
||||||
|
const rowHeight = heightFor(row);
|
||||||
|
const reachedRowLimit = current.rows.length >= DIFF_VIRTUALIZATION_CONFIG.groupRowLimit;
|
||||||
|
const reachedHeightLimit =
|
||||||
|
current.rows.length > 0 &&
|
||||||
|
current.height + rowHeight > DIFF_VIRTUALIZATION_CONFIG.groupHeightLimit;
|
||||||
|
if (reachedRowLimit || reachedHeightLimit) {
|
||||||
|
groups.push(current);
|
||||||
|
current = { rows: [], startRowIndex: rowIndex, height: 0 };
|
||||||
|
}
|
||||||
|
current.rows.push(row);
|
||||||
|
current.height += rowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.rows.length > 0) {
|
||||||
|
groups.push(current);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentColumnsForUnified(lines: UnifiedDiffDisplayLine[]): number {
|
||||||
|
return lines.reduce((longest, line) => Math.max(longest, displayColumns(line.line.content)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentColumnsForSplit(rows: SplitDiffRow[]): number {
|
||||||
|
return rows.reduce((longest, row) => {
|
||||||
|
if (row.kind === "header") {
|
||||||
|
return Math.max(longest, displayColumns(row.content));
|
||||||
|
}
|
||||||
|
return Math.max(
|
||||||
|
longest,
|
||||||
|
displayColumns(row.left?.content ?? ""),
|
||||||
|
displayColumns(row.right?.content ?? ""),
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableTextColumns(input: BuildDiffRowIndexInput): number {
|
||||||
|
const viewportWidth = Math.round(input.viewportWidth);
|
||||||
|
const columnWidth = input.layout === "split" ? viewportWidth / 2 : viewportWidth;
|
||||||
|
const characterWidth = input.codeFontSize * 0.62;
|
||||||
|
return Math.max(8, Math.floor((columnWidth - 72) / characterWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDiffRowIndex(input: BuildDiffRowIndexInput): DiffRowIndex {
|
||||||
|
const items: DiffListItem[] = [];
|
||||||
|
const heights: number[] = [];
|
||||||
|
const stickyHeaderIndices: number[] = [];
|
||||||
|
const fileIndexByPath = new Map<string, number>();
|
||||||
|
const groupIndexByKey = new Map<string, number>();
|
||||||
|
const availableColumns = availableTextColumns(input);
|
||||||
|
const rowCache = input.rowCache ?? defaultRowCache;
|
||||||
|
|
||||||
|
function push(item: DiffListItem, height: number): void {
|
||||||
|
if (item.type === "unified_group" || item.type === "split_group") {
|
||||||
|
groupIndexByKey.set(item.key, items.length);
|
||||||
|
}
|
||||||
|
items.push(item);
|
||||||
|
heights.push(height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushUnifiedGroups(file: ParsedDiffFile, fileIndex: number, depth: number): void {
|
||||||
|
const lines = rowCache.unifiedFor(file);
|
||||||
|
const contentColumns = contentColumnsForUnified(lines);
|
||||||
|
const groups = groupRows(lines, (line) => {
|
||||||
|
const visualLines = input.wrapLines
|
||||||
|
? estimatedVisualLines(line.line.content, availableColumns)
|
||||||
|
: 1;
|
||||||
|
return visualLines * input.lineHeight + input.reviewHeightFor(line.reviewTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [groupIndex, group] of groups.entries()) {
|
||||||
|
const isFirst = groupIndex === 0;
|
||||||
|
const isLast = groupIndex === groups.length - 1;
|
||||||
|
const key = `${input.geometryKey}:${file.path}:unified:${group.startRowIndex}`;
|
||||||
|
const estimatedHeight = group.height + (isFirst ? 1 : 0) + (isLast ? 1 : 0);
|
||||||
|
push(
|
||||||
|
{
|
||||||
|
type: "unified_group",
|
||||||
|
file,
|
||||||
|
fileIndex,
|
||||||
|
depth,
|
||||||
|
groupIndex,
|
||||||
|
startRowIndex: group.startRowIndex,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
contentColumns,
|
||||||
|
key,
|
||||||
|
lines: group.rows,
|
||||||
|
},
|
||||||
|
input.measuredGroupHeights.get(key) ?? estimatedHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushSplitGroups(file: ParsedDiffFile, fileIndex: number, depth: number): void {
|
||||||
|
const rows = rowCache.splitFor(file);
|
||||||
|
const contentColumns = contentColumnsForSplit(rows);
|
||||||
|
const groups = groupRows(rows, (row) => {
|
||||||
|
if (row.kind === "header") {
|
||||||
|
const visualLines = input.wrapLines
|
||||||
|
? estimatedVisualLines(row.content, availableColumns)
|
||||||
|
: 1;
|
||||||
|
return visualLines * input.lineHeight;
|
||||||
|
}
|
||||||
|
const visualLines = input.wrapLines
|
||||||
|
? Math.max(
|
||||||
|
estimatedVisualLines(row.left?.content ?? "", availableColumns),
|
||||||
|
estimatedVisualLines(row.right?.content ?? "", availableColumns),
|
||||||
|
)
|
||||||
|
: 1;
|
||||||
|
const reviewHeight = Math.max(
|
||||||
|
input.reviewHeightFor(row.left?.reviewTarget),
|
||||||
|
input.reviewHeightFor(row.right?.reviewTarget),
|
||||||
|
);
|
||||||
|
return visualLines * input.lineHeight + reviewHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [groupIndex, group] of groups.entries()) {
|
||||||
|
const isFirst = groupIndex === 0;
|
||||||
|
const isLast = groupIndex === groups.length - 1;
|
||||||
|
const key = `${input.geometryKey}:${file.path}:split:${group.startRowIndex}`;
|
||||||
|
const estimatedHeight = group.height + (isFirst ? 1 : 0) + (isLast ? 1 : 0);
|
||||||
|
push(
|
||||||
|
{
|
||||||
|
type: "split_group",
|
||||||
|
file,
|
||||||
|
fileIndex,
|
||||||
|
depth,
|
||||||
|
groupIndex,
|
||||||
|
startRowIndex: group.startRowIndex,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
contentColumns,
|
||||||
|
key,
|
||||||
|
rows: group.rows,
|
||||||
|
},
|
||||||
|
input.measuredGroupHeights.get(key) ?? estimatedHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushFile(file: ParsedDiffFile, fileIndex: number, depth: number): void {
|
||||||
|
const isExpanded = input.expandedPaths.has(file.path);
|
||||||
|
const headerIndex = items.length;
|
||||||
|
fileIndexByPath.set(file.path, headerIndex);
|
||||||
|
push({ type: "header", file, fileIndex, isExpanded, depth }, input.headerHeightFor(file));
|
||||||
|
if (!isExpanded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stickyHeaderIndices.push(headerIndex);
|
||||||
|
if (file.status === "binary" || file.status === "too_large") {
|
||||||
|
push({ type: "status", file, fileIndex, depth }, input.statusHeight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input.layout === "split") {
|
||||||
|
pushSplitGroups(file, fileIndex, depth);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushUnifiedGroups(file, fileIndex, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.viewMode === "flat") {
|
||||||
|
for (const [fileIndex, file] of input.files.entries()) {
|
||||||
|
pushFile(file, fileIndex, 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const indexByPath = new Map(input.files.map((file, index) => [file.path, index]));
|
||||||
|
const tree = input.tree ?? compressSingleChildChains(buildDiffTree(input.files));
|
||||||
|
for (const row of flattenDiffTree(tree, input.collapsedFolders)) {
|
||||||
|
if (row.kind === "folder") {
|
||||||
|
push(
|
||||||
|
{
|
||||||
|
type: "folder",
|
||||||
|
dirPath: row.dirPath,
|
||||||
|
displayName: row.displayName,
|
||||||
|
depth: row.depth,
|
||||||
|
collapsed: input.collapsedFolders.has(row.dirPath),
|
||||||
|
additions: row.additions,
|
||||||
|
deletions: row.deletions,
|
||||||
|
},
|
||||||
|
input.folderHeight,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fileIndex = indexByPath.get(row.file.path);
|
||||||
|
if (fileIndex !== undefined) {
|
||||||
|
pushFile(row.file, fileIndex, row.depth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsets = Array.from({ length: items.length + 1 }, () => 0);
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
offsets[index + 1] = offsets[index] + heights[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
stickyHeaderIndices,
|
||||||
|
heightAt: (index) => heights[index] ?? 0,
|
||||||
|
offsetAt: (index) => offsets[Math.max(0, Math.min(index, items.length))],
|
||||||
|
offsetOfFile: (path) => offsets[fileIndexByPath.get(path) ?? 0],
|
||||||
|
indexOfFile: (path) => fileIndexByPath.get(path) ?? -1,
|
||||||
|
indexOfGroup: (key) => groupIndexByKey.get(key) ?? -1,
|
||||||
|
updateHeight: (key, height) => {
|
||||||
|
const index = groupIndexByKey.get(key);
|
||||||
|
if (index === undefined || !Number.isFinite(height) || height < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const previousHeight = heights[index];
|
||||||
|
const delta = height - previousHeight;
|
||||||
|
if (delta === 0) {
|
||||||
|
return { delta, index, previousHeight };
|
||||||
|
}
|
||||||
|
heights[index] = height;
|
||||||
|
for (let offsetIndex = index + 1; offsetIndex < offsets.length; offsetIndex += 1) {
|
||||||
|
offsets[offsetIndex] += delta;
|
||||||
|
}
|
||||||
|
return { delta, index, previousHeight };
|
||||||
|
},
|
||||||
|
get totalHeight() {
|
||||||
|
return offsets[items.length];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { boundRetainedRangeOnDataGrowth } from "@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth";
|
||||||
|
|
||||||
|
describe("bounded VirtualizedList data growth", () => {
|
||||||
|
it("limits the carried range when middle insertion grows the list", () => {
|
||||||
|
expect(boundRetainedRangeOnDataGrowth({ first: 0, last: 39 }, 40, 115, 16)).toEqual({
|
||||||
|
first: 0,
|
||||||
|
last: 15,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds from the current scrolled range instead of jumping to the start", () => {
|
||||||
|
expect(boundRetainedRangeOnDataGrowth({ first: 84, last: 123 }, 160, 235, 16)).toEqual({
|
||||||
|
first: 84,
|
||||||
|
last: 99,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not alter the range when data shrinks or the carried range is already bounded", () => {
|
||||||
|
const shortRange = { first: 12, last: 18 };
|
||||||
|
expect(boundRetainedRangeOnDataGrowth(shortRange, 40, 32, 16)).toBe(shortRange);
|
||||||
|
expect(boundRetainedRangeOnDataGrowth(shortRange, 40, 115, 16)).toEqual(shortRange);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -614,7 +614,6 @@ export const ar: TranslationResources = {
|
|||||||
reloadingAgent: "وكيل إعادة التحميل...",
|
reloadingAgent: "وكيل إعادة التحميل...",
|
||||||
reloadedAgent: "وكيل إعادة تحميل",
|
reloadedAgent: "وكيل إعادة تحميل",
|
||||||
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
|
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
|
||||||
failedToCloseAgent: "فشل في إغلاق الوكيل",
|
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
unsavedTitle: "تغييرات غير محفوظة",
|
unsavedTitle: "تغييرات غير محفوظة",
|
||||||
|
|||||||
@@ -613,7 +613,6 @@ 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,7 +619,6 @@ 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,7 +619,6 @@ 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,7 +619,6 @@ export const ja: TranslationResources = {
|
|||||||
reloadingAgent: "エージェントを再読み込み中...",
|
reloadingAgent: "エージェントを再読み込み中...",
|
||||||
reloadedAgent: "エージェントを再読み込みしました",
|
reloadedAgent: "エージェントを再読み込みしました",
|
||||||
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
|
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
|
||||||
failedToCloseAgent: "エージェントを閉じられませんでした",
|
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
unsavedTitle: "未保存の変更",
|
unsavedTitle: "未保存の変更",
|
||||||
|
|||||||
@@ -616,7 +616,6 @@ export const ko: TranslationResources = {
|
|||||||
reloadingAgent: "에이전트 다시 로드 중...",
|
reloadingAgent: "에이전트 다시 로드 중...",
|
||||||
reloadedAgent: "에이전트를 다시 로드했습니다",
|
reloadedAgent: "에이전트를 다시 로드했습니다",
|
||||||
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
|
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
|
||||||
failedToCloseAgent: "에이전트를 닫지 못했습니다",
|
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
close: "닫기",
|
close: "닫기",
|
||||||
|
|||||||
@@ -618,7 +618,6 @@ 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,7 +618,6 @@ export const ru: TranslationResources = {
|
|||||||
reloadingAgent: "Перезагрузка агента...",
|
reloadingAgent: "Перезагрузка агента...",
|
||||||
reloadedAgent: "Перезагруженный агент",
|
reloadedAgent: "Перезагруженный агент",
|
||||||
failedToReloadAgent: "Не удалось перезагрузить агент",
|
failedToReloadAgent: "Не удалось перезагрузить агент",
|
||||||
failedToCloseAgent: "Не удалось закрыть агент",
|
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
unsavedTitle: "Несохранённые изменения",
|
unsavedTitle: "Несохранённые изменения",
|
||||||
|
|||||||
@@ -614,7 +614,6 @@ export const zhCN: TranslationResources = {
|
|||||||
reloadingAgent: "正在重新加载 Agent...",
|
reloadingAgent: "正在重新加载 Agent...",
|
||||||
reloadedAgent: "已重新加载 Agent",
|
reloadedAgent: "已重新加载 Agent",
|
||||||
failedToReloadAgent: "重新加载 Agent 失败",
|
failedToReloadAgent: "重新加载 Agent 失败",
|
||||||
failedToCloseAgent: "关闭 Agent 失败",
|
|
||||||
},
|
},
|
||||||
confirmations: {
|
confirmations: {
|
||||||
unsavedTitle: "未保存的更改",
|
unsavedTitle: "未保存的更改",
|
||||||
|
|||||||
@@ -404,13 +404,6 @@ describe("keyboard-shortcuts", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("leaves Escape to an editable field", () => {
|
|
||||||
expectNoShortcutResolution({
|
|
||||||
event: { key: "Escape", code: "Escape" },
|
|
||||||
context: { focusScope: "editable" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const nonMatchingCases: NonMatchingShortcutCase[] = [
|
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;
|
||||||
/** Allowed focus scope or scopes */
|
/** Exact focus scope match */
|
||||||
focusScope?: KeyboardFocusScope | readonly KeyboardFocusScope[];
|
focusScope?: 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, focusScope: ["message-input", "other"] },
|
when: { commandCenter: false, terminal: false },
|
||||||
preventDefault: false,
|
preventDefault: false,
|
||||||
stopPropagation: false,
|
stopPropagation: false,
|
||||||
help: {
|
help: {
|
||||||
@@ -1215,14 +1215,7 @@ 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 (
|
if (when.focusScope !== undefined && context.focusScope !== when.focusScope) return false;
|
||||||
when.focusScope !== undefined &&
|
|
||||||
!(typeof when.focusScope === "string"
|
|
||||||
? context.focusScope === when.focusScope
|
|
||||||
: when.focusScope.includes(context.focusScope))
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export function InlineReviewGutterCell({
|
|||||||
onStartComment,
|
onStartComment,
|
||||||
style,
|
style,
|
||||||
actionTestID,
|
actionTestID,
|
||||||
|
testID,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
reviewTarget: ReviewableDiffTarget | null | undefined;
|
reviewTarget: ReviewableDiffTarget | null | undefined;
|
||||||
@@ -270,6 +271,7 @@ 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);
|
||||||
@@ -309,7 +311,6 @@ 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
|
||||||
@@ -318,13 +319,15 @@ export function InlineReviewGutterCell({
|
|||||||
[lineHeight],
|
[lineHeight],
|
||||||
);
|
);
|
||||||
|
|
||||||
const labelStyle = useMemo<StyleProp<ViewStyle>>(
|
const pressableStyle = useCallback(
|
||||||
() => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive],
|
(): StyleProp<ViewStyle> => [
|
||||||
[hasComments, lineHeightStyle],
|
style,
|
||||||
);
|
styles.gutterInner,
|
||||||
const innerStyle = useMemo<StyleProp<ViewStyle>>(
|
styles.gutterLabel,
|
||||||
() => [styles.gutterInner, lineHeightStyle],
|
lineHeightStyle,
|
||||||
[lineHeightStyle],
|
hasComments && styles.gutterLabelActive,
|
||||||
|
],
|
||||||
|
[hasComments, lineHeightStyle, style],
|
||||||
);
|
);
|
||||||
const actionIconStyle = useMemo<StyleProp<ViewStyle>>(
|
const actionIconStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
() => [
|
() => [
|
||||||
@@ -346,17 +349,14 @@ export function InlineReviewGutterCell({
|
|||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPressOut={handlePressOut}
|
onPressOut={handlePressOut}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
|
testID={testID}
|
||||||
>
|
>
|
||||||
<View style={innerStyle}>
|
|
||||||
<View style={labelStyle}>
|
|
||||||
{children}
|
{children}
|
||||||
{showAction ? (
|
{showAction ? (
|
||||||
<View style={actionIconStyle} testID={actionTestID}>
|
<View style={actionIconStyle} testID={actionTestID}>
|
||||||
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
|
<ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
|
||||||
</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(4);
|
expect(persisted.version).toBe(3);
|
||||||
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 3 cache data and overwrites it on flush", async () => {
|
it("rejects version 1 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: 3,
|
version: 1,
|
||||||
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: 4,
|
version: 3,
|
||||||
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 = 4;
|
const CACHE_VERSION = 3;
|
||||||
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,8 +42,7 @@ describe("workspace bulk close helpers", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(groups).toEqual({
|
expect(groups).toEqual({
|
||||||
archiveAgentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
|
agentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
|
||||||
layoutOnlyAgentTabs: [],
|
|
||||||
terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }],
|
terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }],
|
||||||
otherTabs: [
|
otherTabs: [
|
||||||
{
|
{
|
||||||
@@ -54,21 +53,6 @@ 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([
|
||||||
@@ -122,7 +106,6 @@ describe("workspace bulk close helpers", () => {
|
|||||||
closeWorkspaceTabWithCleanup: (input) => {
|
closeWorkspaceTabWithCleanup: (input) => {
|
||||||
cleanupCalls.push(input);
|
cleanupCalls.push(input);
|
||||||
},
|
},
|
||||||
closeLayoutOnlyAgent: async () => {},
|
|
||||||
logLabel: "all tabs",
|
logLabel: "all tabs",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,7 +152,6 @@ describe("workspace bulk close helpers", () => {
|
|||||||
closeWorkspaceTabWithCleanup: (input) => {
|
closeWorkspaceTabWithCleanup: (input) => {
|
||||||
cleanupCalls.push(input);
|
cleanupCalls.push(input);
|
||||||
},
|
},
|
||||||
closeLayoutOnlyAgent: async () => {},
|
|
||||||
warn,
|
warn,
|
||||||
logLabel: "others",
|
logLabel: "others",
|
||||||
});
|
});
|
||||||
@@ -184,31 +166,4 @@ 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,8 +3,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
|||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
|
|
||||||
export interface BulkClosableTabGroups {
|
export interface BulkClosableTabGroups {
|
||||||
archiveAgentTabs: Array<{ tabId: string; agentId: string }>;
|
agentTabs: 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"] }>;
|
||||||
}
|
}
|
||||||
@@ -44,30 +43,20 @@ 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(
|
export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups {
|
||||||
tabs: WorkspaceTabDescriptor[],
|
|
||||||
resolveAgentCloseKind: (agentId: string) => "archive" | "layout-only" = () => "archive",
|
|
||||||
): BulkClosableTabGroups {
|
|
||||||
const groups: BulkClosableTabGroups = {
|
const groups: BulkClosableTabGroups = {
|
||||||
archiveAgentTabs: [],
|
agentTabs: [],
|
||||||
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") {
|
||||||
const agentTab = { tabId: tab.tabId, agentId: tab.target.agentId };
|
groups.agentTabs.push({ 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") {
|
||||||
@@ -84,73 +73,49 @@ export function buildBulkCloseConfirmationMessage(
|
|||||||
input: BulkClosableTabGroups,
|
input: BulkClosableTabGroups,
|
||||||
labels: BulkCloseConfirmationLabels = DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS,
|
labels: BulkCloseConfirmationLabels = DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS,
|
||||||
): string {
|
): string {
|
||||||
const { archiveAgentTabs, layoutOnlyAgentTabs, terminalTabs, otherTabs } = input;
|
const { agentTabs, terminalTabs, otherTabs } = input;
|
||||||
const tabCount = layoutOnlyAgentTabs.length + otherTabs.length;
|
if (agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0) {
|
||||||
if (archiveAgentTabs.length > 0 && terminalTabs.length > 0 && tabCount > 0) {
|
|
||||||
return labels.all({
|
return labels.all({
|
||||||
agents: archiveAgentTabs.length,
|
agents: agentTabs.length,
|
||||||
terminals: terminalTabs.length,
|
terminals: terminalTabs.length,
|
||||||
tabs: tabCount,
|
tabs: otherTabs.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (archiveAgentTabs.length > 0 && terminalTabs.length > 0) {
|
if (agentTabs.length > 0 && terminalTabs.length > 0) {
|
||||||
return labels.agentsAndTerminals({
|
return labels.agentsAndTerminals({
|
||||||
agents: archiveAgentTabs.length,
|
agents: agentTabs.length,
|
||||||
terminals: terminalTabs.length,
|
terminals: terminalTabs.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (terminalTabs.length > 0 && tabCount > 0) {
|
if (terminalTabs.length > 0 && otherTabs.length > 0) {
|
||||||
return labels.terminalsAndTabs({
|
return labels.terminalsAndTabs({
|
||||||
terminals: terminalTabs.length,
|
terminals: terminalTabs.length,
|
||||||
tabs: tabCount,
|
tabs: otherTabs.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (archiveAgentTabs.length > 0 && tabCount > 0) {
|
if (agentTabs.length > 0 && otherTabs.length > 0) {
|
||||||
return labels.agentsAndTabs({
|
return labels.agentsAndTabs({
|
||||||
agents: archiveAgentTabs.length,
|
agents: agentTabs.length,
|
||||||
tabs: tabCount,
|
tabs: otherTabs.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (terminalTabs.length > 0) {
|
if (terminalTabs.length > 0) {
|
||||||
return labels.terminals({ terminals: terminalTabs.length });
|
return labels.terminals({ terminals: terminalTabs.length });
|
||||||
}
|
}
|
||||||
if (tabCount > 0) {
|
if (otherTabs.length > 0) {
|
||||||
return labels.tabs({ tabs: tabCount });
|
return labels.tabs({ tabs: otherTabs.length });
|
||||||
}
|
}
|
||||||
return labels.agents({ agents: archiveAgentTabs.length });
|
return labels.agents({ agents: agentTabs.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
|
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
|
||||||
const {
|
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
|
||||||
client,
|
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
|
||||||
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.archiveAgentTabs.map((tab) => tab.agentId),
|
agentIds: groups.agentTabs.map((tab) => tab.agentId),
|
||||||
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -162,27 +127,27 @@ export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
for (const { tabId, agentId } of groups.agentTabs) {
|
||||||
...groups.archiveAgentTabs.map(({ tabId, agentId }) =>
|
void closeTab(tabId, async () => {
|
||||||
closeTab(tabId, async () => {
|
|
||||||
closeWorkspaceTabWithCleanup({
|
closeWorkspaceTabWithCleanup({
|
||||||
tabId,
|
tabId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
),
|
}
|
||||||
...groups.terminalTabs.map(({ tabId, terminalId }) =>
|
|
||||||
closeTab(tabId, async () => {
|
for (const { tabId, terminalId } of groups.terminalTabs) {
|
||||||
|
void closeTab(tabId, async () => {
|
||||||
closeWorkspaceTabWithCleanup({
|
closeWorkspaceTabWithCleanup({
|
||||||
tabId,
|
tabId,
|
||||||
target: { kind: "terminal", terminalId },
|
target: { kind: "terminal", terminalId },
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
),
|
}
|
||||||
...groups.otherTabs.map(({ tabId, target }) =>
|
|
||||||
closeTab(tabId, async () => {
|
for (const { tabId, target } of groups.otherTabs) {
|
||||||
|
void closeTab(tabId, async () => {
|
||||||
closeWorkspaceTabWithCleanup({ tabId, target });
|
closeWorkspaceTabWithCleanup({ tabId, target });
|
||||||
}),
|
});
|
||||||
),
|
}
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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,
|
||||||
@@ -67,7 +66,6 @@ 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";
|
||||||
@@ -114,7 +112,6 @@ 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,
|
||||||
@@ -1973,12 +1970,6 @@ 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,
|
||||||
@@ -2660,7 +2651,7 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
const agent =
|
const agent =
|
||||||
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||||
let closePolicy = resolveCloseAgentTabPolicy(agent);
|
const 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") {
|
||||||
@@ -2676,27 +2667,6 @@ 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({
|
||||||
@@ -2713,15 +2683,7 @@ 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(
|
||||||
@@ -2937,10 +2899,7 @@ function WorkspaceScreenContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
|
const groups = classifyBulkClosableTabs(tabsToClose);
|
||||||
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({
|
||||||
@@ -2968,20 +2927,6 @@ 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;
|
||||||
@@ -2998,7 +2943,6 @@ 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,
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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,10 +1,9 @@
|
|||||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { 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";
|
||||||
@@ -21,6 +20,8 @@ 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 });
|
||||||
@@ -69,11 +70,17 @@ 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;
|
||||||
@@ -87,19 +94,22 @@ export function SubagentsTrack({
|
|||||||
<View style={styles.track}>
|
<View style={styles.track}>
|
||||||
<View style={surfaceStyle}>
|
<View style={surfaceStyle}>
|
||||||
<View style={headerContainerStyle}>
|
<View style={headerContainerStyle}>
|
||||||
<Button
|
<Pressable
|
||||||
variant="ghost"
|
accessibilityRole="button"
|
||||||
size="xs"
|
|
||||||
accessibilityLabel={headerLabel}
|
accessibilityLabel={headerLabel}
|
||||||
accessibilityState={headerAccessibilityState}
|
|
||||||
testID="subagents-track-header"
|
testID="subagents-track-header"
|
||||||
onPress={toggleExpanded}
|
onPress={toggleExpanded}
|
||||||
leftIcon={expanded ? ChevronDown : ChevronRight}
|
style={headerStyle}
|
||||||
style={styles.headerToggle}
|
|
||||||
textStyle={styles.headerLabel}
|
|
||||||
>
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
|
||||||
|
) : (
|
||||||
|
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
|
||||||
|
)}
|
||||||
|
<Text style={styles.headerLabel} numberOfLines={1}>
|
||||||
{headerLabel}
|
{headerLabel}
|
||||||
</Button>
|
</Text>
|
||||||
|
</Pressable>
|
||||||
{finishedCount > 0 && onArchiveFinished ? (
|
{finishedCount > 0 && onArchiveFinished ? (
|
||||||
<View style={styles.headerAction}>
|
<View style={styles.headerAction}>
|
||||||
<SubagentActionButton
|
<SubagentActionButton
|
||||||
@@ -336,8 +346,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
headerToggle: {
|
headerToggle: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
justifyContent: "flex-start",
|
flexDirection: "row",
|
||||||
borderRadius: 0,
|
alignItems: "center",
|
||||||
|
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],
|
||||||
@@ -348,6 +359,9 @@ 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,
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
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]);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
declare module "@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth" {
|
||||||
|
export function boundRetainedRangeOnDataGrowth(
|
||||||
|
cellsAroundViewport: { first: number; last: number },
|
||||||
|
previousItemCount: number,
|
||||||
|
itemCount: number,
|
||||||
|
retainedCellLimit: number,
|
||||||
|
): { first: number; last: number };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
compactHighlightTokens,
|
||||||
formatDiffContentText,
|
formatDiffContentText,
|
||||||
formatDiffGutterText,
|
formatDiffGutterText,
|
||||||
hasVisibleDiffTokens,
|
hasVisibleDiffTokens,
|
||||||
@@ -28,4 +29,23 @@ 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,6 +2,28 @@ 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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"@getpaseo/client": "0.3.1",
|
||||||
"@getpaseo/protocol": "0.4.0-beta.2",
|
"@getpaseo/protocol": "0.3.1",
|
||||||
"@getpaseo/server": "0.4.0-beta.2",
|
"@getpaseo/server": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"@getpaseo/protocol": "0.3.1",
|
||||||
"@getpaseo/relay": "0.4.0-beta.2",
|
"@getpaseo/relay": "0.3.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"!dist/**/*.map"
|
"!dist/**/*.map"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"description": "Paseo shared protocol schemas and wire types",
|
"description": "Paseo shared protocol schemas and wire types",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -21,19 +18,4 @@ 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,13 +1,4 @@
|
|||||||
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;
|
||||||
@@ -23,9 +14,3 @@ 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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"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.4.0-beta.2",
|
"@getpaseo/client": "0.3.1",
|
||||||
"@getpaseo/highlight": "0.4.0-beta.2",
|
"@getpaseo/highlight": "0.3.1",
|
||||||
"@getpaseo/protocol": "0.4.0-beta.2",
|
"@getpaseo/protocol": "0.3.1",
|
||||||
"@getpaseo/relay": "0.4.0-beta.2",
|
"@getpaseo/relay": "0.3.1",
|
||||||
"@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 { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
import { 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,9 +41,6 @@ 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;
|
||||||
@@ -3593,7 +3590,7 @@ test("setLabels merges and persists labels", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("detachAgent removes relationship lifecycle labels from a live agent and emits state", async () => {
|
test("detachAgent removes only the parent label 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);
|
||||||
@@ -3624,8 +3621,6 @@ test("detachAgent removes relationship lifecycle labels from a live agent and em
|
|||||||
{
|
{
|
||||||
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,
|
||||||
@@ -3653,7 +3648,7 @@ test("detachAgent removes relationship lifecycle labels from a live agent and em
|
|||||||
expect(emittedLabels).toContainEqual({ team: "infra" });
|
expect(emittedLabels).toContainEqual({ team: "infra" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("detachAgent removes relationship lifecycle labels from a stored-only agent", async () => {
|
test("detachAgent removes the parent label 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);
|
||||||
@@ -3684,8 +3679,6 @@ test("detachAgent removes relationship lifecycle labels from a stored-only agent
|
|||||||
{
|
{
|
||||||
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,
|
||||||
@@ -6731,182 +6724,6 @@ 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,9 +7,7 @@ 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";
|
||||||
@@ -615,27 +613,6 @@ 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>();
|
||||||
@@ -654,7 +631,6 @@ 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;
|
||||||
@@ -1518,10 +1494,6 @@ 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");
|
||||||
@@ -1555,10 +1527,6 @@ 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;
|
||||||
@@ -1566,27 +1534,11 @@ export class AgentManager {
|
|||||||
if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const child = await registry.get(record.id);
|
if (this.agents.has(record.id)) {
|
||||||
if (!child || child.archivedAt || child.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
await this.archiveAgent(record.id);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await this.runLifecycleMutation(child.id, async () => {
|
|
||||||
const currentChild = await registry.get(child.id);
|
|
||||||
if (
|
|
||||||
!currentChild ||
|
|
||||||
currentChild.archivedAt ||
|
|
||||||
currentChild.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (shouldDetachFromArchivedParent(parent, currentChild)) {
|
|
||||||
await this.detachAgentUnlocked(currentChild.id);
|
|
||||||
} else if (this.agents.has(currentChild.id)) {
|
|
||||||
await this.archiveAgentUnlocked(currentChild.id);
|
|
||||||
} else {
|
} else {
|
||||||
await this.archiveSnapshot(currentChild.id, new Date().toISOString());
|
await this.archiveSnapshot(record.id, new Date().toISOString());
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1759,10 +1711,8 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
||||||
await this.runLifecycleMutation(agentId, async () => {
|
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
await this.writeLabels(agent.id, labels);
|
await this.writeLabels(agent.id, labels);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
||||||
@@ -1804,14 +1754,6 @@ 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);
|
||||||
@@ -1826,7 +1768,7 @@ export class AgentManager {
|
|||||||
return { record, live: true, previousParentAgentId: null };
|
return { record, live: true, previousParentAgentId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { record } = await this.writeLabels(agentId, detachedAgentLabelPatch(liveAgent.labels));
|
const { record } = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
@@ -1842,7 +1784,7 @@ export class AgentManager {
|
|||||||
return { record, live: false, previousParentAgentId: null };
|
return { record, live: false, previousParentAgentId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.writeLabels(agentId, detachedAgentLabelPatch(record.labels));
|
const result = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
@@ -1948,18 +1890,6 @@ 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) {
|
||||||
@@ -1975,24 +1905,6 @@ 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,80 +2241,6 @@ describe("Codex app-server provider", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps a settled child completed until Codex starts another child turn", async () => {
|
|
||||||
const appServer = createFakeCodexAppServer();
|
|
||||||
const session = new CodexAppServerAgentSession(
|
|
||||||
createConfig({ cwd: "/workspace/project" }),
|
|
||||||
null,
|
|
||||||
createTestLogger(),
|
|
||||||
async () => appServer.child,
|
|
||||||
);
|
|
||||||
const events: AgentStreamEvent[] = [];
|
|
||||||
session.subscribe((event) => events.push(event));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resultPromise = session.run("Delegate the investigation.");
|
|
||||||
await appServer.waitForTurnStart();
|
|
||||||
|
|
||||||
appServer.startsSubAgent({
|
|
||||||
callId: "call-settled-child",
|
|
||||||
threadId: "settled-child-thread",
|
|
||||||
agentPath: "/root/settled-child",
|
|
||||||
});
|
|
||||||
appServer.completeTurn({ threadId: "settled-child-thread" });
|
|
||||||
appServer.says({
|
|
||||||
threadId: "settled-child-thread",
|
|
||||||
itemId: "late-child-message",
|
|
||||||
text: "Late trailing output.",
|
|
||||||
});
|
|
||||||
|
|
||||||
const providerUpserts = events.flatMap((event) =>
|
|
||||||
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
|
|
||||||
);
|
|
||||||
expect(providerUpserts.at(-1)).toMatchObject({
|
|
||||||
id: "settled-child-thread",
|
|
||||||
status: "completed",
|
|
||||||
});
|
|
||||||
expect(events.at(-1)).toMatchObject({
|
|
||||||
type: "timeline",
|
|
||||||
item: {
|
|
||||||
callId: "call-settled-child",
|
|
||||||
status: "completed",
|
|
||||||
detail: { type: "sub_agent", log: "[Assistant] Late trailing output." },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
appServer.completesSubAgentActivity({
|
|
||||||
callId: "late-child-interaction",
|
|
||||||
threadId: "settled-child-thread",
|
|
||||||
agentPath: "/root/settled-child",
|
|
||||||
kind: "interacted",
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
events.findLast(
|
|
||||||
(event) => event.type === "provider_subagent" && event.event.type === "upsert",
|
|
||||||
),
|
|
||||||
).toMatchObject({ event: { id: "settled-child-thread", status: "completed" } });
|
|
||||||
|
|
||||||
appServer.startsTurn({
|
|
||||||
threadId: "settled-child-thread",
|
|
||||||
turnId: "next-child-turn",
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
events.findLast(
|
|
||||||
(event) => event.type === "provider_subagent" && event.event.type === "upsert",
|
|
||||||
),
|
|
||||||
).toMatchObject({ event: { id: "settled-child-thread", status: "running" } });
|
|
||||||
|
|
||||||
appServer.completeTurn({ threadId: "settled-child-thread" });
|
|
||||||
appServer.completeTurn();
|
|
||||||
await resultPromise;
|
|
||||||
appServer.assertNoErrors();
|
|
||||||
} finally {
|
|
||||||
await session.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("updates a registered child with its later native activity name", () => {
|
test("updates a registered child with its later native activity name", () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
@@ -3527,10 +3453,6 @@ 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: {
|
||||||
@@ -3559,10 +3481,6 @@ 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,12 +1660,6 @@ 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) {
|
||||||
@@ -4907,7 +4901,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", { reopen: true });
|
this.emitSubAgentActivityUpdate(callId, "running");
|
||||||
return;
|
return;
|
||||||
case "turn_started":
|
case "turn_started":
|
||||||
case "turn_completed":
|
case "turn_completed":
|
||||||
@@ -5180,13 +5174,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
|
this.emitSubAgentActivityUpdate(
|
||||||
if (activity.kind === "interrupted") {
|
callId,
|
||||||
nextStatus = "canceled";
|
activity.kind === "interrupted" ? "canceled" : "running",
|
||||||
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
|
);
|
||||||
nextStatus = undefined;
|
|
||||||
}
|
|
||||||
this.emitSubAgentActivityUpdate(callId, nextStatus);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5280,7 +5271,6 @@ 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") {
|
||||||
@@ -5291,14 +5281,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
childTimeline.length > 0
|
childTimeline.length > 0
|
||||||
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
|
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
|
||||||
: "";
|
: "";
|
||||||
let resolvedStatus = status ?? state.toolCall.status;
|
const 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);
|
||||||
}
|
}
|
||||||
@@ -5437,7 +5420,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);
|
this.emitSubAgentActivityUpdate(callId, "running");
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSubAgentContextCompactionItem(
|
private handleSubAgentContextCompactionItem(
|
||||||
@@ -5573,7 +5556,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", { reopen: true });
|
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.currentTurnId = parsed.turnId;
|
this.currentTurnId = parsed.turnId;
|
||||||
|
|||||||
@@ -379,28 +379,6 @@ describe("codex tool-call mapper", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
|
||||||
["shutdown", "canceled"],
|
|
||||||
["notFound", "failed"],
|
|
||||||
] as const)("maps terminal child state %s to %s", (childStatus, expectedStatus) => {
|
|
||||||
const item = mapCodexToolCallFromThreadItem({
|
|
||||||
type: "collabAgentToolCall",
|
|
||||||
id: `call-sub-agent-${childStatus}`,
|
|
||||||
tool: "closeAgent",
|
|
||||||
status: "completed",
|
|
||||||
receiverThreadIds: ["child-thread-1"],
|
|
||||||
agentsStates: {
|
|
||||||
"child-thread-1": { status: childStatus, message: null },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(item).toMatchObject({
|
|
||||||
type: "tool_call",
|
|
||||||
callId: `call-sub-agent-${childStatus}`,
|
|
||||||
status: expectedStatus,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps mcp read_file completion with detail", () => {
|
it("maps mcp read_file completion with detail", () => {
|
||||||
const item = expectMapped(
|
const item = expectMapped(
|
||||||
mapCodexToolCallFromThreadItem(
|
mapCodexToolCallFromThreadItem(
|
||||||
|
|||||||
@@ -569,17 +569,10 @@ 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();
|
||||||
switch (normalized) {
|
if (normalized === "error" || normalized === "errored") {
|
||||||
case "error":
|
|
||||||
case "errored":
|
|
||||||
return "running";
|
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,7 +480,6 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -530,11 +529,6 @@ 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",
|
||||||
@@ -830,40 +824,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
|||||||
}, 180_000);
|
}, 180_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("OpenCode adapter normalization", () => {
|
describe("OpenCode adapter context-window 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,7 +111,6 @@ 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;
|
||||||
@@ -581,14 +580,6 @@ 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) {
|
||||||
@@ -600,19 +591,15 @@ function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConfig {
|
function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConfig {
|
||||||
const normalized = {
|
if (normalizeOpenCodeModeId(config.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
|
||||||
...config,
|
return { ...config };
|
||||||
thinkingOptionId: normalizeOpenCodeVariantId(config.thinkingOptionId) ?? undefined,
|
|
||||||
};
|
|
||||||
if (normalizeOpenCodeModeId(normalized.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...normalized,
|
...config,
|
||||||
modeId: OPENCODE_BUILD_MODE_ID,
|
modeId: OPENCODE_BUILD_MODE_ID,
|
||||||
featureValues: {
|
featureValues: {
|
||||||
...normalized.featureValues,
|
...config.featureValues,
|
||||||
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
|
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -736,13 +723,11 @@ function buildOpenCodeModelDefinition(
|
|||||||
},
|
},
|
||||||
): AgentModelDefinition {
|
): AgentModelDefinition {
|
||||||
const rawVariants = model.variants ? Object.keys(model.variants) : [];
|
const rawVariants = model.variants ? Object.keys(model.variants) : [];
|
||||||
// OpenCode lists only overrides; its base model behavior is selected by omitting `variant`.
|
const thinkingOptions = rawVariants.map((id, index) => ({
|
||||||
const thinkingOptions = rawVariants.length
|
id,
|
||||||
? [
|
label: id,
|
||||||
{ id: OPENCODE_DEFAULT_VARIANT_ID, label: "Default", isDefault: true },
|
isDefault: index === 0,
|
||||||
...rawVariants.map((id) => ({ id, label: id })),
|
}));
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
@@ -3210,7 +3195,10 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||||
const normalizedThinkingOptionId = normalizeOpenCodeVariantId(thinkingOptionId);
|
const normalizedThinkingOptionId =
|
||||||
|
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
|
||||||
|
? thinkingOptionId
|
||||||
|
: null;
|
||||||
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
|
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.4.0-beta.2",
|
"version": "0.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js
|
||||||
|
index c92ac9d..6bdc84f 100644
|
||||||
|
--- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js
|
||||||
|
+++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js
|
||||||
|
@@ -47,6 +47,7 @@ import {
|
||||||
|
windowSizeOrDefault,
|
||||||
|
} from './VirtualizedListProps';
|
||||||
|
+import {boundRetainedRangeOnDataGrowth} from './boundRetainedRangeOnDataGrowth';
|
||||||
|
import {
|
||||||
|
computeWindowedRenderLimits,
|
||||||
|
keyExtractor as defaultKeyExtractor,
|
||||||
|
} from './VirtualizeUtils';
|
||||||
|
@@ -706,7 +706,8 @@ class VirtualizedList extends StateSafePureComponent<
|
||||||
|
// first and last could be stale (e.g. if a new, shorter items props is passed in), so we make
|
||||||
|
// sure we're rendering a reasonable range here.
|
||||||
|
const itemCount = newProps.getItemCount(newProps.data);
|
||||||
|
- if (itemCount === prevState.renderMask.numCells()) {
|
||||||
|
+ const previousItemCount = prevState.renderMask.numCells();
|
||||||
|
+ if (itemCount === previousItemCount) {
|
||||||
|
return prevState;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -741,7 +742,7 @@ class VirtualizedList extends StateSafePureComponent<
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- const constrainedCells = VirtualizedList._constrainToItemCount(
|
||||||
|
+ let constrainedCells = VirtualizedList._constrainToItemCount(
|
||||||
|
maintainVisibleContentPositionAdjustment != null
|
||||||
|
? {
|
||||||
|
first:
|
||||||
|
@@ -756,6 +758,20 @@ class VirtualizedList extends StateSafePureComponent<
|
||||||
|
newProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
+ if (
|
||||||
|
+ newProps.unstable_boundedRangeOnDataChange === true &&
|
||||||
|
+ itemCount > previousItemCount
|
||||||
|
+ ) {
|
||||||
|
+ const retainedCellLimit =
|
||||||
|
+ maxToRenderPerBatchOrDefault(newProps.maxToRenderPerBatch) * 2;
|
||||||
|
+ constrainedCells = boundRetainedRangeOnDataGrowth(
|
||||||
|
+ constrainedCells,
|
||||||
|
+ previousItemCount,
|
||||||
|
+ itemCount,
|
||||||
|
+ retainedCellLimit,
|
||||||
|
+ );
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
return {
|
||||||
|
cellsAroundViewport: constrainedCells,
|
||||||
|
renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells),
|
||||||
|
diff --git a/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js b/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..1e5b1f5
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js
|
||||||
|
@@ -0,0 +1,28 @@
|
||||||
|
+/**
|
||||||
|
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
+ *
|
||||||
|
+ * This source code is licensed under the MIT license found in the
|
||||||
|
+ * LICENSE file in the root directory of this source tree.
|
||||||
|
+ *
|
||||||
|
+ * @format
|
||||||
|
+ */
|
||||||
|
+
|
||||||
|
+'use strict';
|
||||||
|
+
|
||||||
|
+export function boundRetainedRangeOnDataGrowth(
|
||||||
|
+ cellsAroundViewport,
|
||||||
|
+ previousItemCount,
|
||||||
|
+ itemCount,
|
||||||
|
+ retainedCellLimit,
|
||||||
|
+) {
|
||||||
|
+ if (itemCount <= previousItemCount) {
|
||||||
|
+ return cellsAroundViewport;
|
||||||
|
+ }
|
||||||
|
+ return {
|
||||||
|
+ first: cellsAroundViewport.first,
|
||||||
|
+ last: Math.min(
|
||||||
|
+ cellsAroundViewport.last,
|
||||||
|
+ cellsAroundViewport.first + retainedCellLimit - 1,
|
||||||
|
+ ),
|
||||||
|
+ };
|
||||||
|
+}
|
||||||
|
diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js
|
||||||
|
index 9d6d49e..54324a8 100644
|
||||||
|
--- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js
|
||||||
|
+++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js
|
||||||
|
@@ -264,8 +264,14 @@ type OptionalVirtualizedListProps = {
|
||||||
|
* Amount of time between low-pri item render batches, e.g. for rendering items quite a ways off
|
||||||
|
* screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`.
|
||||||
|
*/
|
||||||
|
updateCellsBatchingPeriod?: ?number,
|
||||||
|
+
|
||||||
|
+ /**
|
||||||
|
+ * Limits the synchronous render range retained when data grows. This is useful for lists that
|
||||||
|
+ * insert many bounded cells in the middle of an already-rendered range.
|
||||||
|
+ */
|
||||||
|
+ unstable_boundedRangeOnDataChange?: ?boolean,
|
||||||
|
/**
|
||||||
|
* See `ViewabilityHelper` for flow type and further documentation.
|
||||||
|
*/
|
||||||
|
viewabilityConfig?: ViewabilityConfig,
|
||||||
@@ -8,6 +8,10 @@ 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