mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 19:05:16 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f00a940e74 | ||
|
|
c81c7bb7c0 | ||
|
|
d1f19a5cdd | ||
|
|
8a1243e8d3 | ||
|
|
dd8a111c30 | ||
|
|
780c6513f1 | ||
|
|
8b54d35818 | ||
|
|
e699f07a17 | ||
|
|
30b871e8d2 | ||
|
|
894fa8516e | ||
|
|
35f5171477 | ||
|
|
5dfe50ca74 | ||
|
|
de69b2a2af | ||
|
|
10da5ca169 | ||
|
|
512c9b31a8 | ||
|
|
76a5edb020 | ||
|
|
9952615c33 | ||
|
|
bd937850b3 | ||
|
|
42ee5a5949 | ||
|
|
68993b7ab3 | ||
|
|
246c07fba5 | ||
|
|
0afcc96370 | ||
|
|
3a8d08ba91 | ||
|
|
f22a8ea8e0 |
+27
-5
@@ -1,12 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.0-beta.3 - 2026-07-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
|
||||||
|
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
|
||||||
|
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
|
||||||
|
- Use Oh My Pi's Write Approval mode to allow reads while requiring approval for changes ([#2228](https://github.com/getpaseo/paseo/pull/2228) by [@theslava](https://github.com/theslava))
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
|
||||||
|
- Oh My Pi advisor results now retain their structure and severity ([#2219](https://github.com/getpaseo/paseo/pull/2219) by [@ebg1223](https://github.com/ebg1223))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
|
||||||
|
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
|
||||||
|
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
|
||||||
|
- Oh My Pi slash commands now include commands from every supported source ([#2175](https://github.com/getpaseo/paseo/pull/2175) by [@bendavid](https://github.com/bendavid))
|
||||||
|
- Oh My Pi chats no longer stay stuck as running after delayed or incomplete completion events ([#2261](https://github.com/getpaseo/paseo/pull/2261), [#2282](https://github.com/getpaseo/paseo/pull/2282) by [@isac322](https://github.com/isac322))
|
||||||
|
|
||||||
## 0.2.0-beta.2 - 2026-07-22
|
## 0.2.0-beta.2 - 2026-07-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309))
|
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309))
|
||||||
- Automate local and worktree-backed workspaces through the CLI and agent tools ([#2186](https://github.com/getpaseo/paseo/pull/2186))
|
|
||||||
- Start a new workspace from a pasted pull request or merge request link ([#2290](https://github.com/getpaseo/paseo/pull/2290))
|
|
||||||
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
|
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
|
||||||
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
|
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
|
||||||
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
|
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
|
||||||
@@ -15,9 +35,11 @@
|
|||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
|
|
||||||
- Workspace and chat sync uses less data without losing or reordering history ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
|
- Improved parity of CLI and MCP tools for workspace, agent and schedule management ([#2186](https://github.com/getpaseo/paseo/pull/2186))
|
||||||
- Added folders remain independent projects across nested repositories, worktrees, and filesystem mounts ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
|
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
|
||||||
- Idle agents release provider resources automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
|
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
|
||||||
|
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
|
||||||
|
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
|
||||||
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
|
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
|
||||||
- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn))
|
- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn))
|
||||||
- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312))
|
- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312))
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ archived workspace. History navigation must not infer workspace lifecycle from `
|
|||||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||||
|
|
||||||
|
History navigation preserves the selected agent as an explicit recovery target. If both that agent
|
||||||
|
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
|
||||||
|
the selected agent as one user action. Other archived agents in the restored workspace remain
|
||||||
|
recoverable from History. Opening one pins its tab and renders the archived-agent callout before any
|
||||||
|
provider timeline is loaded; **Unarchive** runs the provider's native unarchive hook (including Codex
|
||||||
|
`thread/unarchive`) before the normal agent resume and timeline hydration flow.
|
||||||
|
|
||||||
## Tabs vs archive
|
## Tabs vs archive
|
||||||
|
|
||||||
These are two distinct concepts that used to be conflated:
|
These are two distinct concepts that used to be conflated:
|
||||||
|
|||||||
+7
-1
@@ -383,13 +383,14 @@ install.
|
|||||||
|
|
||||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||||
|
|
||||||
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --isolation local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run cli -- ls -a -g # List all agents globally
|
npm run cli -- ls -a -g # List all agents globally
|
||||||
npm run cli -- ls -a -g --json # Same, as JSON
|
npm run cli -- ls -a -g --json # Same, as JSON
|
||||||
npm run cli -- inspect <id> # Show detailed agent info
|
npm run cli -- inspect <id> # Show detailed agent info
|
||||||
npm run cli -- logs <id> # View agent timeline
|
npm run cli -- logs <id> # View agent timeline
|
||||||
|
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
|
||||||
npm run cli -- daemon status # Check daemon status
|
npm run cli -- daemon status # Check daemon status
|
||||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||||
```
|
```
|
||||||
@@ -400,6 +401,11 @@ Use `--host <host:port>` to point the CLI at a different daemon:
|
|||||||
npm run cli -- --host localhost:7777 ls -a
|
npm run cli -- --host localhost:7777 ls -a
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Desktop integrations can focus an existing agent without creating one or
|
||||||
|
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
|
||||||
|
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
|
||||||
|
default; pass `--server <server-id>` when targeting another server.
|
||||||
|
|
||||||
## Agent state
|
## Agent state
|
||||||
|
|
||||||
Agent data lives at:
|
Agent data lives at:
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
|
|||||||
hidden workspace can overwrite the remembered workspace before Settings or
|
hidden workspace can overwrite the remembered workspace before Settings or
|
||||||
History returns.
|
History returns.
|
||||||
|
|
||||||
|
## Agent Targets
|
||||||
|
|
||||||
|
Notifications and agent URLs enter the router with different authoritative
|
||||||
|
targets.
|
||||||
|
|
||||||
|
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
|
||||||
|
directly to the workspace with the agent open intent.
|
||||||
|
- Agent URLs carry only `serverId` and `agentId`. Route them through
|
||||||
|
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
|
||||||
|
the agent's workspace from the host, and then opens the agent there.
|
||||||
|
|
||||||
|
Both paths converge on `navigateToAgent()`. Do not make notification routing
|
||||||
|
guess a workspace, and do not add a workspace to the stable agent URL format.
|
||||||
|
|
||||||
## Params
|
## Params
|
||||||
|
|
||||||
Required dynamic params belong to the matched route.
|
Required dynamic params belong to the matched route.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-prXQpWLMwN1JO2tGhGDie/Ud9vnmUSF44581Mm7R7eA=
|
sha256-x6jiaw7zkCuBRlddLHD0tXQxolXwr7tzIQBfWu5xnUU=
|
||||||
|
|||||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -35211,7 +35211,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -36236,12 +36236,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.2.0-beta.2",
|
"@getpaseo/client": "0.2.0-beta.3",
|
||||||
"@getpaseo/protocol": "0.2.0-beta.2",
|
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||||
"@getpaseo/server": "0.2.0-beta.2",
|
"@getpaseo/server": "0.2.0-beta.3",
|
||||||
"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",
|
||||||
@@ -36487,10 +36487,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.2.0-beta.2",
|
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||||
"@getpaseo/relay": "0.2.0-beta.2",
|
"@getpaseo/relay": "0.2.0-beta.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -36501,7 +36501,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -36744,7 +36744,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.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -37640,7 +37640,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -37872,7 +37872,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -37885,7 +37885,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38103,15 +38103,15 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.2.0-beta.2",
|
"@getpaseo/client": "0.2.0-beta.3",
|
||||||
"@getpaseo/highlight": "0.2.0-beta.2",
|
"@getpaseo/highlight": "0.2.0-beta.3",
|
||||||
"@getpaseo/protocol": "0.2.0-beta.2",
|
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||||
"@getpaseo/relay": "0.2.0-beta.2",
|
"@getpaseo/relay": "0.2.0-beta.3",
|
||||||
"@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",
|
||||||
@@ -38648,7 +38648,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"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.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"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": [
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test, expect, type Page } from "./fixtures";
|
||||||
|
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
|
||||||
|
|
||||||
|
function visibleComposer(page: Page) {
|
||||||
|
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("adds a changed file to the focused chat without replacing its composer draft", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "add-file-to-chat-",
|
||||||
|
title: "Target chat",
|
||||||
|
});
|
||||||
|
const relativePath = "src/changed file.ts";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||||
|
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||||
|
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await openAgentRoute(page, {
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
agentId: workspace.agentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentComposer = visibleComposer(page);
|
||||||
|
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||||
|
await agentComposer.fill("Preserve this thought");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||||
|
await page.getByTestId("explorer-tab-changes").click();
|
||||||
|
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
|
||||||
|
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||||
|
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||||
|
|
||||||
|
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||||
|
await expect(attachment).toContainText("changed file.ts");
|
||||||
|
await expect(attachment).toContainText(relativePath);
|
||||||
|
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||||
|
await expect(agentComposer).toBeFocused();
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { mkdtempSync, realpathSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { openSessions } from "./helpers/archive-tab";
|
||||||
|
import {
|
||||||
|
assertChatTranscript,
|
||||||
|
cleanupRewindFlow,
|
||||||
|
launchAgent,
|
||||||
|
sendMessage,
|
||||||
|
type AgentHandle,
|
||||||
|
} from "./helpers/rewind-flow";
|
||||||
|
import type { SeedDaemonClient } from "./helpers/seed-client";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
|
interface TimelineClient extends SeedDaemonClient {
|
||||||
|
fetchAgentTimeline(
|
||||||
|
agentId: string,
|
||||||
|
options: { direction: "tail"; projection: "projected"; limit: number },
|
||||||
|
): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else.";
|
||||||
|
const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL";
|
||||||
|
const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else.";
|
||||||
|
const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL";
|
||||||
|
|
||||||
|
async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise<boolean> {
|
||||||
|
const history = await client.fetchAgentHistory({ page: { limit: 200 } });
|
||||||
|
return history.entries.some((entry) => entry.agent.id === agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("archived Codex agent recovery", () => {
|
||||||
|
test.setTimeout(600_000);
|
||||||
|
|
||||||
|
test("cold-opens without provider history, then unarchives and restores the conversation", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-")));
|
||||||
|
let handle: AgentHandle | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
handle = await launchAgent({
|
||||||
|
page,
|
||||||
|
provider: "codex",
|
||||||
|
cwd,
|
||||||
|
mode: "full-access",
|
||||||
|
});
|
||||||
|
await sendMessage(handle, INITIAL_PROMPT);
|
||||||
|
await assertChatTranscript(handle, [
|
||||||
|
{ role: "user", text: INITIAL_PROMPT },
|
||||||
|
{ role: "assistant", text: INITIAL_REPLY },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await handle.client.archiveAgent(handle.agentId);
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
(await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ??
|
||||||
|
null,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
const timelineClient = handle.client as TimelineClient;
|
||||||
|
await expect(
|
||||||
|
timelineClient.fetchAgentTimeline(handle.agentId, {
|
||||||
|
direction: "tail",
|
||||||
|
projection: "projected",
|
||||||
|
limit: 100,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/archiv/i);
|
||||||
|
await expect
|
||||||
|
.poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openSessions(page);
|
||||||
|
await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("agent-load-error")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("user-message")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await assertChatTranscript(handle, [
|
||||||
|
{ role: "user", text: INITIAL_PROMPT },
|
||||||
|
{ role: "assistant", text: INITIAL_REPLY },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await sendMessage(handle, FOLLOW_UP_PROMPT);
|
||||||
|
await assertChatTranscript(handle, [
|
||||||
|
{ role: "user", text: INITIAL_PROMPT },
|
||||||
|
{ role: "assistant", text: INITIAL_REPLY },
|
||||||
|
{ role: "user", text: FOLLOW_UP_PROMPT },
|
||||||
|
{ role: "assistant", text: FOLLOW_UP_REPLY },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await cleanupRewindFlow({ handle, cwd });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,26 +28,20 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
|||||||
|
|
||||||
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
||||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
|
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||||
"aria-selected",
|
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await panel.getByTestId("commit-diff-layout-split").click();
|
await layoutToggle.click();
|
||||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||||
"aria-selected",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
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-body")).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
||||||
await expect(panel).toHaveCount(0);
|
await expect(panel).toHaveCount(0);
|
||||||
await commitRow.click();
|
await commitRow.click();
|
||||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
|
||||||
"aria-selected",
|
"Switch to unified diff",
|
||||||
"true",
|
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { writeFile } from "node:fs/promises";
|
import { unlink, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type Page } from "@playwright/test";
|
import { type Page } from "@playwright/test";
|
||||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
|
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
|
||||||
@@ -10,6 +10,11 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
|||||||
|
|
||||||
interface DirtyWorkspace {
|
interface DirtyWorkspace {
|
||||||
id: string;
|
id: string;
|
||||||
|
repoPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceFixtureOptions {
|
||||||
|
includeDeletedFile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CleanupTask {
|
interface CleanupTask {
|
||||||
@@ -218,6 +223,99 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("changes file actions open from the kebab and right-click", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||||
|
await page.getByTestId("diff-file-1-actions").click();
|
||||||
|
await expect(page.getByText("Copy path")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||||
|
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||||
|
await page.getByTestId("diff-file-0-open-file").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||||
|
await useUnwrappedDiffLines(page);
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
const changesTabToggle = page.getByTestId("changes-open-tab");
|
||||||
|
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
|
||||||
|
await changesTabToggle.click();
|
||||||
|
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
|
||||||
|
|
||||||
|
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
|
await expect(visiblePanel).toBeVisible();
|
||||||
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||||
|
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||||
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
|
||||||
|
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||||
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||||
|
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||||
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
|
||||||
|
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||||
|
await workingDiffLayoutToggle.click();
|
||||||
|
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||||
|
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||||
|
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
|
||||||
|
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||||
|
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
|
||||||
|
await page.getByTestId("working-diff-toggle-wrap-lines").click();
|
||||||
|
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||||
|
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
|
||||||
|
"Scroll long lines",
|
||||||
|
);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||||
|
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
|
||||||
|
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||||
|
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||||
|
|
||||||
|
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||||
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||||
|
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||||
|
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
|
||||||
|
"zz-deleted.ts",
|
||||||
|
);
|
||||||
|
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
|
||||||
|
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||||
|
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
|
||||||
|
await expect(visiblePanel).toContainText("Deleted");
|
||||||
|
|
||||||
|
await changesTabToggle.click();
|
||||||
|
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
@@ -243,6 +341,11 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
|||||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Collapse all" }).click();
|
||||||
|
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "Expand all" }).click();
|
||||||
|
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
|
||||||
await 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);
|
||||||
|
|
||||||
@@ -250,6 +353,53 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
|||||||
await expectFlatFileList(page);
|
await expectFlatFileList(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
|
||||||
|
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||||
|
await openWorkspaceChanges(page, workspace);
|
||||||
|
|
||||||
|
await page.getByTestId("changes-toggle-view-mode").click();
|
||||||
|
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||||
|
|
||||||
|
const changesRightRail = await Promise.all([
|
||||||
|
readSvgRight(page, "explorer-close"),
|
||||||
|
readSvgRight(page, "changes-options-menu"),
|
||||||
|
readSvgRight(page, "diff-file-0-actions"),
|
||||||
|
]);
|
||||||
|
expectAligned(changesRightRail);
|
||||||
|
|
||||||
|
const [folderStat, fileStat] = await Promise.all([
|
||||||
|
page.getByTestId("diff-folder-src-stat").boundingBox(),
|
||||||
|
page.getByTestId("diff-file-0-stat").boundingBox(),
|
||||||
|
]);
|
||||||
|
expect(folderStat).not.toBeNull();
|
||||||
|
expect(fileStat).not.toBeNull();
|
||||||
|
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
|
||||||
|
|
||||||
|
await page.getByTestId("explorer-tab-files").click();
|
||||||
|
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
|
||||||
|
|
||||||
|
const filesRightRail = await Promise.all([
|
||||||
|
readSvgRight(page, "explorer-close"),
|
||||||
|
readSvgRight(page, "files-refresh"),
|
||||||
|
readSvgRight(page, "file-explorer-row-0-actions"),
|
||||||
|
]);
|
||||||
|
expectAligned(filesRightRail);
|
||||||
|
|
||||||
|
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
|
||||||
|
page.getByTestId("files-sort-label").boundingBox(),
|
||||||
|
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
|
||||||
|
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
|
||||||
|
page.getByTestId("file-explorer-row-0").boundingBox(),
|
||||||
|
]);
|
||||||
|
expect(sortLabel).not.toBeNull();
|
||||||
|
expect(firstRowIcon).not.toBeNull();
|
||||||
|
expect(treeBounds).not.toBeNull();
|
||||||
|
expect(rowBounds).not.toBeNull();
|
||||||
|
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
|
||||||
|
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
|
||||||
|
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -399,10 +549,14 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
async function createWorkspaceWithMountedTabDiff(
|
||||||
const repo = await createTempGitRepo("diff-row-alignment-", {
|
options: WorkspaceFixtureOptions = {},
|
||||||
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
|
): Promise<DirtyWorkspace> {
|
||||||
});
|
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
|
||||||
|
if (options.includeDeletedFile) {
|
||||||
|
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
||||||
|
}
|
||||||
|
const repo = await createTempGitRepo("diff-row-alignment-", { files });
|
||||||
const client = await connectSeedClient();
|
const client = await connectSeedClient();
|
||||||
cleanupTasks.push({
|
cleanupTasks.push({
|
||||||
run: async () => {
|
run: async () => {
|
||||||
@@ -412,13 +566,16 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||||
|
if (options.includeDeletedFile) {
|
||||||
|
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
||||||
|
}
|
||||||
const createdWorkspace = await client.createWorkspace({
|
const createdWorkspace = await client.createWorkspace({
|
||||||
source: { kind: "directory", path: repo.path },
|
source: { kind: "directory", path: repo.path },
|
||||||
});
|
});
|
||||||
if (!createdWorkspace.workspace) {
|
if (!createdWorkspace.workspace) {
|
||||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
|
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
|
||||||
}
|
}
|
||||||
return { id: createdWorkspace.workspace.id };
|
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||||
@@ -436,6 +593,21 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
|||||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readSvgRight(page: Page, testID: string): Promise<number> {
|
||||||
|
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
|
||||||
|
if (!box) {
|
||||||
|
throw new Error(`Could not measure ${testID}`);
|
||||||
|
}
|
||||||
|
return box.x + box.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectAligned(values: number[]): void {
|
||||||
|
const [first, ...rest] = values;
|
||||||
|
for (const value of rest) {
|
||||||
|
expect(value).toBeCloseTo(first, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export interface SeedDaemonClient {
|
|||||||
} | null;
|
} | null;
|
||||||
fetchAgentHistory(options?: {
|
fetchAgentHistory(options?: {
|
||||||
page?: { limit: number };
|
page?: { limit: number };
|
||||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
|
||||||
subscribeTerminal(
|
subscribeTerminal(
|
||||||
terminalId: string,
|
terminalId: string,
|
||||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import {
|
||||||
|
buildHostAgentDetailRoute,
|
||||||
|
buildHostWorkspaceOpenRoute,
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +38,7 @@ import { getServerId } from "./helpers/server-id";
|
|||||||
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
|
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
|
||||||
import { expectAppRoute } from "./helpers/route-assertions";
|
import { expectAppRoute } from "./helpers/route-assertions";
|
||||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||||
|
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||||
|
|
||||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||||
type StartupPresentation = "splash" | "app";
|
type StartupPresentation = "splash" | "app";
|
||||||
@@ -158,6 +163,43 @@ async function expectWorkspaceLocation(
|
|||||||
test.describe("Workspace navigation regression", () => {
|
test.describe("Workspace navigation regression", () => {
|
||||||
test.describe.configure({ timeout: 240_000 });
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
|
test("opens a notification's workspace on a different offline host", async ({ page }) => {
|
||||||
|
const target = {
|
||||||
|
serverId: "notification-offline-host",
|
||||||
|
workspaceId: "notification-workspace",
|
||||||
|
agentId: "notification-agent",
|
||||||
|
};
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await addOfflineHostAndReload(page, {
|
||||||
|
serverId: target.serverId,
|
||||||
|
label: "Notification Host",
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("sidebar-settings").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.evaluate((data) => {
|
||||||
|
globalThis.dispatchEvent(
|
||||||
|
new CustomEvent("paseo:web-notification-click", {
|
||||||
|
detail: { data: { ...data, reason: "finished" } },
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, target);
|
||||||
|
|
||||||
|
await expectAppRoute(
|
||||||
|
page,
|
||||||
|
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
|
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
|
||||||
page,
|
page,
|
||||||
withWorkspace,
|
withWorkspace,
|
||||||
@@ -420,12 +462,13 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expectWorkspaceDeckEntryCount(page, 2);
|
await expectWorkspaceDeckEntryCount(page, 2);
|
||||||
|
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ agentId, serverId: targetServerId }) => {
|
({ agentId, serverId: targetServerId, workspaceId }) => {
|
||||||
globalThis.dispatchEvent(
|
globalThis.dispatchEvent(
|
||||||
new CustomEvent("paseo:web-notification-click", {
|
new CustomEvent("paseo:web-notification-click", {
|
||||||
detail: {
|
detail: {
|
||||||
data: {
|
data: {
|
||||||
serverId: targetServerId,
|
serverId: targetServerId,
|
||||||
|
workspaceId,
|
||||||
agentId,
|
agentId,
|
||||||
reason: "finished",
|
reason: "finished",
|
||||||
},
|
},
|
||||||
@@ -434,7 +477,7 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{ agentId: secondAgent.id, serverId },
|
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
|
||||||
);
|
);
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ test.describe("Worktree restore", () => {
|
|||||||
tempRepo = await createTempGitRepo("wt-restore-");
|
tempRepo = await createTempGitRepo("wt-restore-");
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createArchivedMissingWorktree(prefix: string) {
|
async function createArchivedMissingWorktree(
|
||||||
|
prefix: string,
|
||||||
|
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
|
||||||
|
) {
|
||||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||||
createdProjectIds.add(project.projectKey);
|
createdProjectIds.add(project.projectKey);
|
||||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||||
@@ -53,11 +56,19 @@ test.describe("Worktree restore", () => {
|
|||||||
});
|
});
|
||||||
createdProjectIds.add(worktree.projectKey);
|
createdProjectIds.add(worktree.projectKey);
|
||||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||||
const agent = await createIdleAgent(client, {
|
const agents = await Promise.all(
|
||||||
cwd: worktree.workspaceDirectory,
|
Array.from({ length: options.agentCount ?? 1 }, () =>
|
||||||
workspaceId: worktree.workspaceId,
|
createIdleAgent(client, {
|
||||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
cwd: worktree.workspaceDirectory,
|
||||||
});
|
workspaceId: worktree.workspaceId,
|
||||||
|
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const agent = agents[0];
|
||||||
|
if (!agent) {
|
||||||
|
throw new Error("Expected at least one archived-worktree agent");
|
||||||
|
}
|
||||||
|
|
||||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||||
await expect
|
await expect
|
||||||
@@ -67,11 +78,21 @@ test.describe("Worktree restore", () => {
|
|||||||
// Match the remote cloud-race record: workspace archived and absent, while
|
// Match the remote cloud-race record: workspace archived and absent, while
|
||||||
// the surviving closed agent record is not agent-archived. Refresh now owns
|
// the surviving closed agent record is not agent-archived. Refresh now owns
|
||||||
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
|
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
|
||||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
if (options.keepAgentsArchived) {
|
||||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
for (const archivedAgent of agents) {
|
||||||
|
await expect
|
||||||
|
.poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 })
|
||||||
|
.not.toBeNull();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||||
|
await expect
|
||||||
|
.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 })
|
||||||
|
.toBeNull();
|
||||||
|
}
|
||||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||||
|
|
||||||
return { agent, worktree };
|
return { agent, agents, worktree };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
|
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
|
||||||
@@ -241,6 +262,54 @@ test.describe("Worktree restore", () => {
|
|||||||
).toHaveText(switchedBranch, { timeout: 30_000 });
|
).toHaveText(switchedBranch, { timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
|
||||||
|
agentCount: 2,
|
||||||
|
keepAgentsArchived: true,
|
||||||
|
});
|
||||||
|
const [firstAgent, secondAgent] = agents;
|
||||||
|
if (!firstAgent || !secondAgent) {
|
||||||
|
throw new Error("Expected two archived agents");
|
||||||
|
}
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openSessions(page);
|
||||||
|
await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByTestId("workspace-recovery-action").click();
|
||||||
|
await expect
|
||||||
|
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||||
|
.toBe(true);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect
|
||||||
|
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
|
||||||
|
.toBeNull();
|
||||||
|
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await openSessions(page);
|
||||||
|
await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
||||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
||||||
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.2.0-beta.2",
|
"version": "0.2.0-beta.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -257,6 +257,35 @@ describe("bottom anchor controller driver", () => {
|
|||||||
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
expect(harness.scrollToBottom).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pauses sticky maintenance while a user scroll owns the viewport", () => {
|
||||||
|
const harness = createDriverHarness({
|
||||||
|
transportBehavior: {
|
||||||
|
verificationDelayFrames: 2,
|
||||||
|
verificationRetryMode: "recheck",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.driver.prepareForStickyContentChange();
|
||||||
|
harness.driver.beginUserScroll();
|
||||||
|
harness.driver.handleContentSizeChange({
|
||||||
|
previousContentHeight: 1200,
|
||||||
|
contentHeight: 1400,
|
||||||
|
});
|
||||||
|
harness.context.nearBottom = false;
|
||||||
|
harness.driver.handleScrollNearBottomChange({
|
||||||
|
nextIsNearBottom: false,
|
||||||
|
scrollDelta: 1,
|
||||||
|
});
|
||||||
|
harness.scheduler.flushAll();
|
||||||
|
|
||||||
|
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
|
||||||
|
expect(harness.driver.getSnapshot()).toMatchObject({
|
||||||
|
mode: "detached",
|
||||||
|
pendingRequest: null,
|
||||||
|
pendingVerification: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
|
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
|
||||||
const harness = createDriverHarness({
|
const harness = createDriverHarness({
|
||||||
isNearBottom: false,
|
isNearBottom: false,
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ interface BottomAnchorControllerDriver {
|
|||||||
resetForAgent: () => void;
|
resetForAgent: () => void;
|
||||||
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
||||||
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
||||||
|
beginUserScroll: () => void;
|
||||||
|
endUserScroll: () => void;
|
||||||
detachByUser: () => void;
|
detachByUser: () => void;
|
||||||
handleViewportMetricsChange: (params: {
|
handleViewportMetricsChange: (params: {
|
||||||
previousViewportWidth: number;
|
previousViewportWidth: number;
|
||||||
@@ -243,6 +245,7 @@ function createBottomAnchorControllerDriver(
|
|||||||
let lastRouteRequestKey: string | null = null;
|
let lastRouteRequestKey: string | null = null;
|
||||||
let stickyMeasurementRevision = 0;
|
let stickyMeasurementRevision = 0;
|
||||||
let lastVerifiedStickyMeasurementRevision = 0;
|
let lastVerifiedStickyMeasurementRevision = 0;
|
||||||
|
let isUserScrollActive = false;
|
||||||
|
|
||||||
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
||||||
if (blockedReason === nextBlockedReason) {
|
if (blockedReason === nextBlockedReason) {
|
||||||
@@ -481,6 +484,7 @@ function createBottomAnchorControllerDriver(
|
|||||||
cancelPendingAttempt();
|
cancelPendingAttempt();
|
||||||
stickyMeasurementRevision = 0;
|
stickyMeasurementRevision = 0;
|
||||||
lastVerifiedStickyMeasurementRevision = 0;
|
lastVerifiedStickyMeasurementRevision = 0;
|
||||||
|
isUserScrollActive = false;
|
||||||
mode = "sticky-bottom";
|
mode = "sticky-bottom";
|
||||||
input.onModeChange("sticky-bottom");
|
input.onModeChange("sticky-bottom");
|
||||||
},
|
},
|
||||||
@@ -497,6 +501,21 @@ function createBottomAnchorControllerDriver(
|
|||||||
requestLocalAnchor(request) {
|
requestLocalAnchor(request) {
|
||||||
createRequest(request);
|
createRequest(request);
|
||||||
},
|
},
|
||||||
|
beginUserScroll() {
|
||||||
|
isUserScrollActive = true;
|
||||||
|
cancelPendingRequest("user_scroll_started");
|
||||||
|
},
|
||||||
|
endUserScroll() {
|
||||||
|
isUserScrollActive = false;
|
||||||
|
if (mode !== "sticky-bottom") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input.isNearBottom()) {
|
||||||
|
markStickyMeasurementVerified();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.detachByUser();
|
||||||
|
},
|
||||||
detachByUser() {
|
detachByUser() {
|
||||||
if (mode === "detached") {
|
if (mode === "detached") {
|
||||||
return;
|
return;
|
||||||
@@ -511,6 +530,9 @@ function createBottomAnchorControllerDriver(
|
|||||||
) {
|
) {
|
||||||
markStickyMeasurementChanged();
|
markStickyMeasurementChanged();
|
||||||
}
|
}
|
||||||
|
if (isUserScrollActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const shouldRestick = __private__.shouldRestickOnViewportChange({
|
const shouldRestick = __private__.shouldRestickOnViewportChange({
|
||||||
mode,
|
mode,
|
||||||
previousViewportWidth: params.previousViewportWidth,
|
previousViewportWidth: params.previousViewportWidth,
|
||||||
@@ -529,6 +551,9 @@ function createBottomAnchorControllerDriver(
|
|||||||
if (params.previousContentHeight !== params.contentHeight) {
|
if (params.previousContentHeight !== params.contentHeight) {
|
||||||
markStickyMeasurementChanged();
|
markStickyMeasurementChanged();
|
||||||
}
|
}
|
||||||
|
if (isUserScrollActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const shouldRestick = __private__.shouldRestickOnContentChange({
|
const shouldRestick = __private__.shouldRestickOnContentChange({
|
||||||
mode,
|
mode,
|
||||||
previousContentHeight: params.previousContentHeight,
|
previousContentHeight: params.previousContentHeight,
|
||||||
@@ -558,6 +583,9 @@ function createBottomAnchorControllerDriver(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
markStickyMeasurementChanged();
|
markStickyMeasurementChanged();
|
||||||
|
if (isUserScrollActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!pendingRequest) {
|
if (!pendingRequest) {
|
||||||
pendingVerification = { requestId: null, retries: 0 };
|
pendingVerification = { requestId: null, retries: 0 };
|
||||||
if (attemptHandle) {
|
if (attemptHandle) {
|
||||||
@@ -571,6 +599,12 @@ function createBottomAnchorControllerDriver(
|
|||||||
},
|
},
|
||||||
handleScrollNearBottomChange(params) {
|
handleScrollNearBottomChange(params) {
|
||||||
const { nextIsNearBottom, scrollDelta } = params;
|
const { nextIsNearBottom, scrollDelta } = params;
|
||||||
|
if (isUserScrollActive) {
|
||||||
|
if (mode === "sticky-bottom" && !nextIsNearBottom) {
|
||||||
|
this.detachByUser();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
nextIsNearBottom &&
|
nextIsNearBottom &&
|
||||||
mode === "sticky-bottom" &&
|
mode === "sticky-bottom" &&
|
||||||
@@ -737,6 +771,12 @@ export function useBottomAnchorController(input: {
|
|||||||
requestLocalAnchor(request: BottomAnchorLocalRequest) {
|
requestLocalAnchor(request: BottomAnchorLocalRequest) {
|
||||||
driverRef.current?.requestLocalAnchor(request);
|
driverRef.current?.requestLocalAnchor(request);
|
||||||
},
|
},
|
||||||
|
beginUserScroll() {
|
||||||
|
driverRef.current?.beginUserScroll();
|
||||||
|
},
|
||||||
|
endUserScroll() {
|
||||||
|
driverRef.current?.endUserScroll();
|
||||||
|
},
|
||||||
detachByUser() {
|
detachByUser() {
|
||||||
driverRef.current?.detachByUser();
|
driverRef.current?.detachByUser();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
contentMeasuredForKey: null as string | null,
|
contentMeasuredForKey: null as string | null,
|
||||||
});
|
});
|
||||||
const scrollOffsetYRef = useRef(0);
|
const scrollOffsetYRef = useRef(0);
|
||||||
|
const isUserScrollActiveRef = useRef(false);
|
||||||
|
const userScrollEndFrameIdRef = useRef<number | null>(null);
|
||||||
const programmaticScrollEventBudgetRef = useRef(0);
|
const programmaticScrollEventBudgetRef = useRef(0);
|
||||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||||
@@ -129,6 +131,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearPendingUserScrollEnd = useCallback(() => {
|
||||||
|
if (userScrollEndFrameIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(userScrollEndFrameIdRef.current);
|
||||||
|
userScrollEndFrameIdRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const markNativeViewportSettling = useCallback(() => {
|
const markNativeViewportSettling = useCallback(() => {
|
||||||
clearNativeViewportSettling();
|
clearNativeViewportSettling();
|
||||||
setIsNativeViewportSettling(true);
|
setIsNativeViewportSettling(true);
|
||||||
@@ -208,6 +217,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
contentMeasuredForKey: null,
|
contentMeasuredForKey: null,
|
||||||
};
|
};
|
||||||
scrollOffsetYRef.current = 0;
|
scrollOffsetYRef.current = 0;
|
||||||
|
isUserScrollActiveRef.current = false;
|
||||||
|
clearPendingUserScrollEnd();
|
||||||
clearNativeViewportSettling();
|
clearNativeViewportSettling();
|
||||||
setIsNativeViewportSettling(false);
|
setIsNativeViewportSettling(false);
|
||||||
historyStartReadyRef.current = false;
|
historyStartReadyRef.current = false;
|
||||||
@@ -216,8 +227,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(frame);
|
cancelAnimationFrame(frame);
|
||||||
|
clearPendingUserScrollEnd();
|
||||||
};
|
};
|
||||||
}, [agentId, clearNativeViewportSettling]);
|
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const keyboardEvents = [
|
const keyboardEvents = [
|
||||||
@@ -301,7 +313,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
onNearHistoryStart();
|
onNearHistoryStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
|
if (
|
||||||
|
!isUserScrollActiveRef.current &&
|
||||||
|
programmaticScrollEventBudgetRef.current > 0 &&
|
||||||
|
contentOffset.y <= 8
|
||||||
|
) {
|
||||||
programmaticScrollEventBudgetRef.current -= 1;
|
programmaticScrollEventBudgetRef.current -= 1;
|
||||||
} else {
|
} else {
|
||||||
programmaticScrollEventBudgetRef.current = 0;
|
programmaticScrollEventBudgetRef.current = 0;
|
||||||
@@ -312,6 +328,31 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleScrollBeginDrag = useStableEvent(() => {
|
||||||
|
clearPendingUserScrollEnd();
|
||||||
|
isUserScrollActiveRef.current = true;
|
||||||
|
bottomAnchorController.beginUserScroll();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleScrollEndDrag = useStableEvent(() => {
|
||||||
|
clearPendingUserScrollEnd();
|
||||||
|
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
|
||||||
|
userScrollEndFrameIdRef.current = null;
|
||||||
|
isUserScrollActiveRef.current = false;
|
||||||
|
bottomAnchorController.endUserScroll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMomentumScrollBegin = useStableEvent(() => {
|
||||||
|
clearPendingUserScrollEnd();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMomentumScrollEnd = useStableEvent(() => {
|
||||||
|
clearPendingUserScrollEnd();
|
||||||
|
isUserScrollActiveRef.current = false;
|
||||||
|
bottomAnchorController.endUserScroll();
|
||||||
|
});
|
||||||
|
|
||||||
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
|
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
|
||||||
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
|
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
|
||||||
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
|
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
|
||||||
@@ -419,6 +460,10 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
style={listStyle}
|
style={listStyle}
|
||||||
onLayout={handleListLayout}
|
onLayout={handleListLayout}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
|
onScrollBeginDrag={handleScrollBeginDrag}
|
||||||
|
onScrollEndDrag={handleScrollEndDrag}
|
||||||
|
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||||
|
onMomentumScrollEnd={handleMomentumScrollEnd}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
onContentSizeChange={handleContentSizeChange}
|
onContentSizeChange={handleContentSizeChange}
|
||||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ import {
|
|||||||
useWorkspaceAttachmentsStore,
|
useWorkspaceAttachmentsStore,
|
||||||
} from "@/attachments/workspace-attachments-store";
|
} from "@/attachments/workspace-attachments-store";
|
||||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import {
|
|||||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||||
import { ThemedStack } from "@/navigation/themed-stack";
|
import { ThemedStack } from "@/navigation/themed-stack";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
@@ -146,9 +147,10 @@ function PushNotificationRouter() {
|
|||||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||||
const target = resolveNotificationTarget(data);
|
const target = resolveNotificationTarget(data);
|
||||||
const serverId = target.serverId;
|
const serverId = target.serverId;
|
||||||
|
const workspaceId = target.workspaceId;
|
||||||
const agentId = target.agentId;
|
const agentId = target.agentId;
|
||||||
if (serverId && agentId) {
|
if (serverId && workspaceId && agentId) {
|
||||||
navigateToAgent({ serverId, agentId, pin: true });
|
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -908,6 +910,7 @@ function AppShell() {
|
|||||||
<MobilePanelsProvider>
|
<MobilePanelsProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<OpenProjectListener />
|
<OpenProjectListener />
|
||||||
|
<AgentNavigationListener />
|
||||||
<AppWithSidebar>
|
<AppWithSidebar>
|
||||||
<WorkspaceRouteNavigationBridge />
|
<WorkspaceRouteNavigationBridge />
|
||||||
<RootStack />
|
<RootStack />
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
|
import { useFetchQuery } from "@/data/query";
|
||||||
|
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
|
||||||
|
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
@@ -21,97 +24,128 @@ function HostAgentReadyRouteContent() {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
}>();
|
}>();
|
||||||
const redirectedRef = useRef(false);
|
const handledNavigationRef = useRef<string | null>(null);
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||||
const client = useHostRuntimeClient(serverId);
|
const hosts = useHosts();
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const runtimeSnapshot = useHostRuntimeSnapshot(serverId);
|
||||||
|
const client = runtimeSnapshot?.client ?? null;
|
||||||
|
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
|
||||||
|
const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||||
const agentWorkspaceId = useSessionStore((state) => {
|
const agentWorkspaceId = useSessionStore((state) => {
|
||||||
if (!serverId || !agentId) {
|
if (!serverId || !agentId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
||||||
});
|
});
|
||||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
const shouldLookupAgent = Boolean(
|
||||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
||||||
);
|
);
|
||||||
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
const lookupQuery = useFetchQuery({
|
||||||
|
queryKey: ["agentRouteResolution", serverId, agentId, runtimeSnapshot?.clientGeneration ?? 0],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Target host client is unavailable");
|
||||||
|
}
|
||||||
|
const result = await client.fetchAgent({ agentId });
|
||||||
|
return result?.agent?.workspaceId ?? null;
|
||||||
|
},
|
||||||
|
enabled: shouldLookupAgent,
|
||||||
|
retry: false,
|
||||||
|
dataShape: "value",
|
||||||
|
staleTimeMs: 0,
|
||||||
|
});
|
||||||
|
const lookup = useMemo<AgentRouteLookup>(() => {
|
||||||
|
if (!shouldLookupAgent) {
|
||||||
|
return { kind: "idle" };
|
||||||
|
}
|
||||||
|
if (lookupQuery.isFetching) {
|
||||||
|
return { kind: "fetching" };
|
||||||
|
}
|
||||||
|
if (lookupQuery.isError) {
|
||||||
|
return { kind: "failed", error: toErrorMessage(lookupQuery.error) };
|
||||||
|
}
|
||||||
|
if (lookupQuery.isSuccess) {
|
||||||
|
return { kind: "found", workspaceId: lookupQuery.data };
|
||||||
|
}
|
||||||
|
return { kind: "fetching" };
|
||||||
|
}, [
|
||||||
|
lookupQuery.data,
|
||||||
|
lookupQuery.error,
|
||||||
|
lookupQuery.isError,
|
||||||
|
lookupQuery.isFetching,
|
||||||
|
lookupQuery.isSuccess,
|
||||||
|
shouldLookupAgent,
|
||||||
|
]);
|
||||||
|
const resolution = resolveAgentRoute({
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
cachedWorkspaceId: agentWorkspaceId,
|
||||||
|
connectionStatus,
|
||||||
|
lookup,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
let navigationKey: string | null = null;
|
||||||
return;
|
if (resolution.kind === "invalid") {
|
||||||
|
navigationKey = "invalid";
|
||||||
|
} else if (resolution.kind === "resolved") {
|
||||||
|
navigationKey = `workspace:${resolution.workspaceId}`;
|
||||||
|
} else if (resolution.kind === "notFound") {
|
||||||
|
navigationKey = "not-found";
|
||||||
}
|
}
|
||||||
if (!serverId || !agentId) {
|
if (!navigationKey || handledNavigationRef.current === navigationKey) {
|
||||||
redirectedRef.current = true;
|
|
||||||
router.replace("/" as Href);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
handledNavigationRef.current = navigationKey;
|
||||||
|
|
||||||
if (resolvedWorkspaceId) {
|
if (resolution.kind === "resolved") {
|
||||||
redirectedRef.current = true;
|
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
|
||||||
navigateToAgent({
|
return;
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
|
||||||
|
}, [agentId, resolution, router, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleRetry = useCallback(() => {
|
||||||
if (redirectedRef.current) {
|
if (resolution.kind === "lookupError") {
|
||||||
|
void lookupQuery.refetch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!serverId || !agentId) {
|
if (serverId) {
|
||||||
|
void getHostRuntimeStore().runProbeCycleNow(serverId);
|
||||||
|
}
|
||||||
|
}, [lookupQuery, resolution.kind, serverId]);
|
||||||
|
const handleManageHost = useCallback(() => {
|
||||||
|
if (serverId) {
|
||||||
|
router.push(buildSettingsHostRoute(serverId));
|
||||||
|
}
|
||||||
|
}, [router, serverId]);
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (router.canGoBack()) {
|
||||||
|
router.back();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (agentWorkspaceId && !hasHydratedWorkspaces) {
|
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
|
||||||
return;
|
}, [router, serverId]);
|
||||||
}
|
|
||||||
if (!client || !isConnected) {
|
|
||||||
redirectedRef.current = true;
|
|
||||||
router.replace(buildHostRootRoute(serverId));
|
|
||||||
}
|
|
||||||
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (
|
||||||
if (redirectedRef.current) {
|
resolution.kind === "waitingForHost" ||
|
||||||
return;
|
resolution.kind === "fetchingAgent" ||
|
||||||
}
|
resolution.kind === "lookupError"
|
||||||
if (!serverId || !agentId || !client || !isConnected) {
|
) {
|
||||||
return;
|
// Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host
|
||||||
}
|
// reconnects, then resolve the workspace from the authoritative agent record.
|
||||||
|
return (
|
||||||
let cancelled = false;
|
<AgentRouteResolutionView
|
||||||
void client
|
resolution={resolution}
|
||||||
.fetchAgent({ agentId })
|
hostName={hostName}
|
||||||
.then((result) => {
|
lastHostError={runtimeSnapshot?.lastError ?? null}
|
||||||
if (cancelled || redirectedRef.current) {
|
onRetry={handleRetry}
|
||||||
return;
|
onManageHost={handleManageHost}
|
||||||
}
|
onBack={handleBack}
|
||||||
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
|
/>
|
||||||
redirectedRef.current = true;
|
);
|
||||||
if (workspaceId) {
|
}
|
||||||
navigateToAgent({
|
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace(buildHostRootRoute(serverId));
|
|
||||||
return;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (cancelled || redirectedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
redirectedRef.current = true;
|
|
||||||
router.replace(buildHostRootRoute(serverId));
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [agentId, client, isConnected, router, serverId]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useActiveWorkspaceSelection,
|
useActiveWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||||
import {
|
import {
|
||||||
@@ -110,7 +110,9 @@ function HostWorkspaceRouteContent() {
|
|||||||
const openValue = getParamValue(globalParams.open);
|
const openValue = getParamValue(globalParams.open);
|
||||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||||
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
|
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
|
||||||
|
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
|
||||||
|
const isAgentOpenIntent = recoveryAgentId !== null;
|
||||||
const isOpenIntentWaitingForWorkspace = Boolean(
|
const isOpenIntentWaitingForWorkspace = Boolean(
|
||||||
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
|
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
|
||||||
);
|
);
|
||||||
@@ -147,7 +149,6 @@ function HostWorkspaceRouteContent() {
|
|||||||
}
|
}
|
||||||
consumedIntentRef.current = consumptionKey;
|
consumedIntentRef.current = consumptionKey;
|
||||||
|
|
||||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
|
||||||
if (openIntent) {
|
if (openIntent) {
|
||||||
prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -171,6 +172,7 @@ function HostWorkspaceRouteContent() {
|
|||||||
hasHydratedWorkspaceLayoutStore,
|
hasHydratedWorkspaceLayoutStore,
|
||||||
isOpenIntentWaitingForWorkspace,
|
isOpenIntentWaitingForWorkspace,
|
||||||
navigation,
|
navigation,
|
||||||
|
openIntent,
|
||||||
openValue,
|
openValue,
|
||||||
rootNavigationState?.key,
|
rootNavigationState?.key,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -185,10 +187,16 @@ function HostWorkspaceRouteContent() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
function WorkspaceDeck({
|
||||||
|
recoveryRequested,
|
||||||
|
recoveryAgentId,
|
||||||
|
}: {
|
||||||
|
recoveryRequested: boolean;
|
||||||
|
recoveryAgentId: string | null;
|
||||||
|
}) {
|
||||||
const activeSelection = useActiveWorkspaceSelection();
|
const activeSelection = useActiveWorkspaceSelection();
|
||||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||||
activeSelection ? [activeSelection] : [],
|
activeSelection ? [activeSelection] : [],
|
||||||
@@ -234,6 +242,7 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
|||||||
selection={selection}
|
selection={selection}
|
||||||
activeSelection={activeSelection}
|
activeSelection={activeSelection}
|
||||||
recoveryRequested={recoveryRequested}
|
recoveryRequested={recoveryRequested}
|
||||||
|
recoveryAgentId={recoveryAgentId}
|
||||||
onUnmountInactive={unmountWorkspaceSelection}
|
onUnmountInactive={unmountWorkspaceSelection}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -246,11 +255,13 @@ function WorkspaceDeckEntry({
|
|||||||
selection,
|
selection,
|
||||||
activeSelection,
|
activeSelection,
|
||||||
recoveryRequested,
|
recoveryRequested,
|
||||||
|
recoveryAgentId,
|
||||||
onUnmountInactive,
|
onUnmountInactive,
|
||||||
}: {
|
}: {
|
||||||
selection: ActiveWorkspaceSelection;
|
selection: ActiveWorkspaceSelection;
|
||||||
activeSelection: ActiveWorkspaceSelection;
|
activeSelection: ActiveWorkspaceSelection;
|
||||||
recoveryRequested: boolean;
|
recoveryRequested: boolean;
|
||||||
|
recoveryAgentId: string | null;
|
||||||
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||||
}) {
|
}) {
|
||||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||||
@@ -282,6 +293,7 @@ function WorkspaceDeckEntry({
|
|||||||
workspaceId={selection.workspaceId}
|
workspaceId={selection.workspaceId}
|
||||||
isRouteFocused={isActive}
|
isRouteFocused={isActive}
|
||||||
recoveryRequested={isActive && recoveryRequested}
|
recoveryRequested={isActive && recoveryRequested}
|
||||||
|
recoveryAgentId={isActive ? recoveryAgentId : null}
|
||||||
/>
|
/>
|
||||||
</RetainedPanel>
|
</RetainedPanel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -92,9 +92,20 @@ export interface ChatHistoryContextAttachment {
|
|||||||
|
|
||||||
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
|
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
|
||||||
|
|
||||||
|
export type WorkspaceFileSelection =
|
||||||
|
| { kind: "whole_file" }
|
||||||
|
| { kind: "line_range"; startLine: number; endLine: number };
|
||||||
|
|
||||||
|
export interface WorkspaceFileComposerAttachment {
|
||||||
|
kind: "workspace_file";
|
||||||
|
path: string;
|
||||||
|
selection: WorkspaceFileSelection;
|
||||||
|
}
|
||||||
|
|
||||||
export type UserComposerAttachment =
|
export type UserComposerAttachment =
|
||||||
| { kind: "image"; metadata: AttachmentMetadata }
|
| { kind: "image"; metadata: AttachmentMetadata }
|
||||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||||
|
| WorkspaceFileComposerAttachment
|
||||||
| { kind: "forge_issue"; item: ForgeSearchItem }
|
| { kind: "forge_issue"; item: ForgeSearchItem }
|
||||||
| { kind: "forge_change_request"; item: ForgeSearchItem }
|
| { kind: "forge_change_request"; item: ForgeSearchItem }
|
||||||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { RefCallback } from "react";
|
||||||
|
import type { View } from "react-native";
|
||||||
|
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
|
||||||
|
|
||||||
|
export function useWorkspaceFileDragSource(
|
||||||
|
_input: WorkspaceFileDragSourceInput,
|
||||||
|
): RefCallback<View> | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useCallback, useEffect, useState, type RefCallback } from "react";
|
||||||
|
import type { View } from "react-native";
|
||||||
|
import { createWorkspaceFileAttachment } from "./workspace-file";
|
||||||
|
import { serializeWorkspaceFileDragPayload, WORKSPACE_FILE_DRAG_MIME } from "./workspace-file-drag";
|
||||||
|
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
|
||||||
|
|
||||||
|
export function useWorkspaceFileDragSource({
|
||||||
|
enabled,
|
||||||
|
disabled = false,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
path,
|
||||||
|
selection,
|
||||||
|
}: WorkspaceFileDragSourceInput): RefCallback<View> {
|
||||||
|
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||||
|
const dragSourceRef = useCallback((node: View | null) => {
|
||||||
|
setElement(node as unknown as HTMLElement | null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element || !enabled || disabled || !serverId || !workspaceId) {
|
||||||
|
if (element) {
|
||||||
|
element.draggable = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceServerId = serverId;
|
||||||
|
const sourceWorkspaceId = workspaceId;
|
||||||
|
element.draggable = true;
|
||||||
|
function handleDragStart(event: DragEvent) {
|
||||||
|
if (!event.dataTransfer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.dataTransfer.effectAllowed = "copy";
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
WORKSPACE_FILE_DRAG_MIME,
|
||||||
|
serializeWorkspaceFileDragPayload({
|
||||||
|
version: 1,
|
||||||
|
serverId: sourceServerId,
|
||||||
|
workspaceId: sourceWorkspaceId,
|
||||||
|
attachment: createWorkspaceFileAttachment({ path, selection }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
element.addEventListener("dragstart", handleDragStart);
|
||||||
|
return () => {
|
||||||
|
element.draggable = false;
|
||||||
|
element.removeEventListener("dragstart", handleDragStart);
|
||||||
|
};
|
||||||
|
}, [disabled, element, enabled, path, selection, serverId, workspaceId]);
|
||||||
|
|
||||||
|
return dragSourceRef;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { WorkspaceFileSelection } from "./types";
|
||||||
|
|
||||||
|
export interface WorkspaceFileDragSourceInput {
|
||||||
|
enabled: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
serverId?: string;
|
||||||
|
workspaceId: string | null | undefined;
|
||||||
|
path: string;
|
||||||
|
selection?: WorkspaceFileSelection;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createWorkspaceFileAttachment } from "./workspace-file";
|
||||||
|
import {
|
||||||
|
parseWorkspaceFileDragPayload,
|
||||||
|
resolveWorkspaceFileDrop,
|
||||||
|
serializeWorkspaceFileDragPayload,
|
||||||
|
type WorkspaceFileDragPayload,
|
||||||
|
} from "./workspace-file-drag";
|
||||||
|
|
||||||
|
function payload(): WorkspaceFileDragPayload {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
attachment: createWorkspaceFileAttachment({
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "line_range", startLine: 12, endLine: 24 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("workspace file drag payload", () => {
|
||||||
|
it("round-trips workspace identity and future line selections", () => {
|
||||||
|
expect(parseWorkspaceFileDragPayload(serializeWorkspaceFileDragPayload(payload()))).toEqual(
|
||||||
|
payload(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed and invalid payloads", () => {
|
||||||
|
expect(parseWorkspaceFileDragPayload("not json")).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseWorkspaceFileDragPayload(
|
||||||
|
JSON.stringify({
|
||||||
|
...payload(),
|
||||||
|
attachment: {
|
||||||
|
kind: "workspace_file",
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "line_range", startLine: 24, endLine: 12 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts drops only within the originating server and workspace", () => {
|
||||||
|
const dragged = payload();
|
||||||
|
expect(
|
||||||
|
resolveWorkspaceFileDrop({
|
||||||
|
payload: dragged,
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
}),
|
||||||
|
).toEqual(dragged.attachment);
|
||||||
|
expect(
|
||||||
|
resolveWorkspaceFileDrop({
|
||||||
|
payload: dragged,
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-2",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { WorkspaceFileComposerAttachment } from "./types";
|
||||||
|
import { isWorkspaceFileComposerAttachment } from "./workspace-file";
|
||||||
|
|
||||||
|
export const WORKSPACE_FILE_DRAG_MIME = "application/x-paseo-workspace-file+json";
|
||||||
|
|
||||||
|
export interface WorkspaceFileDragPayload {
|
||||||
|
version: 1;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
attachment: WorkspaceFileComposerAttachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeWorkspaceFileDragPayload(payload: WorkspaceFileDragPayload): string {
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkspaceFileDragPayload(serialized: string): WorkspaceFileDragPayload | null {
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(serialized);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
record.version !== 1 ||
|
||||||
|
typeof record.serverId !== "string" ||
|
||||||
|
record.serverId.length === 0 ||
|
||||||
|
typeof record.workspaceId !== "string" ||
|
||||||
|
record.workspaceId.length === 0 ||
|
||||||
|
!isWorkspaceFileComposerAttachment(record.attachment)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
serverId: record.serverId,
|
||||||
|
workspaceId: record.workspaceId,
|
||||||
|
attachment: record.attachment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkspaceFileDrop(input: {
|
||||||
|
payload: WorkspaceFileDragPayload;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}): WorkspaceFileComposerAttachment | null {
|
||||||
|
return input.payload.serverId === input.serverId &&
|
||||||
|
input.payload.workspaceId === input.workspaceId
|
||||||
|
? input.payload.attachment
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
|
import {
|
||||||
|
appendWorkspaceFileAttachment,
|
||||||
|
createWorkspaceFileAttachment,
|
||||||
|
getWorkspaceFileAttachmentKey,
|
||||||
|
getWorkspaceFileAttachmentSubtitle,
|
||||||
|
workspaceFileAttachmentToAgentAttachment,
|
||||||
|
} from "./workspace-file";
|
||||||
|
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||||
|
|
||||||
|
describe("workspace file attachments", () => {
|
||||||
|
it("models whole files and line ranges as distinct selections", () => {
|
||||||
|
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
|
||||||
|
const lineRange = createWorkspaceFileAttachment({
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "line_range", startLine: 12, endLine: 24 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wholeFile).toEqual({
|
||||||
|
kind: "workspace_file",
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "whole_file" },
|
||||||
|
});
|
||||||
|
expect(getWorkspaceFileAttachmentKey(wholeFile)).not.toBe(
|
||||||
|
getWorkspaceFileAttachmentKey(lineRange),
|
||||||
|
);
|
||||||
|
expect(getWorkspaceFileAttachmentSubtitle(wholeFile)).toBe("src/app.ts");
|
||||||
|
expect(getWorkspaceFileAttachmentSubtitle(lineRange)).toBe("src/app.ts · 12-24");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates only identical paths and selections", () => {
|
||||||
|
const image = {
|
||||||
|
kind: "image" as const,
|
||||||
|
metadata: {
|
||||||
|
id: "image-1",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb" as const,
|
||||||
|
storageKey: "image-1",
|
||||||
|
createdAt: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
|
||||||
|
const range = createWorkspaceFileAttachment({
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "line_range", startLine: 1, endLine: 5 },
|
||||||
|
});
|
||||||
|
const current: UserComposerAttachment[] = [image, wholeFile];
|
||||||
|
|
||||||
|
expect(appendWorkspaceFileAttachment(current, wholeFile)).toBe(current);
|
||||||
|
expect(appendWorkspaceFileAttachment(current, range)).toEqual([image, wholeFile, range]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits a path reference without uploading or inserting prompt text", () => {
|
||||||
|
const attachment = createWorkspaceFileAttachment({
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "line_range", startLine: 12, endLine: 24 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workspaceFileAttachmentToAgentAttachment(attachment)).toEqual({
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
title: "app.ts",
|
||||||
|
text: "Workspace file: src/app.ts\nLines: 12-24",
|
||||||
|
});
|
||||||
|
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
|
||||||
|
images: [],
|
||||||
|
attachments: [workspaceFileAttachmentToAgentAttachment(attachment)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||||
|
import type {
|
||||||
|
UserComposerAttachment,
|
||||||
|
WorkspaceFileComposerAttachment,
|
||||||
|
WorkspaceFileSelection,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface CreateWorkspaceFileAttachmentInput {
|
||||||
|
path: string;
|
||||||
|
selection?: WorkspaceFileSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
return path.trim().replace(/^\.\//, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkspaceFileComposerAttachment(
|
||||||
|
value: unknown,
|
||||||
|
): value is WorkspaceFileComposerAttachment {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
record.kind !== "workspace_file" ||
|
||||||
|
typeof record.path !== "string" ||
|
||||||
|
record.path.trim().length === 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const selection = record.selection;
|
||||||
|
if (!selection || typeof selection !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { kind, startLine, endLine } = selection as Record<string, unknown>;
|
||||||
|
if (kind === "whole_file") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
kind === "line_range" &&
|
||||||
|
typeof startLine === "number" &&
|
||||||
|
Number.isInteger(startLine) &&
|
||||||
|
typeof endLine === "number" &&
|
||||||
|
Number.isInteger(endLine) &&
|
||||||
|
startLine > 0 &&
|
||||||
|
endLine >= startLine
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkspaceFileAttachment({
|
||||||
|
path,
|
||||||
|
selection = { kind: "whole_file" },
|
||||||
|
}: CreateWorkspaceFileAttachmentInput): WorkspaceFileComposerAttachment {
|
||||||
|
return {
|
||||||
|
kind: "workspace_file",
|
||||||
|
path: normalizePath(path),
|
||||||
|
selection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspaceFileAttachmentKey(attachment: WorkspaceFileComposerAttachment): string {
|
||||||
|
const selection = attachment.selection;
|
||||||
|
const selectionKey =
|
||||||
|
selection.kind === "whole_file"
|
||||||
|
? selection.kind
|
||||||
|
: `${selection.kind}:${selection.startLine}-${selection.endLine}`;
|
||||||
|
return `${normalizePath(attachment.path)}:${selectionKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendWorkspaceFileAttachment(
|
||||||
|
current: UserComposerAttachment[],
|
||||||
|
attachment: WorkspaceFileComposerAttachment,
|
||||||
|
): UserComposerAttachment[] {
|
||||||
|
const attachmentKey = getWorkspaceFileAttachmentKey(attachment);
|
||||||
|
const alreadyAttached = current.some(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.kind === "workspace_file" &&
|
||||||
|
getWorkspaceFileAttachmentKey(candidate) === attachmentKey,
|
||||||
|
);
|
||||||
|
return alreadyAttached ? current : [...current, attachment];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceFileAttachmentToAgentAttachment(
|
||||||
|
attachment: WorkspaceFileComposerAttachment,
|
||||||
|
): Extract<AgentAttachment, { type: "text" }> {
|
||||||
|
const fileName = attachment.path.split("/").pop() ?? attachment.path;
|
||||||
|
const lines =
|
||||||
|
attachment.selection.kind === "line_range"
|
||||||
|
? `\nLines: ${attachment.selection.startLine}-${attachment.selection.endLine}`
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
title: fileName,
|
||||||
|
text: `Workspace file: ${attachment.path}${lines}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspaceFileAttachmentSubtitle(
|
||||||
|
attachment: WorkspaceFileComposerAttachment,
|
||||||
|
): string {
|
||||||
|
if (attachment.selection.kind === "whole_file") {
|
||||||
|
return attachment.path;
|
||||||
|
}
|
||||||
|
return `${attachment.path} · ${attachment.selection.startLine}-${attachment.selection.endLine}`;
|
||||||
|
}
|
||||||
@@ -4,10 +4,8 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
|||||||
import { mountBrowserAutomationHandler } from "./handler";
|
import { mountBrowserAutomationHandler } from "./handler";
|
||||||
import type { DesktopHostBridge } from "@/desktop/host";
|
import type { DesktopHostBridge } from "@/desktop/host";
|
||||||
import { useBrowserStore } from "@/stores/browser-store";
|
import { useBrowserStore } from "@/stores/browser-store";
|
||||||
import {
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
buildWorkspaceTabPersistenceKey,
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
useWorkspaceLayoutStore,
|
|
||||||
} from "@/stores/workspace-layout-store";
|
|
||||||
|
|
||||||
type BrowserAutomationExecuteRequest = Extract<
|
type BrowserAutomationExecuteRequest = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ import {
|
|||||||
resizeResidentBrowserWebview,
|
resizeResidentBrowserWebview,
|
||||||
} from "@/components/browser-webview-resident";
|
} from "@/components/browser-webview-resident";
|
||||||
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
||||||
import {
|
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
buildWorkspaceTabPersistenceKey,
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
collectAllTabs,
|
|
||||||
useWorkspaceLayoutStore,
|
|
||||||
} from "@/stores/workspace-layout-store";
|
|
||||||
|
|
||||||
type BrowserAutomationExecuteRequest = Extract<
|
type BrowserAutomationExecuteRequest = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Agent } from "@/stores/session-store";
|
import type { Agent } from "@/stores/session-store";
|
||||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
|
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
|
||||||
export type ClientSlashCommandExecution = "immediate" | "insert";
|
export type ClientSlashCommandExecution = "immediate" | "insert";
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ export function AgentList({
|
|||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
workspaceId: agent.workspaceId,
|
workspaceId: agent.workspaceId,
|
||||||
|
pin: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[isActionSheetVisible, onAgentSelect],
|
[isActionSheetVisible, onAgentSelect],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
|||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
|
|
||||||
interface ArchivedAgentCalloutProps {
|
interface ArchivedAgentCalloutProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -21,6 +22,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
|||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const [isUnarchiving, setIsUnarchiving] = useState(false);
|
const [isUnarchiving, setIsUnarchiving] = useState(false);
|
||||||
|
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
|
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
|
||||||
|
|
||||||
@@ -32,10 +34,11 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
|||||||
const handleUnarchive = useCallback(async () => {
|
const handleUnarchive = useCallback(async () => {
|
||||||
if (!client || !isConnected || isUnarchiving) return;
|
if (!client || !isConnected || isUnarchiving) return;
|
||||||
setIsUnarchiving(true);
|
setIsUnarchiving(true);
|
||||||
|
setUnarchiveError(null);
|
||||||
try {
|
try {
|
||||||
await client.refreshAgent(agentId);
|
await client.refreshAgent(agentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
|
setUnarchiveError(toErrorMessage(error));
|
||||||
setIsUnarchiving(false);
|
setIsUnarchiving(false);
|
||||||
}
|
}
|
||||||
}, [client, isConnected, isUnarchiving, agentId]);
|
}, [client, isConnected, isUnarchiving, agentId]);
|
||||||
@@ -44,16 +47,23 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
|||||||
<Animated.View style={containerStyle}>
|
<Animated.View style={containerStyle}>
|
||||||
<View style={styles.inputAreaContainer}>
|
<View style={styles.inputAreaContainer}>
|
||||||
<View style={styles.inputAreaContent}>
|
<View style={styles.inputAreaContent}>
|
||||||
<View style={styles.callout}>
|
<View style={styles.calloutStack}>
|
||||||
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
<View style={styles.callout}>
|
||||||
<Button
|
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
||||||
size="sm"
|
<Button
|
||||||
variant="secondary"
|
size="sm"
|
||||||
onPress={handleUnarchive}
|
variant="secondary"
|
||||||
disabled={!isConnected || isUnarchiving}
|
onPress={handleUnarchive}
|
||||||
>
|
disabled={!isConnected || isUnarchiving}
|
||||||
{t("agentPanel.archived.unarchive")}
|
>
|
||||||
</Button>
|
{t("agentPanel.archived.unarchive")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
{unarchiveError ? (
|
||||||
|
<Text style={styles.errorText} testID="agent-unarchive-error">
|
||||||
|
{unarchiveError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -97,8 +107,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
md: theme.spacing[6],
|
md: theme.spacing[6],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
calloutStack: {
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
calloutText: {
|
calloutText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
|
errorText: {
|
||||||
|
color: theme.colors.statusDanger,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
})) as unknown as Record<string, object>;
|
})) as unknown as Record<string, object>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
|||||||
interface DiffStatProps {
|
interface DiffStatProps {
|
||||||
additions: number;
|
additions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||||
@@ -15,9 +16,9 @@ export function formatDiffCount(value: number): string {
|
|||||||
return compactFormatter.format(value).toLowerCase();
|
return compactFormatter.format(value).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiffStat({ additions, deletions }: DiffStatProps) {
|
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row} testID={testID}>
|
||||||
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
|
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
|
||||||
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
|
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
|
|||||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||||
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
|
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
|
||||||
|
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||||
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
|
|
||||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||||
|
|
||||||
@@ -386,15 +391,16 @@ function ExplorerSidebarContent({
|
|||||||
{/* Content based on active tab */}
|
{/* Content based on active tab */}
|
||||||
<View style={styles.contentArea} testID="explorer-content-area">
|
<View style={styles.contentArea} testID="explorer-content-area">
|
||||||
{resolvedTab === "changes" && (
|
{resolvedTab === "changes" && (
|
||||||
<GitDiffPane
|
<ChangedFilesPane
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
cwd={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
enabled={isOpen}
|
isOpen={isOpen}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{resolvedTab === "files" && (
|
{resolvedTab === "files" && (
|
||||||
<FileExplorerPane
|
<FilesPane
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
@@ -415,6 +421,83 @@ function ExplorerSidebarContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared add-to-chat state for the changes/files panes: both expose an "add file
|
||||||
|
* to chat" action that attaches the file to the focused chat's composer.
|
||||||
|
* Available only when a workspace with a focused chat is available.
|
||||||
|
*/
|
||||||
|
function useAddFileToChat({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
|
||||||
|
const workspaceKey = workspaceId
|
||||||
|
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
|
||||||
|
: null;
|
||||||
|
const layout = useWorkspaceLayoutStore((state) =>
|
||||||
|
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||||
|
);
|
||||||
|
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||||
|
const focusedChat = useMemo(
|
||||||
|
() => resolveFocusedChatTarget({ serverId, layout }),
|
||||||
|
[serverId, layout],
|
||||||
|
);
|
||||||
|
const addFile = useCallback(
|
||||||
|
(filePath: string) => {
|
||||||
|
if (!focusedChat || !workspaceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void useDraftStore.getState().attachWorkspaceFile({
|
||||||
|
draftKey: focusedChat.draftKey,
|
||||||
|
attachment: createWorkspaceFileAttachment({ path: filePath }),
|
||||||
|
});
|
||||||
|
focusTab(workspaceKey, focusedChat.tabId);
|
||||||
|
},
|
||||||
|
[focusTab, focusedChat, workspaceKey],
|
||||||
|
);
|
||||||
|
return { addFile, canAddToChat: focusedChat !== null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangedFilesPane({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRoot,
|
||||||
|
isOpen,
|
||||||
|
onOpenFile,
|
||||||
|
}: Pick<
|
||||||
|
SidebarContentProps,
|
||||||
|
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
|
||||||
|
>) {
|
||||||
|
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||||
|
return (
|
||||||
|
<GitDiffPane
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
cwd={workspaceRoot}
|
||||||
|
enabled={isOpen}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
onAddToChat={canAddToChat ? addFile : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilesPane({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRoot,
|
||||||
|
onOpenFile,
|
||||||
|
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
|
||||||
|
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||||
|
return (
|
||||||
|
<FileExplorerPane
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
onAddToChat={canAddToChat ? addFile : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface PrTabContentProps {
|
interface PrTabContentProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useMemo, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { type PressableStateCallbackType } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
MessageSquarePlus,
|
||||||
|
MoreVertical,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||||
|
|
||||||
|
/** Width occupied by a file action trigger, including its visual padding. */
|
||||||
|
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
|
||||||
|
|
||||||
|
interface FileAction {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
onSelect: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileActionsMenuProps {
|
||||||
|
fileKind: "file" | "directory";
|
||||||
|
fileExists?: boolean;
|
||||||
|
onOpenFile?: () => void;
|
||||||
|
onCopyPath?: () => void;
|
||||||
|
onDownload?: () => void;
|
||||||
|
onAddToChat?: () => void;
|
||||||
|
/** Optional metadata block rendered above the actions (e.g. size/modified). */
|
||||||
|
header?: ReactNode;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
hitSlop?: number;
|
||||||
|
accessibilityLabel: string;
|
||||||
|
testIDPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The menu lives inside pressable rows (diff header, explorer entry); stop the
|
||||||
|
// press so opening it doesn't also trigger the row.
|
||||||
|
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
|
||||||
|
event.stopPropagation?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerStyle({
|
||||||
|
hovered,
|
||||||
|
pressed,
|
||||||
|
open,
|
||||||
|
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
|
||||||
|
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
|
||||||
|
* git diff pane so both surfaces share action availability, ordering, and chrome.
|
||||||
|
*/
|
||||||
|
export function FileActionsMenu({
|
||||||
|
fileKind,
|
||||||
|
fileExists = true,
|
||||||
|
onOpenFile,
|
||||||
|
onCopyPath,
|
||||||
|
onDownload,
|
||||||
|
onAddToChat,
|
||||||
|
header,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
hitSlop = 12,
|
||||||
|
accessibilityLabel,
|
||||||
|
testIDPrefix,
|
||||||
|
}: FileActionsMenuProps): ReactElement | null {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const actions = useMemo<FileAction[]>(() => {
|
||||||
|
const availableFile = fileKind === "file" && fileExists;
|
||||||
|
const next: FileAction[] = [];
|
||||||
|
if (availableFile && onOpenFile) {
|
||||||
|
next.push({
|
||||||
|
key: "open-file",
|
||||||
|
label: t("workspace.fileActions.openFile"),
|
||||||
|
icon: FileText,
|
||||||
|
onSelect: onOpenFile,
|
||||||
|
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (onCopyPath) {
|
||||||
|
next.push({
|
||||||
|
key: "copy-path",
|
||||||
|
label: t("workspace.fileActions.copyPath"),
|
||||||
|
icon: Copy,
|
||||||
|
onSelect: onCopyPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (availableFile && onDownload) {
|
||||||
|
next.push({
|
||||||
|
key: "download",
|
||||||
|
label: t("workspace.fileActions.download"),
|
||||||
|
icon: Download,
|
||||||
|
onSelect: onDownload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (availableFile && onAddToChat) {
|
||||||
|
next.push({
|
||||||
|
key: "add-to-chat",
|
||||||
|
label: t("workspace.fileActions.addToChat"),
|
||||||
|
icon: MessageSquarePlus,
|
||||||
|
onSelect: onAddToChat,
|
||||||
|
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
|
||||||
|
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
hitSlop={hitSlop}
|
||||||
|
onPressIn={stopTriggerPropagation}
|
||||||
|
style={triggerStyle}
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
|
||||||
|
>
|
||||||
|
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={220}>
|
||||||
|
{header ? (
|
||||||
|
<>
|
||||||
|
{header}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{actions.map((action) => (
|
||||||
|
<FileActionMenuItem key={action.key} action={action} />
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
|
||||||
|
const Icon = action.icon;
|
||||||
|
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
|
||||||
|
const leading = useMemo(
|
||||||
|
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
|
||||||
|
[ThemedIcon],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
|
||||||
|
{action.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
trigger: {
|
||||||
|
// The hover box comes from padding, but an equal negative vertical margin
|
||||||
|
// cancels its height contribution so the trigger overlaps the row's natural
|
||||||
|
// line height instead of growing it. The comfortable tap target is `hitSlop`,
|
||||||
|
// never padding.
|
||||||
|
padding: theme.spacing[1],
|
||||||
|
width: FILE_ACTIONS_MENU_WIDTH,
|
||||||
|
marginVertical: -theme.spacing[1],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
triggerActive: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ImageAttachment } from "@/composer/types";
|
import type { ImageAttachment } from "@/composer/types";
|
||||||
|
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
|
||||||
|
|
||||||
export interface DroppedFileItem {
|
export interface DroppedFileItem {
|
||||||
kind: "web-file";
|
kind: "web-file";
|
||||||
@@ -18,4 +19,5 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
|
|||||||
export interface FileDropSink {
|
export interface FileDropSink {
|
||||||
onFiles: (images: ImageAttachment[]) => void;
|
onFiles: (images: ImageAttachment[]) => void;
|
||||||
onGenericFiles?: (items: DroppedItem[]) => void;
|
onGenericFiles?: (items: DroppedItem[]) => void;
|
||||||
|
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
} from "@/attachments/file-types";
|
} from "@/attachments/file-types";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
|
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
|
||||||
|
import {
|
||||||
|
parseWorkspaceFileDragPayload,
|
||||||
|
WORKSPACE_FILE_DRAG_MIME,
|
||||||
|
} from "@/attachments/workspace-file-drag";
|
||||||
|
|
||||||
type DesktopDragDropPayload =
|
type DesktopDragDropPayload =
|
||||||
| { type: "enter"; paths: string[] }
|
| { type: "enter"; paths: string[] }
|
||||||
@@ -185,7 +189,11 @@ export function useDropListeners({
|
|||||||
if (disabledRef.current) return;
|
if (disabledRef.current) return;
|
||||||
|
|
||||||
dragCounter.current++;
|
dragCounter.current++;
|
||||||
if (e.dataTransfer?.types.includes("Files")) {
|
if (suppressed.value || !hasSink.value) return;
|
||||||
|
const types = new Set(e.dataTransfer?.types ?? []);
|
||||||
|
const acceptsWorkspaceFile =
|
||||||
|
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
|
||||||
|
if (types.has("Files") || acceptsWorkspaceFile) {
|
||||||
isDragging.value = true;
|
isDragging.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +205,11 @@ export function useDropListeners({
|
|||||||
if (!e.dataTransfer) return;
|
if (!e.dataTransfer) return;
|
||||||
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
|
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
|
||||||
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
|
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
|
||||||
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
|
const types = new Set(e.dataTransfer.types);
|
||||||
|
const acceptsWorkspaceFile =
|
||||||
|
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
|
||||||
|
const acceptsDrop = types.has("Files") || acceptsWorkspaceFile;
|
||||||
|
const canAccept = acceptsDrop && !disabledRef.current && !suppressed.value && hasSink.value;
|
||||||
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
|
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +237,14 @@ export function useDropListeners({
|
|||||||
const sink = getSink();
|
const sink = getSink();
|
||||||
if (!sink) return;
|
if (!sink) return;
|
||||||
|
|
||||||
|
const serializedWorkspaceFile = e.dataTransfer?.getData(WORKSPACE_FILE_DRAG_MIME);
|
||||||
|
if (serializedWorkspaceFile && sink.onWorkspaceFile) {
|
||||||
|
const payload = parseWorkspaceFileDragPayload(serializedWorkspaceFile);
|
||||||
|
if (payload) {
|
||||||
|
sink.onWorkspaceFile(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||||
const genericItems: DroppedItem[] = files.map((file) => ({
|
const genericItems: DroppedItem[] = files.map((file) => ({
|
||||||
kind: "web-file",
|
kind: "web-file",
|
||||||
|
|||||||
@@ -15,35 +15,26 @@ import {
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import {
|
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
|
||||||
ChevronDown,
|
|
||||||
Copy,
|
|
||||||
Download,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
MoreVertical,
|
|
||||||
RotateCw,
|
|
||||||
} from "lucide-react-native";
|
|
||||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||||
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
|
import {
|
||||||
|
TreeChevron,
|
||||||
|
TreeIndentGuides,
|
||||||
|
treeRowPaddingLeft,
|
||||||
|
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||||
|
} from "@/components/tree-primitives";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useDownloadStore } from "@/stores/download-store";
|
import { FileActionsMenu } from "@/components/file-actions-menu";
|
||||||
import {
|
import { useFileDownload } from "@/hooks/use-file-download";
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||||
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||||
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
|
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
|
||||||
|
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
||||||
|
|
||||||
const SORT_OPTIONS: { value: SortOption }[] = [
|
const SORT_OPTIONS: { value: SortOption }[] = [
|
||||||
{ value: "name" },
|
{ value: "name" },
|
||||||
@@ -62,6 +53,8 @@ function formatFileSize({ size }: { size: number }): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TreeRowItemProps {
|
interface TreeRowItemProps {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId?: string | null;
|
||||||
entry: ExplorerEntry;
|
entry: ExplorerEntry;
|
||||||
depth: number;
|
depth: number;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
@@ -70,21 +63,8 @@ interface TreeRowItemProps {
|
|||||||
onEntryPress: (entry: ExplorerEntry) => void;
|
onEntryPress: (entry: ExplorerEntry) => void;
|
||||||
onCopyPath: (path: string) => void;
|
onCopyPath: (path: string) => void;
|
||||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||||
}
|
onAddToChat?: (path: string) => void;
|
||||||
|
testID?: string;
|
||||||
function stopPressInPropagation(event: { stopPropagation?: () => void }) {
|
|
||||||
event.stopPropagation?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function menuButtonStyle({
|
|
||||||
hovered,
|
|
||||||
pressed,
|
|
||||||
open,
|
|
||||||
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
|
|
||||||
return [
|
|
||||||
styles.menuButton,
|
|
||||||
(Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortTriggerStyle({
|
function sortTriggerStyle({
|
||||||
@@ -103,6 +83,8 @@ function treeRowKeyExtractor(row: TreeRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TreeRowItem({
|
function TreeRowItem({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
entry,
|
entry,
|
||||||
depth,
|
depth,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
@@ -111,10 +93,17 @@ function TreeRowItem({
|
|||||||
onEntryPress,
|
onEntryPress,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
onDownloadEntry,
|
onDownloadEntry,
|
||||||
|
onAddToChat,
|
||||||
|
testID,
|
||||||
}: TreeRowItemProps) {
|
}: TreeRowItemProps) {
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isDirectory = entry.kind === "directory";
|
const isDirectory = entry.kind === "directory";
|
||||||
|
const dragSourceRef = useWorkspaceFileDragSource({
|
||||||
|
enabled: !isDirectory,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
path: entry.path,
|
||||||
|
});
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
onEntryPress(entry);
|
onEntryPress(entry);
|
||||||
@@ -123,10 +112,10 @@ function TreeRowItem({
|
|||||||
const pressableStyle = useCallback(
|
const pressableStyle = useCallback(
|
||||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
styles.entryRow,
|
styles.entryRow,
|
||||||
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
|
{ paddingLeft: treeRowPaddingLeft(depth) },
|
||||||
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
||||||
],
|
],
|
||||||
[depth, isSelected, theme.spacing],
|
[depth, isSelected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
@@ -137,19 +126,38 @@ function TreeRowItem({
|
|||||||
onDownloadEntry(entry);
|
onDownloadEntry(entry);
|
||||||
}, [onDownloadEntry, entry]);
|
}, [onDownloadEntry, entry]);
|
||||||
|
|
||||||
const copyLeading = useMemo(
|
const handleAddToChat = useCallback(() => {
|
||||||
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
|
onAddToChat?.(entry.path);
|
||||||
[theme.colors.foregroundMuted],
|
}, [onAddToChat, entry.path]);
|
||||||
);
|
|
||||||
const downloadLeading = useMemo(
|
const metaHeader = useMemo(
|
||||||
() => <Download size={14} color={theme.colors.foregroundMuted} />,
|
() => (
|
||||||
[theme.colors.foregroundMuted],
|
<View style={styles.contextMetaBlock}>
|
||||||
|
<View style={styles.contextMetaRow}>
|
||||||
|
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||||
|
{t("workspace.fileExplorer.context.size")}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{formatFileSize({ size: entry.size })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.contextMetaRow}>
|
||||||
|
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||||
|
{t("workspace.fileExplorer.context.modified")}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{formatTimeAgo(new Date(entry.modifiedAt))}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[entry.modifiedAt, entry.size, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={pressableStyle}>
|
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
|
||||||
<TreeIndentGuides depth={depth} />
|
<TreeIndentGuides depth={depth} />
|
||||||
<View style={styles.entryInfo}>
|
<View ref={dragSourceRef} style={styles.entryInfo}>
|
||||||
<View style={styles.entryIcon}>
|
<View style={styles.entryIcon}>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (!isDirectory) {
|
if (!isDirectory) {
|
||||||
@@ -163,40 +171,15 @@ function TreeRowItem({
|
|||||||
{entry.name}
|
{entry.name}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu>
|
<FileActionsMenu
|
||||||
<DropdownMenuTrigger hitSlop={8} onPressIn={stopPressInPropagation} style={menuButtonStyle}>
|
fileKind={entry.kind}
|
||||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
onCopyPath={handleCopy}
|
||||||
</DropdownMenuTrigger>
|
onDownload={handleDownload}
|
||||||
<DropdownMenuContent align="end" width={220}>
|
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
||||||
<View style={styles.contextMetaBlock}>
|
header={metaHeader}
|
||||||
<View style={styles.contextMetaRow}>
|
accessibilityLabel={t("workspace.fileActions.moreActions")}
|
||||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
testIDPrefix={testID}
|
||||||
{t("workspace.fileExplorer.context.size")}
|
/>
|
||||||
</Text>
|
|
||||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
|
||||||
{formatFileSize({ size: entry.size })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.contextMetaRow}>
|
|
||||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
|
||||||
{t("workspace.fileExplorer.context.modified")}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
|
||||||
{formatTimeAgo(new Date(entry.modifiedAt))}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
|
|
||||||
{t("workspace.fileExplorer.context.copyPath")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{entry.kind === "file" ? (
|
|
||||||
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
|
|
||||||
{t("workspace.fileExplorer.context.download")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -206,6 +189,7 @@ interface FileExplorerPaneProps {
|
|||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
onOpenFile?: (filePath: string) => void;
|
onOpenFile?: (filePath: string) => void;
|
||||||
|
onAddToChat?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TreeRow {
|
interface TreeRow {
|
||||||
@@ -218,14 +202,10 @@ export function FileExplorerPane({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
onAddToChat,
|
||||||
}: FileExplorerPaneProps) {
|
}: FileExplorerPaneProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const daemons = useHosts();
|
|
||||||
const daemonProfile = useMemo(
|
|
||||||
() => daemons.find((daemon) => daemon.serverId === serverId),
|
|
||||||
[daemons, serverId],
|
|
||||||
);
|
|
||||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||||
const workspaceStateKey = useMemo(
|
const workspaceStateKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -235,10 +215,6 @@ export function FileExplorerPane({
|
|||||||
}),
|
}),
|
||||||
[normalizedWorkspaceRoot, workspaceId],
|
[normalizedWorkspaceRoot, workspaceId],
|
||||||
);
|
);
|
||||||
const workspaceScopeId = useMemo(
|
|
||||||
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
|
||||||
[normalizedWorkspaceRoot, workspaceId],
|
|
||||||
);
|
|
||||||
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
|
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
|
||||||
const explorerState = useSessionStore((state) =>
|
const explorerState = useSessionStore((state) =>
|
||||||
workspaceStateKey && state.sessions[serverId]
|
workspaceStateKey && state.sessions[serverId]
|
||||||
@@ -246,12 +222,16 @@ export function FileExplorerPane({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
|
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
|
||||||
useFileExplorerActions({
|
serverId,
|
||||||
serverId,
|
workspaceId,
|
||||||
workspaceId,
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
workspaceRoot: normalizedWorkspaceRoot,
|
});
|
||||||
});
|
const downloadFile = useFileDownload({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
|
});
|
||||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||||
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
|
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
|
||||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||||
@@ -346,18 +326,14 @@ export function FileExplorerPane({
|
|||||||
[normalizedWorkspaceRoot],
|
[normalizedWorkspaceRoot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const startDownload = useDownloadStore((state) => state.startDownload);
|
|
||||||
const handleDownloadEntry = useCallback(
|
const handleDownloadEntry = useCallback(
|
||||||
(entry: ExplorerEntry) =>
|
(entry: ExplorerEntry) => {
|
||||||
downloadExplorerEntry({
|
if (entry.kind !== "file") {
|
||||||
entry,
|
return;
|
||||||
workspaceScopeId,
|
}
|
||||||
serverId,
|
downloadFile({ fileName: entry.name, path: entry.path });
|
||||||
daemonProfile,
|
},
|
||||||
startDownload,
|
[downloadFile],
|
||||||
requestFileDownloadToken,
|
|
||||||
}),
|
|
||||||
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSortCycle = useCallback(() => {
|
const handleSortCycle = useCallback(() => {
|
||||||
@@ -419,6 +395,8 @@ export function FileExplorerPane({
|
|||||||
const renderTreeRow = useCallback(
|
const renderTreeRow = useCallback(
|
||||||
(info: ListRenderItemInfo<TreeRow>) => (
|
(info: ListRenderItemInfo<TreeRow>) => (
|
||||||
<TreeRowDispatcher
|
<TreeRowDispatcher
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
info={info}
|
info={info}
|
||||||
expandedPaths={expandedPaths}
|
expandedPaths={expandedPaths}
|
||||||
selectedEntryPath={selectedEntryPath}
|
selectedEntryPath={selectedEntryPath}
|
||||||
@@ -426,6 +404,7 @@ export function FileExplorerPane({
|
|||||||
onEntryPress={handleEntryPress}
|
onEntryPress={handleEntryPress}
|
||||||
onCopyPath={handleCopyPath}
|
onCopyPath={handleCopyPath}
|
||||||
onDownloadEntry={handleDownloadEntry}
|
onDownloadEntry={handleDownloadEntry}
|
||||||
|
onAddToChat={onAddToChat}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
@@ -435,6 +414,9 @@ export function FileExplorerPane({
|
|||||||
handleDownloadEntry,
|
handleDownloadEntry,
|
||||||
isDirectoryLoading,
|
isDirectoryLoading,
|
||||||
selectedEntryPath,
|
selectedEntryPath,
|
||||||
|
onAddToChat,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -576,8 +558,14 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||||
<View style={styles.paneHeader} testID="files-pane-header">
|
<View style={styles.paneHeader} testID="files-pane-header">
|
||||||
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
|
<Pressable
|
||||||
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
|
onPress={handleSortCycle}
|
||||||
|
style={sortTriggerStyleProp}
|
||||||
|
testID="files-sort-trigger"
|
||||||
|
>
|
||||||
|
<Text style={styles.sortTriggerText} testID="files-sort-label">
|
||||||
|
{currentSortLabel}
|
||||||
|
</Text>
|
||||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
@@ -588,6 +576,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
|
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
|
||||||
accessibilityState={hiddenFilesToggleAccessibilityState}
|
accessibilityState={hiddenFilesToggleAccessibilityState}
|
||||||
|
testID="files-hidden-toggle"
|
||||||
>
|
>
|
||||||
{showHiddenFiles ? (
|
{showHiddenFiles ? (
|
||||||
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
@@ -606,6 +595,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
|||||||
? t("workspace.fileExplorer.actions.refreshing")
|
? t("workspace.fileExplorer.actions.refreshing")
|
||||||
: t("workspace.fileExplorer.actions.refresh")
|
: t("workspace.fileExplorer.actions.refresh")
|
||||||
}
|
}
|
||||||
|
testID="files-refresh"
|
||||||
>
|
>
|
||||||
<View style={styles.refreshIcon}>
|
<View style={styles.refreshIcon}>
|
||||||
{isRefreshFetching ? (
|
{isRefreshFetching ? (
|
||||||
@@ -778,39 +768,6 @@ function resolveTreeRows({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type StartDownloadFn = ReturnType<typeof useDownloadStore.getState>["startDownload"];
|
|
||||||
type StartDownloadParams = Parameters<StartDownloadFn>[0];
|
|
||||||
|
|
||||||
function downloadExplorerEntry({
|
|
||||||
entry,
|
|
||||||
workspaceScopeId,
|
|
||||||
serverId,
|
|
||||||
daemonProfile,
|
|
||||||
startDownload,
|
|
||||||
requestFileDownloadToken,
|
|
||||||
}: {
|
|
||||||
entry: ExplorerEntry;
|
|
||||||
workspaceScopeId: string | undefined;
|
|
||||||
serverId: string;
|
|
||||||
daemonProfile: StartDownloadParams["daemonProfile"];
|
|
||||||
startDownload: StartDownloadFn;
|
|
||||||
requestFileDownloadToken: (
|
|
||||||
targetPath: string,
|
|
||||||
) => ReturnType<StartDownloadParams["requestFileDownloadToken"]>;
|
|
||||||
}): void {
|
|
||||||
if (!workspaceScopeId || entry.kind !== "file") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
startDownload({
|
|
||||||
serverId,
|
|
||||||
scopeId: workspaceScopeId,
|
|
||||||
fileName: entry.name,
|
|
||||||
path: entry.path,
|
|
||||||
daemonProfile,
|
|
||||||
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDirectory({
|
function toggleDirectory({
|
||||||
entry,
|
entry,
|
||||||
workspaceStateKey,
|
workspaceStateKey,
|
||||||
@@ -850,6 +807,8 @@ function toggleDirectory({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TreeRowDispatcher({
|
function TreeRowDispatcher({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
info,
|
info,
|
||||||
expandedPaths,
|
expandedPaths,
|
||||||
selectedEntryPath,
|
selectedEntryPath,
|
||||||
@@ -857,7 +816,10 @@ function TreeRowDispatcher({
|
|||||||
onEntryPress,
|
onEntryPress,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
onDownloadEntry,
|
onDownloadEntry,
|
||||||
|
onAddToChat,
|
||||||
}: {
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId?: string | null;
|
||||||
info: ListRenderItemInfo<TreeRow>;
|
info: ListRenderItemInfo<TreeRow>;
|
||||||
expandedPaths: Set<string>;
|
expandedPaths: Set<string>;
|
||||||
selectedEntryPath: string | null;
|
selectedEntryPath: string | null;
|
||||||
@@ -865,6 +827,7 @@ function TreeRowDispatcher({
|
|||||||
onEntryPress: (entry: ExplorerEntry) => void;
|
onEntryPress: (entry: ExplorerEntry) => void;
|
||||||
onCopyPath: (path: string) => void | Promise<void>;
|
onCopyPath: (path: string) => void | Promise<void>;
|
||||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||||
|
onAddToChat?: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const entry = info.item.entry;
|
const entry = info.item.entry;
|
||||||
const depth = info.item.depth;
|
const depth = info.item.depth;
|
||||||
@@ -875,6 +838,8 @@ function TreeRowDispatcher({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeRowItem
|
<TreeRowItem
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
@@ -883,6 +848,8 @@ function TreeRowDispatcher({
|
|||||||
onEntryPress={onEntryPress}
|
onEntryPress={onEntryPress}
|
||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
onDownloadEntry={onDownloadEntry}
|
onDownloadEntry={onDownloadEntry}
|
||||||
|
onAddToChat={onAddToChat}
|
||||||
|
testID={`file-explorer-row-${info.index}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1061,7 +1028,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
entriesContent: {
|
entriesContent: {
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
paddingTop: theme.spacing[2],
|
paddingTop: theme.spacing[2],
|
||||||
paddingBottom: theme.spacing[4],
|
paddingBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
@@ -1111,9 +1077,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingVertical: 2,
|
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
},
|
},
|
||||||
entryRowActive: {
|
entryRowActive: {
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
@@ -1133,16 +1098,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
menuButton: {
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
menuButtonActive: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
contextMetaBlock: {
|
contextMetaBlock: {
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ import {
|
|||||||
type SplitPane,
|
type SplitPane,
|
||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||||
import { RenderProfile } from "@/utils/render-profiler";
|
import { RenderProfile } from "@/utils/render-profiler";
|
||||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|||||||
// 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 = 16;
|
||||||
|
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
|
||||||
|
|
||||||
/** 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. */
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
|
|||||||
input.setLightboxMetadata(input.attachment.metadata);
|
input.setLightboxMetadata(input.attachment.metadata);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (input.attachment.kind === "file") {
|
if (input.attachment.kind === "file" || input.attachment.kind === "workspace_file") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isWorkspaceAttachment(input.attachment)) {
|
if (isWorkspaceAttachment(input.attachment)) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
buildForgeAttachmentFromSearchItem,
|
buildForgeAttachmentFromSearchItem,
|
||||||
buildLegacyGitHubAttachmentFromSearchItem,
|
buildLegacyGitHubAttachmentFromSearchItem,
|
||||||
} from "@/utils/review-attachments";
|
} from "@/utils/review-attachments";
|
||||||
|
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
|
||||||
|
|
||||||
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
|
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
|
||||||
|
|
||||||
@@ -49,6 +50,11 @@ export function splitComposerAttachmentsForSubmit(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attachment.kind === "workspace_file") {
|
||||||
|
agentAttachments.push(workspaceFileAttachmentToAgentAttachment(attachment));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (isWorkspaceAttachment(attachment)) {
|
if (isWorkspaceAttachment(attachment)) {
|
||||||
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
|
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
|
||||||
images.push(attachment.attachment.screenshot);
|
images.push(attachment.attachment.screenshot);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||||
|
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||||
|
|
||||||
const { asyncStorage } = vi.hoisted(() => ({
|
const { asyncStorage } = vi.hoisted(() => ({
|
||||||
asyncStorage: new Map<string, string>(),
|
asyncStorage: new Map<string, string>(),
|
||||||
@@ -159,7 +160,11 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
useDraftStore.setState({
|
||||||
|
drafts: {},
|
||||||
|
createModalDraft: null,
|
||||||
|
attachmentFocusRequestByDraftKey: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
|
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
|
||||||
@@ -424,6 +429,39 @@ describe("useAgentInputDraft live contract", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches to an unmounted legacy draft without losing its input", async () => {
|
||||||
|
const image: AttachmentMetadata = {
|
||||||
|
id: "legacy-image",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "attachments/legacy-image",
|
||||||
|
createdAt: 10,
|
||||||
|
};
|
||||||
|
useDraftStore.setState({
|
||||||
|
drafts: {
|
||||||
|
"draft:legacy-workspace-file": {
|
||||||
|
input: { text: "legacy text", images: [image] },
|
||||||
|
lifecycle: "active",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
version: 1,
|
||||||
|
} as unknown as DraftRecordForTest,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDraftStore.getState().attachWorkspaceFile({
|
||||||
|
draftKey: "draft:legacy-workspace-file",
|
||||||
|
attachment: createWorkspaceFileAttachment({ path: "src/app.ts" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useDraftStore.getState().getDraftInput("draft:legacy-workspace-file")).toEqual({
|
||||||
|
text: "legacy text",
|
||||||
|
attachments: [
|
||||||
|
{ kind: "image", metadata: image },
|
||||||
|
createWorkspaceFileAttachment({ path: "src/app.ts" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("clear resets text and attachments", async () => {
|
it("clear resets text and attachments", async () => {
|
||||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||||
const image: AttachmentMetadata = {
|
const image: AttachmentMetadata = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import type { UserComposerAttachment } from "@/attachments/types";
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from "@/hooks/use-agent-form-state";
|
} from "@/hooks/use-agent-form-state";
|
||||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||||
import {
|
import {
|
||||||
areAttachmentsEqual,
|
|
||||||
buildDraftAgentControls,
|
buildDraftAgentControls,
|
||||||
hasDraftContent,
|
hasDraftContent,
|
||||||
resolveDraftKey,
|
resolveDraftKey,
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
type ProviderSelectionState,
|
type ProviderSelectionState,
|
||||||
} from "@/provider-selection/provider-selection";
|
} from "@/provider-selection/provider-selection";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
|
import { toDraftInputIfReady } from "@/stores/draft-store/state";
|
||||||
|
|
||||||
type AttachmentUpdater =
|
type AttachmentUpdater =
|
||||||
| UserComposerAttachment[]
|
| UserComposerAttachment[]
|
||||||
@@ -57,6 +57,7 @@ export interface AgentInputDraft {
|
|||||||
setAttachments: (updater: AttachmentUpdater) => void;
|
setAttachments: (updater: AttachmentUpdater) => void;
|
||||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||||
isHydrated: boolean;
|
isHydrated: boolean;
|
||||||
|
attachmentFocusRequestId: number;
|
||||||
composerState: DraftComposerState | null;
|
composerState: DraftComposerState | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,67 +78,66 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
}),
|
}),
|
||||||
[formState.selectedServerId, input.draftKey],
|
[formState.selectedServerId, input.draftKey],
|
||||||
);
|
);
|
||||||
const [text, setText] = useState("");
|
const draftRecord = useDraftStore((state) => state.drafts[draftKey]);
|
||||||
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
|
const draft = useMemo(() => toDraftInputIfReady(draftRecord), [draftRecord]);
|
||||||
const [isHydrated, setIsHydrated] = useState(false);
|
const attachmentFocusRequestId = useDraftStore(
|
||||||
const draftGenerationRef = useRef(0);
|
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
|
||||||
const hydratedGenerationRef = useRef(0);
|
);
|
||||||
|
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
|
||||||
|
const text = draft?.text ?? "";
|
||||||
|
const attachments = draft?.attachments ?? [];
|
||||||
|
const isHydrated = hydratedDraftKey === draftKey;
|
||||||
|
|
||||||
const setAttachments = useCallback((updater: AttachmentUpdater) => {
|
const saveDraft = useCallback(
|
||||||
setAttachmentsState((previousAttachments) => {
|
(
|
||||||
if (typeof updater === "function") {
|
update: (draft: { text: string; attachments: UserComposerAttachment[] }) => {
|
||||||
return updater(previousAttachments);
|
text: string;
|
||||||
|
attachments: UserComposerAttachment[];
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const store = useDraftStore.getState();
|
||||||
|
const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] };
|
||||||
|
const next = update(current);
|
||||||
|
if (!hasDraftContent(next)) {
|
||||||
|
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return updater;
|
store.saveDraftInput({ draftKey, draft: next });
|
||||||
});
|
},
|
||||||
}, []);
|
[draftKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setText = useCallback(
|
||||||
|
(nextText: string) => {
|
||||||
|
saveDraft((current) => ({ ...current, text: nextText }));
|
||||||
|
},
|
||||||
|
[saveDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setAttachments = useCallback(
|
||||||
|
(updater: AttachmentUpdater) => {
|
||||||
|
saveDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
attachments: typeof updater === "function" ? updater(current.attachments) : updater,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[saveDraft],
|
||||||
|
);
|
||||||
|
|
||||||
const clear = useCallback(
|
const clear = useCallback(
|
||||||
(lifecycle: "sent" | "abandoned") => {
|
(lifecycle: "sent" | "abandoned") => {
|
||||||
const store = useDraftStore.getState();
|
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
|
||||||
store.clearDraftInput({ draftKey, lifecycle });
|
|
||||||
|
|
||||||
const generation = store.beginDraftGeneration(draftKey);
|
|
||||||
draftGenerationRef.current = generation;
|
|
||||||
hydratedGenerationRef.current = generation;
|
|
||||||
|
|
||||||
setText("");
|
|
||||||
setAttachmentsState([]);
|
|
||||||
setIsHydrated(true);
|
|
||||||
},
|
},
|
||||||
[draftKey],
|
[draftKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const store = useDraftStore.getState();
|
|
||||||
const generation = store.beginDraftGeneration(draftKey);
|
|
||||||
draftGenerationRef.current = generation;
|
|
||||||
hydratedGenerationRef.current = 0;
|
|
||||||
|
|
||||||
setText("");
|
|
||||||
setAttachmentsState([]);
|
|
||||||
setIsHydrated(false);
|
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const draft = await store.hydrateDraftInput({
|
await useDraftStore.getState().hydrateDraftInput({ draftKey });
|
||||||
draftKey,
|
if (!cancelled) {
|
||||||
});
|
setHydratedDraftKey(draftKey);
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (draft) {
|
|
||||||
setText(draft.text);
|
|
||||||
setAttachmentsState(draft.attachments);
|
|
||||||
}
|
|
||||||
|
|
||||||
hydratedGenerationRef.current = generation;
|
|
||||||
setIsHydrated(true);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -145,52 +145,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
};
|
};
|
||||||
}, [draftKey]);
|
}, [draftKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentGeneration = draftGenerationRef.current;
|
|
||||||
if (currentGeneration <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = useDraftStore.getState();
|
|
||||||
const isCurrentGeneration = store.isDraftGenerationCurrent({
|
|
||||||
draftKey,
|
|
||||||
generation: currentGeneration,
|
|
||||||
});
|
|
||||||
if (!isCurrentGeneration) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hydratedGenerationRef.current !== currentGeneration) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = store.getDraftInput(draftKey);
|
|
||||||
const isSameDraft =
|
|
||||||
existing !== undefined &&
|
|
||||||
existing.text === text &&
|
|
||||||
areAttachmentsEqual({
|
|
||||||
left: existing.attachments,
|
|
||||||
right: attachments,
|
|
||||||
});
|
|
||||||
if (isSameDraft) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasDraftContent({ text, attachments })) {
|
|
||||||
if (existing) {
|
|
||||||
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
store.saveDraftInput({
|
|
||||||
draftKey,
|
|
||||||
draft: {
|
|
||||||
text,
|
|
||||||
attachments,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [attachments, draftKey, text]);
|
|
||||||
|
|
||||||
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!composerOptions || !lockedWorkingDir) {
|
if (!composerOptions || !lockedWorkingDir) {
|
||||||
@@ -304,6 +258,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
setAttachments,
|
setAttachments,
|
||||||
clear,
|
clear,
|
||||||
isHydrated,
|
isHydrated,
|
||||||
|
attachmentFocusRequestId,
|
||||||
composerState,
|
composerState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import {
|
|||||||
useIsCompactFormFactor,
|
useIsCompactFormFactor,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||||
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
||||||
@@ -751,6 +751,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
<Composer
|
<Composer
|
||||||
agentId={tabId}
|
agentId={tabId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
externalKeyboardShift
|
externalKeyboardShift
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
onSubmitMessage={handleCreateFromInput}
|
onSubmitMessage={handleCreateFromInput}
|
||||||
@@ -765,6 +766,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
cwd={composerState.workingDir}
|
cwd={composerState.workingDir}
|
||||||
clearDraft={draftInput.clear}
|
clearDraft={draftInput.clear}
|
||||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||||
|
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||||
onFocusInput={handleFocusInputCallback}
|
onFocusInput={handleFocusInputCallback}
|
||||||
commandDraftConfig={composerState.commandDraftConfig}
|
commandDraftConfig={composerState.commandDraftConfig}
|
||||||
agentControls={composerAgentControls}
|
agentControls={composerAgentControls}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||||
|
import { resolveFocusedChatTarget } from "./focused-chat-target";
|
||||||
|
|
||||||
|
function layoutWithTarget(
|
||||||
|
target: import("@/workspace-tabs/model").WorkspaceTab["target"],
|
||||||
|
): WorkspaceLayout {
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
kind: "pane",
|
||||||
|
pane: {
|
||||||
|
id: "pane-1",
|
||||||
|
tabIds: ["focused-tab"],
|
||||||
|
focusedTabId: "focused-tab",
|
||||||
|
tabs: [{ tabId: "focused-tab", target, createdAt: 1 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focusedPaneId: "pane-1",
|
||||||
|
} as WorkspaceLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("focused chat target", () => {
|
||||||
|
it("targets the focused agent draft", () => {
|
||||||
|
expect(
|
||||||
|
resolveFocusedChatTarget({
|
||||||
|
serverId: "server-1",
|
||||||
|
layout: layoutWithTarget({ kind: "agent", agentId: "agent-1" }),
|
||||||
|
}),
|
||||||
|
).toEqual({ tabId: "focused-tab", draftKey: "agent:server-1:agent-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("targets the focused unsent draft", () => {
|
||||||
|
expect(
|
||||||
|
resolveFocusedChatTarget({
|
||||||
|
serverId: "server-1",
|
||||||
|
layout: layoutWithTarget({ kind: "draft", draftId: "draft-1" }),
|
||||||
|
}),
|
||||||
|
).toEqual({ tabId: "focused-tab", draftKey: "draft:server-1:draft-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not guess when the focused tab is not a chat", () => {
|
||||||
|
expect(
|
||||||
|
resolveFocusedChatTarget({
|
||||||
|
serverId: "server-1",
|
||||||
|
layout: layoutWithTarget({ kind: "terminal", terminalId: "terminal-1" }),
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
|
import {
|
||||||
|
collectAllTabs,
|
||||||
|
findPaneById,
|
||||||
|
type WorkspaceLayout,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
|
|
||||||
|
export interface FocusedChatTarget {
|
||||||
|
tabId: string;
|
||||||
|
draftKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFocusedChatTarget(input: {
|
||||||
|
serverId: string;
|
||||||
|
layout: WorkspaceLayout | undefined;
|
||||||
|
}): FocusedChatTarget | null {
|
||||||
|
if (!input.layout) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const pane = findPaneById(input.layout.root, input.layout.focusedPaneId);
|
||||||
|
const focusedTabId = pane?.focusedTabId;
|
||||||
|
if (!focusedTabId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tab = collectAllTabs(input.layout.root).find(
|
||||||
|
(candidate) => candidate.tabId === focusedTabId,
|
||||||
|
);
|
||||||
|
if (!tab) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (tab.target.kind === "agent") {
|
||||||
|
return {
|
||||||
|
tabId: tab.tabId,
|
||||||
|
draftKey: buildDraftStoreKey({ serverId: input.serverId, agentId: tab.target.agentId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (tab.target.kind === "draft") {
|
||||||
|
return {
|
||||||
|
tabId: tab.tabId,
|
||||||
|
draftKey: buildDraftStoreKey({
|
||||||
|
serverId: input.serverId,
|
||||||
|
agentId: tab.tabId,
|
||||||
|
draftId: tab.target.draftId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -99,6 +99,7 @@ import type {
|
|||||||
AttachmentMetadata,
|
AttachmentMetadata,
|
||||||
ComposerAttachment,
|
ComposerAttachment,
|
||||||
UserComposerAttachment,
|
UserComposerAttachment,
|
||||||
|
WorkspaceFileComposerAttachment,
|
||||||
WorkspaceComposerAttachment,
|
WorkspaceComposerAttachment,
|
||||||
} from "@/attachments/types";
|
} from "@/attachments/types";
|
||||||
import type { PickedFile } from "@/attachments/picked-file";
|
import type { PickedFile } from "@/attachments/picked-file";
|
||||||
@@ -119,6 +120,15 @@ import { getForgePresentation } from "@/git/forge";
|
|||||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||||
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
||||||
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
|
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
|
||||||
|
import {
|
||||||
|
appendWorkspaceFileAttachment,
|
||||||
|
getWorkspaceFileAttachmentKey,
|
||||||
|
getWorkspaceFileAttachmentSubtitle,
|
||||||
|
} from "@/attachments/workspace-file";
|
||||||
|
import {
|
||||||
|
resolveWorkspaceFileDrop,
|
||||||
|
type WorkspaceFileDragPayload,
|
||||||
|
} from "@/attachments/workspace-file-drag";
|
||||||
|
|
||||||
type QueuedMessage = QueuedComposerMessage;
|
type QueuedMessage = QueuedComposerMessage;
|
||||||
|
|
||||||
@@ -399,6 +409,18 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (attachment.kind === "workspace_file") {
|
||||||
|
return (
|
||||||
|
<WorkspaceFileAttachmentPill
|
||||||
|
key={`workspace-file:${getWorkspaceFileAttachmentKey(attachment)}`}
|
||||||
|
attachment={attachment}
|
||||||
|
index={index}
|
||||||
|
disabled={disabled}
|
||||||
|
onRemove={onRemove}
|
||||||
|
removeLabel={labels.removeFile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (composerWorkspaceAttachment.is(attachment)) {
|
if (composerWorkspaceAttachment.is(attachment)) {
|
||||||
return composerWorkspaceAttachment.renderPill({
|
return composerWorkspaceAttachment.renderPill({
|
||||||
attachment,
|
attachment,
|
||||||
@@ -711,6 +733,43 @@ function FileAttachmentPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceFileAttachmentPillProps {
|
||||||
|
attachment: WorkspaceFileComposerAttachment;
|
||||||
|
index: number;
|
||||||
|
disabled: boolean;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
removeLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceFileAttachmentPill({
|
||||||
|
attachment,
|
||||||
|
index,
|
||||||
|
disabled,
|
||||||
|
onRemove,
|
||||||
|
removeLabel,
|
||||||
|
}: WorkspaceFileAttachmentPillProps) {
|
||||||
|
const handleRemove = useCallback(() => {
|
||||||
|
onRemove(index);
|
||||||
|
}, [index, onRemove]);
|
||||||
|
const fileName = attachment.path.split("/").pop() ?? attachment.path;
|
||||||
|
return (
|
||||||
|
<AttachmentPill
|
||||||
|
testID="composer-workspace-file-attachment-pill"
|
||||||
|
onOpen={noopCallback}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
openAccessibilityLabel={fileName}
|
||||||
|
removeAccessibilityLabel={removeLabel}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<AttachmentLabel
|
||||||
|
icon={filePillIcon}
|
||||||
|
title={fileName}
|
||||||
|
subtitle={getWorkspaceFileAttachmentSubtitle(attachment)}
|
||||||
|
/>
|
||||||
|
</AttachmentPill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface GithubPickerOptionProps {
|
interface GithubPickerOptionProps {
|
||||||
label: string;
|
label: string;
|
||||||
testID: string;
|
testID: string;
|
||||||
@@ -755,6 +814,7 @@ function GithubPickerOption({
|
|||||||
interface ComposerProps {
|
interface ComposerProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId?: string | null;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||||
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
|
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
|
||||||
@@ -786,6 +846,8 @@ interface ComposerProps {
|
|||||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||||
/** When true, auto-focuses the text input on web. */
|
/** When true, auto-focuses the text input on web. */
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
/** Changing this value requests focus again while autoFocus remains true. */
|
||||||
|
autoFocusKey?: string;
|
||||||
/** Callback to expose a focus function to parent components (desktop only). */
|
/** Callback to expose a focus function to parent components (desktop only). */
|
||||||
onFocusInput?: (focus: () => void) => void;
|
onFocusInput?: (focus: () => void) => void;
|
||||||
/** Optional draft context for listing commands before an agent exists. */
|
/** Optional draft context for listing commands before an agent exists. */
|
||||||
@@ -977,6 +1039,7 @@ function ComposerVoiceModeButton({
|
|||||||
export function Composer({
|
export function Composer({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceId,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
onClientSlashCommand,
|
onClientSlashCommand,
|
||||||
@@ -1000,6 +1063,7 @@ export function Composer({
|
|||||||
cwd,
|
cwd,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
|
autoFocusKey,
|
||||||
onFocusInput,
|
onFocusInput,
|
||||||
commandDraftConfig,
|
commandDraftConfig,
|
||||||
onMessageSent,
|
onMessageSent,
|
||||||
@@ -1195,6 +1259,21 @@ export function Composer({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleWorkspaceFileDropped = useCallback(
|
||||||
|
(payload: WorkspaceFileDragPayload) => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attachment = resolveWorkspaceFileDrop({ payload, serverId, workspaceId });
|
||||||
|
if (!attachment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedAttachments((current) => appendWorkspaceFileAttachment(current, attachment));
|
||||||
|
focusInput();
|
||||||
|
},
|
||||||
|
[focusInput, serverId, setSelectedAttachments, workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onFocusInput?.(focusInput);
|
onFocusInput?.(focusInput);
|
||||||
}, [focusInput, onFocusInput]);
|
}, [focusInput, onFocusInput]);
|
||||||
@@ -1969,7 +2048,11 @@ export function Composer({
|
|||||||
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
|
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
|
||||||
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
|
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
|
||||||
useFileDrop(
|
useFileDrop(
|
||||||
{ onFiles: addImages, onGenericFiles: handleGenericFilesDropped },
|
{
|
||||||
|
onFiles: addImages,
|
||||||
|
onGenericFiles: handleGenericFilesDropped,
|
||||||
|
onWorkspaceFile: handleWorkspaceFileDropped,
|
||||||
|
},
|
||||||
{ disabled: isSubmitBusy },
|
{ disabled: isSubmitBusy },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2030,7 +2113,7 @@ export function Composer({
|
|||||||
isReadyForDictation={isDictationReady}
|
isReadyForDictation={isDictationReady}
|
||||||
placeholder={messagePlaceholder}
|
placeholder={messagePlaceholder}
|
||||||
autoFocus={messageInputAutoFocus}
|
autoFocus={messageInputAutoFocus}
|
||||||
autoFocusKey={`${serverId}:${agentId}`}
|
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
|
||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol
|
|||||||
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
buildAgentAttentionNotificationPayload,
|
buildAgentAttentionNotificationPayload,
|
||||||
|
type AgentAttentionReason,
|
||||||
type AgentAttentionNotificationPayload,
|
type AgentAttentionNotificationPayload,
|
||||||
type NotificationPermissionRequest,
|
type NotificationPermissionRequest,
|
||||||
} from "@getpaseo/protocol/agent-attention-notification";
|
} from "@getpaseo/protocol/agent-attention-notification";
|
||||||
@@ -154,6 +155,35 @@ const getLatestPermissionRequest = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface AgentAttentionNotificationInput {
|
||||||
|
notification?: AgentAttentionNotificationPayload;
|
||||||
|
reason: AgentAttentionReason;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string | undefined;
|
||||||
|
agentId: string;
|
||||||
|
assistantMessage: string | null;
|
||||||
|
permissionRequest: NotificationPermissionRequest | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAgentAttentionNotification(
|
||||||
|
input: AgentAttentionNotificationInput,
|
||||||
|
): AgentAttentionNotificationPayload | null {
|
||||||
|
if (input.notification) {
|
||||||
|
return input.notification.data.workspaceId ? input.notification : null;
|
||||||
|
}
|
||||||
|
if (!input.workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return buildAgentAttentionNotificationPayload({
|
||||||
|
reason: input.reason,
|
||||||
|
serverId: input.serverId,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
agentId: input.agentId,
|
||||||
|
assistantMessage: input.reason === "finished" ? input.assistantMessage : null,
|
||||||
|
permissionRequest: input.reason === "permission" ? input.permissionRequest : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceSetupProgressPayload = Extract<
|
type WorkspaceSetupProgressPayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "workspace_setup_progress" }
|
{ type: "workspace_setup_progress" }
|
||||||
@@ -500,16 +530,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const assistantMessage =
|
const assistantMessage =
|
||||||
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
|
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
|
||||||
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
|
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
|
||||||
|
const workspaceId = session?.agents?.get(params.agentId)?.workspaceId;
|
||||||
|
|
||||||
const notification =
|
const notification = resolveAgentAttentionNotification({
|
||||||
params.notification ??
|
notification: params.notification,
|
||||||
buildAgentAttentionNotificationPayload({
|
reason: params.reason,
|
||||||
reason: params.reason,
|
serverId,
|
||||||
serverId,
|
workspaceId,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
assistantMessage: params.reason === "finished" ? assistantMessage : null,
|
assistantMessage,
|
||||||
permissionRequest: params.reason === "permission" ? permissionRequest : null,
|
permissionRequest,
|
||||||
});
|
});
|
||||||
|
if (!notification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void sendOsNotification({
|
void sendOsNotification({
|
||||||
title: notification.title,
|
title: notification.title,
|
||||||
|
|||||||
@@ -150,10 +150,10 @@ const CATALOG_DATA = [
|
|||||||
title: "Dirac",
|
title: "Dirac",
|
||||||
description:
|
description:
|
||||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||||
version: "0.4.21",
|
version: "0.4.22",
|
||||||
iconId: "dirac",
|
iconId: "dirac",
|
||||||
installLink: "https://dirac.run",
|
installLink: "https://dirac.run",
|
||||||
command: ["npx", "-y", "dirac-cli@0.4.21", "--acp"],
|
command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "factory-droid",
|
id: "factory-droid",
|
||||||
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
|
|||||||
id: "fast-agent",
|
id: "fast-agent",
|
||||||
title: "fast-agent",
|
title: "fast-agent",
|
||||||
description: "Code and build agents with comprehensive multi-provider support",
|
description: "Code and build agents with comprehensive multi-provider support",
|
||||||
version: "0.9.20",
|
version: "0.9.21",
|
||||||
iconId: "fast-agent",
|
iconId: "fast-agent",
|
||||||
installLink: "https://fast-agent.ai/acp/",
|
installLink: "https://fast-agent.ai/acp/",
|
||||||
command: ["uvx", "--from", "fast-agent-acp==0.9.20", "fast-agent-acp", "-x"],
|
command: ["uvx", "--from", "fast-agent-acp==0.9.21", "fast-agent-acp", "-x"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "gemini",
|
id: "gemini",
|
||||||
@@ -302,10 +302,10 @@ const CATALOG_DATA = [
|
|||||||
id: "qoder",
|
id: "qoder",
|
||||||
title: "Qoder CLI",
|
title: "Qoder CLI",
|
||||||
description: "AI coding assistant with agentic capabilities",
|
description: "AI coding assistant with agentic capabilities",
|
||||||
version: "1.1.2",
|
version: "1.1.3",
|
||||||
iconId: "qoder",
|
iconId: "qoder",
|
||||||
installLink: "https://qoder.com",
|
installLink: "https://qoder.com",
|
||||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.2", "--acp"],
|
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.3", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "qwen-code",
|
id: "qwen-code",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
|
|
||||||
|
interface OpenAgentEventPayload {
|
||||||
|
serverId?: unknown;
|
||||||
|
agentId?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentNavigationListener() {
|
||||||
|
const openAgent = useStableEvent((payload: OpenAgentEventPayload | null) => {
|
||||||
|
const serverId = typeof payload?.serverId === "string" ? payload.serverId.trim() : "";
|
||||||
|
const agentId = typeof payload?.agentId === "string" ? payload.agentId.trim() : "";
|
||||||
|
if (!serverId || !agentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToAgent({ serverId, agentId });
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = getDesktopHost();
|
||||||
|
const ready = host?.agentNavigation?.ready;
|
||||||
|
if (typeof host?.events?.on !== "function" || typeof ready !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
|
let unlisten: (() => void) | null = null;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
let dispose: (() => void) | null = null;
|
||||||
|
try {
|
||||||
|
dispose = await listenToDesktopEvent<OpenAgentEventPayload>("open-agent", openAgent);
|
||||||
|
if (disposed) {
|
||||||
|
dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unlisten = dispose;
|
||||||
|
const pending = await ready();
|
||||||
|
if (!disposed && pending) {
|
||||||
|
openAgent(pending);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
dispose?.();
|
||||||
|
if (unlisten === dispose) {
|
||||||
|
unlisten = null;
|
||||||
|
}
|
||||||
|
if (!disposed) {
|
||||||
|
retryTimer = setTimeout(() => void connect(), 1_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
}
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, [openAgent]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -123,6 +123,10 @@ export interface DesktopEventsBridge {
|
|||||||
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
|
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopAgentNavigationBridge {
|
||||||
|
ready?: () => Promise<{ serverId: string; agentId: string } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
export type DesktopBrowserShortcutEvent =
|
export type DesktopBrowserShortcutEvent =
|
||||||
| { browserId?: string; action: "focus-url" }
|
| { browserId?: string; action: "focus-url" }
|
||||||
| { browserId: string; action: "new-tab" };
|
| { browserId: string; action: "new-tab" };
|
||||||
@@ -169,6 +173,7 @@ export interface DesktopHostBridge {
|
|||||||
platform?: string;
|
platform?: string;
|
||||||
invoke?: DesktopInvokeBridge["invoke"];
|
invoke?: DesktopInvokeBridge["invoke"];
|
||||||
getPendingOpenProject?: () => Promise<string | null>;
|
getPendingOpenProject?: () => Promise<string | null>;
|
||||||
|
agentNavigation?: DesktopAgentNavigationBridge;
|
||||||
events?: DesktopEventsBridge;
|
events?: DesktopEventsBridge;
|
||||||
window?: DesktopWindowModuleBridge;
|
window?: DesktopWindowModuleBridge;
|
||||||
dialog?: DesktopDialogBridge;
|
dialog?: DesktopDialogBridge;
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
|
import { FILE_ACTIONS_MENU_WIDTH } from "@/components/file-actions-menu";
|
||||||
|
import {
|
||||||
|
TreeChevron,
|
||||||
|
TreeIndentGuides,
|
||||||
|
treeRowPaddingLeft,
|
||||||
|
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||||
|
} from "@/components/tree-primitives";
|
||||||
import { type Theme } from "@/styles/theme";
|
import { type Theme } from "@/styles/theme";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
|
|
||||||
@@ -80,7 +86,12 @@ export function DiffFolderRow({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.right}>
|
<View style={styles.right}>
|
||||||
<DiffStat additions={additions} deletions={deletions} />
|
<DiffStat
|
||||||
|
additions={additions}
|
||||||
|
deletions={deletions}
|
||||||
|
testID={testID ? `${testID}-stat` : undefined}
|
||||||
|
/>
|
||||||
|
<View style={styles.actionSlot} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -94,8 +105,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
folderRow: {
|
folderRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingRight: theme.spacing[2],
|
paddingRight: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
@@ -113,6 +124,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
actionSlot: {
|
||||||
|
width: FILE_ACTIONS_MENU_WIDTH,
|
||||||
},
|
},
|
||||||
folderName: {
|
folderName: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
+727
-408
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ interface UseCheckoutDiffQueryOptions {
|
|||||||
baseRef?: string;
|
baseRef?: string;
|
||||||
ignoreWhitespace?: boolean;
|
ignoreWhitespace?: boolean;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
queryScope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||||
@@ -44,6 +45,7 @@ export function useCheckoutDiffQuery({
|
|||||||
baseRef,
|
baseRef,
|
||||||
ignoreWhitespace,
|
ignoreWhitespace,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
queryScope,
|
||||||
}: UseCheckoutDiffQueryOptions) {
|
}: UseCheckoutDiffQueryOptions) {
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const normalizedCompare = useMemo(
|
const normalizedCompare = useMemo(
|
||||||
@@ -53,10 +55,17 @@ export function useCheckoutDiffQuery({
|
|||||||
const compareMode = normalizedCompare.mode;
|
const compareMode = normalizedCompare.mode;
|
||||||
const compareBaseRef = normalizedCompare.baseRef;
|
const compareBaseRef = normalizedCompare.baseRef;
|
||||||
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(() => {
|
||||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
const comparisonKey = checkoutDiffQueryKey(
|
||||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
serverId,
|
||||||
);
|
cwd,
|
||||||
|
compareMode,
|
||||||
|
compareBaseRef,
|
||||||
|
compareIgnoreWhitespace,
|
||||||
|
);
|
||||||
|
const normalizedScope = queryScope?.trim();
|
||||||
|
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
|
||||||
|
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
|
||||||
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
|
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
|
||||||
const routeEnabled = Boolean(enabled && isConnected && cwd);
|
const routeEnabled = Boolean(enabled && isConnected && cwd);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
buildWorkspaceAttachmentScopeKey,
|
||||||
|
useWorkspaceAttachmentsStore,
|
||||||
|
} from "@/attachments/workspace-attachments-store";
|
||||||
|
import {
|
||||||
|
buildReviewDraftKey,
|
||||||
|
buildReviewDraftScopeKey,
|
||||||
|
useInlineReviewController,
|
||||||
|
useResolvedDiffMode,
|
||||||
|
useReviewAttachmentSnapshot,
|
||||||
|
useSetDiffModeOverride,
|
||||||
|
} from "@/review";
|
||||||
|
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
|
||||||
|
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||||
|
|
||||||
|
interface UseWorkingDiffOptions {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
cwd: string;
|
||||||
|
ignoreWhitespace: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
queryScope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkingDiff({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
cwd,
|
||||||
|
ignoreWhitespace,
|
||||||
|
enabled,
|
||||||
|
queryScope,
|
||||||
|
}: UseWorkingDiffOptions) {
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
isLoading: isStatusLoading,
|
||||||
|
isError: isStatusError,
|
||||||
|
error: statusError,
|
||||||
|
} = useCheckoutStatusQuery({ serverId, cwd });
|
||||||
|
const gitStatus = status && status.isGit ? status : null;
|
||||||
|
const isGit = Boolean(gitStatus);
|
||||||
|
const notGit = status !== null && !status.isGit && !status.error;
|
||||||
|
const statusErrorMessage =
|
||||||
|
status?.error?.message ??
|
||||||
|
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
||||||
|
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||||
|
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
|
||||||
|
const currentBranchName =
|
||||||
|
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
||||||
|
|
||||||
|
const reviewDraftScopeKey = useMemo(
|
||||||
|
() =>
|
||||||
|
buildReviewDraftScopeKey({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
cwd,
|
||||||
|
baseRef,
|
||||||
|
ignoreWhitespace,
|
||||||
|
}),
|
||||||
|
[baseRef, cwd, ignoreWhitespace, serverId, workspaceId],
|
||||||
|
);
|
||||||
|
const diffMode = useResolvedDiffMode({
|
||||||
|
scopeKey: reviewDraftScopeKey,
|
||||||
|
hasUncommittedChanges,
|
||||||
|
});
|
||||||
|
const setDiffModeOverride = useSetDiffModeOverride();
|
||||||
|
const selectDiffMode = useCallback(
|
||||||
|
(mode: "uncommitted" | "base") => {
|
||||||
|
setDiffModeOverride({
|
||||||
|
scopeKey: reviewDraftScopeKey,
|
||||||
|
override: { serverId, cwd, mode, isDirtyAtSelection: hasUncommittedChanges },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride],
|
||||||
|
);
|
||||||
|
const selectUncommitted = useCallback(() => selectDiffMode("uncommitted"), [selectDiffMode]);
|
||||||
|
const selectBase = useCallback(() => selectDiffMode("base"), [selectDiffMode]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
files,
|
||||||
|
payloadError: diffPayloadError,
|
||||||
|
isLoading: isDiffLoading,
|
||||||
|
} = useCheckoutDiffQuery({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
mode: diffMode,
|
||||||
|
baseRef,
|
||||||
|
ignoreWhitespace,
|
||||||
|
enabled: enabled && isGit,
|
||||||
|
queryScope,
|
||||||
|
});
|
||||||
|
const reviewDraftKey = useMemo(
|
||||||
|
() =>
|
||||||
|
buildReviewDraftKey({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
cwd,
|
||||||
|
mode: diffMode,
|
||||||
|
baseRef,
|
||||||
|
ignoreWhitespace,
|
||||||
|
}),
|
||||||
|
[baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId],
|
||||||
|
);
|
||||||
|
const reviewActions = useInlineReviewController({ reviewDraftKey });
|
||||||
|
const reviewAttachment = useReviewAttachmentSnapshot({
|
||||||
|
key: reviewDraftKey,
|
||||||
|
diffFiles: files,
|
||||||
|
cwd,
|
||||||
|
mode: diffMode,
|
||||||
|
baseRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
isStatusLoading,
|
||||||
|
isGit,
|
||||||
|
notGit,
|
||||||
|
statusErrorMessage,
|
||||||
|
baseRef,
|
||||||
|
currentBranchName,
|
||||||
|
diffMode,
|
||||||
|
selectUncommitted,
|
||||||
|
selectBase,
|
||||||
|
files,
|
||||||
|
diffPayloadError,
|
||||||
|
isDiffLoading,
|
||||||
|
reviewActions,
|
||||||
|
reviewAttachment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePublishWorkingDiffAttachment({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
cwd,
|
||||||
|
attachment,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
cwd: string;
|
||||||
|
attachment: ReturnType<typeof useWorkingDiff>["reviewAttachment"];
|
||||||
|
enabled: boolean;
|
||||||
|
}) {
|
||||||
|
const scopeKey = useMemo(
|
||||||
|
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
|
||||||
|
[cwd, serverId, workspaceId],
|
||||||
|
);
|
||||||
|
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
||||||
|
(state) => state.setWorkspaceAttachments,
|
||||||
|
);
|
||||||
|
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
||||||
|
(state) => state.clearWorkspaceAttachments,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attachments = attachment ? [attachment] : [];
|
||||||
|
setWorkspaceAttachments({ scopeKey, attachments });
|
||||||
|
return () => {
|
||||||
|
const current = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey];
|
||||||
|
if (current === attachments) {
|
||||||
|
clearWorkspaceAttachments({ scopeKey });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [attachment, clearWorkspaceAttachments, enabled, scopeKey, setWorkspaceAttachments]);
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ function createAgentWithStatus({ id, status }: { id: string; status: Agent["stat
|
|||||||
function createBaseInput(): AgentScreenMachineInput {
|
function createBaseInput(): AgentScreenMachineInput {
|
||||||
return {
|
return {
|
||||||
agent: null,
|
agent: null,
|
||||||
|
isArchived: false,
|
||||||
continuity: { kind: "none" },
|
continuity: { kind: "none" },
|
||||||
missingAgentState: { kind: "idle" },
|
missingAgentState: { kind: "idle" },
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
@@ -385,6 +386,22 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
expect(result.memory.lastReadyAgent).toBeNull();
|
expect(result.memory.lastReadyAgent).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders an archived agent before provider history is initialized", () => {
|
||||||
|
const result = deriveAgentScreenViewState({
|
||||||
|
input: {
|
||||||
|
...createBaseInput(),
|
||||||
|
agent: createAgent("agent-1"),
|
||||||
|
isArchived: true,
|
||||||
|
needsAuthoritativeSync: true,
|
||||||
|
},
|
||||||
|
memory: createBaseMemory(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ready = expectReadyState(result.state);
|
||||||
|
expect(ready.agent.id).toBe("agent-1");
|
||||||
|
expect(ready.sync).toEqual({ status: "idle" });
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps optimistic create non-blocking while timeline and authoritative history catch up", () => {
|
it("keeps optimistic create non-blocking while timeline and authoritative history catch up", () => {
|
||||||
const memory = createBaseMemory();
|
const memory = createBaseMemory();
|
||||||
const input: AgentScreenMachineInput = {
|
const input: AgentScreenMachineInput = {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type AgentScreenMissingState =
|
|||||||
|
|
||||||
export interface AgentScreenMachineInput {
|
export interface AgentScreenMachineInput {
|
||||||
agent: AgentScreenAgent | null;
|
agent: AgentScreenAgent | null;
|
||||||
|
isArchived: boolean;
|
||||||
missingAgentState: AgentScreenMissingState;
|
missingAgentState: AgentScreenMissingState;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isArchivingCurrentAgent: boolean;
|
isArchivingCurrentAgent: boolean;
|
||||||
@@ -61,6 +62,7 @@ function hasOptimisticCreateContinuity(input: AgentScreenMachineInput): boolean
|
|||||||
|
|
||||||
function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInput): boolean {
|
function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInput): boolean {
|
||||||
return (
|
return (
|
||||||
|
!input.isArchived &&
|
||||||
!hasOptimisticCreateContinuity(input) &&
|
!hasOptimisticCreateContinuity(input) &&
|
||||||
!input.hasHydratedHistoryBefore &&
|
!input.hasHydratedHistoryBefore &&
|
||||||
(input.needsAuthoritativeSync || input.isHistorySyncing)
|
(input.needsAuthoritativeSync || input.isHistorySyncing)
|
||||||
@@ -164,6 +166,9 @@ function resolveAgentScreenSync(args: {
|
|||||||
hadInitialSyncFailure: boolean;
|
hadInitialSyncFailure: boolean;
|
||||||
}): AgentScreenReadySyncState {
|
}): AgentScreenReadySyncState {
|
||||||
const { input, hadInitialSyncFailure } = args;
|
const { input, hadInitialSyncFailure } = args;
|
||||||
|
if (input.isArchived) {
|
||||||
|
return { status: "idle" };
|
||||||
|
}
|
||||||
if (!input.isConnected) {
|
if (!input.isConnected) {
|
||||||
return { status: "reconnecting" };
|
return { status: "reconnecting" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
|
import { useDownloadStore } from "@/stores/download-store";
|
||||||
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
|
|
||||||
|
interface UseFileDownloadParams {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId?: string | null;
|
||||||
|
workspaceRoot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a stable callback that downloads a single workspace file by its
|
||||||
|
* workspace-relative path. Shared by the file explorer tree and the git diff
|
||||||
|
* pane so both surfaces download through the same host token + download-store
|
||||||
|
* pipeline instead of duplicating the plumbing.
|
||||||
|
*/
|
||||||
|
export function useFileDownload({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRoot,
|
||||||
|
}: UseFileDownloadParams): (input: { fileName: string; path: string }) => void {
|
||||||
|
const daemons = useHosts();
|
||||||
|
const daemonProfile = useMemo(
|
||||||
|
() => daemons.find((daemon) => daemon.serverId === serverId),
|
||||||
|
[daemons, serverId],
|
||||||
|
);
|
||||||
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||||
|
const workspaceScopeId = useMemo(
|
||||||
|
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
||||||
|
[normalizedWorkspaceRoot, workspaceId],
|
||||||
|
);
|
||||||
|
const { requestFileDownloadToken } = useFileExplorerActions({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
|
});
|
||||||
|
const startDownload = useDownloadStore((state) => state.startDownload);
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
({ fileName, path }) => {
|
||||||
|
if (!workspaceScopeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void startDownload({
|
||||||
|
serverId,
|
||||||
|
scopeId: workspaceScopeId,
|
||||||
|
fileName,
|
||||||
|
path,
|
||||||
|
daemonProfile,
|
||||||
|
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -366,6 +366,13 @@ export const ar: TranslationResources = {
|
|||||||
copyBranchName: "نسخ اسم الفرع",
|
copyBranchName: "نسخ اسم الفرع",
|
||||||
copied: "تم النسخ",
|
copied: "تم النسخ",
|
||||||
},
|
},
|
||||||
|
fileActions: {
|
||||||
|
openFile: "افتح الملف",
|
||||||
|
copyPath: "نسخ المسار",
|
||||||
|
download: "تحميل",
|
||||||
|
addToChat: "إضافة إلى الدردشة…",
|
||||||
|
moreActions: "المزيد من الإجراءات",
|
||||||
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
name: "اسم",
|
name: "اسم",
|
||||||
@@ -375,8 +382,6 @@ export const ar: TranslationResources = {
|
|||||||
context: {
|
context: {
|
||||||
size: "مقاس",
|
size: "مقاس",
|
||||||
modified: "معدل",
|
modified: "معدل",
|
||||||
copyPath: "نسخ المسار",
|
|
||||||
download: "تحميل",
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
back: "خلف",
|
back: "خلف",
|
||||||
@@ -753,6 +758,8 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
|
openChangesTab: "فتح علامة تبويب التغييرات",
|
||||||
|
closeChangesTab: "إغلاق علامة تبويب التغييرات",
|
||||||
binaryFile: "ملف ثنائي",
|
binaryFile: "ملف ثنائي",
|
||||||
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
|
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
|
||||||
unified: "الفرق الموحدة",
|
unified: "الفرق الموحدة",
|
||||||
@@ -1489,6 +1496,8 @@ export const ar: TranslationResources = {
|
|||||||
changesLabel: "التغييرات",
|
changesLabel: "التغييرات",
|
||||||
changesSubtitle: "فروقات شجرة العمل",
|
changesSubtitle: "فروقات شجرة العمل",
|
||||||
commitSubtitle: "فروقات الالتزام",
|
commitSubtitle: "فروقات الالتزام",
|
||||||
|
uncommittedSubtitle: "تغييرات غير ملتزم بها",
|
||||||
|
baseSubtitle: "مقارنة مع {{baseRef}}",
|
||||||
directoryMissing: "لم يتم العثور على دليل Workspace.",
|
directoryMissing: "لم يتم العثور على دليل Workspace.",
|
||||||
empty: "لا توجد تغييرات",
|
empty: "لا توجد تغييرات",
|
||||||
loadError: "فشل تحميل الفروقات",
|
loadError: "فشل تحميل الفروقات",
|
||||||
|
|||||||
@@ -365,6 +365,13 @@ export const en = {
|
|||||||
copyBranchName: "Copy branch name",
|
copyBranchName: "Copy branch name",
|
||||||
copied: "Copied",
|
copied: "Copied",
|
||||||
},
|
},
|
||||||
|
fileActions: {
|
||||||
|
openFile: "Open file",
|
||||||
|
copyPath: "Copy path",
|
||||||
|
download: "Download",
|
||||||
|
addToChat: "Add to chat…",
|
||||||
|
moreActions: "More actions",
|
||||||
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
name: "Name",
|
name: "Name",
|
||||||
@@ -374,8 +381,6 @@ export const en = {
|
|||||||
context: {
|
context: {
|
||||||
size: "Size",
|
size: "Size",
|
||||||
modified: "Modified",
|
modified: "Modified",
|
||||||
copyPath: "Copy path",
|
|
||||||
download: "Download",
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
back: "Back",
|
back: "Back",
|
||||||
@@ -763,6 +768,8 @@ export const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
|
openChangesTab: "Open Changes tab",
|
||||||
|
closeChangesTab: "Close Changes tab",
|
||||||
binaryFile: "Binary file",
|
binaryFile: "Binary file",
|
||||||
tooLarge: "Diff too large to display",
|
tooLarge: "Diff too large to display",
|
||||||
unified: "Unified diff",
|
unified: "Unified diff",
|
||||||
@@ -1500,6 +1507,8 @@ export const en = {
|
|||||||
changesLabel: "Changes",
|
changesLabel: "Changes",
|
||||||
changesSubtitle: "Working tree diff",
|
changesSubtitle: "Working tree diff",
|
||||||
commitSubtitle: "Commit diff",
|
commitSubtitle: "Commit diff",
|
||||||
|
uncommittedSubtitle: "Uncommitted changes",
|
||||||
|
baseSubtitle: "Compared with {{baseRef}}",
|
||||||
directoryMissing: "Workspace directory not found.",
|
directoryMissing: "Workspace directory not found.",
|
||||||
empty: "No changes",
|
empty: "No changes",
|
||||||
loadError: "Failed to load diff",
|
loadError: "Failed to load diff",
|
||||||
|
|||||||
@@ -370,6 +370,13 @@ export const es: TranslationResources = {
|
|||||||
copyBranchName: "Copiar nombre de rama",
|
copyBranchName: "Copiar nombre de rama",
|
||||||
copied: "Copiado",
|
copied: "Copiado",
|
||||||
},
|
},
|
||||||
|
fileActions: {
|
||||||
|
openFile: "Abrir archivo",
|
||||||
|
copyPath: "Copiar ruta",
|
||||||
|
download: "Descargar",
|
||||||
|
addToChat: "Añadir al chat…",
|
||||||
|
moreActions: "Más acciones",
|
||||||
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
name: "Nombre",
|
name: "Nombre",
|
||||||
@@ -379,8 +386,6 @@ export const es: TranslationResources = {
|
|||||||
context: {
|
context: {
|
||||||
size: "Tamaño",
|
size: "Tamaño",
|
||||||
modified: "Modificado",
|
modified: "Modificado",
|
||||||
copyPath: "Copiar ruta",
|
|
||||||
download: "Descargar",
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
back: "Atrás",
|
back: "Atrás",
|
||||||
@@ -784,6 +789,8 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
|
openChangesTab: "Abrir la pestaña Cambios",
|
||||||
|
closeChangesTab: "Cerrar la pestaña Cambios",
|
||||||
binaryFile: "archivo binario",
|
binaryFile: "archivo binario",
|
||||||
tooLarge: "La diferencia es demasiado grande para mostrarse",
|
tooLarge: "La diferencia es demasiado grande para mostrarse",
|
||||||
unified: "Diferencia unificada",
|
unified: "Diferencia unificada",
|
||||||
@@ -1532,6 +1539,8 @@ export const es: TranslationResources = {
|
|||||||
changesLabel: "Cambios",
|
changesLabel: "Cambios",
|
||||||
changesSubtitle: "Diferencias del árbol de trabajo",
|
changesSubtitle: "Diferencias del árbol de trabajo",
|
||||||
commitSubtitle: "Diferencias del commit",
|
commitSubtitle: "Diferencias del commit",
|
||||||
|
uncommittedSubtitle: "Cambios sin confirmar",
|
||||||
|
baseSubtitle: "Comparado con {{baseRef}}",
|
||||||
directoryMissing: "No se encontró el directorio de Workspace.",
|
directoryMissing: "No se encontró el directorio de Workspace.",
|
||||||
empty: "Sin cambios",
|
empty: "Sin cambios",
|
||||||
loadError: "No se pudieron cargar las diferencias",
|
loadError: "No se pudieron cargar las diferencias",
|
||||||
|
|||||||
@@ -370,6 +370,13 @@ export const fr: TranslationResources = {
|
|||||||
copyBranchName: "Copier le nom de la branche",
|
copyBranchName: "Copier le nom de la branche",
|
||||||
copied: "Copié",
|
copied: "Copié",
|
||||||
},
|
},
|
||||||
|
fileActions: {
|
||||||
|
openFile: "Ouvrir le fichier",
|
||||||
|
copyPath: "Copier le chemin",
|
||||||
|
download: "Télécharger",
|
||||||
|
addToChat: "Ajouter au chat…",
|
||||||
|
moreActions: "Plus de propositions",
|
||||||
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
name: "Nom",
|
name: "Nom",
|
||||||
@@ -379,8 +386,6 @@ export const fr: TranslationResources = {
|
|||||||
context: {
|
context: {
|
||||||
size: "Taille",
|
size: "Taille",
|
||||||
modified: "Modifié",
|
modified: "Modifié",
|
||||||
copyPath: "Copier le chemin",
|
|
||||||
download: "Télécharger",
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
back: "Dos",
|
back: "Dos",
|
||||||
@@ -783,6 +788,8 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
|
openChangesTab: "Ouvrir l'onglet Modifications",
|
||||||
|
closeChangesTab: "Fermer l'onglet Modifications",
|
||||||
binaryFile: "Fichier binaire",
|
binaryFile: "Fichier binaire",
|
||||||
tooLarge: "Diff trop grand pour être affiché",
|
tooLarge: "Diff trop grand pour être affiché",
|
||||||
unified: "Différentiel unifié",
|
unified: "Différentiel unifié",
|
||||||
@@ -1535,6 +1542,8 @@ export const fr: TranslationResources = {
|
|||||||
changesLabel: "Modifications",
|
changesLabel: "Modifications",
|
||||||
changesSubtitle: "Différences de l'arbre de travail",
|
changesSubtitle: "Différences de l'arbre de travail",
|
||||||
commitSubtitle: "Différences du commit",
|
commitSubtitle: "Différences du commit",
|
||||||
|
uncommittedSubtitle: "Modifications non validées",
|
||||||
|
baseSubtitle: "Comparé à {{baseRef}}",
|
||||||
directoryMissing: "Répertoire Workspace introuvable.",
|
directoryMissing: "Répertoire Workspace introuvable.",
|
||||||
empty: "Aucune modification",
|
empty: "Aucune modification",
|
||||||
loadError: "Échec du chargement des différences",
|
loadError: "Échec du chargement des différences",
|
||||||
|
|||||||
@@ -370,6 +370,13 @@ export const ja: TranslationResources = {
|
|||||||
copyBranchName: "ブランチ名をコピー",
|
copyBranchName: "ブランチ名をコピー",
|
||||||
copied: "コピーしました",
|
copied: "コピーしました",
|
||||||
},
|
},
|
||||||
|
fileActions: {
|
||||||
|
openFile: "ファイルを開く",
|
||||||
|
copyPath: "パスをコピー",
|
||||||
|
download: "ダウンロード",
|
||||||
|
addToChat: "チャットに追加…",
|
||||||
|
moreActions: "その他のアクション",
|
||||||
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
name: "名前",
|
name: "名前",
|
||||||
@@ -379,8 +386,6 @@ export const ja: TranslationResources = {
|
|||||||
context: {
|
context: {
|
||||||
size: "サイズ",
|
size: "サイズ",
|
||||||
modified: "更新日時",
|
modified: "更新日時",
|
||||||
copyPath: "パスをコピー",
|
|
||||||
download: "ダウンロード",
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
back: "戻る",
|
back: "戻る",
|
||||||
@@ -764,6 +769,8 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
|
openChangesTab: "変更タブを開く",
|
||||||
|
closeChangesTab: "変更タブを閉じる",
|
||||||
binaryFile: "バイナリファイル",
|
binaryFile: "バイナリファイル",
|
||||||
tooLarge: "差分が大きすぎて表示できません",
|
tooLarge: "差分が大きすぎて表示できません",
|
||||||
unified: "ユニファイド差分",
|
unified: "ユニファイド差分",
|
||||||
@@ -1505,6 +1512,8 @@ export const ja: TranslationResources = {
|
|||||||
changesLabel: "変更",
|
changesLabel: "変更",
|
||||||
changesSubtitle: "作業ツリーの差分",
|
changesSubtitle: "作業ツリーの差分",
|
||||||
commitSubtitle: "コミット差分",
|
commitSubtitle: "コミット差分",
|
||||||
|
uncommittedSubtitle: "未コミットの変更",
|
||||||
|
baseSubtitle: "{{baseRef}} との比較",
|
||||||
directoryMissing: "ワークスペースディレクトリが見つかりません。",
|
directoryMissing: "ワークスペースディレクトリが見つかりません。",
|
||||||
empty: "変更はありません",
|
empty: "変更はありません",
|
||||||
loadError: "差分の読み込みに失敗しました",
|
loadError: "差分の読み込みに失敗しました",
|
||||||
|
|||||||
@@ -370,6 +370,13 @@ export const ptBR: TranslationResources = {
|
|||||||
copyBranchName: "Copiar nome da branch",
|
copyBranchName: "Copiar nome da branch",
|
||||||
copied: "Copiado",
|
copied: "Copiado",
|
||||||
},
|
},
|
||||||
|
fileActions: {
|
||||||
|
openFile: "Abrir arquivo",
|
||||||
|
copyPath: "Copiar caminho",
|
||||||
|
download: "Baixar",
|
||||||
|
addToChat: "Adicionar ao chat…",
|
||||||
|
moreActions: "Mais ações",
|
||||||
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
name: "Nome",
|
name: "Nome",
|
||||||
@@ -379,8 +386,6 @@ export const ptBR: TranslationResources = {
|
|||||||
context: {
|
context: {
|
||||||
size: "Tamanho",
|
size: "Tamanho",
|
||||||
modified: "Modificado",
|
modified: "Modificado",
|
||||||
copyPath: "Copiar caminho",
|
|
||||||
download: "Baixar",
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
back: "Voltar",
|
back: "Voltar",
|
||||||
@@ -775,6 +780,8 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
|
openChangesTab: "Abrir a aba Alterações",
|
||||||
|
closeChangesTab: "Fechar a aba Alterações",
|
||||||
binaryFile: "Arquivo binário",
|
binaryFile: "Arquivo binário",
|
||||||
tooLarge: "Diff grande demais para exibir",
|
tooLarge: "Diff grande demais para exibir",
|
||||||
unified: "Diff unificado",
|
unified: "Diff unificado",
|
||||||
@@ -1518,6 +1525,8 @@ export const ptBR: TranslationResources = {
|
|||||||
changesLabel: "Alterações",
|
changesLabel: "Alterações",
|
||||||
changesSubtitle: "Diff da árvore de trabalho",
|
changesSubtitle: "Diff da árvore de trabalho",
|
||||||
commitSubtitle: "Diff do commit",
|
commitSubtitle: "Diff do commit",
|
||||||
|
uncommittedSubtitle: "Alterações não commitadas",
|
||||||
|
baseSubtitle: "Comparado com {{baseRef}}",
|
||||||
directoryMissing: "Diretório do workspace não encontrado.",
|
directoryMissing: "Diretório do workspace não encontrado.",
|
||||||
empty: "Nenhuma alteração",
|
empty: "Nenhuma alteração",
|
||||||
loadError: "Falha ao carregar diff",
|
loadError: "Falha ao carregar diff",
|
||||||
|
|||||||
@@ -369,6 +369,13 @@ export const ru: TranslationResources = {
|
|||||||
copyBranchName: "Копировать имя ветки",
|
copyBranchName: "Копировать имя ветки",
|
||||||
copied: "Скопировано",
|
copied: "Скопировано",
|
||||||
},
|
},
|
||||||
|
fileActions: {
|
||||||
|
openFile: "Открыть файл",
|
||||||
|
copyPath: "Копировать путь",
|
||||||
|
download: "Скачать",
|
||||||
|
addToChat: "Добавить в чат…",
|
||||||
|
moreActions: "Дополнительные действия",
|
||||||
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
name: "Имя",
|
name: "Имя",
|
||||||
@@ -378,8 +385,6 @@ export const ru: TranslationResources = {
|
|||||||
context: {
|
context: {
|
||||||
size: "Размер",
|
size: "Размер",
|
||||||
modified: "Модифицированный",
|
modified: "Модифицированный",
|
||||||
copyPath: "Копировать путь",
|
|
||||||
download: "Скачать",
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
back: "Назад",
|
back: "Назад",
|
||||||
@@ -775,6 +780,8 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
|
openChangesTab: "Открыть вкладку «Изменения»",
|
||||||
|
closeChangesTab: "Закрыть вкладку «Изменения»",
|
||||||
binaryFile: "Бинарный файл",
|
binaryFile: "Бинарный файл",
|
||||||
tooLarge: "Разница слишком велика для отображения",
|
tooLarge: "Разница слишком велика для отображения",
|
||||||
unified: "Единый дифференциал",
|
unified: "Единый дифференциал",
|
||||||
@@ -1523,6 +1530,8 @@ export const ru: TranslationResources = {
|
|||||||
changesLabel: "Изменения",
|
changesLabel: "Изменения",
|
||||||
changesSubtitle: "Различия рабочего дерева",
|
changesSubtitle: "Различия рабочего дерева",
|
||||||
commitSubtitle: "Различия коммита",
|
commitSubtitle: "Различия коммита",
|
||||||
|
uncommittedSubtitle: "Незафиксированные изменения",
|
||||||
|
baseSubtitle: "Сравнение с {{baseRef}}",
|
||||||
directoryMissing: "Каталог Workspace не найден.",
|
directoryMissing: "Каталог Workspace не найден.",
|
||||||
empty: "Нет изменений",
|
empty: "Нет изменений",
|
||||||
loadError: "Не удалось загрузить различия",
|
loadError: "Не удалось загрузить различия",
|
||||||
|
|||||||
@@ -366,6 +366,13 @@ export const zhCN: TranslationResources = {
|
|||||||
copyBranchName: "复制分支名称",
|
copyBranchName: "复制分支名称",
|
||||||
copied: "已复制",
|
copied: "已复制",
|
||||||
},
|
},
|
||||||
|
fileActions: {
|
||||||
|
openFile: "打开文件",
|
||||||
|
copyPath: "复制路径",
|
||||||
|
download: "下载",
|
||||||
|
addToChat: "添加到聊天…",
|
||||||
|
moreActions: "更多操作",
|
||||||
|
},
|
||||||
fileExplorer: {
|
fileExplorer: {
|
||||||
sort: {
|
sort: {
|
||||||
name: "名称",
|
name: "名称",
|
||||||
@@ -375,8 +382,6 @@ export const zhCN: TranslationResources = {
|
|||||||
context: {
|
context: {
|
||||||
size: "大小",
|
size: "大小",
|
||||||
modified: "修改时间",
|
modified: "修改时间",
|
||||||
copyPath: "复制路径",
|
|
||||||
download: "下载",
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
back: "返回",
|
back: "返回",
|
||||||
@@ -745,6 +750,8 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
diff: {
|
diff: {
|
||||||
|
openChangesTab: "打开“更改”标签页",
|
||||||
|
closeChangesTab: "关闭“更改”标签页",
|
||||||
binaryFile: "二进制文件",
|
binaryFile: "二进制文件",
|
||||||
tooLarge: "Diff 过大,无法显示",
|
tooLarge: "Diff 过大,无法显示",
|
||||||
unified: "Unified diff",
|
unified: "Unified diff",
|
||||||
@@ -1470,6 +1477,8 @@ export const zhCN: TranslationResources = {
|
|||||||
changesLabel: "更改",
|
changesLabel: "更改",
|
||||||
changesSubtitle: "工作区差异",
|
changesSubtitle: "工作区差异",
|
||||||
commitSubtitle: "提交差异",
|
commitSubtitle: "提交差异",
|
||||||
|
uncommittedSubtitle: "未提交的更改",
|
||||||
|
baseSubtitle: "与 {{baseRef}} 比较",
|
||||||
directoryMissing: "未找到 workspace 目录。",
|
directoryMissing: "未找到 workspace 目录。",
|
||||||
empty: "没有更改",
|
empty: "没有更改",
|
||||||
loadError: "加载差异失败",
|
loadError: "加载差异失败",
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import type { AgentRouteResolution } from "@/navigation/agent-route-resolution";
|
||||||
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
type VisibleAgentRouteResolution = Extract<
|
||||||
|
AgentRouteResolution,
|
||||||
|
{ kind: "waitingForHost" | "fetchingAgent" | "lookupError" }
|
||||||
|
>;
|
||||||
|
|
||||||
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||||
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AgentRouteResolutionView({
|
||||||
|
resolution,
|
||||||
|
hostName,
|
||||||
|
lastHostError,
|
||||||
|
onRetry,
|
||||||
|
onManageHost,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
resolution: VisibleAgentRouteResolution;
|
||||||
|
hostName: string;
|
||||||
|
lastHostError: string | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
onManageHost: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (resolution.kind === "fetchingAgent") {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState} testID="agent-route-fetching">
|
||||||
|
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||||
|
<View style={styles.textStack}>
|
||||||
|
<Text style={styles.title}>
|
||||||
|
{t("agentPanel.unavailable.preparingSession", { serverLabel: hostName })}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.description}>{t("agentPanel.unavailable.showSoon")}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolution.kind === "lookupError") {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState} testID="agent-route-lookup-error">
|
||||||
|
<View style={styles.textStack}>
|
||||||
|
<Text style={styles.title}>{t("agentPanel.states.failedToLoad")}</Text>
|
||||||
|
<Text style={styles.error}>{resolution.error}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
|
||||||
|
{t("common.actions.retry")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" leftIcon={ArrowLeftToLine} onPress={onBack}>
|
||||||
|
{t("common.actions.back")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConnecting =
|
||||||
|
resolution.connectionStatus === "connecting" || resolution.connectionStatus === "idle";
|
||||||
|
let title = t("workspace.route.cannotReachHost", { hostName });
|
||||||
|
if (isConnecting) {
|
||||||
|
title = t("agentPanel.unavailable.connecting", { serverLabel: hostName });
|
||||||
|
} else if (resolution.connectionStatus === "offline") {
|
||||||
|
title = t("workspace.route.hostOffline", { hostName });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyState} testID="agent-route-waiting-for-host">
|
||||||
|
{isConnecting ? (
|
||||||
|
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||||
|
) : null}
|
||||||
|
<View style={styles.textStack}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
{isConnecting
|
||||||
|
? t("agentPanel.unavailable.showWhenOnline")
|
||||||
|
: t("workspace.route.hostStatus", {
|
||||||
|
status: formatConnectionStatus(resolution.connectionStatus),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
{lastHostError ? <Text style={styles.error}>{lastHostError}</Text> : null}
|
||||||
|
</View>
|
||||||
|
{!isConnecting ? (
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
|
||||||
|
{t("common.actions.retry")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" leftIcon={Settings} onPress={onManageHost}>
|
||||||
|
{t("workspace.route.manageHost")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
emptyState: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[6],
|
||||||
|
},
|
||||||
|
textStack: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
maxWidth: 520,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: Math.round(theme.fontSize.sm * 1.4),
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveAgentRoute } from "@/navigation/agent-route-resolution";
|
||||||
|
|
||||||
|
const VALID_ROUTE = {
|
||||||
|
serverId: "server-1",
|
||||||
|
agentId: "agent-1",
|
||||||
|
cachedWorkspaceId: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
describe("resolveAgentRoute", () => {
|
||||||
|
it("opens a cached workspace without waiting for its host", () => {
|
||||||
|
expect(
|
||||||
|
resolveAgentRoute({
|
||||||
|
...VALID_ROUTE,
|
||||||
|
cachedWorkspaceId: "workspace-1",
|
||||||
|
connectionStatus: "offline",
|
||||||
|
lookup: { kind: "idle" },
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "resolved", workspaceId: "workspace-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["idle", "connecting", "offline", "error"] as const)(
|
||||||
|
"waits for a %s target host instead of abandoning the agent",
|
||||||
|
(connectionStatus) => {
|
||||||
|
expect(
|
||||||
|
resolveAgentRoute({
|
||||||
|
...VALID_ROUTE,
|
||||||
|
connectionStatus,
|
||||||
|
lookup: { kind: "idle" },
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "waitingForHost", connectionStatus });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("fetches the agent after its target host connects", () => {
|
||||||
|
expect(
|
||||||
|
resolveAgentRoute({
|
||||||
|
...VALID_ROUTE,
|
||||||
|
connectionStatus: "online",
|
||||||
|
lookup: { kind: "idle" },
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "fetchingAgent" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the workspace returned by the target host", () => {
|
||||||
|
expect(
|
||||||
|
resolveAgentRoute({
|
||||||
|
...VALID_ROUTE,
|
||||||
|
connectionStatus: "online",
|
||||||
|
lookup: { kind: "found", workspaceId: "workspace-2" },
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "resolved", workspaceId: "workspace-2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abandons the agent only after the target host says it is missing", () => {
|
||||||
|
expect(
|
||||||
|
resolveAgentRoute({
|
||||||
|
...VALID_ROUTE,
|
||||||
|
connectionStatus: "online",
|
||||||
|
lookup: { kind: "found", workspaceId: null },
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "notFound" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps lookup failures retryable", () => {
|
||||||
|
expect(
|
||||||
|
resolveAgentRoute({
|
||||||
|
...VALID_ROUTE,
|
||||||
|
connectionStatus: "online",
|
||||||
|
lookup: { kind: "failed", error: "connection closed" },
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "lookupError", error: "connection closed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects incomplete route parameters", () => {
|
||||||
|
expect(
|
||||||
|
resolveAgentRoute({
|
||||||
|
...VALID_ROUTE,
|
||||||
|
agentId: "",
|
||||||
|
connectionStatus: "online",
|
||||||
|
lookup: { kind: "idle" },
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "invalid" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
|
||||||
|
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||||
|
|
||||||
|
export type AgentRouteLookup =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "fetching" }
|
||||||
|
| { kind: "found"; workspaceId: string | null | undefined }
|
||||||
|
| { kind: "failed"; error: string };
|
||||||
|
|
||||||
|
export type AgentRouteResolution =
|
||||||
|
| { kind: "invalid" }
|
||||||
|
| { kind: "resolved"; workspaceId: string }
|
||||||
|
| {
|
||||||
|
kind: "waitingForHost";
|
||||||
|
connectionStatus: Exclude<HostRuntimeConnectionStatus, "online">;
|
||||||
|
}
|
||||||
|
| { kind: "fetchingAgent" }
|
||||||
|
| { kind: "notFound" }
|
||||||
|
| { kind: "lookupError"; error: string };
|
||||||
|
|
||||||
|
export function resolveAgentRoute(input: {
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
cachedWorkspaceId: string | null | undefined;
|
||||||
|
connectionStatus: HostRuntimeConnectionStatus;
|
||||||
|
lookup: AgentRouteLookup;
|
||||||
|
}): AgentRouteResolution {
|
||||||
|
if (!input.serverId || !input.agentId) {
|
||||||
|
return { kind: "invalid" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedWorkspaceId = normalizeWorkspaceOpaqueId(input.cachedWorkspaceId);
|
||||||
|
if (cachedWorkspaceId) {
|
||||||
|
return { kind: "resolved", workspaceId: cachedWorkspaceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.connectionStatus !== "online") {
|
||||||
|
return { kind: "waitingForHost", connectionStatus: input.connectionStatus };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.lookup.kind === "found") {
|
||||||
|
const fetchedWorkspaceId = normalizeWorkspaceOpaqueId(input.lookup.workspaceId);
|
||||||
|
return fetchedWorkspaceId
|
||||||
|
? { kind: "resolved", workspaceId: fetchedWorkspaceId }
|
||||||
|
: { kind: "notFound" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.lookup.kind === "failed") {
|
||||||
|
return { kind: "lookupError", error: input.lookup.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "fetchingAgent" };
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
|||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import {
|
import {
|
||||||
useHideFinishedProviderSubagents,
|
useHideFinishedProviderSubagents,
|
||||||
@@ -341,13 +341,14 @@ function useAgentPanelDescriptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AgentPanel() {
|
function AgentPanel() {
|
||||||
const { serverId, target, openFileInWorkspace } = usePaneContext();
|
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||||
const { isInteractive } = usePaneFocus();
|
const { isInteractive } = usePaneFocus();
|
||||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentPanelContent
|
<AgentPanelContent
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
agentId={target.agentId}
|
agentId={target.agentId}
|
||||||
isPaneFocused={isInteractive}
|
isPaneFocused={isInteractive}
|
||||||
onOpenWorkspaceFile={openFileInWorkspace}
|
onOpenWorkspaceFile={openFileInWorkspace}
|
||||||
@@ -481,11 +482,13 @@ type AgentLookupState =
|
|||||||
|
|
||||||
function AgentPanelContent({
|
function AgentPanelContent({
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceId,
|
||||||
agentId,
|
agentId,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
@@ -535,6 +538,7 @@ function AgentPanelContent({
|
|||||||
return (
|
return (
|
||||||
<AgentPanelBody
|
<AgentPanelBody
|
||||||
serverId={resolvedServerId}
|
serverId={resolvedServerId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
agentId={resolvedAgentId}
|
agentId={resolvedAgentId}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
client={runtimeClient}
|
client={runtimeClient}
|
||||||
@@ -547,6 +551,7 @@ function AgentPanelContent({
|
|||||||
|
|
||||||
function AgentPanelBody({
|
function AgentPanelBody({
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceId,
|
||||||
agentId,
|
agentId,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
client,
|
client,
|
||||||
@@ -555,6 +560,7 @@ function AgentPanelBody({
|
|||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
client: ReturnType<typeof useHostRuntimeClient>;
|
client: ReturnType<typeof useHostRuntimeClient>;
|
||||||
@@ -585,6 +591,8 @@ function AgentPanelBody({
|
|||||||
);
|
);
|
||||||
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
|
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
|
||||||
const lookupAttemptTokenRef = useRef(0);
|
const lookupAttemptTokenRef = useRef(0);
|
||||||
|
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
|
const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
lookupAttemptTokenRef.current += 1;
|
lookupAttemptTokenRef.current += 1;
|
||||||
@@ -596,6 +604,9 @@ function AgentPanelBody({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (agentState.id) {
|
if (agentState.id) {
|
||||||
|
if (workspaceKey) {
|
||||||
|
resolvePendingAgent(workspaceKey, agentId);
|
||||||
|
}
|
||||||
if (lookupState.tag !== "idle") {
|
if (lookupState.tag !== "idle") {
|
||||||
setLookupState({ tag: "idle" });
|
setLookupState({ tag: "idle" });
|
||||||
}
|
}
|
||||||
@@ -618,6 +629,9 @@ function AgentPanelBody({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
if (workspaceKey) {
|
||||||
|
resolvePendingAgent(workspaceKey, agentId);
|
||||||
|
}
|
||||||
setLookupState({
|
setLookupState({
|
||||||
tag: "not_found",
|
tag: "not_found",
|
||||||
message: `Agent not found: ${agentId}`,
|
message: `Agent not found: ${agentId}`,
|
||||||
@@ -626,6 +640,9 @@ function AgentPanelBody({
|
|||||||
}
|
}
|
||||||
|
|
||||||
storeFetchedAgentDetail({ serverId, result });
|
storeFetchedAgentDetail({ serverId, result });
|
||||||
|
if (workspaceKey) {
|
||||||
|
resolvePendingAgent(workspaceKey, agentId);
|
||||||
|
}
|
||||||
setLookupState({ tag: "idle" });
|
setLookupState({ tag: "idle" });
|
||||||
return;
|
return;
|
||||||
})
|
})
|
||||||
@@ -635,12 +652,25 @@ function AgentPanelBody({
|
|||||||
}
|
}
|
||||||
const message = toErrorMessage(error);
|
const message = toErrorMessage(error);
|
||||||
if (isNotFoundErrorMessage(message)) {
|
if (isNotFoundErrorMessage(message)) {
|
||||||
|
if (workspaceKey) {
|
||||||
|
resolvePendingAgent(workspaceKey, agentId);
|
||||||
|
}
|
||||||
setLookupState({ tag: "not_found", message });
|
setLookupState({ tag: "not_found", message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLookupState({ tag: "error", message });
|
setLookupState({ tag: "error", message });
|
||||||
});
|
});
|
||||||
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
|
}, [
|
||||||
|
agentId,
|
||||||
|
agentState.id,
|
||||||
|
client,
|
||||||
|
hasSession,
|
||||||
|
isConnected,
|
||||||
|
lookupState.tag,
|
||||||
|
resolvePendingAgent,
|
||||||
|
serverId,
|
||||||
|
workspaceKey,
|
||||||
|
]);
|
||||||
|
|
||||||
if (lookupState.tag === "not_found") {
|
if (lookupState.tag === "not_found") {
|
||||||
return (
|
return (
|
||||||
@@ -894,6 +924,7 @@ function ChatAgentContent({
|
|||||||
routeKey: `${serverId}:${agentId ?? ""}`,
|
routeKey: `${serverId}:${agentId ?? ""}`,
|
||||||
input: {
|
input: {
|
||||||
agent: agent ?? null,
|
agent: agent ?? null,
|
||||||
|
isArchived: agentState.archivedAt !== null,
|
||||||
missingAgentState,
|
missingAgentState,
|
||||||
isConnected,
|
isConnected,
|
||||||
isArchivingCurrentAgent,
|
isArchivingCurrentAgent,
|
||||||
@@ -947,6 +978,9 @@ function ChatAgentContent({
|
|||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (agentState.archivedAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (agentState.id && hasAppliedAuthoritativeHistory) {
|
if (agentState.id && hasAppliedAuthoritativeHistory) {
|
||||||
if (
|
if (
|
||||||
missingAgentState.kind === "resolving" ||
|
missingAgentState.kind === "resolving" ||
|
||||||
@@ -1012,6 +1046,7 @@ function ChatAgentContent({
|
|||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
agentState.id,
|
agentState.id,
|
||||||
|
agentState.archivedAt,
|
||||||
hasAppliedAuthoritativeHistory,
|
hasAppliedAuthoritativeHistory,
|
||||||
agentId,
|
agentId,
|
||||||
client,
|
client,
|
||||||
@@ -1125,8 +1160,16 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
});
|
});
|
||||||
// Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out
|
// Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out
|
||||||
// when only toast state changes (which does not affect any draft field).
|
// when only toast state changes (which does not affect any draft field).
|
||||||
const { text, setText, attachments, setAttachments, clear, isHydrated, composerState } =
|
const {
|
||||||
rawAgentInputDraft;
|
text,
|
||||||
|
setText,
|
||||||
|
attachments,
|
||||||
|
setAttachments,
|
||||||
|
clear,
|
||||||
|
isHydrated,
|
||||||
|
attachmentFocusRequestId,
|
||||||
|
composerState,
|
||||||
|
} = rawAgentInputDraft;
|
||||||
const agentInputDraft = useMemo(
|
const agentInputDraft = useMemo(
|
||||||
(): AgentInputDraft => ({
|
(): AgentInputDraft => ({
|
||||||
text,
|
text,
|
||||||
@@ -1135,9 +1178,19 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
setAttachments,
|
setAttachments,
|
||||||
clear,
|
clear,
|
||||||
isHydrated,
|
isHydrated,
|
||||||
|
attachmentFocusRequestId,
|
||||||
composerState,
|
composerState,
|
||||||
}),
|
}),
|
||||||
[text, setText, attachments, setAttachments, clear, isHydrated, composerState],
|
[
|
||||||
|
text,
|
||||||
|
setText,
|
||||||
|
attachments,
|
||||||
|
setAttachments,
|
||||||
|
clear,
|
||||||
|
isHydrated,
|
||||||
|
attachmentFocusRequestId,
|
||||||
|
composerState,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const streamSection = (
|
const streamSection = (
|
||||||
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
||||||
@@ -1502,6 +1555,7 @@ function ActiveAgentComposer({
|
|||||||
<Composer
|
<Composer
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
externalKeyboardShift
|
externalKeyboardShift
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
value={agentInputDraft.text}
|
value={agentInputDraft.text}
|
||||||
@@ -1513,6 +1567,7 @@ function ActiveAgentComposer({
|
|||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
clearDraft={agentInputDraft.clear}
|
clearDraft={agentInputDraft.clear}
|
||||||
autoFocus={isPaneFocused}
|
autoFocus={isPaneFocused}
|
||||||
|
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
|
||||||
isSubmitLoading={isSubmitLoading}
|
isSubmitLoading={isSubmitLoading}
|
||||||
onAttentionInputFocus={onAttentionInputFocus}
|
onAttentionInputFocus={onAttentionInputFocus}
|
||||||
onAttentionPromptSend={onAttentionPromptSend}
|
onAttentionPromptSend={onAttentionPromptSend}
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
import { useCallback, useMemo, type ReactNode } from "react";
|
|
||||||
import { Text, View } from "react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { GitCommitHorizontal } from "lucide-react-native";
|
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
||||||
import invariant from "tiny-invariant";
|
|
||||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
|
||||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
|
||||||
import { SharedDiffView } from "@/git/diff-pane";
|
|
||||||
import { useCommitDiffFiles } from "@/git/use-diff-files";
|
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
|
||||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
|
||||||
|
|
||||||
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
|
||||||
|
|
||||||
function CommitDiffPanel() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { serverId, workspaceId, target } = usePaneContext();
|
|
||||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
|
||||||
const { settings } = useAppSettings();
|
|
||||||
const { preferences, updatePreferences } = useChangesPreferences();
|
|
||||||
const isCompact = useIsCompactFormFactor();
|
|
||||||
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
|
|
||||||
|
|
||||||
const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({
|
|
||||||
serverId,
|
|
||||||
cwd: cwd ?? "",
|
|
||||||
sha: target.sha,
|
|
||||||
enabled: Boolean(cwd),
|
|
||||||
});
|
|
||||||
const canUseSplitLayout = isWeb && !isCompact;
|
|
||||||
const effectiveLayout = canUseSplitLayout ? preferences.layout : "unified";
|
|
||||||
const layoutOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
value: "unified" as const,
|
|
||||||
label: t("workspace.git.diff.unified"),
|
|
||||||
testID: "commit-diff-layout-unified",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "split" as const,
|
|
||||||
label: t("workspace.git.diff.split"),
|
|
||||||
testID: "commit-diff-layout-split",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
const handleLayoutChange = useCallback(
|
|
||||||
(layout: "unified" | "split") => {
|
|
||||||
void updatePreferences({ layout });
|
|
||||||
},
|
|
||||||
[updatePreferences],
|
|
||||||
);
|
|
||||||
const displayPreferences = useMemo(
|
|
||||||
() => ({
|
|
||||||
layout: effectiveLayout,
|
|
||||||
wrapLines: preferences.wrapLines,
|
|
||||||
codeFontSize: settings.codeFontSize,
|
|
||||||
monoFontFamily: settings.monoFontFamily,
|
|
||||||
}),
|
|
||||||
[effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily],
|
|
||||||
);
|
|
||||||
const commitMode = useMemo(() => ({ kind: "commit" as const }), []);
|
|
||||||
|
|
||||||
if (!cwd) {
|
|
||||||
return (
|
|
||||||
<View style={styles.centerState}>
|
|
||||||
<Text style={styles.mutedText}>{t("panels.diff.directoryMissing")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (capabilityMissing) {
|
|
||||||
return (
|
|
||||||
<View style={styles.centerState} testID="commit-diff-capability-missing">
|
|
||||||
<Text style={styles.mutedText}>{t("panels.diff.capabilityMissing")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let bodyContent: ReactNode;
|
|
||||||
if (error) {
|
|
||||||
bodyContent = (
|
|
||||||
<View style={styles.centerState} testID="commit-diff-error">
|
|
||||||
<Text style={styles.errorText}>{t("panels.diff.loadError")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
} else if (isLoading && files.length === 0) {
|
|
||||||
bodyContent = (
|
|
||||||
<View style={styles.centerState} testID="commit-diff-loading">
|
|
||||||
<Text style={styles.mutedText}>{t("workspace.tabs.loading")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
} else if (files.length === 0) {
|
|
||||||
bodyContent = (
|
|
||||||
<View style={styles.centerState} testID="commit-diff-empty">
|
|
||||||
<Text style={styles.mutedText}>{t("panels.diff.empty")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
bodyContent = (
|
|
||||||
<SharedDiffView files={files} displayPreferences={displayPreferences} mode={commitMode} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container} testID="commit-diff-panel">
|
|
||||||
{canUseSplitLayout ? (
|
|
||||||
<View style={styles.toolbar} testID="commit-diff-toolbar">
|
|
||||||
<SegmentedControl
|
|
||||||
options={layoutOptions}
|
|
||||||
value={preferences.layout}
|
|
||||||
onValueChange={handleLayoutChange}
|
|
||||||
size="sm"
|
|
||||||
testID="commit-diff-layout-control"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
<View style={styles.body}>{bodyContent}</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useCommitDiffPanelDescriptor(
|
|
||||||
target: Extract<WorkspaceTabTarget, { kind: "commit_diff" }>,
|
|
||||||
): PanelDescriptor {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return {
|
|
||||||
label: target.sha.slice(0, 7),
|
|
||||||
subtitle: t("panels.diff.commitSubtitle"),
|
|
||||||
tooltip: target.sha,
|
|
||||||
titleState: "ready",
|
|
||||||
icon: ThemedGitCommitHorizontal,
|
|
||||||
statusBucket: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
|
|
||||||
kind: "commit_diff",
|
|
||||||
component: CommitDiffPanel,
|
|
||||||
useDescriptor: useCommitDiffPanelDescriptor,
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
},
|
|
||||||
toolbar: {
|
|
||||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderBottomWidth: theme.borderWidth[1],
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
flex: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
},
|
|
||||||
centerState: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
paddingHorizontal: theme.spacing[6],
|
|
||||||
paddingTop: theme.spacing[16],
|
|
||||||
},
|
|
||||||
mutedText: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
color: theme.colors.destructive,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import { useCallback, useMemo, useState, type ComponentProps, type ReactNode } from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FileDiff, GitCommitHorizontal } from "lucide-react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import invariant from "tiny-invariant";
|
||||||
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
|
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||||
|
import {
|
||||||
|
DiffFilesToolbar,
|
||||||
|
DiffLayoutToggle,
|
||||||
|
DiffModeMenu,
|
||||||
|
DiffOptionsMenu,
|
||||||
|
resolveDiffLayout,
|
||||||
|
SharedDiffView,
|
||||||
|
} from "@/git/diff-pane";
|
||||||
|
import { useCommitDiffFiles } from "@/git/use-diff-files";
|
||||||
|
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
|
||||||
|
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||||
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
|
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||||
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
|
const ThemedFileDiff = withUnistyles(FileDiff);
|
||||||
|
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||||
|
|
||||||
|
function useDiffPanelPreferences() {
|
||||||
|
const { settings } = useAppSettings();
|
||||||
|
const { preferences, updatePreferences } = useChangesPreferences();
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
|
const canUseSplitLayout = isWeb && !isCompact;
|
||||||
|
const effectiveLayout = resolveDiffLayout(preferences.layout, canUseSplitLayout);
|
||||||
|
const displayPreferences = useMemo(
|
||||||
|
() => ({
|
||||||
|
layout: effectiveLayout,
|
||||||
|
wrapLines: preferences.wrapLines,
|
||||||
|
codeFontSize: settings.codeFontSize,
|
||||||
|
monoFontFamily: settings.monoFontFamily,
|
||||||
|
}),
|
||||||
|
[effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily],
|
||||||
|
);
|
||||||
|
const toggleLayout = useCallback(() => {
|
||||||
|
void updatePreferences({ layout: preferences.layout === "unified" ? "split" : "unified" });
|
||||||
|
}, [preferences.layout, updatePreferences]);
|
||||||
|
const toggleWrapLines = useCallback(() => {
|
||||||
|
void updatePreferences({ wrapLines: !preferences.wrapLines });
|
||||||
|
}, [preferences.wrapLines, updatePreferences]);
|
||||||
|
const toggleHideWhitespace = useCallback(() => {
|
||||||
|
void updatePreferences({ hideWhitespace: !preferences.hideWhitespace });
|
||||||
|
}, [preferences.hideWhitespace, updatePreferences]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
preferences,
|
||||||
|
isCompact,
|
||||||
|
canUseSplitLayout,
|
||||||
|
displayPreferences,
|
||||||
|
toggleLayout,
|
||||||
|
toggleWrapLines,
|
||||||
|
toggleHideWhitespace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelState({
|
||||||
|
message,
|
||||||
|
tone = "muted",
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
tone?: "muted" | "error";
|
||||||
|
testID?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centerState} testID={testID}>
|
||||||
|
<Text style={tone === "error" ? styles.errorText : styles.mutedText}>{message}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkingDiffBody({
|
||||||
|
cwd,
|
||||||
|
isConnected,
|
||||||
|
workingDiff,
|
||||||
|
hideWhitespace,
|
||||||
|
displayPreferences,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
cwd: string | null | undefined;
|
||||||
|
isConnected: boolean;
|
||||||
|
workingDiff: ReturnType<typeof useWorkingDiff>;
|
||||||
|
hideWhitespace: boolean;
|
||||||
|
displayPreferences: ReturnType<typeof useDiffPanelPreferences>["displayPreferences"];
|
||||||
|
mode: Extract<ComponentProps<typeof SharedDiffView>["mode"], { kind: "working_tab" }>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!cwd) {
|
||||||
|
return <PanelState message={t("panels.diff.directoryMissing")} />;
|
||||||
|
}
|
||||||
|
if (!isConnected) {
|
||||||
|
return <PanelState message={t("workspace.terminal.hostDisconnected")} />;
|
||||||
|
}
|
||||||
|
if (workingDiff.isStatusLoading) {
|
||||||
|
return <PanelState message={t("workspace.git.diff.checkingRepository")} />;
|
||||||
|
}
|
||||||
|
if (workingDiff.statusErrorMessage) {
|
||||||
|
return (
|
||||||
|
<PanelState
|
||||||
|
message={workingDiff.statusErrorMessage}
|
||||||
|
tone="error"
|
||||||
|
testID="working-diff-error"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (workingDiff.notGit) {
|
||||||
|
return <PanelState message={t("workspace.git.diff.notRepository")} />;
|
||||||
|
}
|
||||||
|
if (workingDiff.diffPayloadError) {
|
||||||
|
return (
|
||||||
|
<PanelState message={t("panels.diff.loadError")} tone="error" testID="working-diff-error" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (workingDiff.isDiffLoading && workingDiff.files.length === 0) {
|
||||||
|
return <PanelState message={t("workspace.tabs.loading")} testID="working-diff-loading" />;
|
||||||
|
}
|
||||||
|
if (workingDiff.files.length === 0) {
|
||||||
|
return (
|
||||||
|
<PanelState
|
||||||
|
message={
|
||||||
|
hideWhitespace ? t("workspace.git.diff.emptyHiddenWhitespace") : t("panels.diff.empty")
|
||||||
|
}
|
||||||
|
testID="working-diff-empty"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SharedDiffView files={workingDiff.files} displayPreferences={displayPreferences} mode={mode} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkingDiffPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const { serverId, workspaceId, tabId, target } = usePaneContext();
|
||||||
|
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const isActive = useRetainedPanelActive();
|
||||||
|
const panelPreferences = useDiffPanelPreferences();
|
||||||
|
const [expandedPaths, setExpandedPaths] = useState<string[] | null>(null);
|
||||||
|
invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target");
|
||||||
|
|
||||||
|
const workingDiff = useWorkingDiff({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
cwd: cwd ?? "",
|
||||||
|
ignoreWhitespace: panelPreferences.preferences.hideWhitespace,
|
||||||
|
enabled: Boolean(cwd) && isActive,
|
||||||
|
queryScope: `working-diff-tab:${tabId}`,
|
||||||
|
});
|
||||||
|
usePublishWorkingDiffAttachment({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
cwd: cwd ?? "",
|
||||||
|
attachment: workingDiff.reviewAttachment,
|
||||||
|
enabled: Boolean(cwd) && isActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshSupported = useSessionStore(
|
||||||
|
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
||||||
|
);
|
||||||
|
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
|
||||||
|
const isRefreshing =
|
||||||
|
useCheckoutGitActionsStore((state) =>
|
||||||
|
state.getStatus({ serverId, cwd: cwd ?? "", actionId: "refresh" }),
|
||||||
|
) === "pending";
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
if (!cwd || isRefreshing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runRefresh({ serverId, cwd }).catch((error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
||||||
|
});
|
||||||
|
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
|
||||||
|
|
||||||
|
const expandedPathSet = useMemo(
|
||||||
|
() => (expandedPaths === null ? null : new Set(expandedPaths)),
|
||||||
|
[expandedPaths],
|
||||||
|
);
|
||||||
|
const allFilesExpanded =
|
||||||
|
workingDiff.files.length > 0 &&
|
||||||
|
(expandedPathSet === null || workingDiff.files.every((file) => expandedPathSet.has(file.path)));
|
||||||
|
const toggleExpandAll = useCallback(() => {
|
||||||
|
setExpandedPaths(allFilesExpanded ? [] : null);
|
||||||
|
}, [allFilesExpanded]);
|
||||||
|
const mode = useMemo(
|
||||||
|
() => ({
|
||||||
|
kind: "working_tab" as const,
|
||||||
|
expandedPaths,
|
||||||
|
reviewActions: workingDiff.reviewActions,
|
||||||
|
focusPath: target.focusPath,
|
||||||
|
focusRequestId: target.focusRequestId,
|
||||||
|
onExpandedPathsChange: setExpandedPaths,
|
||||||
|
}),
|
||||||
|
[expandedPaths, target.focusPath, target.focusRequestId, workingDiff.reviewActions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseRefLabel = workingDiff.baseRef?.replace(/^refs\/(heads|remotes)\//, "") ?? "";
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="working-diff-panel">
|
||||||
|
<View style={styles.toolbar}>
|
||||||
|
<DiffModeMenu
|
||||||
|
diffMode={workingDiff.diffMode}
|
||||||
|
committedDescription={baseRefLabel || undefined}
|
||||||
|
testIDPrefix="working-diff"
|
||||||
|
onSelectUncommitted={workingDiff.selectUncommitted}
|
||||||
|
onSelectBase={workingDiff.selectBase}
|
||||||
|
/>
|
||||||
|
<View style={styles.toolbarActions} testID="working-diff-toolbar">
|
||||||
|
{panelPreferences.canUseSplitLayout ? (
|
||||||
|
<DiffLayoutToggle
|
||||||
|
layout={panelPreferences.preferences.layout}
|
||||||
|
isMobile={panelPreferences.isCompact}
|
||||||
|
testID="working-diff-toggle-layout"
|
||||||
|
onToggle={panelPreferences.toggleLayout}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{workingDiff.files.length > 0 ? (
|
||||||
|
<DiffFilesToolbar
|
||||||
|
allFileDiffsExpanded={allFilesExpanded}
|
||||||
|
isMobile={panelPreferences.isCompact}
|
||||||
|
testID="working-diff-toggle-expand-all"
|
||||||
|
onToggleExpandAll={toggleExpandAll}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<DiffOptionsMenu
|
||||||
|
hideWhitespace={panelPreferences.preferences.hideWhitespace}
|
||||||
|
isMobile={panelPreferences.isCompact}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
refreshSupported={refreshSupported}
|
||||||
|
testIDPrefix="working-diff"
|
||||||
|
wrapLines={panelPreferences.preferences.wrapLines}
|
||||||
|
onRefresh={refresh}
|
||||||
|
onToggleHideWhitespace={panelPreferences.toggleHideWhitespace}
|
||||||
|
onToggleWrapLines={panelPreferences.toggleWrapLines}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.body}>
|
||||||
|
<WorkingDiffBody
|
||||||
|
cwd={cwd}
|
||||||
|
isConnected={isConnected}
|
||||||
|
workingDiff={workingDiff}
|
||||||
|
hideWhitespace={panelPreferences.preferences.hideWhitespace}
|
||||||
|
displayPreferences={panelPreferences.displayPreferences}
|
||||||
|
mode={mode}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommitDiffPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { serverId, workspaceId, target } = usePaneContext();
|
||||||
|
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||||
|
const panelPreferences = useDiffPanelPreferences();
|
||||||
|
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
|
||||||
|
const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({
|
||||||
|
serverId,
|
||||||
|
cwd: cwd ?? "",
|
||||||
|
sha: target.sha,
|
||||||
|
enabled: Boolean(cwd),
|
||||||
|
});
|
||||||
|
const mode = useMemo(() => ({ kind: "commit" as const }), []);
|
||||||
|
|
||||||
|
let body: ReactNode;
|
||||||
|
if (!cwd) {
|
||||||
|
body = <PanelState message={t("panels.diff.directoryMissing")} />;
|
||||||
|
} else if (capabilityMissing) {
|
||||||
|
body = (
|
||||||
|
<PanelState
|
||||||
|
message={t("panels.diff.capabilityMissing")}
|
||||||
|
testID="commit-diff-capability-missing"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (error) {
|
||||||
|
body = (
|
||||||
|
<PanelState message={t("panels.diff.loadError")} tone="error" testID="commit-diff-error" />
|
||||||
|
);
|
||||||
|
} else if (isLoading && files.length === 0) {
|
||||||
|
body = <PanelState message={t("workspace.tabs.loading")} testID="commit-diff-loading" />;
|
||||||
|
} else if (files.length === 0) {
|
||||||
|
body = <PanelState message={t("panels.diff.empty")} testID="commit-diff-empty" />;
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<SharedDiffView
|
||||||
|
files={files}
|
||||||
|
displayPreferences={panelPreferences.displayPreferences}
|
||||||
|
mode={mode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="commit-diff-panel">
|
||||||
|
{panelPreferences.canUseSplitLayout ? (
|
||||||
|
<View style={styles.toolbar}>
|
||||||
|
<View style={styles.toolbarActions} testID="commit-diff-toolbar">
|
||||||
|
<DiffLayoutToggle
|
||||||
|
layout={panelPreferences.preferences.layout}
|
||||||
|
isMobile={panelPreferences.isCompact}
|
||||||
|
testID="commit-diff-toggle-layout"
|
||||||
|
onToggle={panelPreferences.toggleLayout}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.body}>{body}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useWorkingDiffPanelDescriptor(): PanelDescriptor {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return {
|
||||||
|
label: t("panels.diff.changesLabel"),
|
||||||
|
subtitle: t("panels.diff.changesSubtitle"),
|
||||||
|
tooltip: t("panels.diff.changesSubtitle"),
|
||||||
|
titleState: "ready",
|
||||||
|
icon: ThemedFileDiff,
|
||||||
|
statusBucket: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCommitDiffPanelDescriptor(
|
||||||
|
target: Extract<WorkspaceTabTarget, { kind: "commit_diff" }>,
|
||||||
|
): PanelDescriptor {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return {
|
||||||
|
label: target.sha.slice(0, 7),
|
||||||
|
subtitle: t("panels.diff.commitSubtitle"),
|
||||||
|
tooltip: target.sha,
|
||||||
|
titleState: "ready",
|
||||||
|
icon: ThemedGitCommitHorizontal,
|
||||||
|
statusBucket: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workingDiffPanelRegistration: PanelRegistration<"working_diff"> = {
|
||||||
|
kind: "working_diff",
|
||||||
|
component: WorkingDiffPanel,
|
||||||
|
useDescriptor: useWorkingDiffPanelDescriptor,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
|
||||||
|
kind: "commit_diff",
|
||||||
|
component: CommitDiffPanel,
|
||||||
|
useDescriptor: useCommitDiffPanelDescriptor,
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
toolbar: {
|
||||||
|
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingRight: theme.spacing[2],
|
||||||
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
toolbarActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
centerState: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: theme.spacing[6],
|
||||||
|
paddingTop: theme.spacing[16],
|
||||||
|
},
|
||||||
|
mutedText: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { createContext, useContext, type ReactNode } from "react";
|
import React, { createContext, useContext, type ReactNode } from "react";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||||
|
|
||||||
export interface PaneContextValue {
|
export interface PaneContextValue {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
|
|
||||||
export interface PanelIconProps {
|
export interface PanelIconProps {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { agentPanelRegistration } from "@/panels/agent-panel";
|
import { agentPanelRegistration } from "@/panels/agent-panel";
|
||||||
import { browserPanelRegistration } from "@/panels/browser-panel";
|
import { browserPanelRegistration } from "@/panels/browser-panel";
|
||||||
import { commitDiffPanelRegistration } from "@/panels/commit-diff-panel";
|
import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel";
|
||||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||||
import { filePanelRegistration } from "@/panels/file-panel";
|
import { filePanelRegistration } from "@/panels/file-panel";
|
||||||
import { registerPanel } from "@/panels/panel-registry";
|
import { registerPanel } from "@/panels/panel-registry";
|
||||||
@@ -22,5 +22,6 @@ export function ensurePanelsRegistered(): void {
|
|||||||
registerPanel(browserPanelRegistration);
|
registerPanel(browserPanelRegistration);
|
||||||
registerPanel(filePanelRegistration);
|
registerPanel(filePanelRegistration);
|
||||||
registerPanel(commitDiffPanelRegistration);
|
registerPanel(commitDiffPanelRegistration);
|
||||||
|
registerPanel(workingDiffPanelRegistration);
|
||||||
panelsRegistered = true;
|
panelsRegistered = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import invariant from "tiny-invariant";
|
|||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ import type { MessagePayload } from "@/composer/types";
|
|||||||
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||||
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
||||||
import {
|
import {
|
||||||
getWorkspaceNamingAttachments,
|
getWorkspaceNamingAttachments,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||||
import { selectVisibleAgentIds } from "./visible-agent-ids";
|
import { selectVisibleAgentIds } from "./visible-agent-ids";
|
||||||
|
|
||||||
test("selects only the active agent tab in every visible pane", () => {
|
test("selects only the active agent tab in every visible pane", () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store";
|
import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||||
import { deriveWorkspacePaneState } from "./workspace-pane-state";
|
import { deriveWorkspacePaneState } from "./workspace-pane-state";
|
||||||
|
|
||||||
export function selectVisibleAgentIds(input: {
|
export function selectVisibleAgentIds(input: {
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ interface WorkspaceDesktopTabsRowProps {
|
|||||||
|
|
||||||
function getFallbackTabLabel(
|
function getFallbackTabLabel(
|
||||||
tab: WorkspaceTabDescriptor,
|
tab: WorkspaceTabDescriptor,
|
||||||
labels: { newAgent: string; setup: string; terminal: string; agent: string },
|
labels: { newAgent: string; setup: string; terminal: string; agent: string; changes: string },
|
||||||
): string {
|
): string {
|
||||||
if (tab.target.kind === "draft") {
|
if (tab.target.kind === "draft") {
|
||||||
return labels.newAgent;
|
return labels.newAgent;
|
||||||
@@ -458,6 +458,9 @@ function getFallbackTabLabel(
|
|||||||
if (tab.target.kind === "file") {
|
if (tab.target.kind === "file") {
|
||||||
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
|
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
|
||||||
}
|
}
|
||||||
|
if (tab.target.kind === "working_diff") {
|
||||||
|
return labels.changes;
|
||||||
|
}
|
||||||
return labels.agent;
|
return labels.agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,6 +839,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
setup: t("workspace.tabs.fallback.setup"),
|
setup: t("workspace.tabs.fallback.setup"),
|
||||||
terminal: t("workspace.tabs.fallback.terminal"),
|
terminal: t("workspace.tabs.fallback.terminal"),
|
||||||
agent: t("workspace.tabs.fallback.agent"),
|
agent: t("workspace.tabs.fallback.agent"),
|
||||||
|
changes: t("panels.diff.changesLabel"),
|
||||||
}),
|
}),
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
createWorkspaceFileTabTarget,
|
createWorkspaceFileTabTarget,
|
||||||
normalizeWorkspaceFileLocation,
|
normalizeWorkspaceFileLocation,
|
||||||
} from "@/workspace/file-open";
|
} from "@/workspace/file-open";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
interface OpenWorkspaceFileFromExplorerInput {
|
interface OpenWorkspaceFileFromExplorerInput {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
resolveSideFileOpenPlacement,
|
resolveSideFileOpenPlacement,
|
||||||
} from "@/screens/workspace/workspace-pane-state";
|
} from "@/screens/workspace/workspace-pane-state";
|
||||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
function createTab(tabId: string, target: WorkspaceTab["target"]): WorkspaceTab {
|
function createTab(tabId: string, target: WorkspaceTab["target"]): WorkspaceTab {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
type SplitPane,
|
type SplitPane,
|
||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import {
|
import {
|
||||||
buildDeterministicWorkspaceTabId,
|
buildDeterministicWorkspaceTabId,
|
||||||
|
|||||||
@@ -74,14 +74,17 @@ import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
|||||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
|
||||||
collectAllTabs,
|
collectAllTabs,
|
||||||
getFocusedBrowserId,
|
getFocusedBrowserId,
|
||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
useWorkspaceLayoutStoreHydrated,
|
useWorkspaceLayoutStoreHydrated,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import {
|
||||||
|
buildWorkspaceTabPersistenceKey,
|
||||||
|
type WorkspaceTab,
|
||||||
|
type WorkspaceTabTarget,
|
||||||
|
} from "@/workspace-tabs/model";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
@@ -291,11 +294,13 @@ interface WorkspaceScreenProps {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
isRouteFocused?: boolean;
|
isRouteFocused?: boolean;
|
||||||
recoveryRequested?: boolean;
|
recoveryRequested?: boolean;
|
||||||
|
recoveryAgentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceScreenContentProps = WorkspaceScreenProps & {
|
type WorkspaceScreenContentProps = WorkspaceScreenProps & {
|
||||||
isRouteFocused: boolean;
|
isRouteFocused: boolean;
|
||||||
recoveryRequested: boolean;
|
recoveryRequested: boolean;
|
||||||
|
recoveryAgentId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
@@ -343,6 +348,7 @@ function getFallbackTabOptionLabel(
|
|||||||
terminal: string;
|
terminal: string;
|
||||||
browser: string;
|
browser: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
|
changes: string;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
if (tab.target.kind === "draft") {
|
if (tab.target.kind === "draft") {
|
||||||
@@ -360,6 +366,9 @@ function getFallbackTabOptionLabel(
|
|||||||
if (tab.target.kind === "file") {
|
if (tab.target.kind === "file") {
|
||||||
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
|
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
|
||||||
}
|
}
|
||||||
|
if (tab.target.kind === "working_diff") {
|
||||||
|
return labels.changes;
|
||||||
|
}
|
||||||
if (tab.target.kind === "commit_diff") {
|
if (tab.target.kind === "commit_diff") {
|
||||||
return tab.target.sha.slice(0, 7);
|
return tab.target.sha.slice(0, 7);
|
||||||
}
|
}
|
||||||
@@ -374,6 +383,7 @@ function getFallbackTabOptionDescription(
|
|||||||
agent: string;
|
agent: string;
|
||||||
terminal: string;
|
terminal: string;
|
||||||
browser: string;
|
browser: string;
|
||||||
|
changes: string;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
if (tab.target.kind === "draft") {
|
if (tab.target.kind === "draft") {
|
||||||
@@ -397,6 +407,9 @@ function getFallbackTabOptionDescription(
|
|||||||
if (tab.target.kind === "commit_diff") {
|
if (tab.target.kind === "commit_diff") {
|
||||||
return tab.target.sha.slice(0, 7);
|
return tab.target.sha.slice(0, 7);
|
||||||
}
|
}
|
||||||
|
if (tab.target.kind === "working_diff") {
|
||||||
|
return labels.changes;
|
||||||
|
}
|
||||||
return tab.target.path;
|
return tab.target.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,6 +682,7 @@ function MobileWorkspaceTabOption({
|
|||||||
terminal: t("workspace.tabs.fallback.terminal"),
|
terminal: t("workspace.tabs.fallback.terminal"),
|
||||||
browser: t("workspace.tabs.fallback.browser"),
|
browser: t("workspace.tabs.fallback.browser"),
|
||||||
agent: t("workspace.tabs.fallback.agent"),
|
agent: t("workspace.tabs.fallback.agent"),
|
||||||
|
changes: t("panels.diff.changesLabel"),
|
||||||
}),
|
}),
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
@@ -912,6 +926,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
isRouteFocused,
|
isRouteFocused,
|
||||||
recoveryRequested,
|
recoveryRequested,
|
||||||
|
recoveryAgentId,
|
||||||
}: WorkspaceScreenProps) {
|
}: WorkspaceScreenProps) {
|
||||||
const navigationFocused = useIsFocused();
|
const navigationFocused = useIsFocused();
|
||||||
return (
|
return (
|
||||||
@@ -920,6 +935,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
isRouteFocused={isRouteFocused ?? navigationFocused}
|
isRouteFocused={isRouteFocused ?? navigationFocused}
|
||||||
recoveryRequested={recoveryRequested ?? false}
|
recoveryRequested={recoveryRequested ?? false}
|
||||||
|
recoveryAgentId={recoveryAgentId ?? null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1715,6 +1731,7 @@ function WorkspaceScreenContent({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
isRouteFocused,
|
isRouteFocused,
|
||||||
recoveryRequested,
|
recoveryRequested,
|
||||||
|
recoveryAgentId,
|
||||||
}: WorkspaceScreenContentProps) {
|
}: WorkspaceScreenContentProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const _insets = useSafeAreaInsets();
|
const _insets = useSafeAreaInsets();
|
||||||
@@ -1791,6 +1808,7 @@ function WorkspaceScreenContent({
|
|||||||
const workspaceRecovery = useWorkspaceRecovery({
|
const workspaceRecovery = useWorkspaceRecovery({
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
agentId: recoveryAgentId,
|
||||||
enabled: shouldInspectWorkspaceRecovery(
|
enabled: shouldInspectWorkspaceRecovery(
|
||||||
hasHydratedWorkspaces,
|
hasHydratedWorkspaces,
|
||||||
workspaceDescriptor,
|
workspaceDescriptor,
|
||||||
@@ -2508,6 +2526,7 @@ function WorkspaceScreenContent({
|
|||||||
terminal: t("workspace.tabs.fallback.terminal"),
|
terminal: t("workspace.tabs.fallback.terminal"),
|
||||||
browser: t("workspace.tabs.fallback.browser"),
|
browser: t("workspace.tabs.fallback.browser"),
|
||||||
agent: t("workspace.tabs.fallback.agent"),
|
agent: t("workspace.tabs.fallback.agent"),
|
||||||
|
changes: t("panels.diff.changesLabel"),
|
||||||
}),
|
}),
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
import {
|
||||||
|
buildWorkspaceDesktopTabActions,
|
||||||
|
buildWorkspaceTabMenuEntries,
|
||||||
|
} from "@/screens/workspace/workspace-tab-menu";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
|
|
||||||
function createAgentTab(): WorkspaceTabDescriptor {
|
function createAgentTab(): WorkspaceTabDescriptor {
|
||||||
@@ -253,6 +256,37 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
expect(onCopyFilePath).toHaveBeenCalledWith("/some/path.ts");
|
expect(onCopyFilePath).toHaveBeenCalledWith("/some/path.ts");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses a Changes close id for the working diff tab", () => {
|
||||||
|
const actions = buildWorkspaceDesktopTabActions({
|
||||||
|
tab: {
|
||||||
|
key: "working_diff_abc",
|
||||||
|
tabId: "working_diff_abc",
|
||||||
|
kind: "working_diff",
|
||||||
|
target: {
|
||||||
|
kind: "working_diff",
|
||||||
|
focusPath: "src/example.ts",
|
||||||
|
focusRequestId: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
index: 0,
|
||||||
|
tabCount: 1,
|
||||||
|
onCopyResumeCommand: vi.fn(),
|
||||||
|
onCopyAgentId: vi.fn(),
|
||||||
|
onCopyFilePath: vi.fn(),
|
||||||
|
onReloadAgent: vi.fn(),
|
||||||
|
onRenameTab: vi.fn(),
|
||||||
|
onCloseTab: vi.fn(),
|
||||||
|
onCloseTabsToLeft: vi.fn(),
|
||||||
|
onCloseTabsToRight: vi.fn(),
|
||||||
|
onCloseOtherTabs: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(actions.closeButtonTestId).toMatch(/^workspace-working-diff-close-/);
|
||||||
|
expect(actions.menuEntries).not.toContainEqual(
|
||||||
|
expect.objectContaining({ kind: "item", key: "copy-file-path" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the same rename entry shape for agent and terminal tabs", () => {
|
it("uses the same rename entry shape for agent and terminal tabs", () => {
|
||||||
const terminalTab: WorkspaceTabDescriptor = {
|
const terminalTab: WorkspaceTabDescriptor = {
|
||||||
key: "terminal_abc",
|
key: "terminal_abc",
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
|||||||
if (tab.target.kind === "commit_diff") {
|
if (tab.target.kind === "commit_diff") {
|
||||||
return `workspace-commit-diff-close-${encodeFilePathForPathSegment(tab.target.sha)}`;
|
return `workspace-commit-diff-close-${encodeFilePathForPathSegment(tab.target.sha)}`;
|
||||||
}
|
}
|
||||||
|
if (tab.target.kind === "working_diff") {
|
||||||
|
return `workspace-working-diff-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`;
|
||||||
|
}
|
||||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
|
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
|
||||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
describe("deriveWorkspaceTabModel", () => {
|
describe("deriveWorkspaceTabModel", () => {
|
||||||
it("keeps normalized tabs in stored order and preserves targets", () => {
|
it("keeps normalized tabs in stored order and preserves targets", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import {
|
import {
|
||||||
deriveWorkspacePaneState,
|
deriveWorkspacePaneState,
|
||||||
type WorkspaceDerivedTab,
|
type WorkspaceDerivedTab,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
export interface WorkspaceTabDescriptor {
|
export interface WorkspaceTabDescriptor {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { createJSONStorage, persist } from "zustand/middleware";
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types";
|
||||||
|
import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||||
import {
|
import {
|
||||||
garbageCollectAttachments,
|
garbageCollectAttachments,
|
||||||
persistAttachmentFromDataUrl,
|
persistAttachmentFromDataUrl,
|
||||||
@@ -39,16 +40,21 @@ interface DraftStoreActions {
|
|||||||
draftKey: string;
|
draftKey: string;
|
||||||
lifecycle?: Exclude<DraftLifecycleState, "active">;
|
lifecycle?: Exclude<DraftLifecycleState, "active">;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
attachWorkspaceFile: (input: {
|
||||||
|
draftKey: string;
|
||||||
|
attachment: WorkspaceFileComposerAttachment;
|
||||||
|
}) => Promise<void>;
|
||||||
getCreateModalDraft: () => DraftInput | null;
|
getCreateModalDraft: () => DraftInput | null;
|
||||||
saveCreateModalDraft: (draft: DraftInput | null) => void;
|
saveCreateModalDraft: (draft: DraftInput | null) => void;
|
||||||
beginDraftGeneration: (draftKey: string) => number;
|
|
||||||
isDraftGenerationCurrent: (input: { draftKey: string; generation: number }) => boolean;
|
|
||||||
collectActiveAttachmentIds: () => string[];
|
collectActiveAttachmentIds: () => string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type DraftStore = DraftStoreState & DraftStoreActions;
|
interface DraftStoreRuntimeState {
|
||||||
|
attachmentFocusRequestByDraftKey: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
|
||||||
|
|
||||||
const draftGenerations = new Map<string, number>();
|
|
||||||
let gcScheduled = false;
|
let gcScheduled = false;
|
||||||
const draftPersistStorage = createDraftPersistStorage(
|
const draftPersistStorage = createDraftPersistStorage(
|
||||||
createJSONStorage<DraftStoreState>(() => AsyncStorage),
|
createJSONStorage<DraftStoreState>(() => AsyncStorage),
|
||||||
@@ -237,6 +243,7 @@ export const useDraftStore = create<DraftStore>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
drafts: {},
|
drafts: {},
|
||||||
createModalDraft: null,
|
createModalDraft: null,
|
||||||
|
attachmentFocusRequestByDraftKey: {},
|
||||||
|
|
||||||
getDraftInput: (draftKey) => {
|
getDraftInput: (draftKey) => {
|
||||||
const record = get().drafts[draftKey];
|
const record = get().drafts[draftKey];
|
||||||
@@ -344,7 +351,32 @@ export const useDraftStore = create<DraftStore>()(
|
|||||||
return { drafts: nextDrafts };
|
return { drafts: nextDrafts };
|
||||||
});
|
});
|
||||||
|
|
||||||
draftGenerations.delete(draftKey);
|
scheduleAttachmentGc();
|
||||||
|
},
|
||||||
|
|
||||||
|
attachWorkspaceFile: async ({ draftKey, attachment }) => {
|
||||||
|
await get().hydrateDraftInput({ draftKey });
|
||||||
|
set((state) => {
|
||||||
|
const existing = state.drafts[draftKey];
|
||||||
|
const draft = toDraftInputIfReady(existing) ?? { text: "", attachments: [] };
|
||||||
|
return {
|
||||||
|
drafts: {
|
||||||
|
...state.drafts,
|
||||||
|
[draftKey]: createDraftRecord({
|
||||||
|
draft: {
|
||||||
|
...draft,
|
||||||
|
attachments: appendWorkspaceFileAttachment(draft.attachments, attachment),
|
||||||
|
},
|
||||||
|
lifecycle: "active",
|
||||||
|
previousVersion: existing?.version,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
attachmentFocusRequestByDraftKey: {
|
||||||
|
...state.attachmentFocusRequestByDraftKey,
|
||||||
|
[draftKey]: (state.attachmentFocusRequestByDraftKey[draftKey] ?? 0) + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
scheduleAttachmentGc();
|
scheduleAttachmentGc();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -369,16 +401,6 @@ export const useDraftStore = create<DraftStore>()(
|
|||||||
scheduleAttachmentGc();
|
scheduleAttachmentGc();
|
||||||
},
|
},
|
||||||
|
|
||||||
beginDraftGeneration: (draftKey) => {
|
|
||||||
const next = (draftGenerations.get(draftKey) ?? 0) + 1;
|
|
||||||
draftGenerations.set(draftKey, next);
|
|
||||||
return next;
|
|
||||||
},
|
|
||||||
|
|
||||||
isDraftGenerationCurrent: ({ draftKey, generation }) => {
|
|
||||||
return (draftGenerations.get(draftKey) ?? 0) === generation;
|
|
||||||
},
|
|
||||||
|
|
||||||
collectActiveAttachmentIds: () => {
|
collectActiveAttachmentIds: () => {
|
||||||
return Array.from(collectReferencedAttachmentIdsFromState(get()).values());
|
return Array.from(collectReferencedAttachmentIdsFromState(get()).values());
|
||||||
},
|
},
|
||||||
@@ -387,6 +409,7 @@ export const useDraftStore = create<DraftStore>()(
|
|||||||
name: "paseo-drafts",
|
name: "paseo-drafts",
|
||||||
version: DRAFT_STORE_VERSION,
|
version: DRAFT_STORE_VERSION,
|
||||||
storage: draftPersistStorage,
|
storage: draftPersistStorage,
|
||||||
|
partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }),
|
||||||
migrate: (persistedState) => {
|
migrate: (persistedState) => {
|
||||||
return migratePersistedState(persistedState, {
|
return migratePersistedState(persistedState, {
|
||||||
migrateLegacyImages,
|
migrateLegacyImages,
|
||||||
|
|||||||
@@ -71,6 +71,46 @@ describe("draft-store lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("draft-store normalization", () => {
|
describe("draft-store normalization", () => {
|
||||||
|
it("preserves uploaded file attachments when hydrating a draft", () => {
|
||||||
|
const attachment = {
|
||||||
|
kind: "file" as const,
|
||||||
|
attachment: {
|
||||||
|
type: "uploaded_file" as const,
|
||||||
|
id: "file-1",
|
||||||
|
fileName: "context.json",
|
||||||
|
mimeType: "application/json",
|
||||||
|
size: 42,
|
||||||
|
path: "/tmp/context.json",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
toDraftInputIfReady({
|
||||||
|
input: { text: "Review this", attachments: [attachment] },
|
||||||
|
lifecycle: "active",
|
||||||
|
updatedAt: 1,
|
||||||
|
version: 1,
|
||||||
|
}),
|
||||||
|
).toEqual({ text: "Review this", attachments: [attachment] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves workspace file selections when hydrating a draft", () => {
|
||||||
|
const attachment = {
|
||||||
|
kind: "workspace_file" as const,
|
||||||
|
path: "src/app.ts",
|
||||||
|
selection: { kind: "line_range" as const, startLine: 12, endLine: 24 },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
toDraftInputIfReady({
|
||||||
|
input: { text: "Review this", attachments: [attachment] },
|
||||||
|
lifecycle: "active",
|
||||||
|
updatedAt: 1,
|
||||||
|
version: 1,
|
||||||
|
}),
|
||||||
|
).toEqual({ text: "Review this", attachments: [attachment] });
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves New Workspace picker ownership when hydrating a draft", () => {
|
it("preserves New Workspace picker ownership when hydrating a draft", () => {
|
||||||
const pickerAttachment = {
|
const pickerAttachment = {
|
||||||
kind: "github_pr" as const,
|
kind: "github_pr" as const,
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import {
|
|||||||
type AttachmentMetadata,
|
type AttachmentMetadata,
|
||||||
type UserComposerAttachment,
|
type UserComposerAttachment,
|
||||||
} from "@/attachments/types";
|
} from "@/attachments/types";
|
||||||
import { ForgeSearchItemSchema, GitHubSearchItemSchema } from "@getpaseo/protocol/messages";
|
import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file";
|
||||||
|
import {
|
||||||
|
ForgeSearchItemSchema,
|
||||||
|
GitHubSearchItemSchema,
|
||||||
|
UploadedFileAttachmentSchema,
|
||||||
|
} from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
export const DRAFT_STORE_VERSION = 5;
|
export const DRAFT_STORE_VERSION = 5;
|
||||||
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
|
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
|
||||||
@@ -83,6 +88,12 @@ export function isUserComposerAttachment(value: unknown): value is UserComposerA
|
|||||||
const metadata = record.metadata;
|
const metadata = record.metadata;
|
||||||
return isAttachmentMetadata(metadata);
|
return isAttachmentMetadata(metadata);
|
||||||
}
|
}
|
||||||
|
if (record.kind === "workspace_file") {
|
||||||
|
return isWorkspaceFileComposerAttachment(value);
|
||||||
|
}
|
||||||
|
if (record.kind === "file") {
|
||||||
|
return UploadedFileAttachmentSchema.safeParse(record.attachment).success;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
record.kind !== "forge_issue" &&
|
record.kind !== "forge_issue" &&
|
||||||
record.kind !== "forge_change_request" &&
|
record.kind !== "forge_change_request" &&
|
||||||
@@ -113,6 +124,13 @@ export function normalizeComposerAttachment(
|
|||||||
metadata: normalizeAttachmentMetadata(attachment.metadata),
|
metadata: normalizeAttachmentMetadata(attachment.metadata),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (attachment.kind === "workspace_file") {
|
||||||
|
return {
|
||||||
|
kind: "workspace_file",
|
||||||
|
path: attachment.path.trim().replace(/^\.\//, ""),
|
||||||
|
selection: attachment.selection,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (attachment.kind === "github_pr") {
|
if (attachment.kind === "github_pr") {
|
||||||
const item =
|
const item =
|
||||||
(attachment.item as { kind: string }).kind === "pr"
|
(attachment.item as { kind: string }).kind === "pr"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import {
|
import {
|
||||||
navigateToLastWorkspace,
|
navigateToLastWorkspace,
|
||||||
navigateToWorkspace,
|
navigateToWorkspace,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
resolveWorkspaceMapKeyByIdentity,
|
resolveWorkspaceMapKeyByIdentity,
|
||||||
} from "@/utils/workspace-identity";
|
} from "@/utils/workspace-identity";
|
||||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
|
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
|
||||||
|
|
||||||
export interface RouteSelectionInput {
|
export interface RouteSelectionInput {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { ComposerAttachment } from "@/attachments/types";
|
import type { ComposerAttachment } from "@/attachments/types";
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
export interface PendingWorkspaceDraftSubmission {
|
export interface PendingWorkspaceDraftSubmission {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user