Compare commits

...
Author SHA1 Message Date
Mohamed Boudra bf3910bdc0 test(workspaces): keep launch fixture alive 2026-08-15 12:30:17 +00:00
Mohamed Boudra b607bed49c test(workspaces): isolate runtime observation routing 2026-08-15 11:25:15 +00:00
Mohamed Boudra ec12c5c35c fix(workspaces): resolve runtime Git observation command 2026-08-15 11:01:11 +00:00
Mohamed Boudra 1daccc4414 test(workspaces): isolate runtime observation routing 2026-08-15 10:25:55 +00:00
Mohamed Boudra af140997a3 fix(workspaces): invalidate watches after helper writes 2026-08-15 10:03:14 +00:00
Mohamed Boudra 98bf569448 test(workspaces): focus runtime observation isolation 2026-08-15 09:35:32 +00:00
Mohamed Boudra bdfa718c83 test(workspaces): budget command runtime observation 2026-08-15 09:13:03 +00:00
Mohamed Boudra 791939081b fix(workspaces): await Git watcher teardown 2026-08-15 08:48:58 +00:00
Mohamed Boudra 18ec41670f test(workspaces): bound runtime Git integration waits 2026-08-15 08:19:28 +00:00
Mohamed Boudra add858f3e8 test(workspaces): isolate command runtime Git contracts 2026-08-15 07:54:55 +00:00
Mohamed Boudra 92a1e3fa66 fix(workspaces): release runtime resources on shutdown 2026-08-15 07:22:01 +00:00
Mohamed Boudra 91f76c9494 test(workspaces): isolate host Git observer seam 2026-08-15 00:51:17 +02:00
Mohamed Boudra d58230404b test(workspaces): expect helper protocol framing 2026-08-15 00:40:51 +02:00
Mohamed Boudra 692d4777c6 style(workspaces): format Windows helper test 2026-08-15 00:34:42 +02:00
Mohamed Boudra 3a8824ed99 fix(workspaces): keep helper executable standalone 2026-08-15 00:31:22 +02:00
Mohamed Boudra 6d16961d58 fix(workspaces): resolve Windows runtime commands 2026-08-15 00:24:34 +02:00
Mohamed Boudra 16e0967408 test(workspaces): make Git isolation deterministic 2026-08-14 23:58:52 +02:00
Mohamed Boudra 697a0002d9 test(workspaces): isolate sibling Git fanout 2026-08-14 23:43:21 +02:00
Mohamed Boudra 1518eb09a9 test(workspaces): isolate Git watcher readiness 2026-08-14 23:26:28 +02:00
Mohamed Boudra 3175a5ff10 chore: merge main into workspace runtime branch 2026-08-14 23:10:24 +02:00
Mohamed Boudra eafa5f2b5c fix(workspaces): resolve runtime Git portably 2026-08-14 22:54:59 +02:00
Mohamed Boudra 12c1a68956 test(workspaces): await runtime teardown 2026-08-14 22:23:22 +02:00
Mohamed Boudra cd354158f5 test(desktop): combine dev prerequisite build states 2026-08-14 21:59:24 +02:00
Mohamed Boudra cf4d702a8e fix(workspaces): release archived runtime backing 2026-08-14 21:44:48 +02:00
Mohamed Boudra e5f3b3dc02 fix(workspaces): preserve portable runtime readiness 2026-08-14 20:13:04 +02:00
Mohamed Boudra b500809229 test: assert worktree backing release 2026-08-14 19:31:04 +02:00
Mohamed Boudra 642c303859 test: remove provider descriptor clock race 2026-08-14 19:12:40 +02:00
Mohamed Boudra a67381795b test: include plugin in isolated build fixtures 2026-08-14 19:01:33 +02:00
Mohamed Boudra 8a37f2f5d3 fix: update Nix dependency hash 2026-08-14 18:49:40 +02:00
Mohamed Boudra 54f0e636b9 Merge remote-tracking branch 'origin/main' into workspace-runtime-poc
# Conflicts:
#	nix/npm-deps.hash
#	package.json
#	packages/client/src/daemon-client.ts
#	packages/server/src/server/bootstrap.ts
#	packages/server/src/server/websocket-server.ts
2026-08-14 18:43:58 +02:00
Mohamed Boudra cd429339a0 fix(workspaces): preserve runtime lifecycle boundaries 2026-08-14 18:35:07 +02:00
Mohamed Boudra 51193e7a92 fix(workspaces): stabilize runtime CI paths 2026-08-14 16:49:05 +02:00
Mohamed Boudra d35c10a8b5 test(workspaces): invoke npm portably on Windows 2026-08-14 14:34:04 +02:00
Mohamed Boudra ff83ca4bdc fix(workspaces): restore clean runtime test paths 2026-08-14 14:11:12 +02:00
Mohamed Boudra accd2d7587 fix(build): update Nix dependency hash 2026-08-14 13:44:12 +02:00
Mohamed Boudra 2501a46f3e fix(build): restore package lock metadata 2026-08-14 13:39:02 +02:00
Mohamed Boudra e2ac23da70 chore(merge): integrate main into workspace runtimes 2026-08-14 13:28:32 +02:00
Mohamed Boudra 7c32fcda98 refactor(workspaces): finalize runtime package boundaries
Keep the daemon dependent only on the public runtime contract and helper, while optional runtime implementations remain external command registrations. Workspace setup continues asynchronously through the bound runtime without coupling production packages to an implementation.
2026-08-14 12:48:35 +02:00
Mohamed Boudra 36d8464068 refactor(workspaces): make Docker a generic runtime
The server build, typecheck, and dry-run package pass with the Docker implementation deleted from the tree. paseo-workspace-helper remains a mandatory generic runtime contract requirement.
2026-08-13 21:59:13 +02:00
Mohamed Boudra cbb8295ceb fix(workspaces): unblock desktop runtime lifecycle
Electron-hosted Local helpers bypassed the sanitized self-Node launch policy, so they lacked ELECTRON_RUN_AS_NODE and provider probes hung.

Command-runtime adapters lacked exact detached POSIX process-group ownership, so forced lifecycle cleanup could leave adapter descendants alive.
2026-08-13 00:46:16 +02:00
Mohamed Boudra 33afa7283b feat(workspaces): define project runtime source authority
Route probe and user materialization through one projectRuntimeSource seam, where persisted Git source overrides the host-only convenience root and must belong to Git project data.

Lock default revision, explicit revision, and subdirectory semantics with real Docker acceptance. Enforce provider-probe internals structurally and document that the current Docker POC needs a locally built image because its default reference is unpublished.
2026-08-12 20:53:05 +02:00
Mohamed Boudra 527b252497 feat(workspaces): reconcile provider probe lifecycle
Keep lifecycle policy in provider-probe while WorkspaceRuntimeService remains the sole lifecycle executor. Idle probes pause without discarding runtime state; source and runtime fingerprints recreate probes and rotate provider bindings, while restart and project removal converge persisted records.

Close provider snapshot and helper bindings before lifecycle transitions to avoid leaks. Keep scriptTerminal in the exact bound-runtime surface because reusable local script terminals were intentionally introduced in the preceding accepted runtime slice.
2026-08-12 19:35:26 +02:00
Mohamed Boudra d03e4298ab feat(workspaces): accept Docker runtime journeys
Exercise probe, creation, terminals, file observation, Git, scripts, and agent edits through the Desktop Docker runtime while proving host decoys remain untouched.

Selected-runtime creation has a longer request budget because materialization and setup can legitimately exceed the legacy RPC ceiling; legacy creation keeps its existing timeout. Materializing records remain durable but private until final placement is published.

Scope Docker resources to the daemon owner and validate both container and volume before every lifecycle operation and creation race. Explicit Local and Worktree selection remains behaviorally unchanged, including reusable Local script terminals and the shared common-Git watcher.

Real-provider manual QA uses ignored checkout-local configuration and is intentionally not committed.
2026-08-12 18:28:06 +02:00
Mohamed Boudra 359d5443a9 feat(workspaces): probe providers in selected runtimes
Pre-creation provider truth now comes from an invisible real runtime workspace and reuses the normal provider snapshot pipeline. Probe records are owned separately, keeping them structurally absent from ordinary workspace projections.

Setup eligibility follows fresh content materialization rather than lifecycle resource ownership. Adopted Local directories never execute repository setup, while fresh Worktree, Docker, and external content may; provider probes never execute repository setup.
2026-08-12 13:02:21 +02:00
Mohamed Boudra 3a36c76733 feat(workspaces): choose runtime when creating workspaces
Expose the runtime catalog and carry an explicit runtime ID through workspace creation while preserving omitted-field compatibility for older clients.

The runtime POC made useGitActions require WorkspaceGitBoundary, but Command Center mounted that consumer without the boundary and crashed. Bind the active workspace at that consumer.

Keep provisional Worktree reservations unpublished until final placement so observers never bind the source checkout as the new workspace. Gate legacy cwd Git compatibility by hostVisiblePath instead of runtime kind because Local and Worktree remain host-visible runtimes.
2026-08-12 11:22:55 +02:00
Mohamed Boudra 6ca27a9b12 feat(workspaces): publish external runtime contract
Publish the rootless workspace runtime protocol, enforce module and host-execution boundaries, and prove an independently authored command runtime across lifecycle, PTY, files, Git, providers, and CI.
2026-08-11 22:14:47 +02:00
Mohamed Boudra cbf59f4c96 feat(workspaces): own lifecycle through runtimes
Reconcile runtime placement and lifecycle by workspace identity, preserve state through archive and recovery, and make deletion, observation replay, scripts, and resource cleanup convergent across local, worktree, and Docker runtimes.
2026-08-11 16:49:27 +02:00
Mohamed Boudra 36241cbd87 feat(workspaces): run providers through workspace runtimes
Bind provider discovery, state, and subprocess launches to workspace identity so selected runtimes cannot fall back to host execution. Preserve explicit legacy behavior and isolate provider snapshots by runtime generation.
2026-08-11 12:18:42 +02:00
Mohamed Boudra 0520481966 feat(workspaces): route Git operations through runtimes
Bind Git callers to workspace identity so selected repositories cannot collapse onto cwd-keyed state.

Run commands and observation through runtime capabilities while preserving explicit cwd-bound behavior for legacy records.
2026-08-11 07:40:20 +02:00
Mohamed Boudra b682f56bb9 feat(workspaces): route file access through runtimes
Keep browsing, streaming, editing, downloads, and live file observation inside the selected workspace runtime. Helper processes own cancellation and teardown so remote workspaces never fall back to host paths.
2026-08-11 01:55:27 +02:00
Mohamed Boudra d340365245 feat(workspaces): route interactive terminals through runtimes
Keep terminal state in Paseo while allocating PTYs and running terminal commands in the selected workspace runtime. Runtime exit and cleanup remain authoritative across worker and wrapper failures.
2026-08-11 00:47:09 +02:00
Mohamed Boudra 4898523351 feat(workspaces): introduce runtime execution boundary
Establish the pipe-mode vertical slice before migrating terminals, files, Git, and providers. Runtime selection is immutable per workspace, and external runtimes remain unavailable to path-based daemon surfaces until those capabilities move behind the boundary.
2026-08-10 22:57:54 +02:00
Mohamed Boudra a18d9ec3d8 test(workspaces): characterize runtime boundaries 2026-08-10 20:30:45 +02:00
336 changed files with 30270 additions and 4289 deletions
+3
View File
@@ -35,6 +35,9 @@ hub:
server: server:
- "packages/server/**" - "packages/server/**"
- "runtimes/fixture/**"
- "packages/workspace-helper/**"
- "packages/workspace-runtime-contract/**"
- "packages/app/e2e/support/fixtures/recording.*" - "packages/app/e2e/support/fixtures/recording.*"
desktop: desktop:
+17
View File
@@ -102,6 +102,9 @@ jobs:
- name: Lint - name: Lint
run: npm run lint run: npm run lint
- name: Enforce workspace runtime ownership boundaries
run: npm run check:workspace-runtime-boundaries && node --test scripts/workspace-runtime-boundaries.test.mjs
typecheck: typecheck:
name: typecheck name: typecheck
needs: changes needs: changes
@@ -129,6 +132,8 @@ jobs:
- name: Verify public package contents - name: Verify public package contents
run: | run: |
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/workspace-runtime-contract
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/workspace-helper
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
@@ -163,6 +168,18 @@ jobs:
- name: Build server dependencies - name: Build server dependencies
run: npm run build:server-deps run: npm run build:server-deps
- name: Test workspace runtime command contract package
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm test --workspace=@getpaseo/workspace-runtime-contract && npm test --workspace=@getpaseo/workspace-helper
- name: Prove the command runtime fixture is independently installable
if: ${{ runner.os == 'Linux' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
run: node --test scripts/workspace-runtime-standalone.test.mjs
- name: Prove clean server builds own public prerequisites
if: ${{ runner.os == 'Linux' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
run: node --test scripts/server-clean-build.test.mjs
- name: Run server tests - name: Run server tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/server run: npm run test --workspace=@getpaseo/server
+3
View File
@@ -16,6 +16,9 @@ on:
- "packages/app/**" - "packages/app/**"
- "packages/cli/**" - "packages/cli/**"
- "packages/client/**" - "packages/client/**"
- "packages/desktop/**"
- "packages/workspace-helper/**"
- "packages/workspace-runtime-contract/**"
- "packages/expo-two-way-audio/**" - "packages/expo-two-way-audio/**"
- "packages/highlight/**" - "packages/highlight/**"
- "packages/plugin/**" - "packages/plugin/**"
+138
View File
@@ -84,6 +84,144 @@
"max-nested-callbacks": ["error", { "max": 3 }] "max-nested-callbacks": ["error", { "max": 3 }]
}, },
"overrides": [ "overrides": [
{
"files": ["packages/server/src/**/*.{ts,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"**/workspace-runtime/internal/**",
"**/workspace-runtime/command/internal/**"
],
"message": "Workspace runtime internals are owned by their public entry points."
},
{
"group": ["**/workspace-helper/internal/**"],
"message": "Normal callers consume WorkspaceFiles from workspace-helper/index. Only the workspace-runtime parent integration and workspace-helper tests may assemble helper clients."
},
{
"group": [
"**/workspace-runtime/integration/**",
"**/workspace-runtime/git-observation/internal/**"
],
"message": "Git callers consume workspace-runtime/git-observation/index. Only runtime internals may assemble shared observation identity, placement, helpers, and lifecycle."
}
]
}
]
}
},
{
"files": [
"packages/server/src/server/workspace-runtime/index.ts",
"packages/server/src/server/workspace-runtime/command/index.ts",
"packages/server/src/server/workspace-runtime/command/internal/**/*.{ts,tsx}",
"packages/server/src/server/workspace-runtime/internal/**/*.{ts,tsx}",
"packages/server/src/server/workspace-helper/**/*.{ts,tsx}",
"packages/server/src/server/workspace-runtime/git-observation/**/*.{ts,tsx}",
"packages/server/src/server/workspace-runtime/internal/service.ts"
],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": [
"packages/server/src/server/session/files/**/*.{ts,tsx}",
"packages/server/src/server/session/git-mutation/**/*.{ts,tsx}",
"packages/server/src/server/session/provider/**/*.{ts,tsx}",
"packages/server/src/server/session/workspace-git-observer/**/*.{ts,tsx}",
"packages/server/src/server/session/workspace-provisioning/**/*.{ts,tsx}",
"packages/server/src/server/session/workspace-scripts/**/*.{ts,tsx}"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{ "name": "child_process", "message": "Use the bound workspace runtime." },
{ "name": "node:child_process", "message": "Use the bound workspace runtime." },
{ "name": "fs", "message": "Use the bound workspace runtime." },
{ "name": "node:fs", "message": "Use the bound workspace runtime." },
{ "name": "fs/promises", "message": "Use the bound workspace runtime." },
{ "name": "node:fs/promises", "message": "Use the bound workspace runtime." },
{ "name": "module", "message": "Dynamic loaders bypass workspace ownership." },
{
"name": "node:module",
"message": "Dynamic loaders bypass workspace ownership."
},
{ "name": "which", "message": "Use the bound workspace runtime." }
],
"patterns": [
{
"group": ["**/utils/run-git-command.{js,ts}"],
"message": "Use the bound workspace runtime."
},
{
"group": [
"**/workspace-runtime/internal/**",
"**/workspace-runtime/command/internal/**",
"**/workspace-runtime/git-observation/internal/**",
"**/workspace-helper/internal/**"
],
"message": "Use the owning public entry point."
}
]
}
]
}
},
{
"files": [
"packages/server/src/server/session/files/**/*.test.{ts,tsx}",
"packages/server/src/server/session/git-mutation/**/*.test.{ts,tsx}",
"packages/server/src/server/session/provider/**/*.test.{ts,tsx}",
"packages/server/src/server/session/workspace-git-observer/**/*.test.{ts,tsx}",
"packages/server/src/server/session/workspace-provisioning/**/*.test.{ts,tsx}",
"packages/server/src/server/session/workspace-scripts/**/*.test.{ts,tsx}"
],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": ["packages/fixture-workspace-runtime/**/*.{js,mjs,ts,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "module",
"message": "The external runtime may not construct dynamic module loaders."
},
{
"name": "node:module",
"message": "The external runtime may not construct dynamic module loaders."
},
{
"name": "@getpaseo/server",
"message": "The external runtime may consume only the published command contract."
}
],
"patterns": [
{
"group": [
"@getpaseo/server/**",
"**/packages/server/**",
"**/workspace-runtime/**/internal/**",
"**/workspace-helper/**/internal/**"
],
"message": "The external runtime may consume only the published command contract."
}
]
}
]
}
},
{ {
"files": ["packages/app/src/**/*.{ts,tsx}"], "files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": { "rules": {
+2
View File
@@ -22,6 +22,8 @@ RUN set -eux; \
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/workspace-runtime-contract --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/workspace-helper --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@paseo/plugin --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@paseo/plugin --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \ npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
+15
View File
@@ -75,6 +75,8 @@ not retain non-Git directories.
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation | | `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK | | `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) | | `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/workspace-runtime/` | Runtime-neutral workspace lifecycle, execution, files, Git observation |
| `server/provider-probe/` | Invisible runtime-bound workspaces for pre-creation provider discovery |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption | | `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents | | `server/schedule/` | Cron-based scheduled agents |
@@ -85,6 +87,19 @@ agent timeline types, provider config schemas, and other values shared by daemon
and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package; and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package;
it does not depend on the server. it does not depend on the server.
### Workspace runtime packages
`packages/workspace-runtime-contract` owns the versioned command-runtime lifecycle and exec wire
contract. `packages/workspace-helper` owns the official confined files/watch/command-resolution
executable and its typed binding. The daemon depends on those two packages and registers command
runtimes without importing an implementation.
`runtimes/fixture` is a private generic contract fixture. Runtime implementations live in their own
repositories and depend on published versions of the two public workspace packages. CLI, Desktop,
server, release workflows, and the daemon image do not depend on, bundle, publish, or register an
implementation. Tests invoke registered runtimes through their public command, never through source
imports.
### `packages/client` — Daemon client library and SDK facade ### `packages/client` — Daemon client library and SDK facade
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient` Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
+26 -15
View File
@@ -16,19 +16,23 @@ names, and workspace foreign keys. Attached workspaces are independently refresh
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too. are observed too.
The workspace registry model defines placement once: initial directory/worktree construction, The workspace registry model defines public placement once: initial directory/worktree
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy construction, mutable reconciliation fields, and the persisted-to-wire projection.
preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding `WorkspaceProvisioningService` owns the corresponding registry writes, so directory opens, agent
registry writes, so directory opens, agent imports, and worktree creation all enter through that imports, and worktree creation do not construct records independently.
service instead of constructing records independently. The workspace record is then the durable
placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing For a runtime-selected workspace, `runtime.runtimeId` is the only persisted runtime identity. `cwd`
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive, is required compatibility and presentation data: an adopted local directory, the actual host
restore, branch auto-name, and descriptor flows consume those persisted facts rather than worktree path, or a runtime-local path such as Docker's `/workspace`. It is not globally unique and
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh never selects a runtime. `hostVisiblePath` is the separate optional capability used by editor and
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`. file-manager actions. Private roots, revisions, execution domains, container and volume IDs,
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing labels, supervisor state, and ownership metadata are recovered from driver `inspect(workspaceId)`
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing and are never written to the workspace record.
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
Records without `runtime` remain the narrow legacy placement boundary. Their `cwd`, `worktreeRoot`,
and `mainRepoRoot` continue to support old local/worktree behavior until classified. Selected
worktree records retain user-visible worktree placement metadata, but lifecycle and deletion are
owned by the runtime driver.
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries. Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
@@ -423,7 +427,8 @@ Array of workspace records. A workspace is a specific working directory within a
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. | | `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership | | `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup | | `cwd` | `string` | Client compatibility projection. Runtime-selected server operations address the workspace by `workspaceId`; the driver owns its execution root. |
| `hostVisiblePath` | `string \| null` | Explicit host-path capability for editor and file-manager integrations. Null for Docker and remote placement. |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification | | `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. | | `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | | `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
@@ -432,13 +437,19 @@ Array of workspace records. A workspace is a specific working directory within a
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees | | `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` | | `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` | | `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `runtime` | `{ runtimeId: string } \| undefined` | Immutable selected workspace runtime. Runtime roots, revisions, execution domains, and resource identifiers are never stored in the workspace record. |
| `createdAt` | `string` (ISO 8601) | | | `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | | | `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | | `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. | | `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | | `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity. > **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Runtime-selected filesystem, Git, terminal, provider, and script operations use it as their server-side address. `cwd` remains on the wire for compatibility and display. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
For a runtime-selected workspace, archive pauses the runtime and sets `archivedAt`; it does not remove
the runtime's files. Restore resumes the same runtime before clearing `archivedAt`. Permanent project
removal destroys each selected runtime before removing its workspace record. Records without
`runtime` remain on the legacy local/worktree lifecycle path until they are explicitly re-created.
`projectId` is still a real FK: workspace records should have a matching project record. Read-only `projectId` is still a real FK: workspace records should have a matching project record. Read-only
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
+1 -1
View File
@@ -304,7 +304,7 @@ Worktrees inherit committed Git state only; uncommitted source-checkout changes
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array `worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially. of commands. Both run sequentially.
Lifecycle commands run in the worktree through a stable script shell: `bash` For runtime-selected workspaces, lifecycle commands run inside the selected runtime through a stable script shell. Legacy worktrees use `bash`
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
Windows. They inherit the daemon environment plus Paseo's lifecycle variables; Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV` login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
+1 -1
View File
@@ -10,7 +10,7 @@ The image source lives in [`docker/`](../docker/).
The official image: The official image:
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs - builds `@getpaseo/server`, `@getpaseo/cli`, and their public workspace packages from source-built workspace tarballs
- runs the daemon as the non-root `paseo` user - runs the daemon as the non-root `paseo` user
- listens on `0.0.0.0:6767` inside the container - listens on `0.0.0.0:6767` inside the container
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true` - enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
+5 -3
View File
@@ -5,8 +5,10 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label. - **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label. - **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. - **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent). - **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Runtime**, which chooses where commands and files live.
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`). - **Runtime** — The selected environment where a workspace's files, terminals, Git operations, scripts, provider discovery, and agents run. UI: "Runtime" on New Workspace. Configured runtimes use their configured label. Code: `WorkspaceRuntimeService`; the remembered choice is `FormPreferences.runtimeId`.
- **Local** — Built-in runtime that adopts the selected host directory. UI: "Local". It does not mean “the current client device”; it means the daemon host.
- **Docker** — Built-in runtime that materializes committed Git content into a runtime-owned Docker volume. UI: "Docker". Host bind mounts may provide configuration outside `/workspace`, but never replace project materialization.
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run". - **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`). - **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host). - **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
@@ -17,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific. - **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads. - **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo. - **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym. - **Worktree** — Built-in runtime that creates a Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label. - **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
+5 -3
View File
@@ -103,7 +103,9 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
## Provider Snapshot Refresh Contract ## Provider Snapshot Refresh Contract
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory. The daemon keeps provider snapshots per bound workspace, with separate legacy cwd and semantic global scopes. New Workspace first ensures one invisible provider-probe workspace for the selected `(projectId, runtimeId)`, then reads the snapshot through that workspace binding. The probe is created through `WorkspaceRuntimeService`, so availability, models, and modes come from the selected runtime. Probe creation never runs repository setup. Old clients and bare-cwd callers keep the cwd snapshot path; do not replace or remove it.
Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` after the workspace runtime has bound its private placement. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
`ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the `ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the
availability check and covers that check plus the complete catalog probe. Providers that make availability check and covers that check plus the complete catalog probe. Providers that make
@@ -114,13 +116,13 @@ that were still active when the deadline expired.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own. Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal. Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and workspace identity, or per cwd for the legacy path, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace. Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path. Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global. Boundary tests should assert observable behavior: New Workspace never falls back to a host cwd after selecting a runtime; cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one workspace; settings refresh wipes all scopes but immediately refreshes only global.
--- ---
+2
View File
@@ -21,6 +21,8 @@ Evidence is something someone else can look at:
- A video of the interaction - A video of the interaction
- Logs, requests, responses - Logs, requests, responses
Workspace-runtime changes include the selected Local/Worktree parity cases, the shared common-Git watcher assertion, the fixture Desktop journey, and the blocking Docker matrix when Docker behavior changes. Provider-probe changes also include lifecycle/restart evidence and proof that probe records, processes, watchers, containers, and volumes do not leak.
Redact what you need to, keep the technical details. If an agent did the work, submit its raw output. A summary drops the details someone else needs to check it. Redact what you need to, keep the technical details. If an agent did the work, submit its raw output. A summary drops the details someone else needs to check it.
## Does it work well ## Does it work well
+1 -1
View File
@@ -1 +1 @@
sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM= sha256-YJpDgaoaBS1lCJIVhxBIfeNelyYRsJMcN/HgEO4KehM=
+65 -11
View File
@@ -20,7 +20,10 @@
"packages/relay", "packages/relay",
"packages/website", "packages/website",
"packages/desktop", "packages/desktop",
"packages/cli" "packages/cli",
"packages/workspace-runtime-contract",
"packages/workspace-helper",
"runtimes/fixture"
], ],
"devDependencies": { "devDependencies": {
"@types/ws": "^8.5.14", "@types/ws": "^8.5.14",
@@ -6256,6 +6259,10 @@
"resolved": "packages/expo-two-way-audio", "resolved": "packages/expo-two-way-audio",
"link": true "link": true
}, },
"node_modules/@getpaseo/fixture-workspace-runtime": {
"resolved": "runtimes/fixture",
"link": true
},
"node_modules/@getpaseo/highlight": { "node_modules/@getpaseo/highlight": {
"resolved": "packages/highlight", "resolved": "packages/highlight",
"link": true "link": true
@@ -6276,6 +6283,14 @@
"resolved": "packages/website", "resolved": "packages/website",
"link": true "link": true
}, },
"node_modules/@getpaseo/workspace-helper": {
"resolved": "packages/workspace-helper",
"link": true
},
"node_modules/@getpaseo/workspace-runtime-contract": {
"resolved": "packages/workspace-runtime-contract",
"link": true
},
"node_modules/@gorhom/bottom-sheet": { "node_modules/@gorhom/bottom-sheet": {
"version": "5.2.14", "version": "5.2.14",
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.14.tgz", "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.14.tgz",
@@ -28470,6 +28485,16 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-pty": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz",
"integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.36", "version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -38847,6 +38872,8 @@
"@getpaseo/highlight": "0.4.0", "@getpaseo/highlight": "0.4.0",
"@getpaseo/protocol": "0.4.0", "@getpaseo/protocol": "0.4.0",
"@getpaseo/relay": "0.4.0", "@getpaseo/relay": "0.4.0",
"@getpaseo/workspace-helper": "0.4.0",
"@getpaseo/workspace-runtime-contract": "0.4.0",
"@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",
@@ -39695,16 +39722,6 @@
"node": "20 || >=22" "node": "20 || >=22"
} }
}, },
"packages/server/node_modules/node-pty": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz",
"integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"packages/server/node_modules/openai": { "packages/server/node_modules/openai": {
"version": "6.44.0", "version": "6.44.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz", "resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz",
@@ -41501,6 +41518,43 @@
"optional": true "optional": true
} }
} }
},
"packages/workspace-helper": {
"name": "@getpaseo/workspace-helper",
"version": "0.4.0",
"dependencies": {
"zod": "^4.4.3"
},
"bin": {
"paseo-workspace-helper": "dist/executable.mjs"
},
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.9.3",
"vitest": "^4.1.6"
}
},
"packages/workspace-runtime-contract": {
"name": "@getpaseo/workspace-runtime-contract",
"version": "0.4.0",
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"typescript": "^5.9.3"
}
},
"runtimes/fixture": {
"name": "@getpaseo/fixture-workspace-runtime",
"version": "0.4.0",
"dependencies": {
"@getpaseo/workspace-helper": "0.4.0",
"@getpaseo/workspace-runtime-contract": "0.4.0",
"node-pty": "1.2.0-beta.15"
},
"bin": {
"paseo-fixture-workspace-runtime": "src/index.mjs"
}
} }
} }
} }
+15 -8
View File
@@ -31,7 +31,10 @@
"packages/relay", "packages/relay",
"packages/website", "packages/website",
"packages/desktop", "packages/desktop",
"packages/cli" "packages/cli",
"packages/workspace-runtime-contract",
"packages/workspace-helper",
"runtimes/fixture"
], ],
"scripts": { "scripts": {
"dev": "npm run dev:server", "dev": "npm run dev:server",
@@ -55,8 +58,11 @@
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol", "build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
"build:client": "npm run build --workspace=@getpaseo/client", "build:client": "npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client", "build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"", "build:workspace-runtime-contract": "npm run build --workspace=@getpaseo/workspace-runtime-contract",
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client:clean", "build:workspace-helper": "npm run build --workspace=@getpaseo/workspace-helper",
"build:workspace-runtime-fixture": "npm run build --workspace=@getpaseo/fixture-workspace-runtime",
"build:server-deps": "npm run build:workspace-runtime-contract && npm run build:workspace-helper && concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean": "npm run build:clean --workspace=@getpaseo/workspace-runtime-contract && npm run build:clean --workspace=@getpaseo/workspace-helper && npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli", "build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli", "build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs", "build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
@@ -72,6 +78,7 @@
"format:check": "oxfmt --check .", "format:check": "oxfmt --check .",
"format:check:files": "oxfmt --check", "format:check:files": "oxfmt --check",
"lint": "oxlint", "lint": "oxlint",
"check:workspace-runtime-boundaries": "node scripts/check-workspace-runtime-boundaries.mjs",
"lint:fix": "oxlint --fix", "lint:fix": "oxlint --fix",
"knip": "knip", "knip": "knip",
"acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs", "acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs",
@@ -102,11 +109,11 @@
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major", "version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next", "version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote", "version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli", "release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/workspace-runtime-contract && npm run typecheck --workspace=@getpaseo/workspace-helper && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/workspace-runtime-contract && npm pack --dry-run --workspace=@getpaseo/workspace-helper && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public", "release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --dry-run --workspace=@getpaseo/workspace-helper --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta", "release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-helper --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public", "release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --workspace=@getpaseo/workspace-helper --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta", "release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --workspace=@getpaseo/workspace-helper --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
"release:push": "node scripts/push-current-release-tag.mjs", "release:push": "node scripts/push-current-release-tag.mjs",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push", "release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push", "release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push",
@@ -809,6 +809,7 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
); );
}); });
const gate = await installDaemonWebSocketGate(page); const gate = await installDaemonWebSocketGate(page);
gate.setAgentStreamItemSuppressed("assistant_message", true);
await gotoWorkspace(page, workspace.workspaceId); await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page); await clickNewChat(page);
await expectComposerVisible(page); await expectComposerVisible(page);
@@ -816,14 +817,20 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
const prompt = "Delay synthetic user message by 300ms."; const prompt = "Delay synthetic user message by 300ms.";
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE); await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill"); await expectAttachmentPill(page, "composer-image-attachment-pill");
gate.holdNextServerMessage("fetch_agent_timeline_response");
await recordTurnFrames(page, prompt); await recordTurnFrames(page, prompt);
await submitMessage(page, prompt); await submitMessage(page, prompt);
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first(); const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
await expect(submittedRow).toBeVisible(); await expect(submittedRow).toBeVisible();
await gate.waitForServerMessage("fetch_agent_timeline_response"); await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
gate.truncateHeldTimelineAfterLast("user_message");
gate.setServerMessageSuppressed("fetch_agent_timeline_response", true);
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
await recordPaintsFor(page, 80); await recordPaintsFor(page, 80);
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page)); expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
gate.setServerMessageSuppressed("fetch_agent_timeline_response", false);
gate.setAgentStreamItemSuppressed("assistant_message", false);
} finally { } finally {
await workspace.cleanup(); await workspace.cleanup();
} }
@@ -1,10 +1,7 @@
import { expect, test, type Page } from "../support/fixtures"; import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app"; import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { import { openNewWorkspaceComposer, selectWorkspaceRuntime } from "../support/helpers/new-workspace";
openNewWorkspaceComposer,
selectWorkspaceIsolation,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
@@ -177,7 +174,7 @@ test.describe("New Workspace dictation submit", () => {
projectKey: seeded.projectKey, projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName, projectDisplayName: seeded.projectDisplayName,
}); });
await selectWorkspaceIsolation(page, "local"); await selectWorkspaceRuntime(page, "local");
await dictateAndSend(page, harness.waitForAudio); await dictateAndSend(page, harness.waitForAudio);
await harness.waitForCreateRequest(); await harness.waitForCreateRequest();
@@ -4,6 +4,8 @@ import {
connectNewWorkspaceDaemonClient, connectNewWorkspaceDaemonClient,
expectNewWorkspaceControlsEnabled, expectNewWorkspaceControlsEnabled,
expectNewWorkspaceProjectSelected, expectNewWorkspaceProjectSelected,
expectWorkspaceRuntimeChoices,
expectWorkspaceRuntimeSelected,
expectNewWorkspaceTriggerLabelsAligned, expectNewWorkspaceTriggerLabelsAligned,
openGlobalNewWorkspaceComposer, openGlobalNewWorkspaceComposer,
openMissingProjectNewWorkspaceComposer, openMissingProjectNewWorkspaceComposer,
@@ -29,7 +31,7 @@ import {
// (preselects that project) — shown for git projects and for non-git projects on // (preselects that project) — shown for git projects and for non-git projects on
// a multiplicity-capable host. These specs prove the global entry opens the // a multiplicity-capable host. These specs prove the global entry opens the
// screen, the project icon preselects the right project across the reused 'new' // screen, the project icon preselects the right project across the reused 'new'
// screen, and non-git projects never offer the worktree Isolation control. // screen, and non-git projects offer only the Local runtime.
function projectRow(page: import("@playwright/test").Page, projectKey: string) { function projectRow(page: import("@playwright/test").Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`); return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
@@ -134,10 +136,11 @@ test.describe("New workspace entry points", () => {
sourceDirectory: "/tmp/missing-project", sourceDirectory: "/tmp/missing-project",
}); });
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible({ timeout: 30_000 });
await expectNewWorkspaceControlsEnabled(page); await expectNewWorkspaceControlsEnabled(page);
}); });
test("Ctrl+P opens the project picker with search focused", async ({ page }) => { test("the project shortcut opens the project picker with search focused", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" }); const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
try { try {
@@ -246,9 +249,7 @@ test.describe("New workspace entry points", () => {
} }
}); });
test("the Isolation control is hidden for a non-git project and shown for a git project", async ({ test("the Runtime control hides Git runtimes for a non-git project", async ({ page }) => {
page,
}) => {
const gitProject: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-iso-git-" }); const gitProject: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-iso-git-" });
const nonGitProject: SeededWorkspace = await seedWorkspace({ const nonGitProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "entry-iso-nongit-", repoPrefix: "entry-iso-nongit-",
@@ -274,11 +275,10 @@ test.describe("New workspace entry points", () => {
await nonGitOption.click(); await nonGitOption.click();
await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName); await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName);
// No git checkout means no worktree isolation choice: the Isolation row is await expectWorkspaceRuntimeSelected(page, "local");
// absent entirely. await expectWorkspaceRuntimeChoices(page, ["local"]);
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
// Switching to the git project on the same screen reveals the Isolation row. // Switching to the git project on the same screen adds Worktree.
await trigger.click(); await trigger.click();
const gitOption = page.getByTestId( const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${projectEquivalenceViewKey(gitProject.projectKey)}`, `new-workspace-project-picker-option-${projectEquivalenceViewKey(gitProject.projectKey)}`,
@@ -287,9 +287,8 @@ test.describe("New workspace entry points", () => {
await gitOption.click(); await gitOption.click();
await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName); await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName);
await expect(page.getByTestId("workspace-create-isolation-trigger")).toBeVisible({ await expectWorkspaceRuntimeSelected(page, "local");
timeout: 30_000, await expectWorkspaceRuntimeChoices(page, ["local", "worktree"]);
});
} finally { } finally {
await gitProject.cleanup(); await gitProject.cleanup();
await nonGitProject.cleanup(); await nonGitProject.cleanup();
@@ -1,29 +1,24 @@
import { expect, test } from "../support/fixtures"; import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon, archiveWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader, assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient, connectNewWorkspaceDaemonClient,
expectWorkspaceIsolationSelected, expectWorkspaceRuntimeSelected,
openNewWorkspaceComposer, openNewWorkspaceComposer,
openProjectViaDaemon,
openStartingRefPicker,
selectBranchInPicker,
} from "../support/helpers/new-workspace"; } from "../support/helpers/new-workspace";
import {
gotoNewWorkspaceForRuntime,
seedGitProjectForRuntime,
} from "../support/helpers/new-workspace-runtime";
import { expectNoTruncation } from "../support/helpers/no-truncation"; import { expectNoTruncation } from "../support/helpers/no-truncation";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Regression for "the local / worktree selection in the new workspace is not // Regression for "the Local / Worktree runtime selection in New Workspace is not
// remembered." The isolation choice persists in the create-form preferences // remembered." The runtime choice persists in the create-form preferences, so it
// (FormPreferences.isolation), so it must survive the create→reopen remount: // must survive the create→reopen remount: creating a Worktree workspace
// creating a worktree workspace navigates away from /new and unmounts it, and // navigates away from /new and unmounts it.
// reopening New Workspace has to still show "New worktree". test.describe("New workspace runtime memory", () => {
test.describe("New workspace isolation memory", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>; let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>(); const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 240_000 }); test.describe.configure({ timeout: 240_000 });
@@ -33,45 +28,26 @@ test.describe("New workspace isolation memory", () => {
}); });
test.afterEach(async () => { test.afterEach(async () => {
if (client) { for (const workspaceDirectory of createdWorktreeDirectories) {
for (const workspaceDirectory of createdWorktreeDirectories) { await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
}
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
} }
createdWorktreeDirectories.clear(); createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
await client?.close().catch(() => undefined); await client?.close().catch(() => undefined);
}); });
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => { test("remembers the Worktree runtime after creating a workspace", async ({ page }) => {
const serverId = getServerId(); const project = await seedGitProjectForRuntime();
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
try { try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path); // First visit: the screen opens on Local, switch it to Worktree and create.
localWorkspaceIds.add(openedProject.workspaceId); await gotoNewWorkspaceForRuntime(page, project);
await expectWorkspaceRuntimeSelected(page, "local");
await gotoAppShell(page); await page.getByRole("button", { name: "Runtime", exact: true }).click();
await waitForSidebarHydration(page); const runtimePopup = page.getByRole("dialog").last();
await expect(runtimePopup).toBeVisible({ timeout: 30_000 });
// First visit: the screen opens on Local, switch it to New worktree and create. await expectNoTruncation(runtimePopup);
await openNewWorkspaceComposer(page, { await runtimePopup.getByRole("button", { name: "Worktree", exact: true }).click();
projectKey: openedProject.projectKey, await expectWorkspaceRuntimeSelected(page, "worktree");
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "local");
await page.getByTestId("workspace-create-isolation-trigger").click();
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
await expectNoTruncation(isolationPopup);
await page.getByTestId("workspace-create-isolation-worktree").click();
await expectWorkspaceIsolationSelected(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
const createButton = page const createButton = page
.getByTestId("message-input-root") .getByTestId("message-input-root")
@@ -80,21 +56,21 @@ test.describe("New workspace isolation memory", () => {
await createButton.click(); await createButton.click();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId, serverId: getServerId(),
client, client,
previousWorkspaceId: openedProject.workspaceId, previousWorkspaceId: "",
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: project.projectDisplayName,
}); });
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory); createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
// Second visit (fresh mount of /new): the worktree choice must stick. // Second visit (fresh mount of /new): the runtime choice must stick.
await openNewWorkspaceComposer(page, { await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey, projectKey: project.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: project.projectDisplayName,
}); });
await expectWorkspaceIsolationSelected(page, "worktree"); await expectWorkspaceRuntimeSelected(page, "worktree");
} finally { } finally {
await tempRepo.cleanup(); await project.cleanup();
} }
}); });
}); });
@@ -21,13 +21,13 @@ import {
type TerminalProfileSeed, type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch"; } from "../support/helpers/new-workspace-launch";
// `sleep` keeps the terminal alive long enough for the UI to attach and // Loaded CI workers can take several seconds to materialize the runtime and attach the UI.
// render output before the process exits — see new-workspace-launch-terminal.spec.ts. // Keep the fixture bounded but alive through that assertion window.
const PROMPT_PROFILE: TerminalProfile = { const PROMPT_PROFILE: TerminalProfile = {
id: "e2e-memory-profile", id: "e2e-memory-profile",
name: "Memory Profile", name: "Memory Profile",
command: "/bin/sh", command: "/bin/sh",
args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"], args: ["-c", 'echo remembered: "$0"; sleep 60', "{{{prompt}}}"],
}; };
function hasLeftNewWorkspaceRoute(url: URL): boolean { function hasLeftNewWorkspaceRoute(url: URL): boolean {
@@ -1,16 +1,17 @@
import { expect, test } from "../support/fixtures"; import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { import {
openNewWorkspaceComposer,
openStartingRefPicker, openStartingRefPicker,
selectBranchInPicker, selectBranchInPicker,
selectWorkspaceIsolation, selectWorkspaceRuntime,
} from "../support/helpers/new-workspace"; } from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import {
gotoNewWorkspaceForRuntime,
seedGitProjectForRuntime,
type SeededRuntimeProject,
} from "../support/helpers/new-workspace-runtime";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
import { seedSavedSettingsHosts } from "../support/helpers/settings"; import { seedSavedSettingsHosts } from "../support/helpers/settings";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const LONG_HOST_NAME = const LONG_HOST_NAME =
"development-macbook-pro.local-connected-through-a-very-long-private-hostname"; "development-macbook-pro.local-connected-through-a-very-long-private-hostname";
@@ -27,17 +28,14 @@ function measureControlRightEdges(controls: HTMLElement[]) {
} }
test.describe("New workspace metadata row layout", () => { test.describe("New workspace metadata row layout", () => {
let workspace: SeededWorkspace; let project: SeededRuntimeProject;
test.beforeEach(async () => { test.beforeEach(async () => {
workspace = await seedWorkspace({ project = await seedGitProjectForRuntime({ branches: [LONG_BRANCH_NAME] });
repoPrefix: "new-workspace-layout-",
repo: { branches: [LONG_BRANCH_NAME] },
});
}); });
test.afterEach(async () => { test.afterEach(async () => {
await workspace?.cleanup(); await project?.cleanup();
}); });
test("long host and branch names stay inside the composer's right rail", async ({ page }) => { test("long host and branch names stay inside the composer's right rail", async ({ page }) => {
@@ -55,13 +53,8 @@ test.describe("New workspace metadata row layout", () => {
}, },
]); ]);
await gotoAppShell(page); await gotoNewWorkspaceForRuntime(page, project);
await waitForSidebarHydration(page); await selectWorkspaceRuntime(page, "worktree");
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page); await openStartingRefPicker(page);
await selectBranchInPicker(page, LONG_BRANCH_NAME); await selectBranchInPicker(page, LONG_BRANCH_NAME);
@@ -32,7 +32,7 @@ import {
selectBranchInPicker, selectBranchInPicker,
selectGitHubPrInPicker, selectGitHubPrInPicker,
selectPickerOptionByKeyboard, selectPickerOptionByKeyboard,
selectWorkspaceIsolation, selectWorkspaceRuntime,
submitNewWorkspacePrompt, submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace"; } from "../support/helpers/new-workspace";
import { import {
@@ -723,7 +723,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey, projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await selectWorkspaceIsolation(page, "worktree"); await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page); await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev"); await selectBranchInPicker(page, "dev");
@@ -771,7 +771,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey, projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await selectWorkspaceIsolation(page, "worktree"); await selectWorkspaceRuntime(page, "worktree");
return openedProject; return openedProject;
} }
@@ -905,7 +905,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey, projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await selectWorkspaceIsolation(page, "worktree"); await selectWorkspaceRuntime(page, "worktree");
await openBranchPicker(page); await openBranchPicker(page);
await expectPickerOpen(page); await expectPickerOpen(page);
@@ -930,7 +930,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey, projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await selectWorkspaceIsolation(page, "worktree"); await selectWorkspaceRuntime(page, "worktree");
await openBranchPicker(page); await openBranchPicker(page);
await expectPickerOpen(page); await expectPickerOpen(page);
@@ -960,7 +960,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey, projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await selectWorkspaceIsolation(page, "worktree"); await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page); await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, pr.number); await selectGitHubPrInPicker(page, pr.number);
@@ -996,7 +996,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey, projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await selectWorkspaceIsolation(page, "worktree"); await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page); await openStartingRefPicker(page);
await selectBranchInPicker(page, "main"); await selectBranchInPicker(page, "main");
@@ -1054,7 +1054,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey, projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await selectWorkspaceIsolation(page, "worktree"); await selectWorkspaceRuntime(page, "worktree");
await pasteGithubPrUrl(page, context, pr.url); await pasteGithubPrUrl(page, context, pr.url);
await expectStartingRefPickerTriggerPr(page, { await expectStartingRefPickerTriggerPr(page, {
number: pr.number, number: pr.number,
@@ -1102,7 +1102,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey, projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await selectWorkspaceIsolation(page, "worktree"); await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page); await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, pr.number); await selectGitHubPrInPicker(page, pr.number);
await submitNewWorkspaceWithoutPrompt(page); await submitNewWorkspaceWithoutPrompt(page);
@@ -11,7 +11,7 @@ import {
connectNewWorkspaceDaemonClient, connectNewWorkspaceDaemonClient,
expectNewWorkspaceProjectSelected, expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer, openGlobalNewWorkspaceComposer,
selectWorkspaceIsolation, selectWorkspaceRuntime,
submitNewWorkspaceEmpty, submitNewWorkspaceEmpty,
submitNewWorkspacePrompt, submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace"; } from "../support/helpers/new-workspace";
@@ -271,7 +271,7 @@ test.describe("Workspace model regressions", () => {
await waitForSidebarHydration(page); await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page); await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceIsolation(page, "local"); await selectWorkspaceRuntime(page, "local");
await submitNewWorkspacePrompt(page, "Fix login bug"); await submitNewWorkspacePrompt(page, "Fix login bug");
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -356,7 +356,7 @@ test.describe("Workspace model regressions", () => {
await openGlobalNewWorkspaceComposer(page); await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceIsolation(page, "local"); await selectWorkspaceRuntime(page, "local");
await submitNewWorkspaceEmpty(page); await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -472,7 +472,7 @@ test.describe("Workspace model regressions", () => {
await openGlobalNewWorkspaceComposer(page); await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceIsolation(page, "local"); await selectWorkspaceRuntime(page, "local");
await submitNewWorkspaceEmpty(page); await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, { const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -6,7 +6,7 @@ import {
connectNewWorkspaceDaemonClient, connectNewWorkspaceDaemonClient,
openGlobalNewWorkspaceComposer, openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject, selectNewWorkspaceProject,
selectWorkspaceIsolation, selectWorkspaceRuntime,
submitNewWorkspaceEmpty, submitNewWorkspaceEmpty,
} from "../support/helpers/new-workspace"; } from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
@@ -15,8 +15,8 @@ import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Model B reshape: a workspace is the unit, its isolation (local checkout or // Model B reshape: a workspace is the unit, its runtime (Local or Worktree) is
// worktree) is a CHOICE at creation, and creation NEVER dedupes by // a CHOICE at creation, and creation NEVER dedupes by
// directory. These specs drive the real creation UI (workspace-create-* test // directory. These specs drive the real creation UI (workspace-create-* test
// IDs) to prove a single directory can back any number of workspaces. // IDs) to prove a single directory can back any number of workspaces.
@@ -39,17 +39,16 @@ async function createWorkspaceViaUi(
page: Page, page: Page,
input: { input: {
project: { projectKey: string; projectDisplayName: string }; project: { projectKey: string; projectDisplayName: string };
// null when the project has no git checkout: there is no Isolation control to // null when the project has no git checkout: Local is the only available runtime.
// touch, the isolation is implicitly local. runtime: "local" | "worktree" | null;
isolation: "local" | "worktree" | null;
previousWorkspaceId: string; previousWorkspaceId: string;
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>; client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
}, },
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> { ): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
await openGlobalNewWorkspaceComposer(page); await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, input.project); await selectNewWorkspaceProject(page, input.project);
if (input.isolation !== null) { if (input.runtime !== null) {
await selectWorkspaceIsolation(page, input.isolation); await selectWorkspaceRuntime(page, input.runtime);
} }
await submitNewWorkspaceEmpty(page); await submitNewWorkspaceEmpty(page);
@@ -97,7 +96,7 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, { const second = await createWorkspaceViaUi(page, {
project, project,
isolation: "local", runtime: "local",
previousWorkspaceId: seeded.workspaceId, previousWorkspaceId: seeded.workspaceId,
client, client,
}); });
@@ -128,9 +127,7 @@ test.describe("Workspace multiplicity creation flow", () => {
} }
}); });
test("New worktree isolation creates a worktree-backed workspace in a distinct directory", async ({ test("the Worktree runtime creates a workspace in a distinct directory", async ({ page }) => {
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({ const seeded: SeededWorkspace = await seedWorkspace({
repoPrefix: "multiplicity-worktree-", repoPrefix: "multiplicity-worktree-",
}); });
@@ -149,7 +146,7 @@ test.describe("Workspace multiplicity creation flow", () => {
const worktree = await createWorkspaceViaUi(page, { const worktree = await createWorkspaceViaUi(page, {
project, project,
isolation: "worktree", runtime: "worktree",
previousWorkspaceId: seeded.workspaceId, previousWorkspaceId: seeded.workspaceId,
client, client,
}); });
@@ -200,8 +197,8 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, { const second = await createWorkspaceViaUi(page, {
project, project,
// Non-git project: no Isolation control, isolation is implicitly local. // Non-git project: Local is the only available runtime.
isolation: null, runtime: null,
previousWorkspaceId: seeded.workspaceId, previousWorkspaceId: seeded.workspaceId,
client, client,
}); });
@@ -59,15 +59,16 @@ test.describe("Worktree restore", () => {
}); });
createdProjectIds.add(worktree.projectKey); createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory); createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agents = await Promise.all( const agents = [];
Array.from({ length: options.agentCount ?? 1 }, () => for (let index = 0; index < (options.agentCount ?? 1); index += 1) {
createIdleAgent(client, { agents.push(
await createIdleAgent(client, {
cwd: worktree.workspaceDirectory, cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId, workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`, title: `${prefix}-${randomUUID().slice(0, 8)}`,
}), }),
), );
); }
const agent = agents[0]; const agent = agents[0];
if (!agent) { if (!agent) {
throw new Error("Expected at least one archived-worktree agent"); throw new Error("Expected at least one archived-worktree agent");
@@ -369,10 +370,7 @@ test.describe("Worktree restore", () => {
timeout: 30_000, timeout: 30_000,
}); });
await expect( await expect(
page.getByText( page.getByText("The archived workspace runtime is missing.", { exact: true }),
"The archived workspace directory no longer exists and cannot be recreated.",
{ exact: true },
),
).toBeVisible(); ).toBeVisible();
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0); await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
} finally { } finally {
+71 -1
View File
@@ -1,5 +1,6 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./paseo-home-fork"; import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./paseo-home-fork";
@@ -139,6 +140,74 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro
); );
} }
async function configureDesktopFixtureRuntime(paseoHome: string): Promise<void> {
if (process.env.E2E_DESKTOP_RUNTIME !== "1") return;
const configPath = path.join(paseoHome, "config.json");
const existing = existsSync(configPath) ? JSON.parse(await readFile(configPath, "utf8")) : {};
const stateDirectory = path.join(paseoHome, "fixture-runtime");
const materializeRoot = path.join(paseoHome, "fixture-workspaces");
const fixtureProviderSource = path.resolve(
__dirname,
"../../../../../runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs",
);
await mkdir(stateDirectory, { recursive: true });
await writeFile(
configPath,
`${JSON.stringify({
...existing,
version: 1,
agents: {
...existing.agents,
providers: {
...existing.agents?.providers,
claude: { ...existing.agents?.providers?.claude, enabled: false },
codex: { ...existing.agents?.providers?.codex, enabled: false },
copilot: { ...existing.agents?.providers?.copilot, enabled: false },
opencode: { ...existing.agents?.providers?.opencode, enabled: false },
pi: { ...existing.agents?.providers?.pi, enabled: false },
"fixture-agent": {
extends: "acp",
label: "Fixture Agent",
command: [process.execPath, "./.paseo-fixture-agent.mjs"],
models: [{ id: "fixture-model", label: "Fixture Model", isDefault: true }],
enabled: true,
},
},
},
workspaceRuntimes: {
...existing.workspaceRuntimes,
fixture: {
type: "command",
label: "Fixture",
command: [
process.execPath,
path.resolve(__dirname, "../../../../../runtimes/fixture/src/index.mjs"),
],
options: {
stateDirectory,
materializeRoot,
fixtureProviderSource,
preserveSourceDisplayCwd: true,
},
},
"fixture-failure": {
type: "command",
label: "Fixture Failure",
command: [
process.execPath,
path.resolve(__dirname, "../../../../../runtimes/fixture/src/index.mjs"),
],
options: {
stateDirectory: path.join(paseoHome, "fixture-failure-runtime"),
failCreate: true,
createError: "Fixture probe creation failed",
},
},
},
})}\n`,
);
}
export async function startE2EWorker( export async function startE2EWorker(
workerIndex: number, workerIndex: number,
options: { forkProviders?: string[] } = {}, options: { forkProviders?: string[] } = {},
@@ -154,6 +223,7 @@ export async function startE2EWorker(
try { try {
await applyMetadataFork(paseoHome, options.forkProviders ?? []); await applyMetadataFork(paseoHome, options.forkProviders ?? []);
await configureDesktopFixtureRuntime(paseoHome);
const daemon = await startIsolatedHostDaemon(serverId, { const daemon = await startIsolatedHostDaemon(serverId, {
paseoHome, paseoHome,
preserveHome, preserveHome,
@@ -0,0 +1,330 @@
import { access, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { waitForConnectedHost } from "./hosts";
import {
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
submitNewWorkspaceEmpty,
} from "./new-workspace";
import { connectSeedClient } from "./seed-client";
import { getServerId } from "./server-id";
import { createTempDirectory, createTempGitRepo } from "./workspace";
export interface SeededRuntimeProject {
projectId: string;
projectKey: string;
projectDisplayName: string;
sourceDirectory: string;
cleanup(): Promise<void>;
}
const SETUP_MARKER = "runtime-user-setup-ran.txt";
export async function seedGitProjectForRuntime(
options?: Parameters<typeof createTempGitRepo>[1],
): Promise<SeededRuntimeProject> {
const repo = await createTempGitRepo("runtime-selector-", {
...options,
paseoConfig: options?.paseoConfig ?? {
worktree: { setup: [`printf setup > ${SETUP_MARKER}`] },
},
});
return seedRuntimeProject(repo);
}
async function readProbeRecords(projectId: string): Promise<Array<{ workspaceId: string }>> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "provider-probes.json"), "utf8"),
) as Array<{ workspaceId: string; projectId: string }>;
return records.filter((record) => record.projectId === projectId);
}
async function markerExists(root: string): Promise<boolean> {
return access(path.join(root, SETUP_MARKER)).then(
() => true,
() => false,
);
}
export async function expectProbeSkippedProjectSetup(project: SeededRuntimeProject): Promise<void> {
expect(await markerExists(project.sourceDirectory)).toBe(false);
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const probeIds = new Set(
(await readProbeRecords(project.projectId)).map((record) => record.workspaceId),
);
const stateDirectory = path.join(paseoHome, "fixture-runtime");
const stateFiles = await readdir(stateDirectory);
const states = await Promise.all(
stateFiles.map(
async (file) =>
JSON.parse(await readFile(path.join(stateDirectory, file), "utf8")) as {
workspaceId: string;
root: string;
},
),
);
const probe = states.find((state) => probeIds.has(state.workspaceId));
expect(probe, "fixture probe runtime state").toBeDefined();
expect(await markerExists(probe!.root)).toBe(false);
}
export async function expectUserWorkspaceRanProjectSetup(
project: SeededRuntimeProject,
): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
) as Array<{ projectId: string; workspaceId: string; runtime?: { runtimeId: string } }>;
const workspace = records.find(
(record) => record.projectId === project.projectId && record.runtime?.runtimeId === "fixture",
);
expect(workspace, "fixture user workspace record").toBeDefined();
const stateFiles = await readdir(path.join(paseoHome, "fixture-runtime"));
const states = await Promise.all(
stateFiles.map(
async (file) =>
JSON.parse(await readFile(path.join(paseoHome, "fixture-runtime", file), "utf8")) as {
workspaceId: string;
root: string;
},
),
);
const state = states.find((candidate) => candidate.workspaceId === workspace!.workspaceId);
expect(state, "fixture user runtime state").toBeDefined();
await expect.poll(() => markerExists(state!.root), { timeout: 30_000 }).toBe(true);
}
export async function expectProviderAvailable(page: Page, providerLabel: string): Promise<void> {
const trigger = page.getByRole("button", { name: /Select model/ });
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
await page.getByRole("dialog").last().getByRole("button", { name: "Back", exact: true }).click();
await page.getByText(providerLabel, { exact: true }).click();
await expect(page.getByRole("button", { name: `Open ${providerLabel} settings` })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
}
export async function selectRuntimeProviderModel(
page: Page,
input: { provider: string; model: string },
): Promise<void> {
const trigger = page.getByRole("button", { name: /Select model/ });
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
const dialog = page.getByRole("dialog").last();
await dialog.getByRole("button", { name: "Back", exact: true }).click();
await dialog.getByText(input.provider, { exact: true }).click();
const model = dialog.getByText(input.model, { exact: true });
await expect(model).toBeVisible({ timeout: 30_000 });
await model.click();
await expect(trigger).toContainText(input.model);
}
export async function expectRuntimeProviderUnavailable(
page: Page,
providerLabel: string,
): Promise<void> {
const trigger = page.getByRole("button", { name: /Select model/ });
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
const dialog = page.getByRole("dialog").last();
await dialog.getByRole("button", { name: "Back", exact: true }).click();
const providerId = providerLabel.toLowerCase().replaceAll(" ", "");
const providerRow = dialog.getByTestId(`model-provider-${providerId}`);
await expect(providerRow).toBeVisible({ timeout: 30_000 });
await expect(providerRow).toHaveText(`${providerLabel}Error`);
await page.keyboard.press("Escape");
}
export async function expectFixtureProviderUnavailable(page: Page): Promise<void> {
const providerLabel = "Fixture Agent";
const trigger = page.getByRole("button", { name: /Select model/ });
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
await page.getByRole("dialog").last().getByRole("button", { name: "Back", exact: true }).click();
await page.getByText(providerLabel, { exact: true }).click();
await expect(page.getByRole("button", { name: `Open ${providerLabel} settings` })).toBeVisible();
await expect(page.getByText(/^Fixture Model/u)).not.toBeVisible();
await page.keyboard.press("Escape");
}
export async function expectProbeFailureWithRetry(page: Page, message: string): Promise<void> {
await expect(page.getByRole("alert")).toContainText(message, { timeout: 30_000 });
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /Select model/ })).toBeDisabled();
await expect(page.getByText("Fixture Agent", { exact: true })).not.toBeVisible();
}
export async function retryFailedProbe(page: Page): Promise<void> {
await page.getByRole("button", { name: "Retry", exact: true }).click();
}
export async function expectNoProbeInWorkspaceProjection(
page: Page,
project: SeededRuntimeProject,
): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const probeIds = new Set(
(await readProbeRecords(project.projectId)).map((record) => record.workspaceId),
);
const workspaces = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(workspaces.entries.some((workspace) => probeIds.has(workspace.id))).toBe(false);
for (const probeId of probeIds) {
await expect(page.getByTestId(`sidebar-workspace-${probeId}`)).toHaveCount(0);
}
} finally {
await client.close();
}
}
export async function seedNonGitProjectForRuntime(): Promise<SeededRuntimeProject> {
const directory = await createTempDirectory("runtime-selector-non-git-");
return seedRuntimeProject(directory);
}
async function seedRuntimeProject(resource: {
path: string;
cleanup(): Promise<void>;
}): Promise<SeededRuntimeProject> {
const client = await connectSeedClient();
const added = await client.addProject(resource.path);
if (added.error || !added.project) {
await client.close();
await resource.cleanup();
throw new Error(added.error ?? "Runtime project was not added");
}
const listed = await client.listProjects();
const project = listed.projects.find(
(candidate) => candidate.projectId === added.project?.projectId,
);
if (!project?.projectKey) {
await client.close();
await resource.cleanup();
throw new Error("Runtime project has no project key");
}
return {
projectId: added.project.projectId,
projectKey: project.projectKey,
projectDisplayName: added.project.projectDisplayName,
sourceDirectory: resource.path,
cleanup: async () => {
await client.removeProject(added.project!.projectId);
await client.close();
await resource.cleanup();
},
};
}
export async function gotoNewWorkspaceForRuntime(
page: Page,
project: SeededRuntimeProject,
): Promise<void> {
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, project);
await expect(page.getByRole("button", { name: "Runtime", exact: true })).toContainText("Local");
}
export async function expectRuntimeChoices(page: Page, labels: readonly string[]): Promise<void> {
await page.getByRole("button", { name: "Runtime", exact: true }).click();
const dialog = page.getByRole("dialog").last();
for (const label of labels) {
await expect(dialog.getByRole("button", { name: label, exact: true })).toBeVisible();
}
await expect(dialog.getByRole("button")).toHaveCount(labels.length);
await page.keyboard.press("Escape");
}
export async function expectRuntimeSelected(page: Page, label: string): Promise<void> {
await expect(page.getByRole("button", { name: "Runtime", exact: true })).toContainText(label);
}
export async function selectRuntime(page: Page, label: string): Promise<void> {
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
await trigger.click();
await page.getByRole("dialog").last().getByRole("button", { name: label, exact: true }).click();
await expect(trigger).toContainText(label);
}
export async function createWorkspaceInSelectedRuntime(page: Page): Promise<void> {
await submitNewWorkspaceEmpty(page);
}
export async function expectWorkspaceOpenInRuntime(
page: Page,
project: SeededRuntimeProject,
runtimeId: string,
): Promise<string> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const workspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
client,
previousWorkspaceId: "",
projectDisplayName: project.projectDisplayName,
timeoutMs: 120_000,
});
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>;
const record = records.find((candidate) => candidate.workspaceId === workspace.workspaceId);
expect(record?.runtime).toEqual({ runtimeId });
return workspace.workspaceId;
} finally {
await client.close();
}
}
export async function expectSelectedHostRuntimePlacement(
project: SeededRuntimeProject,
runtimeId: "local" | "worktree",
setupRan: boolean,
): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
) as Array<{
projectId: string;
cwd: string;
hostVisiblePath?: string | null;
runtime?: { runtimeId: string };
}>;
const workspace = records.find(
(record) => record.projectId === project.projectId && record.runtime?.runtimeId === runtimeId,
);
expect(workspace, `${runtimeId} selected workspace record`).toBeDefined();
expect(workspace?.hostVisiblePath).toBe(workspace?.cwd);
if (setupRan) {
await expect.poll(() => markerExists(workspace!.cwd), { timeout: 30_000 }).toBe(true);
} else {
expect(await markerExists(workspace!.cwd)).toBe(false);
}
}
export async function expectHostWorkspaceAffordances(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Open workspace in VS Code" })).toBeVisible();
await page.getByRole("button", { name: "Choose editor" }).click();
await expect(page.getByText("VS Code", { exact: true })).toBeVisible();
await expect(page.getByText("Finder", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
}
@@ -221,7 +221,7 @@ export async function expectNewWorkspaceControlsEnabled(page: Page): Promise<voi
} }
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> { export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
await page.keyboard.press("Control+P"); await page.keyboard.press(process.platform === "darwin" ? "Meta+P" : "Control+P");
const searchInput = page.getByPlaceholder("Search projects"); const searchInput = page.getByPlaceholder("Search projects");
await expect(searchInput).toBeVisible({ timeout: 30_000 }); await expect(searchInput).toBeVisible({ timeout: 30_000 });
@@ -319,35 +319,61 @@ export async function selectNewWorkspaceProject(
await expectNewWorkspaceProjectSelected(page, input.projectDisplayName); await expectNewWorkspaceProjectSelected(page, input.projectDisplayName);
} }
// The isolation trigger renders the active isolation's label ("Local" / "New type WorkspaceRuntimeChoice = "local" | "worktree";
// worktree"), so asserting its text proves what the screen currently remembers.
const ISOLATION_TRIGGER_LABEL: Record<"local" | "worktree", string> = { const WORKSPACE_RUNTIME_LABEL: Record<WorkspaceRuntimeChoice, string> = {
local: "Local", local: "Local",
worktree: "New worktree", worktree: "Worktree",
}; };
export async function expectWorkspaceIsolationSelected( export async function expectWorkspaceRuntimeSelected(
page: Page, page: Page,
isolation: "local" | "worktree", runtime: WorkspaceRuntimeChoice,
): Promise<void> { ): Promise<void> {
const trigger = page.getByRole("button", { name: "Workspace isolation" }); const trigger = page.getByRole("button", { name: "Runtime", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 }); await expect(trigger).toBeVisible({ timeout: 30_000 });
await expect(trigger).toContainText(ISOLATION_TRIGGER_LABEL[isolation]); await expect(trigger.getByText(WORKSPACE_RUNTIME_LABEL[runtime], { exact: true })).toBeVisible();
} }
export async function selectWorkspaceIsolation( export async function selectWorkspaceRuntime(
page: Page, page: Page,
isolation: "local" | "worktree", runtime: WorkspaceRuntimeChoice,
): Promise<void> { ): Promise<void> {
const trigger = page.getByTestId("workspace-create-isolation-trigger"); const trigger = page.getByRole("button", { name: "Runtime", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 }); await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click(); await trigger.click();
// Isolation options are derived from project capability. Wait for the option const runtimePicker = page.getByRole("dialog").last();
// so this helper also covers route-to-project reconciliation. await expect(runtimePicker).toBeVisible({ timeout: 30_000 });
const option = page.getByTestId(`workspace-create-isolation-${isolation}`); const option = runtimePicker.getByRole("button", {
name: WORKSPACE_RUNTIME_LABEL[runtime],
exact: true,
});
await expect(option).toBeVisible({ timeout: 30_000 }); await expect(option).toBeVisible({ timeout: 30_000 });
await option.click(); await option.click();
await expectWorkspaceRuntimeSelected(page, runtime);
}
export async function expectWorkspaceRuntimeChoices(
page: Page,
runtimes: readonly WorkspaceRuntimeChoice[],
): Promise<void> {
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const runtimePicker = page.getByRole("dialog").last();
await expect(runtimePicker).toBeVisible({ timeout: 30_000 });
for (const runtime of runtimes) {
await expect(
runtimePicker.getByRole("button", {
name: WORKSPACE_RUNTIME_LABEL[runtime],
exact: true,
}),
).toBeVisible();
}
await expect(runtimePicker.getByRole("button")).toHaveCount(runtimes.length);
await page.keyboard.press("Escape");
} }
export async function submitNewWorkspaceEmpty(page: Page): Promise<void> { export async function submitNewWorkspaceEmpty(page: Page): Promise<void> {
@@ -464,7 +490,7 @@ export async function pasteGithubPrUrl(
await context.grantPermissions(["clipboard-read", "clipboard-write"]); await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate((value) => navigator.clipboard.writeText(value), url); await page.evaluate((value) => navigator.clipboard.writeText(value), url);
await composer.focus(); await composer.focus();
await page.keyboard.press("Control+V"); await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
} }
export async function assertNewWorkspaceSidebarAndHeader( export async function assertNewWorkspaceSidebarAndHeader(
@@ -476,6 +502,7 @@ export async function assertNewWorkspaceSidebarAndHeader(
projectDisplayName: string; projectDisplayName: string;
assertSidebarRow?: boolean; assertSidebarRow?: boolean;
assertHeader?: boolean; assertHeader?: boolean;
timeoutMs?: number;
}, },
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> { ): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
// URL is the source of truth so concurrent sidebar rows cannot satisfy this. // URL is the source of truth so concurrent sidebar rows cannot satisfy this.
@@ -485,7 +512,7 @@ export async function assertNewWorkspaceSidebarAndHeader(
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId); const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null; return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
}, },
{ timeout: 60_000 }, { timeout: input.timeoutMs ?? 60_000 },
) )
.not.toBeNull(); .not.toBeNull();
@@ -143,6 +143,7 @@ export interface SeedDaemonClient {
timeout?: number, timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>; ): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>; archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
cancelAgent(agentId: string): Promise<void>;
refreshAgent(agentId: string): Promise<unknown>; refreshAgent(agentId: string): Promise<unknown>;
fetchAgent(options: { fetchAgent(options: {
agentId: string; agentId: string;
@@ -173,6 +174,10 @@ export interface SeedDaemonClient {
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void, handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void; ): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>; killTerminal(terminalId: string): Promise<{ error: string | null }>;
listWorkspaceScripts(workspaceId: string): Promise<{
scripts: Array<{ lifecycle: "running" | "stopped"; scriptName: string }>;
}>;
stopWorkspaceScript(workspaceId: string, scriptName: string): Promise<{ error?: string | null }>;
} }
export async function connectSeedClient(options?: { export async function connectSeedClient(options?: {
+10 -1
View File
@@ -650,6 +650,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
message={item.text} message={item.text}
timestamp={item.timestamp.getTime()} timestamp={item.timestamp.getTime()}
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
workspaceId={context.workspaceId}
serverId={resolvedServerId} serverId={resolvedServerId}
client={client} client={client}
spacing={layoutItem.assistantSpacing} spacing={layoutItem.assistantSpacing}
@@ -658,7 +659,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
</AssistantFileLinkResolverProvider> </AssistantFileLinkResolverProvider>
); );
}, },
[agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], [
agentId,
client,
context.workspaceId,
handleInlinePathPress,
resolvedServerId,
toast,
workspaceRoot,
],
); );
const renderThoughtItem = useCallback( const renderThoughtItem = useCallback(
@@ -94,6 +94,25 @@ describe("assistant image acquisition cache", () => {
expect(laterMessage).not.toBe(first); expect(laterMessage).not.toBe(first);
}); });
it("scopes same-cwd file acquisitions to the selected workspace", () => {
const first = createAssistantImageFileAcquisitionKey({
serverId: "server",
workspaceId: "workspace-1",
occurrenceKey: "message:image",
cwd: "/workspace",
path: "screenshot.png",
});
const second = createAssistantImageFileAcquisitionKey({
serverId: "server",
workspaceId: "workspace-2",
occurrenceKey: "message:image",
cwd: "/workspace",
path: "screenshot.png",
});
expect(second).not.toBe(first);
});
it("scopes persisted file previews to the rendered message occurrence", () => { it("scopes persisted file previews to the rendered message occurrence", () => {
const first = createAssistantImageFilePreviewAttachmentId({ const first = createAssistantImageFilePreviewAttachmentId({
serverId: "server-1", serverId: "server-1",
@@ -19,6 +19,7 @@ export function createAssistantImageOccurrenceKey(input: {
export function createAssistantImageFilePreviewAttachmentId(input: { export function createAssistantImageFilePreviewAttachmentId(input: {
serverId?: string; serverId?: string;
workspaceId?: string;
occurrenceKey: string; occurrenceKey: string;
mimeType: string; mimeType: string;
path: string; path: string;
@@ -32,17 +33,18 @@ export function createAssistantImageFilePreviewAttachmentId(input: {
size: input.size, size: input.size,
modifiedAt: input.modifiedAt, modifiedAt: input.modifiedAt,
contentLength: input.contentLength, contentLength: input.contentLength,
contentKey: `${input.serverId ?? "unknown-server"}:${input.occurrenceKey}`, contentKey: `${input.serverId ?? "unknown-server"}:${input.workspaceId ?? "legacy-workspace"}:${input.occurrenceKey}`,
}); });
} }
export function createAssistantImageFileAcquisitionKey(input: { export function createAssistantImageFileAcquisitionKey(input: {
serverId?: string; serverId?: string;
workspaceId?: string;
occurrenceKey: string; occurrenceKey: string;
cwd: string; cwd: string;
path: string; path: string;
}): string { }): string {
return `file:${input.serverId ?? "unknown-server"}:${input.occurrenceKey}:${input.cwd}:${input.path}`; return `file:${input.serverId ?? "unknown-server"}:${input.workspaceId ?? "legacy-workspace"}:${input.occurrenceKey}:${input.cwd}:${input.path}`;
} }
export function createAssistantImageAcquisitionCache<T>(input: { export function createAssistantImageAcquisitionCache<T>(input: {
@@ -6,10 +6,10 @@ import {
} from "./file-acquisition"; } from "./file-acquisition";
class MemoryFileAcquisitionPort implements AssistantImageFileAcquisitionPort { class MemoryFileAcquisitionPort implements AssistantImageFileAcquisitionPort {
readonly reads: Array<{ cwd: string; path: string }> = []; readonly reads: Array<{ cwd: string; path: string; workspaceId?: string }> = [];
async readFile(cwd: string, path: string) { async readFile(cwd: string, path: string, workspaceId?: string) {
this.reads.push({ cwd, path }); this.reads.push({ cwd, path, ...(workspaceId ? { workspaceId } : {}) });
return { return {
kind: "image" as const, kind: "image" as const,
path, path,
@@ -38,6 +38,7 @@ describe("assistant image file acquisition", () => {
const common = { const common = {
resolution: { kind: "file_rpc" as const, cwd: "/workspace", path: "reconnect.png" }, resolution: { kind: "file_rpc" as const, cwd: "/workspace", path: "reconnect.png" },
serverId: "server", serverId: "server",
workspaceId: "workspace-selected",
occurrenceKey: "agent:message:reconnect-image", occurrenceKey: "agent:message:reconnect-image",
unavailableMessage: "Image unavailable", unavailableMessage: "Image unavailable",
}; };
@@ -48,6 +49,23 @@ describe("assistant image file acquisition", () => {
expect(disconnected?.key).toBe(connected?.key); expect(disconnected?.key).toBe(connected?.key);
await expect(disconnected?.locate()).rejects.toThrow("Image unavailable"); await expect(disconnected?.locate()).rejects.toThrow("Image unavailable");
await expect(connected?.locate()).resolves.toMatchObject({ mimeType: "image/png" }); await expect(connected?.locate()).resolves.toMatchObject({ mimeType: "image/png" });
expect(connectedPort.reads).toEqual([{ cwd: "/workspace", path: "reconnect.png" }]); expect(connectedPort.reads).toEqual([
{ cwd: "/workspace", path: "reconnect.png", workspaceId: "workspace-selected" },
]);
});
it("keeps legacy file acquisition cwd-compatible when workspaceId is absent", async () => {
const port = new MemoryFileAcquisitionPort();
const acquisition = createAssistantImageFileAcquisition({
port,
resolution: { kind: "file_rpc", cwd: "/legacy", path: "preview.png" },
serverId: "server",
occurrenceKey: "agent:message:legacy-image",
unavailableMessage: "Image unavailable",
});
await acquisition?.locate();
expect(port.reads).toEqual([{ cwd: "/legacy", path: "preview.png" }]);
}); });
}); });
@@ -8,7 +8,7 @@ import {
} from "./acquisition-cache"; } from "./acquisition-cache";
export interface AssistantImageFileAcquisitionPort { export interface AssistantImageFileAcquisitionPort {
readFile(cwd: string, path: string): Promise<FileReadResult>; readFile(cwd: string, path: string, workspaceId?: string): Promise<FileReadResult>;
persist(input: { persist(input: {
id: string; id: string;
bytes: Uint8Array; bytes: Uint8Array;
@@ -26,6 +26,7 @@ export function createAssistantImageFileAcquisition(input: {
port: AssistantImageFileAcquisitionPort | null; port: AssistantImageFileAcquisitionPort | null;
resolution: AssistantImageSourceResolution | null; resolution: AssistantImageSourceResolution | null;
serverId?: string; serverId?: string;
workspaceId?: string;
occurrenceKey: string; occurrenceKey: string;
unavailableMessage: string; unavailableMessage: string;
}): AssistantImageAcquisition | null { }): AssistantImageAcquisition | null {
@@ -36,6 +37,7 @@ export function createAssistantImageFileAcquisition(input: {
return { return {
key: createAssistantImageFileAcquisitionKey({ key: createAssistantImageFileAcquisitionKey({
serverId: input.serverId, serverId: input.serverId,
workspaceId: input.workspaceId,
occurrenceKey: input.occurrenceKey, occurrenceKey: input.occurrenceKey,
cwd: resolution.cwd, cwd: resolution.cwd,
path: resolution.path, path: resolution.path,
@@ -44,13 +46,14 @@ export function createAssistantImageFileAcquisition(input: {
if (!port) { if (!port) {
throw new Error(input.unavailableMessage); throw new Error(input.unavailableMessage);
} }
const file = await port.readFile(resolution.cwd, resolution.path); const file = await port.readFile(resolution.cwd, resolution.path, input.workspaceId);
if (file.kind !== "image") { if (file.kind !== "image") {
throw new Error(input.unavailableMessage); throw new Error(input.unavailableMessage);
} }
return await port.persist({ return await port.persist({
id: createAssistantImageFilePreviewAttachmentId({ id: createAssistantImageFilePreviewAttachmentId({
serverId: input.serverId, serverId: input.serverId,
workspaceId: input.workspaceId,
occurrenceKey: input.occurrenceKey, occurrenceKey: input.occurrenceKey,
mimeType: file.mime, mimeType: file.mime,
path: file.path || resolution.path, path: file.path || resolution.path,
@@ -82,6 +82,7 @@ interface UseAssistantImageInput {
client?: DaemonClient | null; client?: DaemonClient | null;
workspaceRoot?: string; workspaceRoot?: string;
serverId?: string; serverId?: string;
workspaceId?: string;
} }
type PreviewUrlState = type PreviewUrlState =
@@ -377,6 +378,7 @@ export function useAssistantImage({
client, client,
workspaceRoot, workspaceRoot,
serverId, serverId,
workspaceId,
}: UseAssistantImageInput): AssistantImageResult { }: UseAssistantImageInput): AssistantImageResult {
const { t } = useTranslation(); const { t } = useTranslation();
const resolution = useMemo( const resolution = useMemo(
@@ -387,7 +389,8 @@ export function useAssistantImage({
const fileAcquisition = useMemo(() => { const fileAcquisition = useMemo(() => {
const port: AssistantImageFileAcquisitionPort | null = client const port: AssistantImageFileAcquisitionPort | null = client
? { ? {
readFile: async (cwd, path) => await client.readFile(cwd, path), readFile: async (cwd, path, selectedWorkspaceId) =>
await client.readFile(cwd, path, undefined, selectedWorkspaceId),
persist: persistAttachmentFromBytes, persist: persistAttachmentFromBytes,
} }
: null; : null;
@@ -395,10 +398,11 @@ export function useAssistantImage({
port, port,
resolution, resolution,
serverId, serverId,
workspaceId,
occurrenceKey, occurrenceKey,
unavailableMessage: t("message.attachments.imagePreviewUnavailable"), unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
}); });
}, [client, occurrenceKey, resolution, serverId, t]); }, [client, occurrenceKey, resolution, serverId, t, workspaceId]);
const dataImageAcquisition = useMemo( const dataImageAcquisition = useMemo(
() => createDataImageAcquisition({ source, dataImage }), () => createDataImageAcquisition({ source, dataImage }),
[dataImage, source], [dataImage, source],
@@ -5,6 +5,7 @@ import { getIsElectron } from "@/constants/platform";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { GIT_ACTION_ICONS } from "@/git/action-icons"; import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { useGitActionRunner, useGitActions } from "@/git/use-actions"; import { useGitActionRunner, useGitActions } from "@/git/use-actions";
import { useBoundWorkspaceGit, WorkspaceGitBoundary } from "@/git/workspace-git";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { import {
resolveShortcutKeysForAction, resolveShortcutKeysForAction,
@@ -13,6 +14,7 @@ import {
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { getCommandCenterIcon } from "./icon"; import { getCommandCenterIcon } from "./icon";
@@ -107,6 +109,26 @@ export function useWorkspaceCommandCenterActions(): void {
} }
export function CommandCenterWorkspaceActions() { export function CommandCenterWorkspaceActions() {
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? "";
const workspaceId = selection?.workspaceId ?? "";
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const client = useHostRuntimeClient(serverId);
const address = useMemo(
() => (workspaceId && cwd ? ({ kind: "selected", workspaceId, cwd } as const) : null),
[cwd, workspaceId],
);
const workspaceGit = useBoundWorkspaceGit(client, address);
if (!workspaceGit) return null;
return (
<WorkspaceGitBoundary workspaceGit={workspaceGit}>
<CommandCenterWorkspaceActionRegistration />
</WorkspaceGitBoundary>
);
}
function CommandCenterWorkspaceActionRegistration() {
useWorkspaceCommandCenterActions(); useWorkspaceCommandCenterActions();
return null; return null;
} }
@@ -6,15 +6,13 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher"; import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
interface BranchSwitcherProps { interface BranchSwitcherProps {
currentBranchName: string | null; currentBranchName: string | null;
serverId: string; serverId: string;
workspaceId: string;
workspaceDirectory: string | null;
isGitCheckout: boolean; isGitCheckout: boolean;
testID?: string; testID?: string;
} }
@@ -29,23 +27,17 @@ const ThemedChevronDown = withUnistyles(ChevronDown);
export function BranchSwitcher({ export function BranchSwitcher({
currentBranchName, currentBranchName,
serverId, serverId,
workspaceId,
workspaceDirectory,
isGitCheckout, isGitCheckout,
testID = "workspace-header-branch-switcher", testID = "workspace-header-branch-switcher",
}: BranchSwitcherProps) { }: BranchSwitcherProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const anchorRef = useRef<View>(null); const anchorRef = useRef<View>(null);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const toast = useToast(); const toast = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({ const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
client,
normalizedServerId: serverId, normalizedServerId: serverId,
normalizedWorkspaceId: workspaceId,
workspaceDirectory,
currentBranchName, currentBranchName,
isGitCheckout, isGitCheckout,
isConnected, isConnected,
@@ -11,6 +11,11 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import {
bindSelectedWorkspaceGit,
WorkspaceGitBoundary,
type WorkspaceGitClient,
} from "@/git/workspace-git";
import { import {
resolveCompactExplorerSidebarHostModel, resolveCompactExplorerSidebarHostModel,
type CompactExplorerSidebarHostModel, type CompactExplorerSidebarHostModel,
@@ -24,6 +29,62 @@ interface CompactExplorerOpenGestureSurfaceProps {
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y"; const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
function useCompactWorkspaceGit(
client: ReturnType<typeof useHostRuntimeClient>,
workspaceId: string | null | undefined,
cwd: string | null | undefined,
): WorkspaceGitClient | null {
return useMemo(() => {
if (!client || !workspaceId || !cwd) {
return null;
}
return bindSelectedWorkspaceGit(client, { kind: "selected", workspaceId, cwd });
}, [client, cwd, workspaceId]);
}
function useCompactExplorerModel({
enabled,
isExplorerOpen,
selection,
workspace,
isGit,
showMobileAgent,
}: {
enabled: boolean;
isExplorerOpen: boolean;
selection: Parameters<typeof resolveCompactExplorerSidebarHostModel>[0]["selection"];
workspace: Parameters<typeof resolveCompactExplorerSidebarHostModel>[0]["workspace"];
isGit: boolean;
showMobileAgent: () => void;
}): CompactExplorerSidebarHostModel | null {
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null);
const resolvedModel = useMemo(
() =>
resolveCompactExplorerSidebarHostModel({
previous: isExplorerOpen ? retainedModelRef.current : null,
selection,
workspace,
isGit,
}),
[isExplorerOpen, isGit, selection, workspace],
);
useEffect(() => {
if (!selection) {
retainedModelRef.current = null;
if (enabled && isExplorerOpen) showMobileAgent();
return;
}
if (!isExplorerOpen) {
retainedModelRef.current = null;
return;
}
if (resolvedModel) retainedModelRef.current = resolvedModel;
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
if (!selection) return null;
return resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null);
}
function CompactExplorerOpenGestureSurface({ function CompactExplorerOpenGestureSurface({
children, children,
enabled, enabled,
@@ -41,9 +102,10 @@ function CompactExplorerOpenGestureSurface({
); );
} }
function useActiveCompactExplorerSidebarModel( function useActiveCompactExplorerSidebarModel(enabled: boolean): {
enabled: boolean, model: CompactExplorerSidebarHostModel | null;
): CompactExplorerSidebarHostModel | null { workspaceGit: WorkspaceGitClient | null;
} {
const selection = useActiveWorkspaceSelection(); const selection = useActiveWorkspaceSelection();
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null); const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
const isExplorerOpen = usePanelStore((state) => const isExplorerOpen = usePanelStore((state) =>
@@ -52,44 +114,32 @@ function useActiveCompactExplorerSidebarModel(
const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const client = useHostRuntimeClient(selection?.serverId ?? ""); const client = useHostRuntimeClient(selection?.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? ""); const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null); const workspaceGit = useCompactWorkspaceGit(
const { checkoutQuery } = useWorkspaceCheckoutStatus({
client, client,
selection?.workspaceId,
workspace?.workspaceDirectory,
);
const { checkoutQuery } = useWorkspaceCheckoutStatus({
workspaceGit,
isConnected, isConnected,
isRouteFocused: enabled && selection !== null, isRouteFocused: enabled && selection !== null,
normalizedServerId: selection?.serverId ?? "", normalizedServerId: selection?.serverId ?? "",
normalizedWorkspaceId: selection?.workspaceId ?? "", normalizedWorkspaceId: selection?.workspaceId ?? "",
workspaceDirectory: workspace?.workspaceDirectory || null, workspaceDirectory: workspace?.workspaceDirectory || null,
}); });
const resolvedModel = useMemo( const model = useCompactExplorerModel({
() => enabled,
resolveCompactExplorerSidebarHostModel({ isExplorerOpen,
previous: isExplorerOpen ? retainedModelRef.current : null, selection,
selection, workspace,
workspace, isGit: checkoutQuery.data?.isGit ?? false,
isGit: checkoutQuery.data?.isGit ?? false, showMobileAgent,
}), });
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
);
useEffect(() => { return {
if (!selection) { model,
retainedModelRef.current = null; workspaceGit,
if (enabled && isExplorerOpen) { };
showMobileAgent();
}
return;
}
if (!isExplorerOpen) {
retainedModelRef.current = null;
return;
}
if (resolvedModel) {
retainedModelRef.current = resolvedModel;
}
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
} }
interface CompactExplorerSidebarHostProps { interface CompactExplorerSidebarHostProps {
@@ -98,7 +148,7 @@ interface CompactExplorerSidebarHostProps {
} }
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) { export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
const model = useActiveCompactExplorerSidebarModel(enabled); const { model, workspaceGit } = useActiveCompactExplorerSidebarModel(enabled);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
@@ -137,20 +187,22 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
return ( return (
<> <>
<CompactExplorerOpenGestureSurface <CompactExplorerOpenGestureSurface
enabled={enabled && Boolean(model?.workspaceRoot)} enabled={enabled && Boolean(model?.workspaceRoot) && Boolean(workspaceGit)}
onOpenExplorer={handleOpenExplorer} onOpenExplorer={handleOpenExplorer}
> >
{children} {children}
</CompactExplorerOpenGestureSurface> </CompactExplorerOpenGestureSurface>
{enabled && model ? ( <WorkspaceGitBoundary workspaceGit={workspaceGit}>
<CompactExplorerSidebar {enabled && model && workspaceGit ? (
serverId={model.serverId} <CompactExplorerSidebar
workspaceId={model.workspaceId} serverId={model.serverId}
workspaceRoot={model.workspaceRoot} workspaceId={model.workspaceId}
isGit={model.isGit} workspaceRoot={model.workspaceRoot}
onOpenFile={handleOpenFile} isGit={model.isGit}
/> onOpenFile={handleOpenFile}
) : null} />
) : null}
</WorkspaceGitBoundary>
</> </>
); );
} }
@@ -22,6 +22,7 @@ import {
usePrPaneData, usePrPaneData,
} from "@/git/pull-request-panel"; } from "@/git/pull-request-panel";
import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data"; import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store"; import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
@@ -320,10 +321,10 @@ function ExplorerSidebarContent({
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right"); const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
const workspaceGit = useRequiredWorkspaceGit();
const canQueryPullRequest = isGit && Boolean(workspaceRoot); const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({ const prPane = usePrPaneData({
serverId, serverId,
cwd: workspaceRoot,
enabled: canQueryPullRequest && isOpen, enabled: canQueryPullRequest && isOpen,
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen, timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
}); });
@@ -335,10 +336,13 @@ function ExplorerSidebarContent({
const prTabLabel = formatPrTabLabel(prPane.prNumber); const prTabLabel = formatPrTabLabel(prPane.prNumber);
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh); const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
const handlePrRetry = useCallback(() => { const handlePrRetry = useCallback(() => {
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => { refreshGitActions({
serverId,
target: workspaceGit,
}).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
}); });
}, [refreshGitActions, serverId, t, toast, workspaceRoot]); }, [refreshGitActions, serverId, t, toast, workspaceGit]);
const workspaceAttachmentScopeKey = useMemo( const workspaceAttachmentScopeKey = useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }), () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
[serverId, workspaceId, workspaceRoot], [serverId, workspaceId, workspaceRoot],
@@ -419,7 +423,7 @@ function ExplorerSidebarContent({
{resolvedTab === "changes" && ( {resolvedTab === "changes" && (
<ChangedFilesPane <ChangedFilesPane
serverId={serverId} serverId={serverId}
workspaceId={workspaceId} workspaceId={workspaceGit.workspaceId}
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
isOpen={isOpen} isOpen={isOpen}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
@@ -428,7 +432,7 @@ function ExplorerSidebarContent({
{resolvedTab === "files" && ( {resolvedTab === "files" && (
<FilesPane <FilesPane
serverId={serverId} serverId={serverId}
workspaceId={workspaceId} workspaceId={workspaceGit.workspaceId}
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
/> />
@@ -436,6 +440,7 @@ function ExplorerSidebarContent({
{resolvedTab === "pr" && ( {resolvedTab === "pr" && (
<PrTabContent <PrTabContent
serverId={serverId} serverId={serverId}
workspaceId={workspaceGit.workspaceId}
cwd={workspaceRoot} cwd={workspaceRoot}
prPane={prPane} prPane={prPane}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey} workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
@@ -526,6 +531,7 @@ function FilesPane({
interface PrTabContentProps { interface PrTabContentProps {
serverId: string; serverId: string;
workspaceId: string;
cwd: string; cwd: string;
prPane: UsePrPaneDataResult; prPane: UsePrPaneDataResult;
workspaceAttachmentScopeKey: string; workspaceAttachmentScopeKey: string;
@@ -534,6 +540,7 @@ interface PrTabContentProps {
function PrTabContent({ function PrTabContent({
serverId, serverId,
workspaceId,
cwd, cwd,
prPane, prPane,
workspaceAttachmentScopeKey, workspaceAttachmentScopeKey,
@@ -543,6 +550,7 @@ function PrTabContent({
return ( return (
<PullRequestPane <PullRequestPane
serverId={serverId} serverId={serverId}
workspaceId={workspaceId}
cwd={cwd} cwd={cwd}
data={prPane.data} data={prPane.data}
activityLoading={prPane.activityLoading} activityLoading={prPane.activityLoading}
@@ -68,10 +68,18 @@ function buildSessionsQueriesConfig(args: {
visible: boolean; visible: boolean;
client: RecentProviderSessionsClient | null; client: RecentProviderSessionsClient | null;
cwd: string | null | undefined; cwd: string | null | undefined;
workspaceId?: string | null;
hostDisconnectedMessage?: string; hostDisconnectedMessage?: string;
}): SessionsQueryConfig[] { }): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } = const {
args; providersToFetch,
sessionsQueryRoot,
visible,
client,
cwd,
workspaceId,
hostDisconnectedMessage,
} = args;
if (providersToFetch === null) return []; if (providersToFetch === null) return [];
const enabled = visible && Boolean(client); const enabled = visible && Boolean(client);
return providersToFetch.map((provider) => ({ return providersToFetch.map((provider) => ({
@@ -82,6 +90,7 @@ function buildSessionsQueriesConfig(args: {
throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")); throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
} }
return await client.fetchRecentProviderSessions({ return await client.fetchRecentProviderSessions({
...(workspaceId ? { workspaceId } : {}),
...(cwd ? { cwd } : {}), ...(cwd ? { cwd } : {}),
providers: [provider], providers: [provider],
limit: PER_PROVIDER_LIMIT, limit: PER_PROVIDER_LIMIT,
@@ -274,6 +283,7 @@ export function ImportSessionSheet({
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, { const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
cwd, cwd,
workspaceId,
enabled: visible, enabled: visible,
}); });
const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget"); const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget");
@@ -294,8 +304,8 @@ export function ImportSessionSheet({
); );
const sessionsQueryRoot = useMemo( const sessionsQueryRoot = useMemo(
() => ["recent-provider-sessions", cwd ?? null] as const, () => ["recent-provider-sessions", workspaceId ?? null, cwd ?? null] as const,
[cwd], [cwd, workspaceId],
); );
const queriesConfig = useMemo( const queriesConfig = useMemo(
@@ -306,9 +316,10 @@ export function ImportSessionSheet({
visible, visible,
client, client,
cwd, cwd,
workspaceId,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}), }),
[providersToFetch, sessionsQueryRoot, visible, client, cwd, t], [providersToFetch, sessionsQueryRoot, visible, client, cwd, workspaceId, t],
); );
const queries = useQueries({ queries: queriesConfig }); const queries = useQueries({ queries: queriesConfig });
+16 -1
View File
@@ -736,6 +736,7 @@ interface AssistantMessageProps {
message: string; message: string;
timestamp: number; timestamp: number;
workspaceRoot?: string; workspaceRoot?: string;
workspaceId?: string;
serverId?: string; serverId?: string;
client?: DaemonClient | null; client?: DaemonClient | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth"; spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
@@ -799,6 +800,7 @@ function AssistantMarkdownImage({
hasLeadingContent, hasLeadingContent,
client, client,
workspaceRoot, workspaceRoot,
workspaceId,
serverId, serverId,
}: { }: {
source: string; source: string;
@@ -807,6 +809,7 @@ function AssistantMarkdownImage({
hasLeadingContent: boolean; hasLeadingContent: boolean;
client?: DaemonClient | null; client?: DaemonClient | null;
workspaceRoot?: string; workspaceRoot?: string;
workspaceId?: string;
serverId?: string; serverId?: string;
}) { }) {
const containerStyle = useMemo<StyleProp<ViewStyle>>( const containerStyle = useMemo<StyleProp<ViewStyle>>(
@@ -821,6 +824,7 @@ function AssistantMarkdownImage({
occurrenceKey, occurrenceKey,
client, client,
workspaceRoot, workspaceRoot,
workspaceId,
serverId, serverId,
}); });
const binding = image.status === "failed" ? null : image.binding; const binding = image.status === "failed" ? null : image.binding;
@@ -1448,6 +1452,7 @@ export const AssistantMessage = memo(function AssistantMessage({
message, message,
timestamp: _timestamp, timestamp: _timestamp,
workspaceRoot, workspaceRoot,
workspaceId,
serverId, serverId,
client, client,
spacing = "default", spacing = "default",
@@ -1885,12 +1890,22 @@ export const AssistantMessage = memo(function AssistantMessage({
hasLeadingContent={hasLeadingContent} hasLeadingContent={hasLeadingContent}
client={client} client={client}
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
workspaceId={workspaceId}
serverId={serverId} serverId={serverId}
/> />
); );
}, },
}; };
}, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]); }, [
client,
fileLinkActions,
markdownParser,
occurrenceKey,
phase,
serverId,
workspaceId,
workspaceRoot,
]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo( const keyedBlocks = useMemo(
@@ -174,7 +174,7 @@ function updateSelectionPreferences(input: {
...(model && thinkingOptionId ? { thinkingByModel: { [model]: thinkingOptionId } } : {}), ...(model && thinkingOptionId ? { thinkingByModel: { [model]: thinkingOptionId } } : {}),
}, },
}), }),
isolation: input.isolation, runtimeId: input.isolation,
}; };
} }
@@ -654,7 +654,7 @@ function WorkspaceRowRightGroup({
isPinned?: boolean; isPinned?: boolean;
onTogglePin?: () => void; onTogglePin?: () => void;
}) { }) {
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath; const workspacePath = workspace.hostVisiblePath;
const { t } = useTranslation(); const { t } = useTranslation();
const trailing = useSidebarWorkspaceTrailing(); const trailing = useSidebarWorkspaceTrailing();
const showShortcut = showShortcutBadge && shortcutNumber !== null; const showShortcut = showShortcutBadge && shortcutNumber !== null;
@@ -1156,7 +1156,7 @@ function WorkspaceRowInner({
archiveShortcutKeys={archiveShortcutKeys} archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned} isPinned={isPinned}
onTogglePin={onTogglePin} onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory} openInFileManagerPath={workspace.hostVisiblePath}
disabled={isArchiving} disabled={isArchiving}
aria-selected={selected} aria-selected={selected}
accessibilityRole="button" accessibilityRole="button"
@@ -882,7 +882,7 @@ function StatusWorkspaceRowInnerContent({
archiveShortcutKeys={archiveShortcutKeys} archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned} isPinned={isPinned}
onTogglePin={onTogglePin} onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory} openInFileManagerPath={workspace.hostVisiblePath}
disabled={isArchiving} disabled={isArchiving}
accessibilityRole="button" accessibilityRole="button"
accessibilityState={accessibilityState} accessibilityState={accessibilityState}
@@ -338,7 +338,7 @@ function WorkspaceRowBody({
archiveStatus={archiveStatus} archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel} archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys} archiveShortcutKeys={archiveShortcutKeys}
openInFileManagerPath={workspace.workspaceDirectory} openInFileManagerPath={workspace.hostVisiblePath}
disabled={isArchiving} disabled={isArchiving}
aria-selected={selected} aria-selected={selected}
accessibilityRole="button" accessibilityRole="button"
@@ -31,6 +31,7 @@ import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import type { MessagePayload } from "@/composer/types"; import type { MessagePayload } from "@/composer/types";
import { PreWorkspaceComposerGitOwner } from "@/git/workspace-git";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) { if (!icon) {
@@ -429,27 +430,29 @@ export function WorkspaceSetupDialog() {
testID="workspace-setup-dialog" testID="workspace-setup-dialog"
desktopMaxWidth={640} desktopMaxWidth={640}
> >
<FileDropZone style={styles.section}> <PreWorkspaceComposerGitOwner client={client} cwd={sourceDirectory}>
<Composer <FileDropZone style={styles.section}>
agentId={`workspace-setup:${serverId}:${sourceDirectory}`} <Composer
serverId={serverId} agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
isPaneFocused={true} serverId={serverId}
onSubmitMessage={handleCreateChatAgent} isPaneFocused={true}
isSubmitLoading={pendingAction === "chat"} onSubmitMessage={handleCreateChatAgent}
blurOnSubmit={true} isSubmitLoading={pendingAction === "chat"}
value={chatDraft.text} blurOnSubmit={true}
onChangeText={chatDraft.editText} value={chatDraft.text}
textReplacementKey={chatDraft.textReplacementKey} onChangeText={chatDraft.editText}
attachments={chatDraft.attachments} textReplacementKey={chatDraft.textReplacementKey}
onChangeAttachments={chatDraft.setAttachments} attachments={chatDraft.attachments}
cwd={sourceDirectory} onChangeAttachments={chatDraft.setAttachments}
clearDraft={chatDraft.clear} cwd={sourceDirectory}
autoFocus clearDraft={chatDraft.clear}
commandDraftConfig={composerState?.commandDraftConfig} autoFocus
agentControls={agentControlsWithDisabled} commandDraftConfig={composerState?.commandDraftConfig}
inputWrapperStyle={styles.composerInputWrapper} agentControls={agentControlsWithDisabled}
/> inputWrapperStyle={styles.composerInputWrapper}
</FileDropZone> />
</FileDropZone>
</PreWorkspaceComposerGitOwner>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null} {errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</AdaptiveModalSheet> </AdaptiveModalSheet>
@@ -349,6 +349,7 @@ function pickDesktopModel({
type AgentControlsSlice = { type AgentControlsSlice = {
provider: string; provider: string;
cwd: string | null; cwd: string | null;
workspaceId: string | undefined;
runtimeModelId: string | null; runtimeModelId: string | null;
model: string | null | undefined; model: string | null | undefined;
features: AgentFeature[] | undefined; features: AgentFeature[] | undefined;
@@ -368,6 +369,7 @@ function selectAgentControlsSlice(
return { return {
provider: currentAgent.provider, provider: currentAgent.provider,
cwd: currentAgent.cwd, cwd: currentAgent.cwd,
workspaceId: currentAgent.workspaceId,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null, runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model, model: currentAgent.model,
features: currentAgent.features, features: currentAgent.features,
@@ -376,6 +378,16 @@ function selectAgentControlsSlice(
}; };
} }
function providerSnapshotTarget(agent: AgentControlsSlice): {
cwd: string | null | undefined;
workspaceId: string | undefined;
} {
return {
cwd: agent?.cwd,
workspaceId: agent?.workspaceId,
};
}
function resolveSnapshotSelectedEntry( function resolveSnapshotSelectedEntry(
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"], snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"],
agentProvider: string | undefined, agentProvider: string | undefined,
@@ -564,7 +576,7 @@ function ControlledAgentControls({
} }
}, [updateDensityForWidth]); }, [updateDensityForWidth]);
const modelDisabled = disabled; const modelDisabled = [disabled, isModelLoading].some(Boolean);
const comboboxProviderOptions = useMemo<ComboboxOption[]>( const comboboxProviderOptions = useMemo<ComboboxOption[]>(
() => toComboboxOptions(providerOptions), () => toComboboxOptions(providerOptions),
@@ -1481,7 +1493,7 @@ export const AgentControls = memo(function AgentControls({
isRefreshing: snapshotIsRefreshing, isRefreshing: snapshotIsRefreshing,
refresh: refreshSnapshot, refresh: refreshSnapshot,
refetchIfStale: refetchSnapshotIfStale, refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, { cwd: agent?.cwd }); } = useProvidersSnapshot(serverId, providerSnapshotTarget(agent));
const snapshotSelectedEntry = useMemo( const snapshotSelectedEntry = useMemo(
() => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider), () => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider),
@@ -253,6 +253,7 @@ export function useLiveAgentModeControl(
return { return {
provider: agent.provider, provider: agent.provider,
cwd: agent.cwd, cwd: agent.cwd,
workspaceId: agent.workspaceId,
currentModeId: agent.currentModeId, currentModeId: agent.currentModeId,
}; };
}), }),
@@ -265,7 +266,10 @@ export function useLiveAgentModeControl(
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const { updatePreferences } = useFormPreferences(); const { updatePreferences } = useFormPreferences();
const toast = useToast(); const toast = useToast();
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd }); const { entries: snapshotEntries } = useProvidersSnapshot(serverId, {
cwd: slice?.cwd,
workspaceId: slice?.workspaceId,
});
const providerDefinitions = useMemo<AgentProviderDefinition[]>(() => { const providerDefinitions = useMemo<AgentProviderDefinition[]>(() => {
if (!slice?.provider) return []; if (!slice?.provider) return [];
+17 -4
View File
@@ -5,6 +5,7 @@ import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { import {
useAgentFormState, useAgentFormState,
type CreateAgentInitialValues, type CreateAgentInitialValues,
type UseAgentFormStateOptions,
type UseAgentFormStateResult, type UseAgentFormStateResult,
} 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";
@@ -28,6 +29,9 @@ type AttachmentUpdater =
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]); | ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
interface AgentInputDraftComposerOptions { interface AgentInputDraftComposerOptions {
workspaceId?: string | null;
providerSnapshotCwd?: string | null;
isTargetDaemonReady?: boolean;
initialServerId: string | null; initialServerId: string | null;
initialValues?: CreateAgentInitialValues; initialValues?: CreateAgentInitialValues;
initialFeatureValues?: Record<string, unknown>; initialFeatureValues?: Record<string, unknown>;
@@ -63,15 +67,24 @@ export interface AgentInputDraft {
composerState: DraftComposerState | null; composerState: DraftComposerState | null;
} }
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft { function buildAgentFormStateOptions(
const composerOptions = input.composer ?? null; composerOptions: AgentInputDraftComposerOptions | null,
const formState = useAgentFormState({ ): UseAgentFormStateOptions {
return {
initialServerId: composerOptions?.initialServerId ?? null, initialServerId: composerOptions?.initialServerId ?? null,
initialValues: composerOptions?.initialValues, initialValues: composerOptions?.initialValues,
isVisible: composerOptions?.isVisible ?? false, isVisible: composerOptions?.isVisible ?? false,
isCreateFlow: true, isCreateFlow: true,
onlineServerIds: composerOptions?.onlineServerIds ?? [], onlineServerIds: composerOptions?.onlineServerIds ?? [],
}); workspaceId: composerOptions?.workspaceId,
providerSnapshotCwd: composerOptions?.providerSnapshotCwd,
isTargetDaemonReady: composerOptions?.isTargetDaemonReady,
};
}
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
const composerOptions = input.composer ?? null;
const formState = useAgentFormState(buildAgentFormStateOptions(composerOptions));
const draftKey = useMemo( const draftKey = useMemo(
() => () =>
resolveDraftKey({ resolveDraftKey({
@@ -365,6 +365,7 @@ export function WorkspaceDraftAgentTab({
const draftInput = useAgentInputDraft({ const draftInput = useAgentInputDraft({
draftKey: draftStoreKey, draftKey: draftStoreKey,
composer: { composer: {
workspaceId,
initialServerId: serverId, initialServerId: serverId,
initialValues: draftInitialValues, initialValues: draftInitialValues,
initialFeatureValues: draftSetup?.featureValues, initialFeatureValues: draftSetup?.featureValues,
@@ -4,7 +4,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook } from "@testing-library/react"; import { act, renderHook } from "@testing-library/react";
import React, { type ReactNode } from "react"; import React, { type ReactNode } from "react";
import { useState } from "react"; import { useMemo, useState } from "react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types"; import type { UserComposerAttachment } from "@/attachments/types";
import type { ForgeSearchClient } from "@/git/use-forge-search-query"; import type { ForgeSearchClient } from "@/git/use-forge-search-query";
@@ -49,11 +49,12 @@ const issue202: ForgeSearchItem = {
}; };
interface SearchCall { interface SearchCall {
cwd: string;
query: string; query: string;
limit?: number; limit?: number;
} }
type TestSearchClient = Pick<ForgeSearchClient, "searchForge" | "searchGitHub">;
interface HarnessInput { interface HarnessInput {
initialAttachments?: UserComposerAttachment[]; initialAttachments?: UserComposerAttachment[];
initialCwd?: string; initialCwd?: string;
@@ -72,7 +73,7 @@ function githubPayload(items: ForgeSearchItem[], requestId: string): ForgeSearch
}; };
} }
function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { calls: SearchCall[] } { function createSearchClient(items: ForgeSearchItem[]): TestSearchClient & { calls: SearchCall[] } {
const calls: SearchCall[] = []; const calls: SearchCall[] = [];
return { return {
calls, calls,
@@ -104,21 +105,28 @@ function createWrapper() {
}; };
} }
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) { function useHarness(client: TestSearchClient, input: HarnessInput = {}) {
const [text, setText] = useState(input.initialText ?? ""); const [text, setText] = useState(input.initialText ?? "");
const [searchClient, setSearchClient] = useState(client); const [searchClient, setSearchClient] = useState(client);
const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd); const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd);
const [attachments, setAttachments] = useState<UserComposerAttachment[]>( const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
input.initialAttachments ?? [], input.initialAttachments ?? [],
); );
const boundSearchClient = useMemo(
() => ({
...searchClient,
queryIdentity: ["selected", "workspace-1"] as const,
cwd: workingDirectory,
}),
[searchClient, workingDirectory],
);
const autoAttach = useComposerGithubAutoAttach({ const autoAttach = useComposerGithubAutoAttach({
text, text,
remoteUrl: input.remote ?? remoteUrl, remoteUrl: input.remote ?? remoteUrl,
attachments, attachments,
client: searchClient, client: boundSearchClient,
isConnected: true, isConnected: true,
serverId: "server-1", serverId: "server-1",
cwd: workingDirectory,
setAttachments, setAttachments,
onPullRequestDetected: input.onPullRequestDetected, onPullRequestDetected: input.onPullRequestDetected,
onPullRequestAdded: input.onPullRequestAdded, onPullRequestAdded: input.onPullRequestAdded,
@@ -161,7 +169,7 @@ describe("useComposerGithubAutoAttach", () => {
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]); expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
expect(result.current.isResolving).toBe(false); expect(result.current.isResolving).toBe(false);
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]); expect(client.calls).toEqual([{ query: "101", limit: 20 }]);
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -235,8 +243,8 @@ describe("useComposerGithubAutoAttach", () => {
{ kind: "forge_issue", item: issue202 }, { kind: "forge_issue", item: issue202 },
]); ]);
expect(client.calls).toEqual([ expect(client.calls).toEqual([
{ cwd, query: "101", limit: 20 }, { query: "101", limit: 20 },
{ cwd, query: "202", limit: 20 }, { query: "202", limit: 20 },
]); ]);
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -245,7 +253,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>(); const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>(); const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = { const client: TestSearchClient = {
searchForge: vi searchForge: vi
.fn() .fn()
.mockReturnValueOnce(firstLookup.promise) .mockReturnValueOnce(firstLookup.promise)
@@ -288,7 +296,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>(); const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>(); const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = { const client: TestSearchClient = {
searchForge: vi searchForge: vi
.fn() .fn()
.mockReturnValueOnce(firstLookup.promise) .mockReturnValueOnce(firstLookup.promise)
@@ -357,8 +365,8 @@ describe("useComposerGithubAutoAttach", () => {
expect(onPullRequestAdded.mock.calls).toEqual([[pr202], [pr101]]); expect(onPullRequestAdded.mock.calls).toEqual([[pr202], [pr101]]);
expect(client.calls).toEqual([ expect(client.calls).toEqual([
{ cwd, query: "202", limit: 20 }, { query: "202", limit: 20 },
{ cwd, query: "101", limit: 20 }, { query: "101", limit: 20 },
]); ]);
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -367,7 +375,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>(); const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>(); const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = { const client: TestSearchClient = {
searchForge: vi searchForge: vi
.fn() .fn()
.mockReturnValueOnce(firstLookup.promise) .mockReturnValueOnce(firstLookup.promise)
@@ -419,7 +427,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>(); const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>(); const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = { const client: TestSearchClient = {
searchForge: vi searchForge: vi
.fn() .fn()
.mockReturnValueOnce(firstLookup.promise) .mockReturnValueOnce(firstLookup.promise)
@@ -459,7 +467,7 @@ describe("useComposerGithubAutoAttach", () => {
it("keeps resolving when a pending pull request URL is removed and re-added", async () => { it("keeps resolving when a pending pull request URL is removed and re-added", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>(); const lookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = { const client: TestSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise), searchForge: vi.fn().mockReturnValue(lookup.promise),
}; };
const onPullRequestAdded = vi.fn(); const onPullRequestAdded = vi.fn();
@@ -500,7 +508,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>(); const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>(); const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = { const client: TestSearchClient = {
searchForge: vi searchForge: vi
.fn() .fn()
.mockReturnValueOnce(firstLookup.promise) .mockReturnValueOnce(firstLookup.promise)
@@ -545,7 +553,7 @@ describe("useComposerGithubAutoAttach", () => {
it("stays resolving when an unrelated attachment is added during lookup", async () => { it("stays resolving when an unrelated attachment is added during lookup", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>(); const lookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = { const client: TestSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise), searchForge: vi.fn().mockReturnValue(lookup.promise),
}; };
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
@@ -578,7 +586,7 @@ describe("useComposerGithubAutoAttach", () => {
it("stops resolving when an in-flight PR URL is removed", async () => { it("stops resolving when an in-flight PR URL is removed", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>(); const lookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = { const client: TestSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise), searchForge: vi.fn().mockReturnValue(lookup.promise),
}; };
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
@@ -598,7 +606,7 @@ describe("useComposerGithubAutoAttach", () => {
it("ignores a lookup that finishes after the target changes", async () => { it("ignores a lookup that finishes after the target changes", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>(); const lookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = { const client: TestSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise), searchForge: vi.fn().mockReturnValue(lookup.promise),
}; };
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() }); const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
@@ -625,7 +633,7 @@ describe("useComposerGithubAutoAttach", () => {
it("accepts a lookup after the transport client is replaced for the same target", async () => { it("accepts a lookup after the transport client is replaced for the same target", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>(); const lookup = deferred<ForgeSearchPayload>();
const firstClient: ForgeSearchClient = { const firstClient: TestSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise), searchForge: vi.fn().mockReturnValue(lookup.promise),
}; };
const replacementClient = createSearchClient([pr101]); const replacementClient = createSearchClient([pr101]);
+32 -13
View File
@@ -10,7 +10,12 @@ import {
type SetStateAction, type SetStateAction,
} from "react"; } from "react";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query"; import {
buildForgeSearchQueryOptions,
forgeSearchClientIdentity,
type ForgeSearchClient,
} from "@/git/use-forge-search-query";
import { workspaceGitQueryIdentitiesEqual } from "@/git/workspace-git";
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs"; import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions"; import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions";
@@ -24,7 +29,6 @@ interface ComposerGithubAutoAttachInput {
client: ForgeSearchClient | null; client: ForgeSearchClient | null;
isConnected: boolean; isConnected: boolean;
serverId: string; serverId: string;
cwd: string;
supportsForgeSearch?: boolean; supportsForgeSearch?: boolean;
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>; setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
onPullRequestDetected?: () => void; onPullRequestDetected?: () => void;
@@ -50,7 +54,7 @@ export function useComposerGithubAutoAttach(
const removedRefKeysRef = useRef(new Set<string>()); const removedRefKeysRef = useRef(new Set<string>());
const presentPullRequestKeysRef = useRef(new Set<string>()); const presentPullRequestKeysRef = useRef(new Set<string>());
const activeLookupsRef = useRef(new Set<ActiveGithubLookup>()); const activeLookupsRef = useRef(new Set<ActiveGithubLookup>());
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd }); const previousTargetRef = useRef({ serverId: params.serverId, client: params.client });
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>( const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
() => new Map(), () => new Map(),
); );
@@ -79,7 +83,7 @@ export function useComposerGithubAutoAttach(
hasClient, hasClient,
params.isConnected, params.isConnected,
params.serverId, params.serverId,
params.cwd, params.client,
]); ]);
useEffect(() => { useEffect(() => {
@@ -143,7 +147,7 @@ export function useComposerGithubAutoAttach(
hasClient, hasClient,
params.isConnected, params.isConnected,
params.serverId, params.serverId,
params.cwd, params.client,
queryClient, queryClient,
]); ]);
@@ -236,15 +240,25 @@ function suppressRefsCarriedAcrossTargets({
removedRefKeys, removedRefKeys,
}: { }: {
params: ComposerGithubAutoAttachInput; params: ComposerGithubAutoAttachInput;
previousTargetRef: RefObject<{ serverId: string; cwd: string }>; previousTargetRef: RefObject<{
serverId: string;
client: ForgeSearchClient | null;
}>;
removedRefKeys: Set<string>; removedRefKeys: Set<string>;
}): void { }): void {
const previous = previousTargetRef.current; const previous = previousTargetRef.current;
const targetChanged = const targetChanged =
previous.cwd.trim().length > 0 && previous.client !== null &&
params.cwd.trim().length > 0 && params.client !== null &&
(previous.serverId !== params.serverId || previous.cwd !== params.cwd); previous.client.cwd.trim().length > 0 &&
previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd }; params.client.cwd.trim().length > 0 &&
(previous.serverId !== params.serverId ||
!workspaceGitQueryIdentitiesEqual(
forgeSearchClientIdentity(previous.client),
forgeSearchClientIdentity(params.client),
) ||
previous.client.cwd !== params.client.cwd);
previousTargetRef.current = { serverId: params.serverId, client: params.client };
if (!targetChanged) return; if (!targetChanged) return;
for (const ref of extractGithubRefs(params.text, params.remoteUrl)) { for (const ref of extractGithubRefs(params.text, params.remoteUrl)) {
@@ -444,7 +458,7 @@ function refsReadyForLookup({
removedRefKeys: Set<string>; removedRefKeys: Set<string>;
activeLookups: ReadonlySet<ActiveGithubLookup>; activeLookups: ReadonlySet<ActiveGithubLookup>;
}): GithubRef[] { }): GithubRef[] {
if (!params.client || !params.isConnected || params.cwd.trim().length === 0) { if (!params.client || !params.isConnected || params.client.cwd.trim().length === 0) {
return []; return [];
} }
@@ -476,7 +490,6 @@ async function fetchGithubRefSearch({
buildForgeSearchQueryOptions({ buildForgeSearchQueryOptions({
client: snapshot.client, client: snapshot.client,
serverId: snapshot.serverId, serverId: snapshot.serverId,
cwd: snapshot.cwd,
query: String(ref.number), query: String(ref.number),
supportsForgeSearch: snapshot.supportsForgeSearch, supportsForgeSearch: snapshot.supportsForgeSearch,
enabled: true, enabled: true,
@@ -499,7 +512,13 @@ function isSameLookupTarget(
): boolean { ): boolean {
return ( return (
initial.serverId === current.serverId && initial.serverId === current.serverId &&
initial.cwd === current.cwd && (initial.client === null || current.client === null
? initial.client === current.client
: workspaceGitQueryIdentitiesEqual(
forgeSearchClientIdentity(initial.client),
forgeSearchClientIdentity(current.client),
)) &&
initial.client?.cwd === current.client?.cwd &&
initial.remoteUrl === current.remoteUrl initial.remoteUrl === current.remoteUrl
); );
} }
+13 -9
View File
@@ -121,6 +121,7 @@ import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
import { useForgeSearchQuery } from "@/git/use-forge-search-query"; import { useForgeSearchQuery } from "@/git/use-forge-search-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useWorkspaceGit } from "@/git/workspace-git";
import { getForgePresentation } from "@/git/forge"; 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";
@@ -855,6 +856,8 @@ interface ComposerProps {
submitIcon?: "arrow" | "return"; submitIcon?: "arrow" | "return";
/** Externally controlled loading state. When true, disables the submit button. */ /** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean; isSubmitLoading?: boolean;
/** Disables submission without locking draft editing or attachments. */
isSubmitDisabled?: boolean;
/** When true, waits for pasted GitHub links to resolve before enabling submit. */ /** When true, waits for pasted GitHub links to resolve before enabling submit. */
waitForGithubAutoAttachOnSubmit?: boolean; waitForGithubAutoAttachOnSubmit?: boolean;
submitBehavior?: "clear" | "preserve-and-lock"; submitBehavior?: "clear" | "preserve-and-lock";
@@ -1066,6 +1069,7 @@ export function Composer({
submitButtonTestID, submitButtonTestID,
submitIcon = "arrow", submitIcon = "arrow",
isSubmitLoading = false, isSubmitLoading = false,
isSubmitDisabled = false,
waitForGithubAutoAttachOnSubmit = false, waitForGithubAutoAttachOnSubmit = false,
submitBehavior = "clear", submitBehavior = "clear",
blurOnSubmit = false, blurOnSubmit = false,
@@ -1149,7 +1153,8 @@ export function Composer({
onOpenWorkspaceAttachment, onOpenWorkspaceAttachment,
}); });
const setSelectedAttachments = onChangeAttachments; const setSelectedAttachments = onChangeAttachments;
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd }); const checkoutStatusQuery = useCheckoutStatusQuery({ serverId });
const workspaceGitClient = useWorkspaceGit();
const supportsForgeSearch = useSessionStore( const supportsForgeSearch = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true, (state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
); );
@@ -1157,10 +1162,9 @@ export function Composer({
text: userInput, text: userInput,
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
attachments, attachments,
client, client: workspaceGitClient,
isConnected, isConnected,
serverId, serverId,
cwd,
supportsForgeSearch, supportsForgeSearch,
setAttachments: setSelectedAttachments, setAttachments: setSelectedAttachments,
onPullRequestDetected: onGithubPrDetected, onPullRequestDetected: onGithubPrDetected,
@@ -1918,16 +1922,14 @@ export function Composer({
// until the forge-specific picker or attachment presentation is visible. // until the forge-specific picker or attachment presentation is visible.
const { forge } = useCheckoutPrStatusQuery({ const { forge } = useCheckoutPrStatusQuery({
serverId, serverId,
cwd,
enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment), enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment),
}); });
const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]); const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]);
const githubSearchQueryTrimmed = githubSearchQuery.trim(); const githubSearchQueryTrimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useForgeSearchQuery({ const githubSearchResultsQuery = useForgeSearchQuery({
client, client: workspaceGitClient,
serverId, serverId,
cwd,
query: githubSearchQueryTrimmed, query: githubSearchQueryTrimmed,
supportsForgeSearch, supportsForgeSearch,
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd), enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
@@ -2145,8 +2147,10 @@ export function Composer({
const isSubmitLoadingVisible = const isSubmitLoadingVisible =
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0; isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
const isSubmitDisabled = const isSubmitActionDisabled =
isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving); isSubmitDisabled ||
isSubmitLoadingVisible ||
(waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
// Disable drops while submitting/uploading: the submit path clears and restores attachments, // Disable drops while submitting/uploading: the submit path clears and restores attachments,
// 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
@@ -2205,7 +2209,7 @@ export function Composer({
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel} submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID} submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon} submitIcon={submitIcon}
isSubmitDisabled={isSubmitDisabled} isSubmitDisabled={isSubmitActionDisabled}
isSubmitLoading={isSubmitLoadingVisible} isSubmitLoading={isSubmitLoadingVisible}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"} preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments} attachments={selectedAttachments}
@@ -5,26 +5,26 @@ describe("optimistic form preferences", () => {
it("removes a rejected update while preserving later queued updates", () => { it("removes a rejected update while preserving later queued updates", () => {
const preferences = new OptimisticFormPreferences({}); const preferences = new OptimisticFormPreferences({});
const failed = preferences.begin({ provider: "claude" }); const failed = preferences.begin({ provider: "claude" });
const pending = preferences.begin({ isolation: "worktree" }); const pending = preferences.begin({ runtimeId: "worktree" });
expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" }); expect(preferences.current()).toEqual({ provider: "claude", runtimeId: "worktree" });
preferences.reject(failed); preferences.reject(failed);
expect(preferences.current()).toEqual({ isolation: "worktree" }); expect(preferences.current()).toEqual({ runtimeId: "worktree" });
preferences.commit(pending, { isolation: "worktree" }); preferences.commit(pending, { runtimeId: "worktree" });
expect(preferences.current()).toEqual({ isolation: "worktree" }); expect(preferences.current()).toEqual({ runtimeId: "worktree" });
}); });
it("does not replace pending optimistic state with a stale external snapshot", () => { it("does not replace pending optimistic state with a stale external snapshot", () => {
const preferences = new OptimisticFormPreferences({ provider: "codex" }); const preferences = new OptimisticFormPreferences({ provider: "codex" });
const pending = preferences.begin({ provider: "claude" }); const pending = preferences.begin({ provider: "claude" });
preferences.reconcile({ provider: "codex", isolation: "local" }); preferences.reconcile({ provider: "codex", runtimeId: "local" });
expect(preferences.current()).toEqual({ provider: "claude" }); expect(preferences.current()).toEqual({ provider: "claude" });
preferences.commit(pending, { provider: "claude" }); preferences.commit(pending, { provider: "claude" });
preferences.reconcile({ provider: "claude", isolation: "worktree" }); preferences.reconcile({ provider: "claude", runtimeId: "worktree" });
expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" }); expect(preferences.current()).toEqual({ provider: "claude", runtimeId: "worktree" });
}); });
}); });
@@ -61,12 +61,12 @@ describe("create agent preferences", () => {
expect(await preferences.load()).toEqual({}); expect(await preferences.load()).toEqual({});
const successfulWrite = preferences.update({ isolation: "worktree" }); const successfulWrite = preferences.update({ runtimeId: "worktree" });
await storage.nextWrite(); await storage.nextWrite();
storage.finishOldestWrite(); storage.finishOldestWrite();
await successfulWrite; await successfulWrite;
expect(storage.savedPreferences()).toEqual({ isolation: "worktree" }); expect(storage.savedPreferences()).toEqual({ runtimeId: "worktree" });
}); });
it("flushes the full create-agent selection into provider preferences", async () => { it("flushes the full create-agent selection into provider preferences", async () => {
@@ -212,36 +212,49 @@ describe("create agent preferences", () => {
expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({}); expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({});
}); });
it("persists and reloads the workspace isolation choice", async () => { it("persists and reloads the workspace runtime choice", async () => {
const storage = new FakeCreateAgentPreferenceStorage(); const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage); const preferences = new CreateAgentPreferencesService(storage);
const save = preferences.update({ isolation: "worktree" }); const save = preferences.update({ runtimeId: "fixture" });
await storage.nextWrite(); await storage.nextWrite();
storage.finishOldestWrite(); storage.finishOldestWrite();
await save; await save;
expect(storage.savedPreferences()).toEqual({ isolation: "worktree" }); expect(storage.savedPreferences()).toEqual({ runtimeId: "fixture" });
expect(await new CreateAgentPreferencesService(storage).load()).toEqual({ expect(await new CreateAgentPreferencesService(storage).load()).toEqual({
isolation: "worktree", runtimeId: "fixture",
}); });
}); });
it("migrates the old isolation choice to a runtime id", () => {
expect(parseFormPreferences({ provider: "codex", isolation: "worktree" })).toEqual({
provider: "codex",
runtimeId: "worktree",
});
});
it("keeps an explicit runtime id when legacy isolation is also present", () => {
expect(
parseFormPreferences({ provider: "codex", runtimeId: "fixture", isolation: "worktree" }),
).toEqual({ provider: "codex", runtimeId: "fixture" });
});
it("preserves legacy favourites across preference writes until host migration", async () => { it("preserves legacy favourites across preference writes until host migration", async () => {
const favoriteModels = [{ provider: "claude", modelId: "opus" }]; const favoriteModels = [{ provider: "claude", modelId: "opus" }];
const storage = new FakeCreateAgentPreferenceStorage({ stored: { favoriteModels } }); const storage = new FakeCreateAgentPreferenceStorage({ stored: { favoriteModels } });
const preferences = new CreateAgentPreferencesService(storage); const preferences = new CreateAgentPreferencesService(storage);
const save = preferences.update({ isolation: "worktree" }); const save = preferences.update({ runtimeId: "worktree" });
await storage.nextWrite(); await storage.nextWrite();
storage.finishOldestWrite(); storage.finishOldestWrite();
await save; await save;
expect(storage.savedPreferences()).toEqual({ favoriteModels, isolation: "worktree" }); expect(storage.savedPreferences()).toEqual({ favoriteModels, runtimeId: "worktree" });
}); });
it("treats stored preferences without an isolation choice as undefined", () => { it("treats stored preferences without a runtime choice as undefined", () => {
expect(parseFormPreferences({ provider: "codex" }).isolation).toBeUndefined(); expect(parseFormPreferences({ provider: "codex" }).runtimeId).toBeUndefined();
}); });
it("rejects an unknown isolation value as invalid stored preferences", () => { it("rejects an unknown isolation value as invalid stored preferences", () => {
@@ -16,7 +16,7 @@ export interface FormPreferences {
provider?: string; provider?: string;
providerPreferences?: Record<string, ProviderPreferences>; providerPreferences?: Record<string, ProviderPreferences>;
favoriteModels?: Array<{ provider: string; modelId: string }>; favoriteModels?: Array<{ provider: string; modelId: string }>;
isolation?: "local" | "worktree"; runtimeId?: string;
launchTarget?: LaunchTarget; launchTarget?: LaunchTarget;
} }
@@ -46,7 +46,7 @@ export const FormPreferencesSchema = z.strictObject({
}), }),
) )
.optional(), .optional(),
isolation: z.enum(["local", "worktree"]).optional(), runtimeId: z.string().min(1).optional(),
// What the New workspace composer submits to: the chat agent (default) or a // What the New workspace composer submits to: the chat agent (default) or a
// terminal profile. See `@/new-workspace-launch` for resolution/fallback. // terminal profile. See `@/new-workspace-launch` for resolution/fallback.
launchTarget: launchTargetSchema.optional(), launchTarget: launchTargetSchema.optional(),
@@ -58,6 +58,9 @@ const LegacyProviderPreferencesSchema = z.strictObject({
thinkingOptionId: z.string().optional(), thinkingOptionId: z.string().optional(),
}); });
const CurrentStoredFormPreferencesSchema = FormPreferencesSchema.extend({
isolation: z.enum(["local", "worktree"]).optional(),
});
const LegacyFormPreferencesSchema = z const LegacyFormPreferencesSchema = z
.strictObject({ .strictObject({
workingDir: z.string().optional(), workingDir: z.string().optional(),
@@ -86,7 +89,11 @@ const LegacyFormPreferencesSchema = z
}); });
export const StoredFormPreferencesSchema: z.ZodType<FormPreferences> = z.union([ export const StoredFormPreferencesSchema: z.ZodType<FormPreferences> = z.union([
FormPreferencesSchema, CurrentStoredFormPreferencesSchema.transform(({ isolation, ...preferences }) =>
preferences.runtimeId !== undefined || isolation === undefined
? preferences
: { ...preferences, runtimeId: isolation },
),
LegacyFormPreferencesSchema, LegacyFormPreferencesSchema,
]); ]);
@@ -4,6 +4,7 @@ import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
import { z } from "zod"; import { z } from "zod";
import { createProviderSnapshotCache, type ProviderSnapshotCache } from "./provider-snapshot-cache"; import { createProviderSnapshotCache, type ProviderSnapshotCache } from "./provider-snapshot-cache";
import type { ProviderSnapshotCacheScope } from "./provider-snapshot-cache";
const SNAPSHOT_KEY_PREFIX = "@paseo/provider-snapshot/v1:"; const SNAPSHOT_KEY_PREFIX = "@paseo/provider-snapshot/v1:";
const SNAPSHOT_INDEX_KEY = "@paseo/provider-snapshot-index/v1"; const SNAPSHOT_INDEX_KEY = "@paseo/provider-snapshot-index/v1";
@@ -12,6 +13,14 @@ const SnapshotIndexSchema = z.object({
entries: z.array(z.object({ key: z.string(), bytes: z.number(), writtenAt: z.string() })), entries: z.array(z.object({ key: z.string(), bytes: z.number(), writtenAt: z.string() })),
}); });
function legacyScope(cwd: string | null): ProviderSnapshotCacheScope {
return { type: "legacy-cwd", cwd };
}
function snapshotKey(serverId: string, cwd: string | null): string {
return `${SNAPSHOT_KEY_PREFIX}${JSON.stringify([serverId, legacyScope(cwd)])}`;
}
function createStorage(maxSnapshotBytes = Number.POSITIVE_INFINITY) { function createStorage(maxSnapshotBytes = Number.POSITIVE_INFINITY) {
const values = new Map<string, string>(); const values = new Map<string, string>();
const stats = { getAllKeysCalls: 0 }; const stats = { getAllKeysCalls: 0 };
@@ -132,7 +141,7 @@ function writeSnapshot(
): Promise<void> { ): Promise<void> {
return cache.write({ return cache.write({
serverId: "server-1", serverId: "server-1",
cwd: input.cwd, scope: legacyScope(input.cwd),
hash: input.label, hash: input.label,
generatedAt: input.generatedAt, generatedAt: input.generatedAt,
compactSnapshot: compactProviderSnapshot(snapshotEntries(input.label)), compactSnapshot: compactProviderSnapshot(snapshotEntries(input.label)),
@@ -161,12 +170,12 @@ describe("provider snapshot cache", () => {
await cache.write({ await cache.write({
serverId: "server-1", serverId: "server-1",
cwd: "/repo", scope: { type: "legacy-cwd", cwd: "/repo" },
hash: "snapshot-hash", hash: "snapshot-hash",
generatedAt: "2026-08-04T00:00:00.000Z", generatedAt: "2026-08-04T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(entries), compactSnapshot: compactProviderSnapshot(entries),
}); });
const cached = await cache.read("server-1", "/repo"); const cached = await cache.read("server-1", { type: "legacy-cwd", cwd: "/repo" });
expect(cached?.entries).toEqual(entries); expect(cached?.entries).toEqual(entries);
expect(cached?.entries[0]?.models?.[0]?.thinkingOptions).toBe( expect(cached?.entries[0]?.models?.[0]?.thinkingOptions).toBe(
@@ -177,9 +186,12 @@ describe("provider snapshot cache", () => {
it("discards an invalid cache record", async () => { it("discards an invalid cache record", async () => {
const storage = createStorage(); const storage = createStorage();
const cache = createProviderSnapshotCache(storage); const cache = createProviderSnapshotCache(storage);
storage.values.set('@paseo/provider-snapshot/v1:["server-1","/repo"]', "not json"); storage.values.set(
'@paseo/provider-snapshot/v1:["server-1",{"type":"legacy-cwd","cwd":"/repo"}]',
"not json",
);
await expect(cache.read("server-1", "/repo")).resolves.toBeNull(); await expect(cache.read("server-1", { type: "legacy-cwd", cwd: "/repo" })).resolves.toBeNull();
expect([...storage.values.keys()].some((key) => key.startsWith(SNAPSHOT_KEY_PREFIX))).toBe( expect([...storage.values.keys()].some((key) => key.startsWith(SNAPSHOT_KEY_PREFIX))).toBe(
false, false,
); );
@@ -193,29 +205,29 @@ describe("provider snapshot cache", () => {
await Promise.all([ await Promise.all([
cache.write({ cache.write({
serverId: "server-1", serverId: "server-1",
cwd: "/oldest", scope: legacyScope("/oldest"),
hash: "oldest", hash: "oldest",
generatedAt: "2026-08-01T00:00:00.000Z", generatedAt: "2026-08-01T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(snapshotEntries("oldest")), compactSnapshot: compactProviderSnapshot(snapshotEntries("oldest")),
}), }),
cache.write({ cache.write({
serverId: "server-1", serverId: "server-1",
cwd: "/middle", scope: legacyScope("/middle"),
hash: "middle", hash: "middle",
generatedAt: "2026-08-02T00:00:00.000Z", generatedAt: "2026-08-02T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(snapshotEntries("middle")), compactSnapshot: compactProviderSnapshot(snapshotEntries("middle")),
}), }),
cache.write({ cache.write({
serverId: "server-1", serverId: "server-1",
cwd: "/newest", scope: legacyScope("/newest"),
hash: "newest", hash: "newest",
generatedAt: "2026-08-03T00:00:00.000Z", generatedAt: "2026-08-03T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(snapshotEntries("newest")), compactSnapshot: compactProviderSnapshot(snapshotEntries("newest")),
}), }),
]); ]);
await expect(cache.read("server-1", "/oldest")).resolves.toBeNull(); await expect(cache.read("server-1", legacyScope("/oldest"))).resolves.toBeNull();
await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); await expect(cache.read("server-1", legacyScope("/newest"))).resolves.not.toBeNull();
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1); expect(storage.stats.getAllKeysCalls).toBe(1);
@@ -228,14 +240,14 @@ describe("provider snapshot cache", () => {
await cache.write({ await cache.write({
serverId: "server-1", serverId: "server-1",
cwd: "/current", scope: legacyScope("/current"),
hash: "current", hash: "current",
generatedAt: "2026-08-03T00:00:00.000Z", generatedAt: "2026-08-03T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(snapshotEntries("current")), compactSnapshot: compactProviderSnapshot(snapshotEntries("current")),
}); });
expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false);
await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); await expect(cache.read("server-1", legacyScope("/current"))).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
}); });
@@ -244,7 +256,7 @@ describe("provider snapshot cache", () => {
storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy"); storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy");
const cache = createProviderSnapshotCache(storage); const cache = createProviderSnapshotCache(storage);
await expect(cache.read("server-1", "/legacy")).resolves.toBeNull(); await expect(cache.read("server-1", legacyScope("/legacy"))).resolves.toBeNull();
expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false);
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
@@ -257,7 +269,7 @@ describe("provider snapshot cache", () => {
const cache = createProviderSnapshotCache(storage); const cache = createProviderSnapshotCache(storage);
await Promise.all([ await Promise.all([
cache.read("server-1", "/legacy"), cache.read("server-1", legacyScope("/legacy")),
writeSnapshot(cache, { writeSnapshot(cache, {
cwd: "/current", cwd: "/current",
label: "current", label: "current",
@@ -265,7 +277,7 @@ describe("provider snapshot cache", () => {
}), }),
]); ]);
await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); await expect(cache.read("server-1", legacyScope("/current"))).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1); expect(storage.stats.getAllKeysCalls).toBe(1);
}); });
@@ -286,7 +298,7 @@ describe("provider snapshot cache", () => {
generatedAt: "2026-08-02T00:00:00.000Z", generatedAt: "2026-08-02T00:00:00.000Z",
}); });
await expect(cache.read("server-1", "/repo")).resolves.toMatchObject({ await expect(cache.read("server-1", legacyScope("/repo"))).resolves.toMatchObject({
hash: "replacement-is-larger", hash: "replacement-is-larger",
}); });
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
@@ -312,7 +324,7 @@ describe("provider snapshot cache", () => {
generatedAt: "2026-08-02T00:00:00.000Z", generatedAt: "2026-08-02T00:00:00.000Z",
}); });
await expect(tinyCache.read("server-1", "/repo")).resolves.toBeNull(); await expect(tinyCache.read("server-1", legacyScope("/repo"))).resolves.toBeNull();
expect(snapshotBytes(storage.values)).toBe(0); expect(snapshotBytes(storage.values)).toBe(0);
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
}); });
@@ -330,7 +342,7 @@ describe("provider snapshot cache", () => {
}); });
expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}legacy`)).toBe(false); expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}legacy`)).toBe(false);
await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); await expect(cache.read("server-1", legacyScope("/current"))).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1); expect(storage.stats.getAllKeysCalls).toBe(1);
}); });
@@ -360,7 +372,7 @@ describe("provider snapshot cache", () => {
generatedAt: "2026-08-02T00:00:00.000Z", generatedAt: "2026-08-02T00:00:00.000Z",
}); });
await expect(cache.read("server-1", "/emoji")).resolves.toBeNull(); await expect(cache.read("server-1", legacyScope("/emoji"))).resolves.toBeNull();
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
}); });
@@ -379,7 +391,7 @@ describe("provider snapshot cache", () => {
await Promise.all(writes); await Promise.all(writes);
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
await expect(cache.read("server-1", "/repo-249")).resolves.not.toBeNull(); await expect(cache.read("server-1", legacyScope("/repo-249"))).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1); expect(storage.stats.getAllKeysCalls).toBe(1);
}); });
@@ -394,7 +406,7 @@ describe("provider snapshot cache", () => {
label: "stable", label: "stable",
generatedAt: "2026-08-01T00:00:00.000Z", generatedAt: "2026-08-01T00:00:00.000Z",
}); });
const failedKey = `${SNAPSHOT_KEY_PREFIX}["server-1","/failed"]`; const failedKey = snapshotKey("server-1", "/failed");
storage.failNext("setItem", timing, failedKey); storage.failNext("setItem", timing, failedKey);
await writeSnapshot(cache, { await writeSnapshot(cache, {
@@ -409,8 +421,8 @@ describe("provider snapshot cache", () => {
label: "next", label: "next",
generatedAt: "2026-08-03T00:00:00.000Z", generatedAt: "2026-08-03T00:00:00.000Z",
}); });
await expect(restarted.read("server-1", "/stable")).resolves.not.toBeNull(); await expect(restarted.read("server-1", legacyScope("/stable"))).resolves.not.toBeNull();
await expect(restarted.read("server-1", "/next")).resolves.not.toBeNull(); await expect(restarted.read("server-1", legacyScope("/next"))).resolves.not.toBeNull();
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(2_000); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(2_000);
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
}, },
@@ -436,7 +448,7 @@ describe("provider snapshot cache", () => {
const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 });
await expect( await expect(
restarted.read("server-1", "/orphan-after-index-failure"), restarted.read("server-1", legacyScope("/orphan-after-index-failure")),
).resolves.not.toBeNull(); ).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(2); expect(storage.stats.getAllKeysCalls).toBe(2);
@@ -482,7 +494,7 @@ describe("provider snapshot cache", () => {
expect(storage.values.get("@paseo/unrelated")).toBe("keep-me"); expect(storage.values.get("@paseo/unrelated")).toBe("keep-me");
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
await expect(restarted.read("server-1", "/final")).resolves.not.toBeNull(); await expect(restarted.read("server-1", legacyScope("/final"))).resolves.not.toBeNull();
}); });
it("evicts before writing when storage has no temporary headroom", async () => { it("evicts before writing when storage has no temporary headroom", async () => {
@@ -506,7 +518,7 @@ describe("provider snapshot cache", () => {
generatedAt: "2026-08-03T00:00:00.000Z", generatedAt: "2026-08-03T00:00:00.000Z",
}); });
await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); await expect(cache.read("server-1", legacyScope("/newest"))).resolves.not.toBeNull();
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
}); });
@@ -515,12 +527,12 @@ describe("provider snapshot cache", () => {
"recovers when invalid-record cleanup fails %s deletion", "recovers when invalid-record cleanup fails %s deletion",
async (timing) => { async (timing) => {
const storage = createStorage(); const storage = createStorage();
const key = `${SNAPSHOT_KEY_PREFIX}["server-1","/invalid"]`; const key = snapshotKey("server-1", "/invalid");
storage.values.set(key, "invalid"); storage.values.set(key, "invalid");
storage.failNext("multiRemove", timing); storage.failNext("multiRemove", timing);
const cache = createProviderSnapshotCache(storage); const cache = createProviderSnapshotCache(storage);
await expect(cache.read("server-1", "/invalid")).resolves.toBeNull(); await expect(cache.read("server-1", legacyScope("/invalid"))).resolves.toBeNull();
await writeSnapshot(cache, { await writeSnapshot(cache, {
cwd: "/recovered", cwd: "/recovered",
label: "recovered", label: "recovered",
@@ -528,8 +540,37 @@ describe("provider snapshot cache", () => {
}); });
expect(storage.values.has(key)).toBe(false); expect(storage.values.has(key)).toBe(false);
await expect(cache.read("server-1", "/recovered")).resolves.not.toBeNull(); await expect(cache.read("server-1", legacyScope("/recovered"))).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values); expectIndexMatchesSnapshots(storage.values);
}, },
); );
it("keeps selected workspaces with the same cwd structurally isolated", async () => {
const storage = createStorage();
const cache = createProviderSnapshotCache(storage);
const snapshot = (provider: "claude" | "codex") =>
compactProviderSnapshot([{ provider, status: "ready", enabled: true, models: [] }]);
await cache.write({
serverId: "server-1",
scope: { type: "workspace", workspaceId: "workspace-a" },
hash: "hash-a",
generatedAt: "2026-08-11T00:00:00.000Z",
compactSnapshot: snapshot("claude"),
});
await cache.write({
serverId: "server-1",
scope: { type: "workspace", workspaceId: "workspace-b" },
hash: "hash-b",
generatedAt: "2026-08-11T00:00:01.000Z",
compactSnapshot: snapshot("codex"),
});
await expect(
cache.read("server-1", { type: "workspace", workspaceId: "workspace-a" }),
).resolves.toMatchObject({ hash: "hash-a" });
await expect(
cache.read("server-1", { type: "workspace", workspaceId: "workspace-b" }),
).resolves.toMatchObject({ hash: "hash-b" });
});
}); });
@@ -56,11 +56,15 @@ export interface CachedProviderSnapshot extends StoredProviderSnapshot {
entries: ProviderSnapshotEntry[]; entries: ProviderSnapshotEntry[];
} }
export type ProviderSnapshotCacheScope =
| { type: "workspace"; workspaceId: string }
| { type: "legacy-cwd"; cwd: string | null };
export interface ProviderSnapshotCache { export interface ProviderSnapshotCache {
read(serverId: string, cwd: string | null): Promise<CachedProviderSnapshot | null>; read(serverId: string, scope: ProviderSnapshotCacheScope): Promise<CachedProviderSnapshot | null>;
write(input: { write(input: {
serverId: string; serverId: string;
cwd: string | null; scope: ProviderSnapshotCacheScope;
hash: string; hash: string;
generatedAt: string; generatedAt: string;
compactSnapshot: CompactProviderSnapshot; compactSnapshot: CompactProviderSnapshot;
@@ -74,8 +78,8 @@ export class ProviderSnapshotCacheMissError extends Error {
} }
} }
function cacheKey(serverId: string, cwd: string | null): string { function cacheKey(serverId: string, scope: ProviderSnapshotCacheScope): string {
return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`; return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, scope])}`;
} }
function storedBytes(key: string, value: string): number { function storedBytes(key: string, value: string): number {
@@ -160,13 +164,13 @@ export function createProviderSnapshotCache(
async function writeSnapshot(input: { async function writeSnapshot(input: {
serverId: string; serverId: string;
cwd: string | null; scope: ProviderSnapshotCacheScope;
hash: string; hash: string;
generatedAt: string; generatedAt: string;
compactSnapshot: CompactProviderSnapshot; compactSnapshot: CompactProviderSnapshot;
}): Promise<void> { }): Promise<void> {
const currentIndex = await ensureCacheIndex(); const currentIndex = await ensureCacheIndex();
const key = cacheKey(input.serverId, input.cwd); const key = cacheKey(input.serverId, input.scope);
const stored = StoredProviderSnapshotSchema.safeParse({ const stored = StoredProviderSnapshotSchema.safeParse({
version: CACHE_VERSION, version: CACHE_VERSION,
hash: input.hash, hash: input.hash,
@@ -206,11 +210,11 @@ export function createProviderSnapshotCache(
} }
return { return {
async read(serverId, cwd) { async read(serverId, scope) {
try { try {
return await runSerialized(async () => { return await runSerialized(async () => {
const currentIndex = await ensureCacheIndex(); const currentIndex = await ensureCacheIndex();
const key = cacheKey(serverId, cwd); const key = cacheKey(serverId, scope);
const value = await storage.getItem(key); const value = await storage.getItem(key);
if (value === null) return null; if (value === null) return null;
try { try {
+10 -1
View File
@@ -11,8 +11,15 @@ export function providersSnapshotQueryRoot(serverId: string | null) {
return [PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId] as const; return [PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId] as const;
} }
export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) { export function providersSnapshotQueryKey(
serverId: string | null,
cwd?: string | null,
workspaceId?: string | null,
) {
const normalizedCwd = normalizeProvidersSnapshotCwd(cwd); const normalizedCwd = normalizeProvidersSnapshotCwd(cwd);
if (workspaceId) {
return [PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "workspace", workspaceId] as const;
}
return normalizedCwd return normalizedCwd
? ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "cwd", normalizedCwd] as const) ? ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "cwd", normalizedCwd] as const)
: ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "home"] as const); : ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "home"] as const);
@@ -22,10 +29,12 @@ export function providersSnapshotRequestOptions(input: {
cwd?: string | null; cwd?: string | null;
providers?: AgentProvider[]; providers?: AgentProvider[];
ifNoneMatch?: string; ifNoneMatch?: string;
workspaceId?: string | null;
}) { }) {
const normalizedCwd = normalizeProvidersSnapshotCwd(input.cwd); const normalizedCwd = normalizeProvidersSnapshotCwd(input.cwd);
return { return {
...(normalizedCwd ? { cwd: normalizedCwd } : {}), ...(normalizedCwd ? { cwd: normalizedCwd } : {}),
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
...(input.providers ? { providers: input.providers } : {}), ...(input.providers ? { providers: input.providers } : {}),
...(input.ifNoneMatch ? { ifNoneMatch: input.ifNoneMatch } : {}), ...(input.ifNoneMatch ? { ifNoneMatch: input.ifNoneMatch } : {}),
}; };
+49 -27
View File
@@ -12,6 +12,7 @@ import {
mountServerDataPushRouter, mountServerDataPushRouter,
workspaceTerminalsPushRoute, workspaceTerminalsPushRoute,
} from "@/data/push-router"; } from "@/data/push-router";
import type { WorkspaceGitClient } from "@/git/workspace-git";
type ProvidersSnapshotUpdateMessage = Extract< type ProvidersSnapshotUpdateMessage = Extract<
SessionOutboundMessage, SessionOutboundMessage,
@@ -56,6 +57,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
unsubscribeCheckoutDiffCalls: string[]; unsubscribeCheckoutDiffCalls: string[];
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>; subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>; unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
workspaceGit: (target: { workspaceId: string; cwd: string }) => WorkspaceGitClient;
} { } {
const handlers: Record<RouterMessageType, RouterHandler[]> = { const handlers: Record<RouterMessageType, RouterHandler[]> = {
providers_snapshot_update: [], providers_snapshot_update: [],
@@ -94,29 +96,35 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
} }
} }
function workspaceGit(target: { workspaceId: string; cwd: string }): WorkspaceGitClient {
return {
...target,
queryIdentity: ["selected", target.workspaceId],
async subscribeDiff(
compare: Parameters<WorkspaceGitClient["subscribeDiff"]>[0],
requestOptions: Parameters<WorkspaceGitClient["subscribeDiff"]>[1],
) {
const subscriptionId = requestOptions?.subscriptionId ?? "subscription";
subscribeCheckoutDiffCalls.push({ cwd: target.cwd, compare, subscriptionId });
if (config.rejectCheckoutDiffSubscribe) throw new Error("subscribe failed");
return {
workspaceId: target.workspaceId,
subscriptionId,
cwd: target.cwd,
files: [],
error: null,
requestId: requestOptions?.requestId ?? "subscribe-checkout-diff",
};
},
unsubscribeDiff(subscriptionId: string) {
unsubscribeCheckoutDiffCalls.push(subscriptionId);
},
} as unknown as WorkspaceGitClient;
}
return { return {
client: { client: {
on, on,
async subscribeCheckoutDiff(cwd, compare, requestOptions) {
subscribeCheckoutDiffCalls.push({
cwd,
compare,
subscriptionId: requestOptions.subscriptionId,
});
if (config.rejectCheckoutDiffSubscribe) {
throw new Error("subscribe failed");
}
return {
subscriptionId: requestOptions.subscriptionId,
cwd,
files: [],
error: null,
requestId: requestOptions.requestId ?? "subscribe-checkout-diff",
};
},
unsubscribeCheckoutDiff(subscriptionId) {
unsubscribeCheckoutDiffCalls.push(subscriptionId);
},
subscribeTerminals(subscription) { subscribeTerminals(subscription) {
subscribeTerminalCalls.push(subscription); subscribeTerminalCalls.push(subscription);
}, },
@@ -129,6 +137,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
unsubscribeCheckoutDiffCalls, unsubscribeCheckoutDiffCalls,
subscribeTerminalCalls, subscribeTerminalCalls,
unsubscribeTerminalCalls, unsubscribeTerminalCalls,
workspaceGit,
}; };
} }
@@ -180,7 +189,8 @@ describe("server data push router", () => {
const fake = createFakeClient(); const fake = createFakeClient();
const serverId = "server-1"; const serverId = "server-1";
const cwd = "/repo"; const cwd = "/repo";
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true); const target = fake.workspaceGit({ workspaceId: "workspace-a", cwd });
const queryKey = checkoutDiffQueryKey(serverId, target, "base", "main", true);
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`; const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
const observer = new QueryObserver(queryClient, { const observer = new QueryObserver(queryClient, {
queryKey, queryKey,
@@ -192,7 +202,7 @@ describe("server data push router", () => {
enabled: true, enabled: true,
serverId, serverId,
subscriptionId, subscriptionId,
cwd, workspaceGit: target,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
}), }),
}); });
@@ -252,7 +262,8 @@ describe("server data push router", () => {
const fake = createFakeClient({ rejectCheckoutDiffSubscribe: true }); const fake = createFakeClient({ rejectCheckoutDiffSubscribe: true });
const serverId = "server-1"; const serverId = "server-1";
const cwd = "/repo"; const cwd = "/repo";
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true); const target = fake.workspaceGit({ workspaceId: "workspace-a", cwd });
const queryKey = checkoutDiffQueryKey(serverId, target, "base", "main", true);
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`; const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
const observer = new QueryObserver(queryClient, { const observer = new QueryObserver(queryClient, {
queryKey, queryKey,
@@ -264,7 +275,7 @@ describe("server data push router", () => {
enabled: true, enabled: true,
serverId, serverId,
subscriptionId, subscriptionId,
cwd, workspaceGit: target,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
}), }),
}); });
@@ -339,7 +350,8 @@ describe("server data push router", () => {
const serverId = "server-1"; const serverId = "server-1";
const cwd = "/repo"; const cwd = "/repo";
const workspaceId = "workspace-a"; const workspaceId = "workspace-a";
const checkoutDiffKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true); const target = fake.workspaceGit({ workspaceId, cwd });
const checkoutDiffKey = checkoutDiffQueryKey(serverId, target, "base", "main", true);
const checkoutDiffSubscriptionId = `checkoutDiff:${JSON.stringify(checkoutDiffKey)}`; const checkoutDiffSubscriptionId = `checkoutDiff:${JSON.stringify(checkoutDiffKey)}`;
const terminalKey = buildTerminalsQueryKey(serverId, cwd, workspaceId); const terminalKey = buildTerminalsQueryKey(serverId, cwd, workspaceId);
const checkoutDiffObserver = new QueryObserver(queryClient, { const checkoutDiffObserver = new QueryObserver(queryClient, {
@@ -352,7 +364,7 @@ describe("server data push router", () => {
enabled: true, enabled: true,
serverId, serverId,
subscriptionId: checkoutDiffSubscriptionId, subscriptionId: checkoutDiffSubscriptionId,
cwd, workspaceGit: target,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
}), }),
}); });
@@ -508,7 +520,17 @@ describe("server data push router", () => {
const providerKey = providersSnapshotQueryKey(serverId); const providerKey = providersSnapshotQueryKey(serverId);
const daemonConfigKey = daemonConfigQueryKey(serverId); const daemonConfigKey = daemonConfigQueryKey(serverId);
const pairingOfferKey = daemonPairingOfferQueryKey(serverId); const pairingOfferKey = daemonPairingOfferQueryKey(serverId);
const diffKey = checkoutDiffQueryKey(serverId, "/repo", "uncommitted", undefined, false); const diffKey = checkoutDiffQueryKey(
serverId,
{
workspaceId: "workspace-a",
cwd: "/repo",
queryIdentity: ["selected", "workspace-a"],
},
"uncommitted",
undefined,
false,
);
const terminalKey = buildTerminalsQueryKey(serverId, "/repo", "workspace-a"); const terminalKey = buildTerminalsQueryKey(serverId, "/repo", "workspace-a");
const otherProviderKey = providersSnapshotQueryKey(otherServerId); const otherProviderKey = providersSnapshotQueryKey(otherServerId);
+49 -28
View File
@@ -14,6 +14,8 @@ import {
providersSnapshotQueryKey, providersSnapshotQueryKey,
providersSnapshotQueryRoot, providersSnapshotQueryRoot,
} from "@/data/providers-snapshot"; } from "@/data/providers-snapshot";
import type { ProviderSnapshotCacheScope } from "@/data/provider-snapshot-cache";
import type { WorkspaceGitClient } from "@/git/workspace-git";
type ProvidersSnapshotUpdateMessage = Extract< type ProvidersSnapshotUpdateMessage = Extract<
SessionOutboundMessage, SessionOutboundMessage,
@@ -47,7 +49,7 @@ interface CheckoutDiffRoute {
enabled: boolean; enabled: boolean;
serverId: string; serverId: string;
subscriptionId: string; subscriptionId: string;
cwd: string; workspaceGit: WorkspaceGitClient;
compare: CheckoutDiffCompare; compare: CheckoutDiffCompare;
} }
@@ -72,12 +74,6 @@ interface ServerDataPushClient {
type: TType, type: TType,
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => void, handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => void,
): () => void; ): () => void;
subscribeCheckoutDiff(
cwd: string,
compare: CheckoutDiffCompare,
options: { subscriptionId: string; requestId?: string },
): Promise<CheckoutDiffResponsePayload>;
unsubscribeCheckoutDiff(subscriptionId: string): void;
subscribeTerminals(input: { cwd: string; workspaceId?: string }): void; subscribeTerminals(input: { cwd: string; workspaceId?: string }): void;
unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void; unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void;
} }
@@ -140,7 +136,7 @@ export function checkoutDiffPushRoute(input: {
enabled: boolean; enabled: boolean;
serverId: string; serverId: string;
subscriptionId: string; subscriptionId: string;
cwd: string; workspaceGit: WorkspaceGitClient;
compare: CheckoutDiffCompare; compare: CheckoutDiffCompare;
}): ServerDataQueryMeta { }): ServerDataQueryMeta {
return { return {
@@ -149,7 +145,7 @@ export function checkoutDiffPushRoute(input: {
enabled: input.enabled, enabled: input.enabled,
serverId: input.serverId, serverId: input.serverId,
subscriptionId: input.subscriptionId, subscriptionId: input.subscriptionId,
cwd: input.cwd, workspaceGit: input.workspaceGit,
compare: input.compare, compare: input.compare,
}, },
}; };
@@ -194,7 +190,11 @@ export function applyProvidersSnapshotUpdate(input: {
if (input.message.type !== "providers_snapshot_update") { if (input.message.type !== "providers_snapshot_update") {
return; return;
} }
const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd); const queryKey = providersSnapshotQueryKey(
input.serverId,
input.message.payload.cwd,
input.message.payload.workspaceId,
);
input.queryClient.setQueryData(queryKey, { input.queryClient.setQueryData(queryKey, {
entries: input.message.payload.entries, entries: input.message.payload.entries,
generatedAt: input.message.payload.generatedAt, generatedAt: input.message.payload.generatedAt,
@@ -204,7 +204,12 @@ export function applyProvidersSnapshotUpdate(input: {
if (compactSnapshot && snapshotHash) { if (compactSnapshot && snapshotHash) {
void (input.cache ?? providerSnapshotCache).write({ void (input.cache ?? providerSnapshotCache).write({
serverId: input.serverId, serverId: input.serverId,
cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd), scope: input.message.payload.workspaceId
? { type: "workspace", workspaceId: input.message.payload.workspaceId }
: ({
type: "legacy-cwd",
cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd),
} satisfies ProviderSnapshotCacheScope),
hash: snapshotHash, hash: snapshotHash,
generatedAt: input.message.payload.generatedAt, generatedAt: input.message.payload.generatedAt,
compactSnapshot, compactSnapshot,
@@ -341,8 +346,8 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void {
unsubscribeCheckoutDiffUpdate(); unsubscribeCheckoutDiffUpdate();
unsubscribeCheckoutDiffResponse(); unsubscribeCheckoutDiffResponse();
unsubscribeTerminalsChanged(); unsubscribeTerminalsChanged();
for (const subscriptionId of activeCheckoutDiffSubscriptions.keys()) { for (const route of activeCheckoutDiffSubscriptions.values()) {
unsubscribeCheckoutDiff(input.client, subscriptionId); unsubscribeCheckoutDiff(route);
} }
activeCheckoutDiffSubscriptions.clear(); activeCheckoutDiffSubscriptions.clear();
for (const route of activeTerminalSubscriptions.values()) { for (const route of activeTerminalSubscriptions.values()) {
@@ -363,7 +368,7 @@ function reconcileCheckoutDiffSubscriptions(input: {
if (desired && areCheckoutDiffRoutesEqual(current, desired)) { if (desired && areCheckoutDiffRoutesEqual(current, desired)) {
continue; continue;
} }
unsubscribeCheckoutDiff(input.client, subscriptionId); unsubscribeCheckoutDiff(current);
input.active.delete(subscriptionId); input.active.delete(subscriptionId);
} }
@@ -372,8 +377,8 @@ function reconcileCheckoutDiffSubscriptions(input: {
continue; continue;
} }
input.active.set(subscriptionId, desired); input.active.set(subscriptionId, desired);
void input.client void desired.workspaceGit
.subscribeCheckoutDiff(desired.cwd, desired.compare, { .subscribeDiff(desired.compare, {
subscriptionId, subscriptionId,
requestId: `push-router:${input.serverId}:${subscriptionId}`, requestId: `push-router:${input.serverId}:${subscriptionId}`,
}) })
@@ -383,7 +388,7 @@ function reconcileCheckoutDiffSubscriptions(input: {
} }
console.error("[server-data] subscribeCheckoutDiff failed", { console.error("[server-data] subscribeCheckoutDiff failed", {
serverId: input.serverId, serverId: input.serverId,
cwd: desired.cwd, cwd: desired.workspaceGit.cwd,
error, error,
}); });
}); });
@@ -652,15 +657,15 @@ function readServerDataRoute(value: Record<string, unknown>): ServerDataRoute |
const domain = value.domain; const domain = value.domain;
const enabled = value.enabled; const enabled = value.enabled;
const serverId = value.serverId; const serverId = value.serverId;
const cwd = value.cwd; if (typeof enabled !== "boolean" || typeof serverId !== "string") {
if (typeof enabled !== "boolean" || typeof serverId !== "string" || typeof cwd !== "string") {
return null; return null;
} }
if (domain === "checkoutDiff") { if (domain === "checkoutDiff") {
const subscriptionId = value.subscriptionId; const subscriptionId = value.subscriptionId;
const compare = readCheckoutDiffCompare(value.compare); const compare = readCheckoutDiffCompare(value.compare);
if (typeof subscriptionId !== "string" || !compare) { const workspaceGit = readWorkspaceGitClient(value.workspaceGit);
if (typeof subscriptionId !== "string" || !compare || !workspaceGit) {
return null; return null;
} }
return { return {
@@ -668,12 +673,14 @@ function readServerDataRoute(value: Record<string, unknown>): ServerDataRoute |
enabled, enabled,
serverId, serverId,
subscriptionId, subscriptionId,
cwd, workspaceGit,
compare, compare,
}; };
} }
if (domain === "workspaceTerminals") { if (domain === "workspaceTerminals") {
const cwd = value.cwd;
if (typeof cwd !== "string") return null;
const workspaceId = value.workspaceId; const workspaceId = value.workspaceId;
if (workspaceId !== undefined && typeof workspaceId !== "string") { if (workspaceId !== undefined && typeof workspaceId !== "string") {
return null; return null;
@@ -720,7 +727,7 @@ function areCheckoutDiffRoutesEqual(
return ( return (
left?.serverId === right.serverId && left?.serverId === right.serverId &&
left.subscriptionId === right.subscriptionId && left.subscriptionId === right.subscriptionId &&
left.cwd === right.cwd && left.workspaceGit === right.workspaceGit &&
left.compare.mode === right.compare.mode && left.compare.mode === right.compare.mode &&
left.compare.baseRef === right.compare.baseRef && left.compare.baseRef === right.compare.baseRef &&
left.compare.ignoreWhitespace === right.compare.ignoreWhitespace left.compare.ignoreWhitespace === right.compare.ignoreWhitespace
@@ -731,10 +738,13 @@ function isCheckoutDiffQueryKeyForRoute(queryKey: QueryKey, route: CheckoutDiffR
return ( return (
queryKey[0] === "checkoutDiff" && queryKey[0] === "checkoutDiff" &&
queryKey[1] === route.serverId && queryKey[1] === route.serverId &&
queryKey[2] === route.cwd && Array.isArray(queryKey[2]) &&
queryKey[3] === route.compare.mode && queryKey[2][0] === route.workspaceGit.queryIdentity[0] &&
queryKey[4] === (route.compare.baseRef ?? "") && queryKey[2][1] === route.workspaceGit.queryIdentity[1] &&
queryKey[5] === (route.compare.ignoreWhitespace === true) queryKey[3] === route.workspaceGit.cwd &&
queryKey[4] === route.compare.mode &&
queryKey[5] === (route.compare.baseRef ?? "") &&
queryKey[6] === (route.compare.ignoreWhitespace === true)
); );
} }
@@ -763,14 +773,25 @@ function workspaceTerminalSubscriptionInput(route: WorkspaceTerminalsRoute): {
}; };
} }
function unsubscribeCheckoutDiff(client: ServerDataPushClient, subscriptionId: string): void { function unsubscribeCheckoutDiff(route: CheckoutDiffRoute): void {
try { try {
client.unsubscribeCheckoutDiff(subscriptionId); route.workspaceGit.unsubscribeDiff(route.subscriptionId);
} catch { } catch {
// Disconnect cleanup can race with explicit subscription teardown. // Disconnect cleanup can race with explicit subscription teardown.
} }
} }
function readWorkspaceGitClient(value: unknown): WorkspaceGitClient | null {
if (!isRecord(value)) return null;
return typeof value.workspaceId === "string" &&
value.workspaceId.length > 0 &&
typeof value.cwd === "string" &&
typeof value.subscribeDiff === "function" &&
typeof value.unsubscribeDiff === "function"
? (value as unknown as WorkspaceGitClient)
: null;
}
function isQueryForServer(queryKey: QueryKey, kind: string, serverId: string): boolean { function isQueryForServer(queryKey: QueryKey, kind: string, serverId: string): boolean {
return queryKey.length >= 2 && queryKey[0] === kind && queryKey[1] === serverId; return queryKey.length >= 2 && queryKey[0] === kind && queryKey[1] === serverId;
} }
+4 -3
View File
@@ -4,6 +4,7 @@ import { LiveFileModel, type LiveFileSession } from "./model";
export function useLiveFile(input: { export function useLiveFile(input: {
client: DaemonClient | null; client: DaemonClient | null;
workspaceId: string;
cwd: string | null; cwd: string | null;
path: string | null; path: string | null;
enabled: boolean; enabled: boolean;
@@ -15,13 +16,13 @@ export function useLiveFile(input: {
const client = input.client; const client = input.client;
return { return {
subscribe(target, onVersion) { subscribe(target, onVersion) {
return client.subscribeFile(target, onVersion); return client.subscribeFile({ ...target, workspaceId: input.workspaceId }, onVersion);
}, },
read(target) { read(target) {
return client.readFile(target.cwd, target.path); return client.readFile(target.cwd, target.path, undefined, input.workspaceId);
}, },
}; };
}, [input.client]); }, [input.client, input.workspaceId]);
useEffect(() => { useEffect(() => {
if (!input.enabled || !session || !input.cwd || !input.path) { if (!input.enabled || !session || !input.cwd || !input.path) {
+11 -2
View File
@@ -399,11 +399,13 @@ function FilePreviewBody({
export function FilePane({ export function FilePane({
serverId, serverId,
workspaceId,
workspaceRoot, workspaceRoot,
location, location,
navigationRevision, navigationRevision,
}: { }: {
serverId: string; serverId: string;
workspaceId: string;
workspaceRoot: string; workspaceRoot: string;
location: WorkspaceFileLocation; location: WorkspaceFileLocation;
navigationRevision: number; navigationRevision: number;
@@ -447,6 +449,7 @@ export function FilePane({
}); });
const liveFile = useLiveFile({ const liveFile = useLiveFile({
client, client,
workspaceId,
cwd: readTarget?.cwd ?? null, cwd: readTarget?.cwd ?? null,
path: readTarget?.path ?? null, path: readTarget?.path ?? null,
enabled, enabled,
@@ -487,6 +490,7 @@ export function FilePane({
<FilePanePresentation <FilePanePresentation
serverId={serverId} serverId={serverId}
client={client} client={client}
workspaceId={workspaceId}
readTarget={readTarget} readTarget={readTarget}
preview={preview} preview={preview}
liveFile={liveFile.model} liveFile={liveFile.model}
@@ -533,6 +537,7 @@ function isEditableTextFile(input: {
function FilePanePresentation({ function FilePanePresentation({
serverId, serverId,
workspaceId,
client, client,
readTarget, readTarget,
preview, preview,
@@ -554,6 +559,7 @@ function FilePanePresentation({
imagePreviewUri, imagePreviewUri,
}: { }: {
serverId: string; serverId: string;
workspaceId: string;
client: DaemonClient | null; client: DaemonClient | null;
readTarget: { cwd: string; path: string } | null; readTarget: { cwd: string; path: string } | null;
preview: ExplorerFile | null; preview: ExplorerFile | null;
@@ -589,6 +595,7 @@ function FilePanePresentation({
<EditableFilePane <EditableFilePane
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`} key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
client={client} client={client}
workspaceId={workspaceId}
cwd={readTarget.cwd} cwd={readTarget.cwd}
path={readTarget.path} path={readTarget.path}
preview={preview as TextExplorerFile} preview={preview as TextExplorerFile}
@@ -644,6 +651,7 @@ function FilePanePresentation({
function EditableFilePane({ function EditableFilePane({
client, client,
workspaceId,
cwd, cwd,
path, path,
preview, preview,
@@ -659,6 +667,7 @@ function EditableFilePane({
navigationRevision, navigationRevision,
}: { }: {
client: DaemonClient; client: DaemonClient;
workspaceId: string;
cwd: string; cwd: string;
path: string; path: string;
preview: TextExplorerFile; preview: TextExplorerFile;
@@ -680,10 +689,10 @@ function EditableFilePane({
const session = useMemo( const session = useMemo(
() => ({ () => ({
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) { write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
return client.writeFile({ cwd, path, ...input }); return client.writeFile({ cwd, path, workspaceId, ...input });
}, },
}), }),
[client, cwd, path], [client, cwd, path, workspaceId],
); );
const [model] = useState(() => { const [model] = useState(() => {
return new FileEditorModel({ return new FileEditorModel({
+76 -250
View File
@@ -1,34 +1,26 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { queryClient as appQueryClient } from "@/data/query-client"; import { queryClient as appQueryClient } from "@/data/query-client";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { import {
__resetCheckoutGitActionsStoreForTests, __resetCheckoutGitActionsStoreForTests,
useCheckoutGitActionsStore, useCheckoutGitActionsStore,
} from "@/git/actions-store"; } from "@/git/actions-store";
import type { WorkspaceGitClient } from "@/git/workspace-git";
vi.mock("@react-native-async-storage/async-storage", () => ({ vi.mock("@react-native-async-storage/async-storage", () => ({
default: { default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() },
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined),
},
})); }));
function createDeferred<T>() { const serverId = "server-1";
let resolve!: (value: T) => void; function capability(methods: Record<string, unknown>): WorkspaceGitClient {
let reject!: (reason?: unknown) => void; return {
const promise = new Promise<T>((res, rej) => { workspaceId: "workspace-1",
resolve = res; cwd: "/tmp/repo/worktrees/feature",
reject = rej; ...methods,
}); } as unknown as WorkspaceGitClient;
return { promise, resolve, reject };
} }
describe("checkout-git-actions-store", () => { describe("checkout-git-actions-store", () => {
const serverId = "server-1";
const cwd = "/tmp/repo/worktrees/feature";
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
__resetCheckoutGitActionsStoreForTests(); __resetCheckoutGitActionsStoreForTests();
@@ -40,267 +32,101 @@ describe("checkout-git-actions-store", () => {
vi.useRealTimers(); vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests(); __resetCheckoutGitActionsStoreForTests();
appQueryClient.clear(); appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
}); });
it("shares pending state per checkout and de-dupes in-flight calls", async () => { it("binds once to workspace identity and de-duplicates an in-flight commit", async () => {
const deferred = createDeferred<unknown>(); let resolve!: (value: { error: null }) => void;
const client = { const commit = vi.fn(() => new Promise<{ error: null }>((done) => (resolve = done)));
checkoutCommit: vi.fn(() => deferred.promise), const target = capability({ commit });
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
const store = useCheckoutGitActionsStore.getState(); const store = useCheckoutGitActionsStore.getState();
const first = store.commit({ serverId, cwd }); const first = store.commit({ serverId, target });
const second = store.commit({ serverId, cwd }); const second = store.commit({ serverId, target });
expect(store.getStatus({ serverId, target, actionId: "commit" })).toBe("pending");
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("pending"); resolve({ error: null });
deferred.resolve({});
await Promise.all([first, second]); await Promise.all([first, second]);
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("success"); expect(commit).toHaveBeenCalledTimes(1);
expect(store.getStatus({ serverId, target, actionId: "commit" })).toBe("success");
vi.advanceTimersByTime(1000);
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle");
}); });
it("runs pull then push sequentially for pull-and-push", async () => { it("runs pull then push sequentially through the same bound capability", async () => {
const order: string[] = []; const order: string[] = [];
const client = { const target = capability({
checkoutPull: vi.fn(async () => { pull: vi.fn(async () => {
order.push("pull"); order.push("pull");
return {}; return { error: null };
}), }),
checkoutPush: vi.fn(async () => { push: vi.fn(async () => {
order.push("push"); order.push("push");
return {}; return { error: null };
}), }),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd });
expect(order).toEqual(["pull", "push"]);
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
).toBe("success");
});
it("does not push when pull fails for pull-and-push", async () => {
const client = {
checkoutPull: vi.fn(async () => ({ error: { message: "pull conflict" } })),
checkoutPush: vi.fn(async () => ({})),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await expect(
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
).rejects.toThrow("pull conflict");
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
).toBe("idle");
});
it("surfaces push errors from pull-and-push after a successful pull", async () => {
const client = {
checkoutPull: vi.fn(async () => ({})),
checkoutPush: vi.fn(async () => ({ error: { message: "push rejected" } })),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await expect(
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
).rejects.toThrow("push rejected");
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
).toBe("idle");
});
it("refreshes git and GitHub state and reports success", async () => {
const client = {
checkoutRefresh: vi.fn(async () => ({ success: true, error: null })),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await useCheckoutGitActionsStore.getState().refresh({ serverId, cwd });
expect(client.checkoutRefresh).toHaveBeenCalledWith(cwd);
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }),
).toBe("success");
});
it("surfaces a refresh error and returns to idle", async () => {
const client = {
checkoutRefresh: vi.fn(async () => ({ error: { message: "not a git repository" } })),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await expect(useCheckoutGitActionsStore.getState().refresh({ serverId, cwd })).rejects.toThrow(
"not a git repository",
);
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }),
).toBe("idle");
});
it("discards selected paths through the shared checkout action workflow", async () => {
const checkoutDiscardChanges = vi.fn(async () => ({ success: true, error: null }));
const client = { checkoutDiscardChanges };
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await useCheckoutGitActionsStore
.getState()
.discardChanges({ serverId, cwd, paths: ["renamed.ts", "original.ts"] });
expect(checkoutDiscardChanges).toHaveBeenCalledWith(cwd, {
paths: ["renamed.ts", "original.ts"],
}); });
await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, target });
expect(order).toEqual(["pull", "push"]);
});
it("does not push when pull fails", async () => {
const push = vi.fn();
const target = capability({
pull: vi.fn(async () => ({ error: { message: "pull conflict" } })),
push,
});
await expect(
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, target }),
).rejects.toThrow("pull conflict");
expect(push).not.toHaveBeenCalled();
expect( expect(
useCheckoutGitActionsStore useCheckoutGitActionsStore
.getState() .getState()
.getStatus({ serverId, cwd, actionId: "discard-changes" }), .getStatus({ serverId, target, actionId: "pull-and-push" }),
).toBe("idle");
});
it("returns to idle when a bound mutation reports an error", async () => {
const target = capability({
refresh: vi.fn(async () => ({ error: { message: "not git" } })),
});
await expect(
useCheckoutGitActionsStore.getState().refresh({ serverId, target }),
).rejects.toThrow("not git");
expect(
useCheckoutGitActionsStore.getState().getStatus({ serverId, target, actionId: "refresh" }),
).toBe("idle");
});
it("discards selected paths through the bound workspace capability", async () => {
const discardChanges = vi.fn(async () => ({ success: true, error: null }));
const target = capability({ discardChanges });
await useCheckoutGitActionsStore
.getState()
.discardChanges({ serverId, target, paths: ["renamed.ts", "original.ts"] });
expect(discardChanges).toHaveBeenCalledWith({ paths: ["renamed.ts", "original.ts"] });
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, target, actionId: "discard-changes" }),
).toBe("success"); ).toBe("success");
}); });
for (const rpc of [ it("fails auto-merge before invoking Git when the daemon lacks the feature", async () => {
{ const setForgeAutoMerge = vi.fn();
label: "forge", const target = capability({ setForgeAutoMerge });
method: "checkoutForgeSetAutoMerge", useSessionStore.getState().initializeSession(serverId, {} as never);
feature: "checkoutForgeSetAutoMerge",
},
{
label: "legacy GitHub",
method: "checkoutGithubSetAutoMerge",
feature: "checkoutGithubSetAutoMerge",
},
] as const) {
it(`enables PR auto-merge through the ${rpc.label} RPC`, async () => {
const setAutoMerge = vi.fn(async () => ({
enabled: true,
success: true,
error: null,
}));
const client = { [rpc.method]: setAutoMerge };
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { [rpc.feature]: true },
});
await useCheckoutGitActionsStore
.getState()
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
expect(setAutoMerge).toHaveBeenCalledWith(cwd, {
enabled: true,
method: "squash",
});
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
).toBe("success");
});
it(`disables PR auto-merge through the ${rpc.label} RPC`, async () => {
const setAutoMerge = vi.fn(async () => ({
enabled: false,
success: true,
error: null,
}));
const client = { [rpc.method]: setAutoMerge };
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { [rpc.feature]: true },
});
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
expect(setAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
).toBe("success");
});
}
it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => {
const client = {
checkoutForgeSetAutoMerge: vi.fn(async () => ({
enabled: true,
success: true,
error: null,
})),
};
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, { useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId, serverId,
hostname: null, hostname: null,
version: null, version: null,
features: {}, features: {},
}); });
await expect( await expect(
useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }),
).rejects.toThrow("Update the host to use auto-merge actions.");
expect(client.checkoutForgeSetAutoMerge).not.toHaveBeenCalled();
expect(
useCheckoutGitActionsStore useCheckoutGitActionsStore
.getState() .getState()
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }), .enablePrAutoMerge({ serverId, target, method: "merge" }),
).toBe("idle"); ).rejects.toThrow("Update the host");
expect(setForgeAutoMerge).not.toHaveBeenCalled();
}); });
}); });
+78 -87
View File
@@ -3,7 +3,9 @@ import { create } from "zustand";
import { queryClient as appQueryClient } from "@/data/query-client"; import { queryClient as appQueryClient } from "@/data/query-client";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { i18n } from "@/i18n/i18next"; import type { WorkspaceGitClient } from "@/git/workspace-git";
type WorkspaceGitTarget = WorkspaceGitClient;
const SUCCESS_DISPLAY_MS = 1000; const SUCCESS_DISPLAY_MS = 1000;
@@ -30,17 +32,8 @@ export type CheckoutGitAsyncActionId =
type CheckoutKey = string; type CheckoutKey = string;
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>; type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
function checkoutKey(serverId: string, cwd: string): CheckoutKey { function checkoutKey(serverId: string, target: WorkspaceGitTarget): CheckoutKey {
return `${serverId}::${cwd}`; return `${serverId}::${target.workspaceId}::${target.cwd}`;
}
function resolveClient(serverId: string) {
const session = useSessionStore.getState().sessions[serverId];
const client = session?.client ?? null;
if (!client) {
throw new Error(i18n.t("common.errors.daemonClientUnavailable"));
}
return client;
} }
type AutoMergeActionsRpc = "forge" | "github"; type AutoMergeActionsRpc = "forge" | "github";
@@ -81,8 +74,8 @@ function setStatus(
}); });
} }
function invalidateCheckoutGitQueries(serverId: string, cwd: string) { function invalidateCheckoutGitQueries(serverId: string, target: WorkspaceGitTarget) {
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd }); return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, target });
} }
const successTimers = new Map<string, ReturnType<typeof setTimeout>>(); const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -97,44 +90,56 @@ interface CheckoutGitActionsStoreState {
getStatus: (params: { getStatus: (params: {
serverId: string; serverId: string;
cwd: string; target: WorkspaceGitTarget;
actionId: CheckoutGitAsyncActionId; actionId: CheckoutGitAsyncActionId;
}) => CheckoutGitActionStatus; }) => CheckoutGitActionStatus;
commit: (params: { serverId: string; cwd: string }) => Promise<void>; commit: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
pull: (params: { serverId: string; cwd: string }) => Promise<void>; pull: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>; push: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>; pullAndPush: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
refresh: (params: { serverId: string; cwd: string }) => Promise<void>; refresh: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>; createPr: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
mergePr: (params: { mergePr: (params: {
serverId: string; serverId: string;
cwd: string; target: WorkspaceGitTarget;
method: CheckoutPrMergeMethod; method: CheckoutPrMergeMethod;
}) => Promise<void>; }) => Promise<void>;
enablePrAutoMerge: (params: { enablePrAutoMerge: (params: {
serverId: string; serverId: string;
cwd: string; target: WorkspaceGitTarget;
method: CheckoutPrMergeMethod; method: CheckoutPrMergeMethod;
}) => Promise<void>; }) => Promise<void>;
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>; disablePrAutoMerge: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>; mergeBranch: (params: {
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>; serverId: string;
discardChanges: (params: { serverId: string; cwd: string; paths: string[] }) => Promise<void>; target: WorkspaceGitTarget;
baseRef: string;
}) => Promise<void>;
mergeFromBase: (params: {
serverId: string;
target: WorkspaceGitTarget;
baseRef: string;
}) => Promise<void>;
discardChanges: (params: {
serverId: string;
target: WorkspaceGitTarget;
paths: string[];
}) => Promise<void>;
} }
async function runCheckoutAction({ async function runCheckoutAction({
serverId, serverId,
cwd, target,
actionId, actionId,
run, run,
}: { }: {
serverId: string; serverId: string;
cwd: string; target: WorkspaceGitTarget;
actionId: CheckoutGitAsyncActionId; actionId: CheckoutGitAsyncActionId;
run: () => Promise<void>; run: () => Promise<void>;
}): Promise<void> { }): Promise<void> {
const key = checkoutKey(serverId, cwd); const key = checkoutKey(serverId, target);
const inflightId = inFlightKey(key, actionId); const inflightId = inFlightKey(key, actionId);
const existing = inFlight.get(inflightId); const existing = inFlight.get(inflightId);
@@ -154,7 +159,7 @@ async function runCheckoutAction({
const promise = (async () => { const promise = (async () => {
try { try {
await run(); await run();
await invalidateCheckoutGitQueries(serverId, cwd); await invalidateCheckoutGitQueries(serverId, target);
setStatus(key, actionId, "success"); setStatus(key, actionId, "success");
const timer = setTimeout(() => { const timer = setTimeout(() => {
setStatus(key, actionId, "idle"); setStatus(key, actionId, "idle");
@@ -176,19 +181,18 @@ async function runCheckoutAction({
export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()((set, get) => ({ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()((set, get) => ({
statusByCheckout: {}, statusByCheckout: {},
getStatus: ({ serverId, cwd, actionId }) => { getStatus: ({ serverId, target, actionId }) => {
const key = checkoutKey(serverId, cwd); const key = checkoutKey(serverId, target);
return get().statusByCheckout[key]?.[actionId] ?? "idle"; return get().statusByCheckout[key]?.[actionId] ?? "idle";
}, },
commit: async ({ serverId, cwd }) => { commit: async ({ serverId, target }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
cwd, target,
actionId: "commit", actionId: "commit",
run: async () => { run: async () => {
const client = resolveClient(serverId); const payload = await target.commit({ addAll: true });
const payload = await client.checkoutCommit(cwd, { addAll: true });
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
@@ -196,14 +200,13 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
pull: async ({ serverId, cwd }) => { pull: async ({ serverId, target }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
cwd, target,
actionId: "pull", actionId: "pull",
run: async () => { run: async () => {
const client = resolveClient(serverId); const payload = await target.pull();
const payload = await client.checkoutPull(cwd);
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
@@ -211,14 +214,13 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
push: async ({ serverId, cwd }) => { push: async ({ serverId, target }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
cwd, target,
actionId: "push", actionId: "push",
run: async () => { run: async () => {
const client = resolveClient(serverId); const payload = await target.push();
const payload = await client.checkoutPush(cwd);
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
@@ -226,14 +228,13 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
refresh: async ({ serverId, cwd }) => { refresh: async ({ serverId, target }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
cwd, target,
actionId: "refresh", actionId: "refresh",
run: async () => { run: async () => {
const client = resolveClient(serverId); const payload = await target.refresh();
const payload = await client.checkoutRefresh(cwd);
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
@@ -241,18 +242,17 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
pullAndPush: async ({ serverId, cwd }) => { pullAndPush: async ({ serverId, target }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
cwd, target,
actionId: "pull-and-push", actionId: "pull-and-push",
run: async () => { run: async () => {
const client = resolveClient(serverId); const pullPayload = await target.pull();
const pullPayload = await client.checkoutPull(cwd);
if (pullPayload.error) { if (pullPayload.error) {
throw new Error(pullPayload.error.message); throw new Error(pullPayload.error.message);
} }
const pushPayload = await client.checkoutPush(cwd); const pushPayload = await target.push();
if (pushPayload.error) { if (pushPayload.error) {
throw new Error(pushPayload.error.message); throw new Error(pushPayload.error.message);
} }
@@ -260,14 +260,13 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
createPr: async ({ serverId, cwd }) => { createPr: async ({ serverId, target }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
cwd, target,
actionId: "create-pr", actionId: "create-pr",
run: async () => { run: async () => {
const client = resolveClient(serverId); const payload = await target.createPr({});
const payload = await client.checkoutPrCreate(cwd, {});
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
@@ -275,14 +274,13 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
mergePr: async ({ serverId, cwd, method }) => { mergePr: async ({ serverId, target, method }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
cwd, target,
actionId: `merge-pr-${method}`, actionId: `merge-pr-${method}`,
run: async () => { run: async () => {
const client = resolveClient(serverId); const payload = await target.mergePr(method);
const payload = await client.checkoutPrMerge(cwd, { method });
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
@@ -290,20 +288,19 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
enablePrAutoMerge: async ({ serverId, cwd, method }) => { enablePrAutoMerge: async ({ serverId, target, method }) => {
const rpc = resolveAutoMergeActionsRpc(serverId); const rpc = resolveAutoMergeActionsRpc(serverId);
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
cwd, target,
actionId: `enable-pr-auto-merge-${method}`, actionId: `enable-pr-auto-merge-${method}`,
run: async () => { run: async () => {
const client = resolveClient(serverId);
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once // COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.set_auto_merge.*. // all supported clients use checkout.forge.set_auto_merge.*.
const payload = const payload =
rpc === "forge" rpc === "forge"
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method }) ? await target.setForgeAutoMerge({ enabled: true, method })
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method }); : await target.setGithubAutoMerge({ enabled: true, method });
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
@@ -311,20 +308,19 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
disablePrAutoMerge: async ({ serverId, cwd }) => { disablePrAutoMerge: async ({ serverId, target }) => {
const rpc = resolveAutoMergeActionsRpc(serverId); const rpc = resolveAutoMergeActionsRpc(serverId);
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
cwd, target,
actionId: "disable-pr-auto-merge", actionId: "disable-pr-auto-merge",
run: async () => { run: async () => {
const client = resolveClient(serverId);
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once // COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.set_auto_merge.*. // all supported clients use checkout.forge.set_auto_merge.*.
const payload = const payload =
rpc === "forge" rpc === "forge"
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false }) ? await target.setForgeAutoMerge({ enabled: false })
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: false }); : await target.setGithubAutoMerge({ enabled: false });
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
@@ -332,14 +328,13 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
mergeBranch: async ({ serverId, cwd, baseRef }) => { mergeBranch: async ({ serverId, target, baseRef }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
cwd, target,
actionId: "merge-branch", actionId: "merge-branch",
run: async () => { run: async () => {
const client = resolveClient(serverId); const payload = await target.merge({
const payload = await client.checkoutMerge(cwd, {
baseRef, baseRef,
strategy: "merge", strategy: "merge",
requireCleanTarget: true, requireCleanTarget: true,
@@ -351,14 +346,13 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
mergeFromBase: async ({ serverId, cwd, baseRef }) => { mergeFromBase: async ({ serverId, target, baseRef }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
cwd, target,
actionId: "merge-from-base", actionId: "merge-from-base",
run: async () => { run: async () => {
const client = resolveClient(serverId); const payload = await target.mergeFromBase({
const payload = await client.checkoutMergeFromBase(cwd, {
baseRef, baseRef,
requireCleanTarget: true, requireCleanTarget: true,
}); });
@@ -369,18 +363,15 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
}); });
}, },
discardChanges: async ({ serverId, cwd, paths }) => { discardChanges: async ({ serverId, target, paths }) => {
await runCheckoutAction({ await runCheckoutAction({
serverId, serverId,
cwd, target,
actionId: "discard-changes", actionId: "discard-changes",
run: async () => { run: async () => {
const client = resolveClient(serverId); const payload = await target.discardChanges({ paths });
const payload = await client.checkoutDiscardChanges(cwd, { paths });
if (!payload.success) { if (!payload.success) {
throw new Error( throw new Error(payload.error?.message ?? "Failed to discard changes");
payload.error?.message ?? i18n.t("workspace.fileActions.confirmRevert.failed"),
);
} }
}, },
}); });
@@ -1,54 +1,32 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceGitClient } from "./workspace-git";
import { createBranchSwitcherOperations } from "./branch-switcher-operations"; import { createBranchSwitcherOperations } from "./branch-switcher-operations";
function createRecordingClient() { function createRecordingClient() {
const cwds: string[] = []; const calls: string[] = [];
const client = { const client = {
getBranchSuggestions: async (options: { cwd: string; limit?: number }) => { workspaceId: "wks_3f9a2b1c",
cwds.push(options.cwd); cwd: "/Users/dev/project",
return { branches: [], error: null }; getBranchSuggestions: async () => (calls.push("suggest"), { branches: [], error: null }),
}, stashList: async () => (calls.push("list"), { entries: [] }),
stashList: async (cwd: string) => { stashSave: async () => (calls.push("save"), { error: null }),
cwds.push(cwd); stashPop: async () => (calls.push("pop"), { error: null }),
return { entries: [] }; switchBranch: async () => (calls.push("switch"), { error: null }),
}, } as unknown as WorkspaceGitClient;
stashSave: async (cwd: string) => { return { client, calls };
cwds.push(cwd);
return { error: null };
},
stashPop: async (cwd: string) => {
cwds.push(cwd);
return { error: null };
},
checkoutSwitchBranch: async (cwd: string) => {
cwds.push(cwd);
return { error: null };
},
} as unknown as DaemonClient;
return { client, cwds };
} }
describe("createBranchSwitcherOperations", () => { describe("createBranchSwitcherOperations", () => {
it("sends the workspace directory as cwd to every git operation, never the workspace id", async () => { it("binds every operation to the selected workspace identity", async () => {
const workspaceDirectory = "/Users/dev/project"; const { client, calls } = createRecordingClient();
const workspaceId = "wks_3f9a2b1c";
const { client, cwds } = createRecordingClient();
const operations = createBranchSwitcherOperations(client, workspaceDirectory); const operations = createBranchSwitcherOperations(client);
await operations.getBranchSuggestions(200); await operations.getBranchSuggestions(200);
await operations.listPaseoStashes(); await operations.listPaseoStashes();
await operations.saveStash("main"); await operations.saveStash("main");
await operations.popStash(0); await operations.popStash(0);
await operations.switchBranch("feature"); await operations.switchBranch("feature");
expect(cwds).toEqual([ expect(calls).toEqual(["suggest", "list", "save", "pop", "switch"]);
workspaceDirectory,
workspaceDirectory,
workspaceDirectory,
workspaceDirectory,
workspaceDirectory,
]);
expect(cwds).not.toContain(workspaceId);
}); });
}); });
@@ -1,15 +1,15 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceGitClient } from "./workspace-git";
// Binds the branch switcher's git operations to a single workspace directory, so a // Binds the branch switcher's git operations to a single workspace directory, so a
// workspace id can never be passed where a cwd is expected. `cwd` is set once here; // workspace id can never be passed where a cwd is expected. `cwd` is set once here;
// callers choose the operation, never the directory. // callers choose the operation, never the directory.
export function createBranchSwitcherOperations(client: DaemonClient, cwd: string) { export function createBranchSwitcherOperations(workspaceGit: WorkspaceGitClient) {
return { return {
getBranchSuggestions: (limit: number) => client.getBranchSuggestions({ cwd, limit }), getBranchSuggestions: (limit: number) => workspaceGit.getBranchSuggestions({ limit }),
listPaseoStashes: () => client.stashList(cwd, { paseoOnly: true }), listPaseoStashes: () => workspaceGit.stashList({ paseoOnly: true }),
saveStash: (branch: string | undefined) => client.stashSave(cwd, { branch }), saveStash: (branch: string | undefined) => workspaceGit.stashSave({ branch }),
popStash: (stashIndex: number) => client.stashPop(cwd, stashIndex), popStash: (stashIndex: number) => workspaceGit.stashPop(stashIndex),
switchBranch: (branch: string) => client.checkoutSwitchBranch(cwd, branch), switchBranch: (branch: string) => workspaceGit.switchBranch(branch),
}; };
} }
@@ -2,332 +2,129 @@
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages"; import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
import { import { checkoutCommitsQueryKey, checkoutStatusQueryKey } from "@/git/query-keys";
checkoutCommitsQueryKey,
checkoutPrStatusQueryKey,
checkoutStatusQueryKey,
} from "@/git/query-keys";
import {
prPanePipelineQueryKey,
prPaneTimelineQueryKey,
} from "@/git/pull-request-panel/query-keys";
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store"; import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
import { import {
applyCheckoutStatusUpdateFromEvent, applyCheckoutStatusUpdateFromEvent,
ensureCheckoutStatus, ensureCheckoutStatus,
type CheckoutPrStatusPayload,
type CheckoutStatusPayload,
fetchCheckoutStatus, fetchCheckoutStatus,
type CheckoutStatusPayload,
} from "./checkout-status-cache"; } from "./checkout-status-cache";
vi.mock("@react-native-async-storage/async-storage", () => ({ vi.mock("@react-native-async-storage/async-storage", () => ({
default: { default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() },
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined),
},
})); }));
const serverId = "server-1"; const serverId = "server-1";
const cwd = "/repo"; const targetA = {
workspaceId: "workspace-a",
cwd: "/workspace",
queryIdentity: ["selected", "workspace-a"] as const,
};
const targetB = {
workspaceId: "workspace-b",
cwd: "/workspace",
queryIdentity: ["selected", "workspace-b"] as const,
};
function checkoutStatus(overrides: Partial<CheckoutStatusPayload> = {}): CheckoutStatusPayload { function status(
target: { workspaceId: string; cwd: string } = targetA,
branch = "main",
isDirty = false,
): CheckoutStatusPayload {
return { return {
cwd, workspaceId: target.workspaceId,
cwd: target.cwd,
error: null, error: null,
requestId: "checkout-status-1", requestId: `status-${target.workspaceId}`,
isGit: true, isGit: true,
isPaseoOwnedWorktree: false, isPaseoOwnedWorktree: false,
repoRoot: cwd, repoRoot: target.cwd,
currentBranch: "main", mainRepoRoot: null,
isDirty: false, currentBranch: branch,
isDirty,
baseRef: "origin/main", baseRef: "origin/main",
aheadBehind: { ahead: 0, behind: 0 }, aheadBehind: { ahead: 0, behind: 0 },
aheadOfOrigin: 0, aheadOfOrigin: 0,
behindOfOrigin: 0, behindOfOrigin: 0,
hasRemote: true, hasRemote: true,
remoteUrl: "git@github.com:getpaseo/paseo.git", remoteUrl: null,
...overrides,
} as CheckoutStatusPayload;
}
function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrStatusPayload {
return {
cwd,
status: {
forge: "github",
url: "https://github.com/getpaseo/paseo/pull/42",
title: "My PR",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
isDraft: false,
mergeable: "MERGEABLE",
checks: [],
checksStatus: "success",
reviewDecision: null,
},
githubFeaturesEnabled: true,
authState: "authenticated",
forge: "github",
error: null,
requestId: "pr-status-1",
...overrides,
}; };
} }
function checkoutStatusUpdate( function update(payload: CheckoutStatusPayload): CheckoutStatusUpdate {
payload: CheckoutStatusPayload, return { type: "checkout_status_update", payload };
extraPrStatus?: NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>,
): CheckoutStatusUpdate {
return {
type: "checkout_status_update",
payload: extraPrStatus ? { ...payload, prStatus: extraPrStatus } : payload,
};
} }
function setDiffModeOverride(isDirtyAtSelection: boolean): void { beforeEach(resetReviewDraftStore);
useReviewDraftStore.getState().setDiffModeOverride({
scopeKey: "review:scope",
override: { serverId, cwd, mode: "base", isDirtyAtSelection },
});
}
function createQueryClient(): QueryClient { describe("selected checkout status cache", () => {
return new QueryClient({ defaultOptions: { queries: { retry: false } } }); it("fetches through the bound workspace capability", async () => {
} const getStatus = vi.fn(async () => status());
await expect(
beforeEach(() => { fetchCheckoutStatus({ client: { getStatus }, serverId, target: targetA }),
resetReviewDraftStore(); ).resolves.toEqual(status());
}); expect(getStatus).toHaveBeenCalledWith();
describe("fetchCheckoutStatus", () => {
it("fetches from the client and returns the payload", async () => {
const fetched = checkoutStatus({ requestId: "fetch-1" });
const client = { getCheckoutStatus: vi.fn(async () => fetched) };
const result = await fetchCheckoutStatus({ client, serverId, cwd });
expect(result).toEqual(fetched);
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
}); });
it("expires a manual diff-mode override when the fetched dirty state flipped", async () => { it("deduplicates a bound status fetch by workspace identity", async () => {
setDiffModeOverride(true); const client = new QueryClient();
const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) }; const getStatus = vi.fn(async () => status());
const first = ensureCheckoutStatus({
await fetchCheckoutStatus({ client, serverId, cwd }); queryClient: client,
client: { getStatus },
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined(); serverId,
}); target: targetA,
});
describe("ensureCheckoutStatus", () => {
it("awaits the canonical checkout-status query and reuses its cached result", async () => {
const queryClient = createQueryClient();
const fetched = checkoutStatus({ currentBranch: "feature/current" });
const client = { getCheckoutStatus: vi.fn(async () => fetched) };
const first = await ensureCheckoutStatus({ queryClient, client, serverId, cwd });
const second = await ensureCheckoutStatus({ queryClient, client, serverId, cwd });
expect(first).toEqual(fetched);
expect(second).toEqual(fetched);
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
});
it("awaits a refetch when the canonical cached status was invalidated", async () => {
const queryClient = createQueryClient();
queryClient.setQueryData(
checkoutStatusQueryKey(serverId, cwd),
checkoutStatus({ currentBranch: "feature/stale" }),
);
await queryClient.invalidateQueries({
queryKey: checkoutStatusQueryKey(serverId, cwd),
refetchType: "none",
}); });
const fetched = checkoutStatus({ currentBranch: "feature/current" }); const second = ensureCheckoutStatus({
const client = { getCheckoutStatus: vi.fn(async () => fetched) }; queryClient: client,
client: { getStatus },
const result = await ensureCheckoutStatus({ queryClient, client, serverId, cwd }); serverId,
target: targetA,
expect(result.currentBranch).toBe("feature/current"); });
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); await Promise.all([first, second]);
expect(getStatus).toHaveBeenCalledTimes(1);
}); });
});
describe("applyCheckoutStatusUpdateFromEvent", () => { it("keeps same-cwd selected status and invalidation isolated", () => {
it("writes the checkout status to the cache using the cwd from the payload", () => { const client = new QueryClient();
const queryClient = createQueryClient(); client.setQueryData(checkoutStatusQueryKey(serverId, targetA), status(targetA, "a"));
const pushed = checkoutStatus({ requestId: "push-1", isDirty: true }); client.setQueryData(checkoutStatusQueryKey(serverId, targetB), status(targetB, "b"));
client.setQueryData(checkoutCommitsQueryKey(serverId, targetA), { commits: [] });
client.setQueryData(checkoutCommitsQueryKey(serverId, targetB), { commits: [] });
applyCheckoutStatusUpdateFromEvent({ applyCheckoutStatusUpdateFromEvent({
queryClient, queryClient: client,
serverId, serverId,
message: checkoutStatusUpdate(pushed), message: update(status(targetA, "a-next", true)),
}); });
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed); expect(
}); client.getQueryData<CheckoutStatusPayload>(checkoutStatusQueryKey(serverId, targetA))
?.currentBranch,
it("invalidates recent commits when checkout status is pushed", () => { ).toBe("a-next");
const queryClient = createQueryClient(); expect(
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] }); client.getQueryData<CheckoutStatusPayload>(checkoutStatusQueryKey(serverId, targetB))
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/repo2"), { commits: [] }); ?.currentBranch,
).toBe("b");
applyCheckoutStatusUpdateFromEvent({ expect(client.getQueryState(checkoutCommitsQueryKey(serverId, targetA))?.isInvalidated).toBe(
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus()),
});
expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe(
true, true,
); );
expect( expect(client.getQueryState(checkoutCommitsQueryKey(serverId, targetB))?.isInvalidated).toBe(
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/repo2"))?.isInvalidated, false,
).toBe(false); );
}); });
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => { it("expires a diff-mode override when a bound update changes dirty state", () => {
const queryClient = createQueryClient(); useReviewDraftStore.getState().setDiffModeOverride({
const pushedPr = prStatus({ requestId: "pr-1" }); scopeKey: "review:scope",
override: { serverId, cwd: targetA.cwd, mode: "base", isDirtyAtSelection: false },
});
applyCheckoutStatusUpdateFromEvent({ applyCheckoutStatusUpdateFromEvent({
queryClient, queryClient: new QueryClient(),
serverId, serverId,
message: checkoutStatusUpdate(checkoutStatus(), pushedPr), message: update(status(targetA, "main", true)),
}); });
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(pushedPr);
const otherCwd = "/repo2";
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ cwd: otherCwd, repoRoot: otherCwd })),
});
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, otherCwd))).toBeUndefined();
});
it("normalizes legacy PR auth state at the pushed-cache boundary", () => {
const queryClient = createQueryClient();
const { authState: _authState, ...legacyPrStatus } = prStatus({
githubFeaturesEnabled: false,
});
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus(), legacyPrStatus),
});
expect(
queryClient.getQueryData<CheckoutPrStatusPayload>(checkoutPrStatusQueryKey(serverId, cwd))
?.authState,
).toBe("unauthenticated");
expect(
queryClient.getQueryData<CheckoutStatusUpdate["payload"]>(
checkoutStatusQueryKey(serverId, cwd),
)?.prStatus?.authState,
).toBe("unauthenticated");
});
it("expires a manual diff-mode override when the pushed dirty state flipped", () => {
const queryClient = createQueryClient();
setDiffModeOverride(false);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
});
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined(); expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined();
}); });
it("keeps a manual diff-mode override while the pushed dirty state still matches", () => {
const queryClient = createQueryClient();
setDiffModeOverride(true);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
});
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined();
});
it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(
checkoutPrStatusQueryKey(serverId, cwd),
prStatus({ requestId: "pr-v1" }),
);
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
const pipelineKey = prPanePipelineQueryKey({
serverId,
cwd,
pipelineId: 9001,
changeRequestNumber: 1,
});
queryClient.setQueryData(timelineKey, { items: [] });
queryClient.setQueryData(pipelineKey, { stages: [] });
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus(), prStatus({ requestId: "pr-v2" })),
});
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(false);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(
checkoutStatus(),
prStatus({
requestId: "pr-v3",
status: { ...prStatus().status!, state: "closed" },
}),
),
});
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true);
});
it("invalidates PR detail queries on the first prStatus emission, scoped to its cwd", () => {
const queryClient = createQueryClient();
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
const otherTimelineKey = prPaneTimelineQueryKey({ serverId, cwd: "/repo2", prNumber: 42 });
const pipelineKey = prPanePipelineQueryKey({
serverId,
cwd,
pipelineId: 9001,
changeRequestNumber: 1,
});
const otherPipelineKey = prPanePipelineQueryKey({
serverId,
cwd: "/repo2",
pipelineId: 9001,
changeRequestNumber: 1,
});
queryClient.setQueryData(timelineKey, { items: [] });
queryClient.setQueryData(otherTimelineKey, { items: [] });
queryClient.setQueryData(pipelineKey, { stages: [] });
queryClient.setQueryData(otherPipelineKey, { stages: [] });
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus(), prStatus()),
});
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherTimelineKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherPipelineKey)?.isInvalidated).toBe(false);
});
}); });
+50 -12
View File
@@ -5,15 +5,21 @@ import {
checkoutCommitsQueryKey, checkoutCommitsQueryKey,
checkoutPrStatusQueryKey, checkoutPrStatusQueryKey,
checkoutStatusQueryKey, checkoutStatusQueryKey,
legacyCheckoutStatusQueryKey,
invalidatePrPaneTimelineForCheckout, invalidatePrPaneTimelineForCheckout,
} from "@/git/query-keys"; } from "@/git/query-keys";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
import { expireStaleDiffModeOverrides } from "@/review/store"; import { expireStaleDiffModeOverrides } from "@/review/store";
import type { WorkspaceGitClient, WorkspaceGitStatusTarget } from "./workspace-git";
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
export type { CheckoutPrStatusPayload } from "@/git/pr-status"; export type { CheckoutPrStatusPayload } from "@/git/pr-status";
export interface CheckoutStatusClient { export interface CheckoutStatusClient {
getStatus: WorkspaceGitClient["getStatus"];
}
export interface LegacyCheckoutStatusClient {
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>; getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
} }
@@ -24,14 +30,18 @@ export interface CheckoutStatusClient {
export async function fetchCheckoutStatus({ export async function fetchCheckoutStatus({
client, client,
serverId, serverId,
cwd, target,
}: { }: {
client: CheckoutStatusClient; client: CheckoutStatusClient;
serverId: string; serverId: string;
cwd: string; target: WorkspaceGitStatusTarget;
}): Promise<CheckoutStatusPayload> { }): Promise<CheckoutStatusPayload> {
const payload = await client.getCheckoutStatus(cwd); const payload = await client.getStatus();
expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty }); expireStaleDiffModeOverrides({
serverId,
cwd: target.cwd,
isDirty: payload.isGit && payload.isDirty,
});
return payload; return payload;
} }
@@ -39,16 +49,38 @@ export async function ensureCheckoutStatus({
queryClient, queryClient,
client, client,
serverId, serverId,
cwd, target,
}: { }: {
queryClient: QueryClient; queryClient: QueryClient;
client: CheckoutStatusClient; client: CheckoutStatusClient;
serverId: string; serverId: string;
target: WorkspaceGitStatusTarget;
}): Promise<CheckoutStatusPayload> {
return await queryClient.fetchQuery({
queryKey: checkoutStatusQueryKey(serverId, target),
queryFn: () => fetchCheckoutStatus({ client, serverId, target }),
staleTime: Infinity,
});
}
export async function ensureLegacyCheckoutStatus({
queryClient,
client,
serverId,
cwd,
}: {
queryClient: QueryClient;
client: LegacyCheckoutStatusClient;
serverId: string;
cwd: string; cwd: string;
}): Promise<CheckoutStatusPayload> { }): Promise<CheckoutStatusPayload> {
return await queryClient.fetchQuery({ return await queryClient.fetchQuery({
queryKey: checkoutStatusQueryKey(serverId, cwd), queryKey: legacyCheckoutStatusQueryKey(serverId, cwd),
queryFn: () => fetchCheckoutStatus({ client, serverId, cwd }), queryFn: async () => {
const payload = await client.getCheckoutStatus(cwd);
expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
return payload;
},
staleTime: Infinity, staleTime: Infinity,
}); });
} }
@@ -67,9 +99,15 @@ export function applyCheckoutStatusUpdateFromEvent({
? normalizeCheckoutPrStatusPayload(payload.prStatus) ? normalizeCheckoutPrStatusPayload(payload.prStatus)
: undefined; : undefined;
const cachePayload = prStatus ? { ...payload, prStatus } : payload; const cachePayload = prStatus ? { ...payload, prStatus } : payload;
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload); if (payload.workspaceId === undefined) return;
const target = {
workspaceId: payload.workspaceId,
cwd: payload.cwd,
queryIdentity: ["selected", payload.workspaceId] as const,
};
queryClient.setQueryData(checkoutStatusQueryKey(serverId, target), cachePayload);
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd), queryKey: checkoutCommitsQueryKey(serverId, target),
}); });
expireStaleDiffModeOverrides({ expireStaleDiffModeOverrides({
serverId, serverId,
@@ -82,14 +120,14 @@ export function applyCheckoutStatusUpdateFromEvent({
} }
const previous = queryClient.getQueryData<CheckoutPrStatusPayload>( const previous = queryClient.getQueryData<CheckoutPrStatusPayload>(
checkoutPrStatusQueryKey(serverId, prStatus.cwd), checkoutPrStatusQueryKey(serverId, target),
); );
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, prStatus.cwd), prStatus); queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, target), prStatus);
// The PR activity timeline has no push channel; mark it stale when the pushed PR status // The PR activity timeline has no push channel; mark it stale when the pushed PR status
// meaningfully changed. Active panes refetch immediately, evicted ones on next mount. // meaningfully changed. Active panes refetch immediately, evicted ones on next mount.
if (hasPrStatusChanged(previous, prStatus)) { if (hasPrStatusChanged(previous, prStatus)) {
void invalidatePrPaneTimelineForCheckout(queryClient, { serverId, cwd: prStatus.cwd }); void invalidatePrPaneTimelineForCheckout(queryClient, { serverId, target });
} }
} }
@@ -66,6 +66,7 @@ export interface MergeCapability {
export interface PaneChecksSlotContext { export interface PaneChecksSlotContext {
serverId: string; serverId: string;
workspaceId: string;
cwd: string; cwd: string;
/** Change request (PR/MR) number, so a section can address its head pipeline. */ /** Change request (PR/MR) number, so a section can address its head pipeline. */
changeRequestNumber: number; changeRequestNumber: number;
@@ -11,7 +11,6 @@ import { CommitRow } from "./commit-row";
interface CommitsSectionProps { interface CommitsSectionProps {
serverId: string; serverId: string;
cwd: string;
onCommitPress: (sha: string) => void; onCommitPress: (sha: string) => void;
} }
@@ -82,7 +81,7 @@ function CommitsSectionContent({
); );
} }
export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) { export function CommitsSection({ serverId, onCommitPress }: CommitsSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { preferences, updatePreferences } = useChangesPreferences(); const { preferences, updatePreferences } = useChangesPreferences();
const isPanelActive = useRetainedPanelActive(); const isPanelActive = useRetainedPanelActive();
@@ -91,7 +90,6 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]); const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
const query = useCheckoutCommitsQuery({ const query = useCheckoutCommitsQuery({
serverId, serverId,
cwd,
enabled: !collapsed, enabled: !collapsed,
}); });
+18 -15
View File
@@ -93,6 +93,7 @@ import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeAuthState } from "@getpaseo/protocol/messages"; import type { ForgeAuthState } from "@getpaseo/protocol/messages";
import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
@@ -241,15 +242,14 @@ function noopStartComment(): void {}
function useDiscardChangesAction({ function useDiscardChangesAction({
serverId, serverId,
cwd,
diffMode, diffMode,
}: { }: {
serverId: string; serverId: string;
cwd: string;
diffMode: "uncommitted" | "base"; diffMode: "uncommitted" | "base";
}): ((path: string, oldPath?: string) => void) | undefined { }): ((path: string, oldPath?: string) => void) | undefined {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const workspaceGit = useRequiredWorkspaceGit();
const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges); const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges);
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
const discardSupported = useSessionStore( const discardSupported = useSessionStore(
@@ -270,7 +270,7 @@ function useDiscardChangesAction({
try { try {
await discardChanges({ await discardChanges({
serverId, serverId,
cwd, target: workspaceGit,
paths: oldPath ? [path, oldPath] : [path], paths: oldPath ? [path, oldPath] : [path],
}); });
} catch (cause) { } catch (cause) {
@@ -279,7 +279,7 @@ function useDiscardChangesAction({
); );
} }
}, },
[cwd, discardChanges, serverId, t, toast], [discardChanges, serverId, t, toast, workspaceGit],
); );
const handleDiscardPath = useCallback( const handleDiscardPath = useCallback(
(path: string, oldPath?: string) => { (path: string, oldPath?: string) => {
@@ -2848,6 +2848,7 @@ export function GitDiffPane({
onOpenFile, onOpenFile,
onAddToChat, onAddToChat,
}: GitDiffPaneProps) { }: GitDiffPaneProps) {
const workspaceGit = useRequiredWorkspaceGit();
const { settings: appSettings } = useAppSettings(); const { settings: appSettings } = useAppSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
@@ -2909,17 +2910,22 @@ export function GitDiffPane({
); );
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh); const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
const isRefreshing = const isRefreshing =
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) === useCheckoutGitActionsStore((s) =>
"pending"; s.getStatus({
serverId,
target: workspaceGit,
actionId: "refresh",
}),
) === "pending";
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
if (isRefreshing) { if (isRefreshing) {
return; return;
} }
void runRefresh({ serverId, cwd }).catch((error) => { void runRefresh({ serverId, target: workspaceGit }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
}); });
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]); }, [isRefreshing, runRefresh, serverId, t, toast, workspaceGit]);
const { const {
status, status,
@@ -2940,7 +2946,7 @@ export function GitDiffPane({
reviewAttachment, reviewAttachment,
} = useWorkingDiff({ } = useWorkingDiff({
serverId, serverId,
workspaceId: workspaceId ?? undefined, workspaceId: workspaceGit.workspaceId,
cwd, cwd,
ignoreWhitespace: changesPreferences.hideWhitespace, ignoreWhitespace: changesPreferences.hideWhitespace,
enabled: enabled !== false, enabled: enabled !== false,
@@ -2959,7 +2965,6 @@ export function GitDiffPane({
payloadError: prPayloadError, payloadError: prPayloadError,
} = useCheckoutPrStatusQuery({ } = useCheckoutPrStatusQuery({
serverId, serverId,
cwd,
enabled: isGit, enabled: isGit,
}); });
const forgeProvidersSupported = useSessionStore( const forgeProvidersSupported = useSessionStore(
@@ -2987,7 +2992,7 @@ export function GitDiffPane({
[updateChangesPreferences], [updateChangesPreferences],
); );
const changesTree = useChangesTreeState({ const changesTree = useChangesTreeState({
workspaceId, workspaceId: workspaceGit.workspaceId,
cwd, cwd,
files, files,
viewMode, viewMode,
@@ -3056,7 +3061,7 @@ export function GitDiffPane({
}, },
[client, cwd, t, toast], [client, cwd, t, toast],
); );
const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode }); const onRevertPath = useDiscardChangesAction({ serverId, diffMode });
const workingTreeMode = useMemo( const workingTreeMode = useMemo(
() => ({ () => ({
kind: "working_tree" as const, kind: "working_tree" as const,
@@ -3161,8 +3166,6 @@ export function GitDiffPane({
<BranchSwitcher <BranchSwitcher
currentBranchName={currentBranchName} currentBranchName={currentBranchName}
serverId={serverId} serverId={serverId}
workspaceId={workspaceId ?? cwd}
workspaceDirectory={cwd}
isGitCheckout={isGit} isGitCheckout={isGit}
testID="changes-branch-switcher" testID="changes-branch-switcher"
/> />
@@ -3236,7 +3239,7 @@ export function GitDiffPane({
<View style={styles.diffContainer}>{bodyContent}</View> <View style={styles.diffContainer}>{bodyContent}</View>
<CommitsSection serverId={serverId} cwd={cwd} onCommitPress={handleCommitPress} /> <CommitsSection serverId={serverId} onCommitPress={handleCommitPress} />
</View> </View>
); );
} }
@@ -54,7 +54,6 @@ function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotC
return ( return (
<GitLabPipelineSection <GitLabPipelineSection
serverId={ctx.serverId} serverId={ctx.serverId}
cwd={ctx.cwd}
changeRequestNumber={ctx.changeRequestNumber} changeRequestNumber={ctx.changeRequestNumber}
summary={summary} summary={summary}
open={ctx.open} open={ctx.open}
@@ -118,7 +117,6 @@ function countPipelineJobs(jobs: CheckoutPipelineJob[]): PipelineJobCounts {
function GitLabPipelineSection({ function GitLabPipelineSection({
serverId, serverId,
cwd,
changeRequestNumber, changeRequestNumber,
summary, summary,
open, open,
@@ -126,7 +124,6 @@ function GitLabPipelineSection({
canFetchCheckDetails, canFetchCheckDetails,
}: { }: {
serverId: string; serverId: string;
cwd: string;
changeRequestNumber: number; changeRequestNumber: number;
summary: GitlabPipelineSummary; summary: GitlabPipelineSummary;
open: boolean; open: boolean;
@@ -136,7 +133,6 @@ function GitLabPipelineSection({
const { t } = useTranslation(); const { t } = useTranslation();
const { pipeline, isLoading, isPlaceholderData, error } = useGitLabPipeline({ const { pipeline, isLoading, isPlaceholderData, error } = useGitLabPipeline({
serverId, serverId,
cwd,
pipelineId: summary.id, pipelineId: summary.id,
changeRequestNumber, changeRequestNumber,
enabled: open && canFetchCheckDetails, enabled: open && canFetchCheckDetails,
@@ -0,0 +1,44 @@
import { expect, test, vi } from "vitest";
import { fetchBoundPullRequestCheckDetails } from "./check-details";
test("selected PR check details keep their bound identity and return runtime logs", async () => {
const getForgeCheckDetails = vi.fn(async () => ({
cwd: "/shared",
requestId: "check-details",
success: true,
details: {
checkRunId: 42,
name: "Runtime check",
output: { title: "Runtime check", summary: "failed", text: "runtime log attachment" },
annotations: [],
failedJobs: [],
truncated: false,
},
error: null,
}));
const workspaceGit = {
workspaceId: "workspace-selected",
cwd: "/shared",
getForgeCheckDetails,
getGithubCheckDetails: vi.fn(),
};
await expect(
fetchBoundPullRequestCheckDetails({
workspaceGit,
transport: "forge",
repoOwner: "getpaseo",
repoName: "paseo",
checkRunId: 42,
changeRequestNumber: 99,
}),
).resolves.toMatchObject({ output: { text: "runtime log attachment" } });
expect(workspaceGit).toMatchObject({ workspaceId: "workspace-selected", cwd: "/shared" });
expect(getForgeCheckDetails).toHaveBeenCalledWith({
repoOwner: "getpaseo",
repoName: "paseo",
checkRunId: 42,
workflowRunId: undefined,
changeRequestNumber: 99,
});
});
@@ -0,0 +1,32 @@
import type { WorkspaceGitClient } from "@/git/workspace-git";
type CheckDetailsClient = Pick<
WorkspaceGitClient,
"workspaceId" | "cwd" | "getForgeCheckDetails" | "getGithubCheckDetails"
>;
export async function fetchBoundPullRequestCheckDetails(input: {
workspaceGit: CheckDetailsClient;
transport: "forge" | "github";
repoOwner: string;
repoName: string;
checkRunId?: number;
workflowRunId?: number;
changeRequestNumber: number;
}) {
const request = {
repoOwner: input.repoOwner,
repoName: input.repoName,
checkRunId: input.checkRunId,
workflowRunId: input.workflowRunId,
changeRequestNumber: input.changeRequestNumber,
};
const payload =
input.transport === "forge"
? await input.workspaceGit.getForgeCheckDetails(request)
: await input.workspaceGit.getGithubCheckDetails(request);
if (!payload.success) {
throw new Error(payload.error?.message ?? "Could not load check details");
}
return payload.details ?? null;
}
@@ -38,11 +38,11 @@ import {
import { MarkdownRenderer } from "@/components/markdown/renderer"; import { MarkdownRenderer } from "@/components/markdown/renderer";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
@@ -67,6 +67,7 @@ import {
canAddPullRequestCheckLogsToChat, canAddPullRequestCheckLogsToChat,
} from "./context-attachment"; } from "./context-attachment";
import { getActivityVerb, getStateLabel } from "./data"; import { getActivityVerb, getStateLabel } from "./data";
import { fetchBoundPullRequestCheckDetails } from "./check-details";
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data"; import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
import { import {
@@ -205,12 +206,14 @@ function removeLoadingCheck(current: ReadonlySet<string>, checkKey: string): Rea
export function PullRequestPane({ export function PullRequestPane({
serverId, serverId,
workspaceId,
cwd, cwd,
data, data,
activityLoading, activityLoading,
workspaceAttachmentScopeKey, workspaceAttachmentScopeKey,
}: { }: {
serverId: string; serverId: string;
workspaceId: string;
cwd: string; cwd: string;
data: PrPaneData; data: PrPaneData;
activityLoading: boolean; activityLoading: boolean;
@@ -218,7 +221,7 @@ export function PullRequestPane({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const daemonClient = useHostRuntimeClient(serverId); const workspaceGit = useRequiredWorkspaceGit();
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.get_check_details.*. // all supported clients use checkout.forge.get_check_details.*.
const canFetchGitHubCheckDetails = useSessionStore( const canFetchGitHubCheckDetails = useSessionStore(
@@ -248,17 +251,17 @@ export function PullRequestPane({
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh); const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
const isRefreshing = const isRefreshing =
useCheckoutGitActionsStore((state) => useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "refresh" }), state.getStatus({ serverId, target: workspaceGit, actionId: "refresh" }),
) === "pending"; ) === "pending";
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
if (isRefreshing) { if (isRefreshing) {
return; return;
} }
void runRefresh({ serverId, cwd }).catch((error) => { void runRefresh({ serverId, target: workspaceGit }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
}); });
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]); }, [isRefreshing, runRefresh, serverId, t, toast, workspaceGit]);
const handleToggleChecks = useCallback(() => { const handleToggleChecks = useCallback(() => {
setChecksOpen((open) => !open); setChecksOpen((open) => !open);
@@ -399,29 +402,21 @@ export function PullRequestPane({
canFetchForgeCheckDetails || (check.provider === "github" && canFetchGitHubCheckDetails); canFetchForgeCheckDetails || (check.provider === "github" && canFetchGitHubCheckDetails);
if ( if (
canFetchDetail && canFetchDetail &&
daemonClient &&
(ref?.checkRunId !== undefined || ref?.workflowRunId !== undefined) && (ref?.checkRunId !== undefined || ref?.workflowRunId !== undefined) &&
data.repoOwner && data.repoOwner &&
data.repoName data.repoName
) { ) {
try { // COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
const request = { // all supported clients use checkout.forge.get_check_details.*.
cwd, details = await fetchBoundPullRequestCheckDetails({
repoOwner: data.repoOwner, workspaceGit,
repoName: data.repoName, transport: canFetchForgeCheckDetails ? "forge" : "github",
checkRunId: ref.checkRunId, repoOwner: data.repoOwner,
workflowRunId: ref.workflowRunId, repoName: data.repoName,
changeRequestNumber: data.number, checkRunId: ref.checkRunId,
}; workflowRunId: ref.workflowRunId,
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once changeRequestNumber: data.number,
// all supported clients use checkout.forge.get_check_details.*. });
const payload = canFetchForgeCheckDetails
? await daemonClient.checkoutForgeGetCheckDetails(request)
: await daemonClient.checkoutGithubGetCheckDetails(request);
details = payload.success ? payload.details : null;
} catch {
details = null;
}
} }
const attachment = buildPullRequestCheckContextAttachment({ const attachment = buildPullRequestCheckContextAttachment({
provider: data.provider, provider: data.provider,
@@ -444,8 +439,6 @@ export function PullRequestPane({
addWorkspaceAttachment, addWorkspaceAttachment,
canFetchForgeCheckDetails, canFetchForgeCheckDetails,
canFetchGitHubCheckDetails, canFetchGitHubCheckDetails,
cwd,
daemonClient,
data.forge, data.forge,
data.number, data.number,
data.provider, data.provider,
@@ -454,6 +447,7 @@ export function PullRequestPane({
data.title, data.title,
data.url, data.url,
workspaceAttachmentScopeKey, workspaceAttachmentScopeKey,
workspaceGit,
], ],
); );
@@ -483,6 +477,7 @@ export function PullRequestPane({
const nativeChecksSection = data.forgeSpecific const nativeChecksSection = data.forgeSpecific
? nativeContribution?.renderChecksSection(data.forgeSpecific, { ? nativeContribution?.renderChecksSection(data.forgeSpecific, {
serverId, serverId,
workspaceId,
cwd, cwd,
changeRequestNumber: data.number, changeRequestNumber: data.number,
open: checksOpen, open: checksOpen,
@@ -2,29 +2,40 @@ export const prPaneTimelineQueryKind = "prPaneTimeline";
export function prPaneTimelineQueryKey({ export function prPaneTimelineQueryKey({
serverId, serverId,
queryIdentity,
cwd, cwd,
prNumber, prNumber,
}: { }: {
serverId: string; serverId: string;
queryIdentity: readonly ["selected", string];
cwd: string; cwd: string;
prNumber: number | null; prNumber: number | null;
}) { }) {
return [prPaneTimelineQueryKind, serverId, cwd, prNumber] as const; return [prPaneTimelineQueryKind, serverId, queryIdentity, cwd, prNumber] as const;
} }
export const prPanePipelineQueryKind = "prPanePipeline"; export const prPanePipelineQueryKind = "prPanePipeline";
export function prPanePipelineQueryKey({ export function prPanePipelineQueryKey({
serverId, serverId,
queryIdentity,
cwd, cwd,
pipelineId, pipelineId,
changeRequestNumber, changeRequestNumber,
}: { }: {
serverId: string; serverId: string;
queryIdentity: readonly ["selected", string];
cwd: string; cwd: string;
pipelineId: number | null; pipelineId: number | null;
/** MR iid the pipeline is fetched by; part of the key since the fetch routes by it. */ /** MR iid the pipeline is fetched by; part of the key since the fetch routes by it. */
changeRequestNumber: number; changeRequestNumber: number;
}) { }) {
return [prPanePipelineQueryKind, serverId, cwd, pipelineId, changeRequestNumber] as const; return [
prPanePipelineQueryKind,
serverId,
queryIdentity,
cwd,
pipelineId,
changeRequestNumber,
] as const;
} }
@@ -16,7 +16,8 @@ import {
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>; type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
type PullRequestTimelineInput = Parameters<PrPaneTimelineClient["pullRequestTimeline"]>[0]; type PullRequestTimelineInput = Parameters<PrPaneTimelineClient["getPullRequestTimeline"]>[0];
const workspaceId = "workspace-1";
const githubStatus: CheckoutPrStatus["github"] = { const githubStatus: CheckoutPrStatus["github"] = {
mergeStateStatus: null, mergeStateStatus: null,
@@ -82,7 +83,7 @@ function createTimelineClient(
const calls: PullRequestTimelineInput[] = []; const calls: PullRequestTimelineInput[] = [];
return { return {
calls, calls,
pullRequestTimeline: async (input) => { getPullRequestTimeline: async (input) => {
calls.push(input); calls.push(input);
return respond(input); return respond(input);
}, },
@@ -181,7 +182,12 @@ describe("shouldFetchTimelineFrom", () => {
describe("createInMemoryUnsupportedTimelineRegistry", () => { describe("createInMemoryUnsupportedTimelineRegistry", () => {
it("remembers added keys and answers has() against them", () => { it("remembers added keys and answers has() against them", () => {
const registry = createInMemoryUnsupportedTimelineRegistry(); const registry = createInMemoryUnsupportedTimelineRegistry();
const key = unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 }); const key = unsupportedTimelineKey({
serverId: "host",
workspaceId,
cwd: "/repo",
prNumber: 99,
});
expect(registry.has(key)).toBe(false); expect(registry.has(key)).toBe(false);
registry.add(key); registry.add(key);
@@ -189,14 +195,20 @@ describe("createInMemoryUnsupportedTimelineRegistry", () => {
}); });
it("uses serverId, cwd, and prNumber to scope each key", () => { it("uses serverId, cwd, and prNumber to scope each key", () => {
expect(unsupportedTimelineKey({ serverId: "host-a", cwd: "/repo", prNumber: 1 })).not.toEqual( expect(
unsupportedTimelineKey({ serverId: "host-b", cwd: "/repo", prNumber: 1 }), unsupportedTimelineKey({ serverId: "host-a", workspaceId, cwd: "/repo", prNumber: 1 }),
).not.toEqual(
unsupportedTimelineKey({ serverId: "host-b", workspaceId, cwd: "/repo", prNumber: 1 }),
); );
expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })).not.toEqual( expect(
unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 1 }), unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-a", prNumber: 1 }),
).not.toEqual(
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-b", prNumber: 1 }),
); );
expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 1 })).not.toEqual( expect(
unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 2 }), unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 1 }),
).not.toEqual(
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 2 }),
); );
}); });
}); });
@@ -210,15 +222,14 @@ describe("fetchPrPaneTimelinePage", () => {
client, client,
registry, registry,
serverId: "host", serverId: "host",
workspaceId,
cwd: "/repo", cwd: "/repo",
prNumber: 42, prNumber: 42,
repoOwner: "getpaseo", repoOwner: "getpaseo",
repoName: "paseo", repoName: "paseo",
}); });
expect(client.calls).toEqual([ expect(client.calls).toEqual([{ prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" }]);
{ cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" },
]);
}); });
it("returns the daemon's timeline payload on success", async () => { it("returns the daemon's timeline payload on success", async () => {
@@ -241,6 +252,7 @@ describe("fetchPrPaneTimelinePage", () => {
client, client,
registry, registry,
serverId: "host", serverId: "host",
workspaceId,
cwd: "/repo", cwd: "/repo",
prNumber: 42, prNumber: 42,
repoOwner: "getpaseo", repoOwner: "getpaseo",
@@ -262,6 +274,7 @@ describe("fetchPrPaneTimelinePage", () => {
client, client,
registry, registry,
serverId: "host", serverId: "host",
workspaceId,
cwd: "/repo", cwd: "/repo",
prNumber: 99, prNumber: 99,
repoOwner: "getpaseo", repoOwner: "getpaseo",
@@ -270,7 +283,9 @@ describe("fetchPrPaneTimelinePage", () => {
).rejects.toBe(error); ).rejects.toBe(error);
expect( expect(
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })), registry.has(
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 99 }),
),
).toBe(true); ).toBe(true);
}); });
@@ -286,6 +301,7 @@ describe("fetchPrPaneTimelinePage", () => {
client, client,
registry, registry,
serverId: "host", serverId: "host",
workspaceId,
cwd: "/repo", cwd: "/repo",
prNumber: 99, prNumber: 99,
repoOwner: "getpaseo", repoOwner: "getpaseo",
@@ -294,16 +310,18 @@ describe("fetchPrPaneTimelinePage", () => {
).rejects.toBe(error); ).rejects.toBe(error);
expect( expect(
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })), registry.has(
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 99 }),
),
).toBe(false); ).toBe(false);
}); });
it("scopes recorded tuples per serverId+cwd+prNumber so other PRs can still be tried", async () => { it("scopes recorded tuples per serverId+cwd+prNumber so other PRs can still be tried", async () => {
const client = createTimelineClient(async (input) => { const client = createTimelineClient(async (input) => {
if (input.cwd === "/repo-a") { if (input.prNumber === 1) {
throw unsupportedTimelineError(); throw unsupportedTimelineError();
} }
return timelinePayload({ cwd: input.cwd, prNumber: input.prNumber }); return timelinePayload({ cwd: "/repo-b", prNumber: input.prNumber });
}); });
const registry = createInMemoryUnsupportedTimelineRegistry(); const registry = createInMemoryUnsupportedTimelineRegistry();
@@ -312,6 +330,7 @@ describe("fetchPrPaneTimelinePage", () => {
client, client,
registry, registry,
serverId: "host", serverId: "host",
workspaceId,
cwd: "/repo-a", cwd: "/repo-a",
prNumber: 1, prNumber: 1,
repoOwner: "getpaseo", repoOwner: "getpaseo",
@@ -323,6 +342,7 @@ describe("fetchPrPaneTimelinePage", () => {
client, client,
registry, registry,
serverId: "host", serverId: "host",
workspaceId,
cwd: "/repo-b", cwd: "/repo-b",
prNumber: 2, prNumber: 2,
repoOwner: "getpaseo", repoOwner: "getpaseo",
@@ -331,10 +351,14 @@ describe("fetchPrPaneTimelinePage", () => {
expect(result.prNumber).toBe(2); expect(result.prNumber).toBe(2);
expect( expect(
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })), registry.has(
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-a", prNumber: 1 }),
),
).toBe(true); ).toBe(true);
expect( expect(
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 2 })), registry.has(
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-b", prNumber: 2 }),
),
).toBe(false); ).toBe(false);
}); });
}); });
@@ -1,12 +1,12 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { import type {
CheckoutPrStatusResponse, CheckoutPrStatusResponse,
PullRequestTimelineResponse, PullRequestTimelineResponse,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { type WorkspaceGitClient, useRequiredWorkspaceGit } from "@/git/workspace-git";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import type { Forge } from "@/git/forge"; import type { Forge } from "@/git/forge";
import { i18n } from "@/i18n/i18next"; import { i18n } from "@/i18n/i18next";
@@ -19,7 +19,6 @@ type PullRequestTimeline = PullRequestTimelineResponse["payload"];
export interface UsePrPaneDataOptions { export interface UsePrPaneDataOptions {
serverId: string; serverId: string;
cwd: string;
enabled?: boolean; enabled?: boolean;
timelineEnabled?: boolean; timelineEnabled?: boolean;
} }
@@ -80,7 +79,7 @@ export function shouldFetchTimelineFrom({
); );
} }
export type PrPaneTimelineClient = Pick<DaemonClient, "pullRequestTimeline">; export type PrPaneTimelineClient = Pick<WorkspaceGitClient, "getPullRequestTimeline">;
export interface UnsupportedTimelineRegistry { export interface UnsupportedTimelineRegistry {
has(key: string): boolean; has(key: string): boolean;
@@ -89,14 +88,16 @@ export interface UnsupportedTimelineRegistry {
export function unsupportedTimelineKey({ export function unsupportedTimelineKey({
serverId, serverId,
workspaceId,
cwd, cwd,
prNumber, prNumber,
}: { }: {
serverId: string; serverId: string;
workspaceId: string;
cwd: string; cwd: string;
prNumber: number; prNumber: number;
}): string { }): string {
return `${serverId}\0${cwd}\0${prNumber}`; return `${serverId}\0${workspaceId}\0${cwd}\0${prNumber}`;
} }
export function createInMemoryUnsupportedTimelineRegistry(): UnsupportedTimelineRegistry { export function createInMemoryUnsupportedTimelineRegistry(): UnsupportedTimelineRegistry {
@@ -115,6 +116,7 @@ export interface FetchPrPaneTimelinePageInput {
client: PrPaneTimelineClient; client: PrPaneTimelineClient;
registry: UnsupportedTimelineRegistry; registry: UnsupportedTimelineRegistry;
serverId: string; serverId: string;
workspaceId: string;
cwd: string; cwd: string;
prNumber: number; prNumber: number;
repoOwner: string; repoOwner: string;
@@ -125,8 +127,7 @@ export async function fetchPrPaneTimelinePage(
input: FetchPrPaneTimelinePageInput, input: FetchPrPaneTimelinePageInput,
): Promise<PullRequestTimeline> { ): Promise<PullRequestTimeline> {
try { try {
return await input.client.pullRequestTimeline({ return await input.client.getPullRequestTimeline({
cwd: input.cwd,
prNumber: input.prNumber, prNumber: input.prNumber,
repoOwner: input.repoOwner, repoOwner: input.repoOwner,
repoName: input.repoName, repoName: input.repoName,
@@ -136,6 +137,7 @@ export async function fetchPrPaneTimelinePage(
input.registry.add( input.registry.add(
unsupportedTimelineKey({ unsupportedTimelineKey({
serverId: input.serverId, serverId: input.serverId,
workspaceId: input.workspaceId,
cwd: input.cwd, cwd: input.cwd,
prNumber: input.prNumber, prNumber: input.prNumber,
}), }),
@@ -197,14 +199,13 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
export function usePrPaneData({ export function usePrPaneData({
serverId, serverId,
cwd,
enabled = true, enabled = true,
timelineEnabled = enabled, timelineEnabled = enabled,
}: UsePrPaneDataOptions): UsePrPaneDataResult { }: UsePrPaneDataOptions): UsePrPaneDataResult {
const { t } = useTranslation(); const { t } = useTranslation();
const daemonClient = useHostRuntimeClient(serverId); const workspaceGit = useRequiredWorkspaceGit();
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled }); const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, enabled });
const status = checkoutPrStatus.status; const status = checkoutPrStatus.status;
const identity = extractPrRepoIdentity(status); const identity = extractPrRepoIdentity(status);
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled; const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled;
@@ -212,26 +213,37 @@ export function usePrPaneData({
const unsupportedKey = const unsupportedKey =
identity.prNumber === null identity.prNumber === null
? null ? null
: unsupportedTimelineKey({ serverId, cwd, prNumber: identity.prNumber }); : unsupportedTimelineKey({
serverId,
workspaceId: workspaceGit.workspaceId,
cwd: workspaceGit.cwd,
prNumber: identity.prNumber,
});
const timelineUnsupported = unsupportedKey ? registry.has(unsupportedKey) : false; const timelineUnsupported = unsupportedKey ? registry.has(unsupportedKey) : false;
const shouldFetchTimeline = shouldFetchTimelineFrom({ const shouldFetchTimeline = shouldFetchTimelineFrom({
hasClient: !!daemonClient, hasClient: !!workspaceGit,
isConnected, isConnected,
timelineEnabled, timelineEnabled,
githubFeaturesEnabled, githubFeaturesEnabled,
cwd, cwd: workspaceGit.cwd,
identity, identity,
timelineUnsupported, timelineUnsupported,
}); });
const timelineQuery = useQuery<PullRequestTimeline>({ const timelineQuery = useQuery<PullRequestTimeline>({
queryKey: useMemo( queryKey: useMemo(
() => prPaneTimelineQueryKey({ serverId, cwd, prNumber: identity.prNumber }), () =>
[serverId, cwd, identity.prNumber], prPaneTimelineQueryKey({
serverId,
queryIdentity: workspaceGit.queryIdentity,
cwd: workspaceGit.cwd,
prNumber: identity.prNumber,
}),
[serverId, workspaceGit, identity.prNumber],
), ),
queryFn: async () => { queryFn: async () => {
if ( if (
!daemonClient || !workspaceGit ||
identity.prNumber === null || identity.prNumber === null ||
identity.repoOwner === null || identity.repoOwner === null ||
identity.repoName === null identity.repoName === null
@@ -239,10 +251,11 @@ export function usePrPaneData({
throw new Error(t("common.errors.daemonClientUnavailable")); throw new Error(t("common.errors.daemonClientUnavailable"));
} }
return fetchPrPaneTimelinePage({ return fetchPrPaneTimelinePage({
client: daemonClient, client: workspaceGit,
registry, registry,
serverId, serverId,
cwd, workspaceId: workspaceGit.workspaceId,
cwd: workspaceGit.cwd,
prNumber: identity.prNumber, prNumber: identity.prNumber,
repoOwner: identity.repoOwner, repoOwner: identity.repoOwner,
repoName: identity.repoName, repoName: identity.repoName,
@@ -1,8 +1,9 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { CheckoutPipeline } from "@getpaseo/protocol/messages"; import type { CheckoutPipeline } from "@getpaseo/protocol/messages";
import { useFetchQuery } from "@/data/query"; import { useFetchQuery } from "@/data/query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { prPanePipelineQueryKey } from "./query-keys"; import { prPanePipelineQueryKey } from "./query-keys";
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
/** Poll cadence for an in-progress pipeline; finished pipelines are immutable. */ /** Poll cadence for an in-progress pipeline; finished pipelines are immutable. */
const LIVE_PIPELINE_REFETCH_MS = 15_000; const LIVE_PIPELINE_REFETCH_MS = 15_000;
@@ -10,7 +11,6 @@ const FINISHED_PIPELINE_STALE_MS = 24 * 60 * 60 * 1_000;
export interface UseGitLabPipelineOptions { export interface UseGitLabPipelineOptions {
serverId: string; serverId: string;
cwd: string;
pipelineId: number | null; pipelineId: number | null;
/** MR iid, so the fetch resolves a fork/detached head pipeline correctly. */ /** MR iid, so the fetch resolves a fork/detached head pipeline correctly. */
changeRequestNumber: number; changeRequestNumber: number;
@@ -38,27 +38,32 @@ export interface UseGitLabPipelineResult {
*/ */
export function useGitLabPipeline({ export function useGitLabPipeline({
serverId, serverId,
cwd,
pipelineId, pipelineId,
changeRequestNumber, changeRequestNumber,
enabled, enabled,
live, live,
}: UseGitLabPipelineOptions): UseGitLabPipelineResult { }: UseGitLabPipelineOptions): UseGitLabPipelineResult {
const daemonClient = useHostRuntimeClient(serverId); const workspaceGit = useRequiredWorkspaceGit();
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const shouldFetch = enabled && !!daemonClient && isConnected && !!cwd && pipelineId !== null; const shouldFetch = enabled && !!workspaceGit && isConnected && pipelineId !== null;
const query = useFetchQuery<CheckoutPipeline | null>({ const query = useFetchQuery<CheckoutPipeline | null>({
queryKey: useMemo( queryKey: useMemo(
() => prPanePipelineQueryKey({ serverId, cwd, pipelineId, changeRequestNumber }), () =>
[serverId, cwd, pipelineId, changeRequestNumber], prPanePipelineQueryKey({
serverId,
queryIdentity: workspaceGit.queryIdentity,
cwd: workspaceGit.cwd,
pipelineId,
changeRequestNumber,
}),
[serverId, workspaceGit, pipelineId, changeRequestNumber],
), ),
queryFn: async () => { queryFn: async () => {
if (!daemonClient || pipelineId === null) { if (!workspaceGit || pipelineId === null) {
return null; return null;
} }
const payload = await daemonClient.checkoutForgeGetCheckDetails({ const payload = await workspaceGit.getForgeCheckDetails({
cwd,
checkRunId: pipelineId, checkRunId: pipelineId,
changeRequestNumber, changeRequestNumber,
}); });
+134 -131
View File
@@ -1,8 +1,8 @@
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
checkoutDiffQueryKey,
checkoutCommitsQueryKey, checkoutCommitsQueryKey,
checkoutDiffQueryKey,
checkoutPrStatusQueryKey, checkoutPrStatusQueryKey,
checkoutStatusQueryKey, checkoutStatusQueryKey,
invalidateCheckoutGitQueriesForClient, invalidateCheckoutGitQueriesForClient,
@@ -15,153 +15,156 @@ import {
describe("checkout query keys", () => { describe("checkout query keys", () => {
const serverId = "server-1"; const serverId = "server-1";
const cwd = "/tmp/repo"; const target = {
workspaceId: "workspace-a",
cwd: "/tmp/repo",
queryIdentity: ["selected", "workspace-a"] as const,
};
const sibling = {
workspaceId: "workspace-b",
cwd: target.cwd,
queryIdentity: ["selected", "workspace-b"] as const,
};
it("invalidates every query for a checkout without touching other checkouts", async () => { it("uses workspace identity before the compatibility cwd", () => {
const queryClient = new QueryClient(); expect(checkoutStatusQueryKey(serverId, target)).toEqual([
"checkoutStatus",
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true }); serverId,
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), { target.queryIdentity,
files: [], target.cwd,
}); ]);
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } }); expect(checkoutStatusQueryKey(serverId, sibling)).not.toEqual(
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] }); checkoutStatusQueryKey(serverId, target),
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/tmp/other"), { commits: [] });
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
items: [],
});
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }), {
items: [],
});
queryClient.setQueryData(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
{
stages: [],
},
); );
queryClient.setQueryData(
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
{ items: [] },
);
queryClient.setQueryData(
prPanePipelineQueryKey({
serverId,
cwd: "/tmp/other",
pipelineId: 9001,
changeRequestNumber: 1,
}),
{ stages: [] },
);
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, cwd });
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true))
?.isInvalidated,
).toBe(true);
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/tmp/other"))?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
)?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
)?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({
serverId,
cwd: "/tmp/other",
pipelineId: 9001,
changeRequestNumber: 1,
}),
)?.isInvalidated,
).toBe(false);
queryClient.clear();
}); });
it("invalidates fetch-based checkout queries server-wide without touching other servers", async () => { it("keeps selected and legacy identity distinct across keys and invalidation", async () => {
const queryClient = new QueryClient(); const selected = {
const otherServerId = "server-2"; ...target,
const otherCwd = "/tmp/repo-2"; workspaceId: "legacy:/shared",
cwd: "/shared",
queryIdentity: ["selected", "legacy:/shared"] as const,
};
const legacy = {
cwd: "/shared",
queryIdentity: ["legacy", "/shared"] as const,
};
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true }); expect(checkoutStatusQueryKey(serverId, selected)).not.toEqual(
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), { isGit: true }); checkoutStatusQueryKey(serverId, legacy),
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] });
queryClient.setQueryData(checkoutCommitsQueryKey(otherServerId, cwd), { commits: [] });
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
items: [],
});
queryClient.setQueryData(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
{
stages: [],
},
); );
// Subscription-fed diff queries are deliberately not part of the server-wide sweep. expect(checkoutPrStatusQueryKey(serverId, selected)).not.toEqual(
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), { checkoutPrStatusQueryKey(serverId, legacy),
);
expect(checkoutDiffQueryKey(serverId, selected, "uncommitted")).toContainEqual(
selected.queryIdentity,
);
expect(checkoutCommitsQueryKey(serverId, selected)).toContainEqual(selected.queryIdentity);
const queryClient = new QueryClient();
queryClient.setQueryData(checkoutStatusQueryKey(serverId, selected), { branch: "selected" });
queryClient.setQueryData(checkoutStatusQueryKey(serverId, legacy), { branch: "legacy" });
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, selected), { status: "selected" });
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, legacy), { status: "legacy" });
queryClient.setQueryData(checkoutDiffQueryKey(serverId, selected, "uncommitted"), {
files: ["selected"],
});
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, selected), {
commits: ["selected"],
});
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, target: selected });
expect(
queryClient.getQueryState(checkoutStatusQueryKey(serverId, selected))?.isInvalidated,
).toBe(true);
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, legacy))?.isInvalidated).toBe(
false,
);
expect(
queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, selected))?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, legacy))?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(checkoutDiffQueryKey(serverId, selected, "uncommitted"))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, selected))?.isInvalidated,
).toBe(true);
});
it("invalidates one selected workspace without touching a same-cwd sibling", async () => {
const queryClient = new QueryClient();
for (const selected of [target, sibling]) {
queryClient.setQueryData(checkoutStatusQueryKey(serverId, selected), { isGit: true });
queryClient.setQueryData(checkoutDiffQueryKey(serverId, selected, "base", "main", true), {
files: [],
});
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, selected), { status: null });
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, selected), { commits: [] });
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, ...selected, prNumber: 12 }), {
items: [],
});
queryClient.setQueryData(
prPanePipelineQueryKey({ serverId, ...selected, pipelineId: 9, changeRequestNumber: 12 }),
{ stages: [] },
);
}
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, target });
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, target))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(checkoutDiffQueryKey(serverId, target, "base", "main", true))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, target))?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, target))?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, ...target, prNumber: 12 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({ serverId, ...target, pipelineId: 9, changeRequestNumber: 12 }),
)?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(checkoutStatusQueryKey(serverId, sibling))?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, sibling))?.isInvalidated,
).toBe(false);
});
it("invalidates fetch-based selected queries server-wide but leaves diffs subscription-fed", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(checkoutStatusQueryKey(serverId, target), { isGit: true });
queryClient.setQueryData(checkoutDiffQueryKey(serverId, target, "base", "main", true), {
files: [], files: [],
}); });
queryClient.setQueryData(checkoutStatusQueryKey(otherServerId, cwd), { isGit: true }); queryClient.setQueryData(checkoutStatusQueryKey("server-2", target), { isGit: true });
await invalidateCheckoutGitQueriesForServer(queryClient, serverId); await invalidateCheckoutGitQueriesForServer(queryClient, serverId);
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe( expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, target))?.isInvalidated).toBe(
true, true,
); );
expect( expect(
queryClient.getQueryState(checkoutStatusQueryKey(serverId, otherCwd))?.isInvalidated, queryClient.getQueryState(checkoutDiffQueryKey(serverId, target, "base", "main", true))
).toBe(true);
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(checkoutCommitsQueryKey(otherServerId, cwd))?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
)?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true))
?.isInvalidated, ?.isInvalidated,
).toBe(false); ).toBe(false);
expect( expect(
queryClient.getQueryState(checkoutStatusQueryKey(otherServerId, cwd))?.isInvalidated, queryClient.getQueryState(checkoutStatusQueryKey("server-2", target))?.isInvalidated,
).toBe(false); ).toBe(false);
queryClient.clear();
}); });
}); });
+47 -17
View File
@@ -1,14 +1,20 @@
import type { Query, QueryClient } from "@tanstack/react-query"; import type { Query, QueryClient } from "@tanstack/react-query";
import {
workspaceGitQueryIdentitiesEqual,
type WorkspaceGitQueryIdentity,
type WorkspaceGitStatusTarget,
type WorkspaceGitTarget,
} from "./workspace-git";
import { prPanePipelineQueryKind, prPaneTimelineQueryKind } from "./pull-request-panel/query-keys"; import { prPanePipelineQueryKind, prPaneTimelineQueryKind } from "./pull-request-panel/query-keys";
interface CheckoutQueryIdentity { interface CheckoutQueryIdentity {
serverId: string; serverId: string;
cwd: string; target: WorkspaceGitTarget;
} }
interface CheckoutQueryScope { interface CheckoutQueryScope {
serverId: string; serverId: string;
cwd?: string; target?: WorkspaceGitTarget;
} }
type CheckoutQueryKey = readonly unknown[]; type CheckoutQueryKey = readonly unknown[];
@@ -17,35 +23,47 @@ type CheckoutQueryKey = readonly unknown[];
// can share the same long-lived cache policy. // can share the same long-lived cache policy.
export const COMMIT_FILE_DIFF_STALE_TIME = 5 * 60_000; export const COMMIT_FILE_DIFF_STALE_TIME = 5 * 60_000;
export function checkoutStatusQueryKey(serverId: string, cwd: string) { export function checkoutStatusQueryKey(serverId: string, target: WorkspaceGitStatusTarget) {
return ["checkoutStatus", serverId, cwd] as const; return ["checkoutStatus", serverId, target.queryIdentity, target.cwd] as const;
}
export function legacyCheckoutStatusQueryKey(serverId: string, cwd: string) {
return ["legacyCheckoutStatus", serverId, cwd] as const;
} }
export function checkoutDiffQueryKey( export function checkoutDiffQueryKey(
serverId: string, serverId: string,
cwd: string, target: WorkspaceGitTarget,
mode: "uncommitted" | "base", mode: "uncommitted" | "base",
baseRef?: string, baseRef?: string,
ignoreWhitespace?: boolean, ignoreWhitespace?: boolean,
) { ) {
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const; return [
"checkoutDiff",
serverId,
target.queryIdentity,
target.cwd,
mode,
baseRef ?? "",
ignoreWhitespace === true,
] as const;
} }
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) { export function checkoutPrStatusQueryKey(serverId: string, target: WorkspaceGitStatusTarget) {
return ["checkoutPrStatus", serverId, cwd] as const; return ["checkoutPrStatus", serverId, target.queryIdentity, target.cwd] as const;
} }
export function checkoutCommitsQueryKey(serverId: string, cwd: string) { export function checkoutCommitsQueryKey(serverId: string, target: WorkspaceGitTarget) {
return ["checkoutCommits", serverId, cwd] as const; return ["checkoutCommits", serverId, target.queryIdentity, target.cwd] as const;
} }
export function checkoutCommitFileDiffQueryKey( export function checkoutCommitFileDiffQueryKey(
serverId: string, serverId: string,
cwd: string, target: WorkspaceGitTarget,
sha: string, sha: string,
path: string, path: string,
) { ) {
return ["checkoutCommitFileDiff", serverId, cwd, sha, path] as const; return ["checkoutCommitFileDiff", serverId, target.queryIdentity, target.cwd, sha, path] as const;
} }
export async function invalidateCheckoutGitQueriesForClient( export async function invalidateCheckoutGitQueriesForClient(
@@ -54,7 +72,7 @@ export async function invalidateCheckoutGitQueriesForClient(
) { ) {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: checkoutStatusQueryKey(identity.serverId, identity.cwd), queryKey: checkoutStatusQueryKey(identity.serverId, identity.target),
}), }),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: checkoutQueryPredicate("checkoutDiff", identity), predicate: checkoutQueryPredicate("checkoutDiff", identity),
@@ -63,7 +81,7 @@ export async function invalidateCheckoutGitQueriesForClient(
predicate: checkoutQueryPredicate("checkoutPrStatus", identity), predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
}), }),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: checkoutCommitsQueryKey(identity.serverId, identity.cwd), queryKey: checkoutCommitsQueryKey(identity.serverId, identity.target),
}), }),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity), predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
@@ -119,16 +137,28 @@ function checkoutQueryPredicate(
isCheckoutQueryKey(key) && isCheckoutQueryKey(key) &&
key[0] === queryKind && key[0] === queryKind &&
key[1] === scope.serverId && key[1] === scope.serverId &&
(scope.cwd === undefined || key[2] === scope.cwd) (scope.target === undefined ||
(isWorkspaceGitQueryIdentity(key[2]) &&
workspaceGitQueryIdentitiesEqual(key[2], scope.target.queryIdentity) &&
key[3] === scope.target.cwd))
); );
}; };
} }
function isCheckoutQueryKey(key: readonly unknown[]): key is CheckoutQueryKey { function isCheckoutQueryKey(key: readonly unknown[]): key is CheckoutQueryKey {
return ( return (
key.length >= 3 && key.length >= 4 &&
typeof key[0] === "string" && typeof key[0] === "string" &&
typeof key[1] === "string" && typeof key[1] === "string" &&
typeof key[2] === "string" isWorkspaceGitQueryIdentity(key[2]) &&
typeof key[3] === "string"
);
}
function isWorkspaceGitQueryIdentity(value: unknown): value is WorkspaceGitQueryIdentity {
return (
Array.isArray(value) &&
(value[0] === "selected" || value[0] === "legacy") &&
typeof value[1] === "string"
); );
} }
+46 -36
View File
@@ -30,6 +30,7 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning"; import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
import { readValidatedString } from "@/storage/validated-storage"; import { readValidatedString } from "@/storage/validated-storage";
export type { GitActionId, GitAction, GitActions } from "@/git/policy"; export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -319,7 +320,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr"); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr");
const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId, cwd }); const target = useRequiredWorkspaceGit();
const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId });
const gitStatus = status && status.isGit ? status : null; const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus); const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error; const notGit = status !== null && !status.isGit && !status.error;
@@ -333,7 +335,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
forge, forge,
} = useCheckoutPrStatusQuery({ } = useCheckoutPrStatusQuery({
serverId, serverId,
cwd,
enabled: isGit, enabled: isGit,
}); });
const prIcon = useMemo(() => renderForgePrIcon(forge), [forge]); const prIcon = useMemo(() => renderForgePrIcon(forge), [forge]);
@@ -397,50 +398,50 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}, [cwd]); }, [cwd]);
const commitStatus = useCheckoutGitActionsStore((s) => const commitStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "commit" }), s.getStatus({ serverId, target, actionId: "commit" }),
); );
const pullStatus = useCheckoutGitActionsStore((s) => const pullStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "pull" }), s.getStatus({ serverId, target, actionId: "pull" }),
); );
const pushStatus = useCheckoutGitActionsStore((s) => const pushStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "push" }), s.getStatus({ serverId, target, actionId: "push" }),
); );
const pullAndPushStatus = useCheckoutGitActionsStore((s) => const pullAndPushStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "pull-and-push" }), s.getStatus({ serverId, target, actionId: "pull-and-push" }),
); );
const prCreateStatus = useCheckoutGitActionsStore((s) => const prCreateStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "create-pr" }), s.getStatus({ serverId, target, actionId: "create-pr" }),
); );
const mergePrStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = { const mergePrStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
squash: useCheckoutGitActionsStore((s) => squash: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-pr-squash" }), s.getStatus({ serverId, target, actionId: "merge-pr-squash" }),
), ),
merge: useCheckoutGitActionsStore((s) => merge: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-pr-merge" }), s.getStatus({ serverId, target, actionId: "merge-pr-merge" }),
), ),
rebase: useCheckoutGitActionsStore((s) => rebase: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }), s.getStatus({ serverId, target, actionId: "merge-pr-rebase" }),
), ),
}; };
const enablePrAutoMergeStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = { const enablePrAutoMergeStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
squash: useCheckoutGitActionsStore((s) => squash: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }), s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-squash" }),
), ),
merge: useCheckoutGitActionsStore((s) => merge: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }), s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-merge" }),
), ),
rebase: useCheckoutGitActionsStore((s) => rebase: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-rebase" }), s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-rebase" }),
), ),
}; };
const disablePrAutoMergeStatus = useCheckoutGitActionsStore((s) => const disablePrAutoMergeStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }), s.getStatus({ serverId, target, actionId: "disable-pr-auto-merge" }),
); );
const mergeStatus = useCheckoutGitActionsStore((s) => const mergeStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-branch" }), s.getStatus({ serverId, target, actionId: "merge-branch" }),
); );
const mergeFromBaseStatus = useCheckoutGitActionsStore((s) => const mergeFromBaseStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-from-base" }), s.getStatus({ serverId, target, actionId: "merge-from-base" }),
); );
const runCommit = useCheckoutGitActionsStore((s) => s.commit); const runCommit = useCheckoutGitActionsStore((s) => s.commit);
@@ -476,7 +477,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
// Handlers // Handlers
const handleCommit = useCallback(() => { const handleCommit = useCallback(() => {
void runCommit({ serverId, cwd }) void runCommit({ serverId, target })
.then(() => { .then(() => {
toastActionSuccess(t("workspace.git.actions.commit.success")); toastActionSuccess(t("workspace.git.actions.commit.success"));
return; return;
@@ -484,10 +485,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
.catch((err) => { .catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedCommit")); toastActionError(err, t("workspace.git.actions.toasts.failedCommit"));
}); });
}, [cwd, runCommit, serverId, t, toastActionError, toastActionSuccess]); }, [runCommit, serverId, t, target, toastActionError, toastActionSuccess]);
const handlePull = useCallback(() => { const handlePull = useCallback(() => {
void runPull({ serverId, cwd }) void runPull({ serverId, target })
.then(() => { .then(() => {
toastActionSuccess(t("workspace.git.actions.pull.success")); toastActionSuccess(t("workspace.git.actions.pull.success"));
return; return;
@@ -495,10 +496,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
.catch((err) => { .catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedPull")); toastActionError(err, t("workspace.git.actions.toasts.failedPull"));
}); });
}, [cwd, runPull, serverId, t, toastActionError, toastActionSuccess]); }, [runPull, serverId, t, target, toastActionError, toastActionSuccess]);
const handlePush = useCallback(() => { const handlePush = useCallback(() => {
void runPush({ serverId, cwd }) void runPush({ serverId, target })
.then(() => { .then(() => {
toastActionSuccess(t("workspace.git.actions.push.success")); toastActionSuccess(t("workspace.git.actions.push.success"));
return; return;
@@ -506,10 +507,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
.catch((err) => { .catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedPush")); toastActionError(err, t("workspace.git.actions.toasts.failedPush"));
}); });
}, [cwd, runPush, serverId, t, toastActionError, toastActionSuccess]); }, [runPush, serverId, t, target, toastActionError, toastActionSuccess]);
const handlePullAndPush = useCallback(() => { const handlePullAndPush = useCallback(() => {
void runPullAndPush({ serverId, cwd }) void runPullAndPush({ serverId, target })
.then(() => { .then(() => {
toastActionSuccess(t("workspace.git.actions.pullAndPush.success")); toastActionSuccess(t("workspace.git.actions.pullAndPush.success"));
return; return;
@@ -517,11 +518,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
.catch((err) => { .catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedPullAndPush")); toastActionError(err, t("workspace.git.actions.toasts.failedPullAndPush"));
}); });
}, [cwd, runPullAndPush, serverId, t, toastActionError, toastActionSuccess]); }, [runPullAndPush, serverId, t, target, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => { const handleCreatePr = useCallback(() => {
void persistShipDefault("pr"); void persistShipDefault("pr");
void runCreatePr({ serverId, cwd }) void runCreatePr({ serverId, target })
.then(() => { .then(() => {
toastActionSuccess(t("workspace.git.actions.createPr.success", forgeVocabulary(forge))); toastActionSuccess(t("workspace.git.actions.createPr.success", forgeVocabulary(forge)));
return; return;
@@ -530,12 +531,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
toastActionError(err, t("workspace.git.actions.toasts.failedCreatePr")); toastActionError(err, t("workspace.git.actions.toasts.failedCreatePr"));
}); });
}, [ }, [
cwd,
forge, forge,
persistShipDefault, persistShipDefault,
runCreatePr, runCreatePr,
serverId, serverId,
t, t,
target,
toastActionError, toastActionError,
toastActionSuccess, toastActionSuccess,
]); ]);
@@ -543,7 +544,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const handleMergePr = useCallback( const handleMergePr = useCallback(
(method: CheckoutPrMergeMethod) => { (method: CheckoutPrMergeMethod) => {
void persistShipDefault("pr"); void persistShipDefault("pr");
void runMergePr({ serverId, cwd, method }) void runMergePr({ serverId, target, method })
.then(() => { .then(() => {
setPostShipArchiveSuggested(true); setPostShipArchiveSuggested(true);
toastActionSuccess(t("workspace.git.actions.mergePr.success", forgeVocabulary(forge))); toastActionSuccess(t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)));
@@ -553,13 +554,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
toastActionError(err, t("workspace.git.actions.toasts.failedMergePr")); toastActionError(err, t("workspace.git.actions.toasts.failedMergePr"));
}); });
}, },
[cwd, forge, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess], [
forge,
persistShipDefault,
runMergePr,
serverId,
t,
target,
toastActionError,
toastActionSuccess,
],
); );
const handleEnablePrAutoMerge = useCallback( const handleEnablePrAutoMerge = useCallback(
(method: CheckoutPrMergeMethod) => { (method: CheckoutPrMergeMethod) => {
void persistShipDefault("pr"); void persistShipDefault("pr");
void runEnablePrAutoMerge({ serverId, cwd, method }) void runEnablePrAutoMerge({ serverId, target, method })
.then(() => { .then(() => {
toastActionSuccess(t("workspace.git.actions.autoMerge.enabled")); toastActionSuccess(t("workspace.git.actions.autoMerge.enabled"));
return; return;
@@ -569,18 +579,18 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}); });
}, },
[ [
cwd,
persistShipDefault, persistShipDefault,
runEnablePrAutoMerge, runEnablePrAutoMerge,
serverId, serverId,
t, t,
target,
toastActionError, toastActionError,
toastActionSuccess, toastActionSuccess,
], ],
); );
const handleDisablePrAutoMerge = useCallback(() => { const handleDisablePrAutoMerge = useCallback(() => {
void runDisablePrAutoMerge({ serverId, cwd }) void runDisablePrAutoMerge({ serverId, target })
.then(() => { .then(() => {
toastActionSuccess(t("workspace.git.actions.autoMerge.disabled")); toastActionSuccess(t("workspace.git.actions.autoMerge.disabled"));
return; return;
@@ -588,7 +598,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
.catch((err) => { .catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedDisableAutoMerge")); toastActionError(err, t("workspace.git.actions.toasts.failedDisableAutoMerge"));
}); });
}, [cwd, runDisablePrAutoMerge, serverId, t, toastActionError, toastActionSuccess]); }, [runDisablePrAutoMerge, serverId, t, target, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => { const handleMergeBranch = useCallback(() => {
if (!baseRef) { if (!baseRef) {
@@ -596,7 +606,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
return; return;
} }
void persistShipDefault("merge"); void persistShipDefault("merge");
void runMergeBranch({ serverId, cwd, baseRef }) void runMergeBranch({ serverId, target, baseRef })
.then(() => { .then(() => {
setPostShipArchiveSuggested(true); setPostShipArchiveSuggested(true);
toastActionSuccess(t("workspace.git.actions.mergeBranch.success")); toastActionSuccess(t("workspace.git.actions.mergeBranch.success"));
@@ -607,11 +617,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}); });
}, [ }, [
baseRef, baseRef,
cwd,
persistShipDefault, persistShipDefault,
runMergeBranch, runMergeBranch,
serverId, serverId,
t, t,
target,
toast, toast,
toastActionError, toastActionError,
toastActionSuccess, toastActionSuccess,
@@ -622,7 +632,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
toast.error(t("workspace.git.actions.toasts.baseRefUnavailable")); toast.error(t("workspace.git.actions.toasts.baseRefUnavailable"));
return; return;
} }
void runMergeFromBase({ serverId, cwd, baseRef }) void runMergeFromBase({ serverId, target, baseRef })
.then(() => { .then(() => {
toastActionSuccess(t("workspace.git.actions.mergeFromBase.success")); toastActionSuccess(t("workspace.git.actions.mergeFromBase.success"));
return; return;
@@ -630,7 +640,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
.catch((err) => { .catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedMergeFromBase")); toastActionError(err, t("workspace.git.actions.toasts.failedMergeFromBase"));
}); });
}, [baseRef, cwd, runMergeFromBase, serverId, t, toast, toastActionError, toastActionSuccess]); }, [baseRef, runMergeFromBase, serverId, t, target, toast, toastActionError, toastActionSuccess]);
const archiveController = useWorkspaceScreenArchiveController({ const archiveController = useWorkspaceScreenArchiveController({
serverId, serverId,
+7 -8
View File
@@ -3,8 +3,9 @@ import invariant from "tiny-invariant";
import { useRetainedPanelActive } from "@/components/retained-panel"; import { useRetainedPanelActive } from "@/components/retained-panel";
import { useFetchQuery } from "@/data/query"; import { useFetchQuery } from "@/data/query";
import { checkoutCommitsQueryKey } from "@/git/query-keys"; import { checkoutCommitsQueryKey } from "@/git/query-keys";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
// Commits ahead of base change rarely while the section is open; this keeps a // Commits ahead of base change rarely while the section is open; this keeps a
// collapse/re-expand cycle warm without leaving the fetch result stale for long. // collapse/re-expand cycle warm without leaving the fetch result stale for long.
@@ -12,7 +13,6 @@ const CHECKOUT_COMMITS_STALE_TIME = 30_000;
interface UseCheckoutCommitsQueryOptions { interface UseCheckoutCommitsQueryOptions {
serverId: string; serverId: string;
cwd: string;
enabled?: boolean; enabled?: boolean;
} }
@@ -70,12 +70,11 @@ export function resolveCheckoutCommitsQueryResult({
export function useCheckoutCommitsQuery({ export function useCheckoutCommitsQuery({
serverId, serverId,
cwd,
enabled = true, enabled = true,
}: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult { }: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult {
const retainedPanelActive = useRetainedPanelActive(); const retainedPanelActive = useRetainedPanelActive();
const queryEnabledByCaller = enabled && retainedPanelActive; const queryEnabledByCaller = enabled && retainedPanelActive;
const client = useHostRuntimeClient(serverId); const workspaceGit = useRequiredWorkspaceGit();
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
// COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16. // COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16.
// COMPAT(commitBaseClassification): added in v0.2.0, remove after 2027-01-23. // COMPAT(commitBaseClassification): added in v0.2.0, remove after 2027-01-23.
@@ -86,16 +85,16 @@ export function useCheckoutCommitsQuery({
state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true, state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true,
); );
const canFetch = Boolean(cwd) && Boolean(client) && isConnected; const canFetch = Boolean(workspaceGit) && isConnected;
const queryEnabled = queryEnabledByCaller && capabilityPresent && canFetch; const queryEnabled = queryEnabledByCaller && capabilityPresent && canFetch;
const query = useFetchQuery<CheckoutCommitsData>({ const query = useFetchQuery<CheckoutCommitsData>({
queryKey: checkoutCommitsQueryKey(serverId, cwd), queryKey: checkoutCommitsQueryKey(serverId, workspaceGit),
queryFn: async () => { queryFn: async () => {
if (!client) { if (!workspaceGit) {
throw new Error("Host disconnected"); throw new Error("Host disconnected");
} }
const data = await client.listCheckoutCommits(cwd); const data = await workspaceGit.listCommits();
const commits = data.commits.map((commit) => { const commits = data.commits.map((commit) => {
invariant(commit.isOnBase !== undefined, "Host omitted commit base classification"); invariant(commit.isOnBase !== undefined, "Host omitted commit base classification");
return { ...commit, isOnBase: commit.isOnBase }; return { ...commit, isOnBase: commit.isOnBase };
+9 -10
View File
@@ -4,7 +4,8 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
import { useFetchQueries } from "@/data/query"; import { useFetchQueries } from "@/data/query";
import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys"; import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys";
import { useCheckoutCommitsQuery } from "@/git/use-commits-query"; import { useCheckoutCommitsQuery } from "@/git/use-commits-query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
/** /**
* Context needed to resolve a commit diff against a host: which daemon * Context needed to resolve a commit diff against a host: which daemon
@@ -13,7 +14,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
*/ */
export interface CommitDiffFilesContext { export interface CommitDiffFilesContext {
serverId: string; serverId: string;
cwd: string;
sha: string; sha: string;
enabled?: boolean; enabled?: boolean;
} }
@@ -57,12 +57,12 @@ export function resolveCommitDiffFiles(
} }
export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFilesResult { export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFilesResult {
const { serverId, cwd, sha, enabled = true } = ctx; const { serverId, sha, enabled = true } = ctx;
const workspaceGit = useRequiredWorkspaceGit();
const retainedPanelActive = useRetainedPanelActive(); const retainedPanelActive = useRetainedPanelActive();
const queryEnabled = enabled && retainedPanelActive; const queryEnabled = enabled && retainedPanelActive;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const commitsQuery = useCheckoutCommitsQuery({ serverId, cwd, enabled: queryEnabled }); const commitsQuery = useCheckoutCommitsQuery({ serverId, enabled: queryEnabled });
const commitsData = commitsQuery.status === "loaded" ? commitsQuery.data : null; const commitsData = commitsQuery.status === "loaded" ? commitsQuery.data : null;
const commitFiles = useMemo(() => { const commitFiles = useMemo(() => {
if (!sha || !commitsData) { if (!sha || !commitsData) {
@@ -74,18 +74,17 @@ export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFiles
const fileDiffsEnabled = const fileDiffsEnabled =
queryEnabled && queryEnabled &&
commitsQuery.status === "loaded" && commitsQuery.status === "loaded" &&
Boolean(cwd) && Boolean(workspaceGit) &&
Boolean(sha) && Boolean(sha) &&
Boolean(client) &&
isConnected; isConnected;
const fileDiffResults = useFetchQueries( const fileDiffResults = useFetchQueries(
commitFiles.map((file) => ({ commitFiles.map((file) => ({
queryKey: checkoutCommitFileDiffQueryKey(serverId, cwd, sha, file.path), queryKey: checkoutCommitFileDiffQueryKey(serverId, workspaceGit, sha, file.path),
queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => { queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => {
if (!client) { if (!workspaceGit) {
throw new Error("Host disconnected"); throw new Error("Host disconnected");
} }
return client.getCommitFileDiff(cwd, sha, file.path); return workspaceGit.getCommitFileDiff(sha, file.path);
}, },
enabled: fileDiffsEnabled, enabled: fileDiffsEnabled,
staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME, staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME,
+6 -6
View File
@@ -5,10 +5,10 @@ import { checkoutDiffPushRoute } from "@/data/push-router";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages"; import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
import { checkoutDiffQueryKey } from "@/git/query-keys"; import { checkoutDiffQueryKey } from "@/git/query-keys";
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
interface UseCheckoutDiffQueryOptions { interface UseCheckoutDiffQueryOptions {
serverId: string; serverId: string;
cwd: string;
mode: "uncommitted" | "base"; mode: "uncommitted" | "base";
baseRef?: string; baseRef?: string;
ignoreWhitespace?: boolean; ignoreWhitespace?: boolean;
@@ -41,13 +41,13 @@ function normalizeCheckoutDiffCompare(compare: {
export function useCheckoutDiffQuery({ export function useCheckoutDiffQuery({
serverId, serverId,
cwd,
mode, mode,
baseRef, baseRef,
ignoreWhitespace, ignoreWhitespace,
enabled = true, enabled = true,
queryScope, queryScope,
}: UseCheckoutDiffQueryOptions) { }: UseCheckoutDiffQueryOptions) {
const workspaceGit = useRequiredWorkspaceGit();
const retainedPanelActive = useRetainedPanelActive(); const retainedPanelActive = useRetainedPanelActive();
const queryEnabled = enabled && retainedPanelActive; const queryEnabled = enabled && retainedPanelActive;
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
@@ -61,16 +61,16 @@ export function useCheckoutDiffQuery({
const queryKey = useMemo(() => { const queryKey = useMemo(() => {
const comparisonKey = checkoutDiffQueryKey( const comparisonKey = checkoutDiffQueryKey(
serverId, serverId,
cwd, workspaceGit,
compareMode, compareMode,
compareBaseRef, compareBaseRef,
compareIgnoreWhitespace, compareIgnoreWhitespace,
); );
const normalizedScope = queryScope?.trim(); const normalizedScope = queryScope?.trim();
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey; return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]); }, [serverId, workspaceGit, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]); const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
const routeEnabled = Boolean(queryEnabled && isConnected && cwd); const routeEnabled = Boolean(queryEnabled && isConnected && workspaceGit);
const query = useReplicaQuery<CheckoutDiffQueryPayload>({ const query = useReplicaQuery<CheckoutDiffQueryPayload>({
queryKey, queryKey,
@@ -80,7 +80,7 @@ export function useCheckoutDiffQuery({
enabled: routeEnabled, enabled: routeEnabled,
serverId, serverId,
subscriptionId, subscriptionId,
cwd, workspaceGit,
compare: { compare: {
mode: compareMode, mode: compareMode,
...(compareBaseRef ? { baseRef: compareBaseRef } : {}), ...(compareBaseRef ? { baseRef: compareBaseRef } : {}),
@@ -1,282 +1,75 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { buildForgeSearchQueryOptions, forgeSearchQueryKey } from "./use-forge-search-query"; import { buildForgeSearchQueryOptions, forgeSearchQueryKey } from "./use-forge-search-query";
describe("forgeSearchQueryKey", () => { const target = {
it("keeps the shared cache key shape for no-kinds searches", () => { cwd: "/workspace",
expect(forgeSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([ queryIdentity: ["selected", "workspace-a"] as const,
"forge-search", };
"server-1",
"/repo", describe("forge search query", () => {
"forge", it("keys same-cwd workspaces independently", () => {
"123", expect(forgeSearchQueryKey("server", target, " issue ")).not.toEqual(
]); forgeSearchQueryKey(
"server",
{ ...target, queryIdentity: ["selected", "workspace-b"] },
"issue",
),
);
}); });
it("adds a deterministic kinds key when kinds are specified", () => { it("does not alias a selected id that resembles a legacy address", () => {
expect(forgeSearchQueryKey("server-1", "/repo", "123", ["change_request", "issue"])).toEqual([ expect(
"forge-search", forgeSearchQueryKey(
"server-1", "server",
"/repo", { cwd: "/shared", queryIdentity: ["selected", "legacy:/shared"] },
"forge", "issue",
"123", ),
"change_request,issue", ).not.toEqual(
]); forgeSearchQueryKey(
"server",
{ cwd: "/shared", queryIdentity: ["legacy", "/shared"] },
"issue",
),
);
}); });
it("separates legacy GitHub fallback results from forge search results", () => { it("searches through the already-bound workspace capability", async () => {
expect(forgeSearchQueryKey("server-1", "/repo", "123", undefined, "github")).toEqual([ const searchForge = vi.fn(async () => ({
"forge-search", items: [],
"server-1", authState: "authenticated" as const,
"/repo", error: null,
"github", requestId: "search-1",
"123", }));
]); const options = buildForgeSearchQueryOptions({
client: { ...target, searchForge },
serverId: "server",
query: " issue ",
supportsForgeSearch: true,
enabled: true,
});
await expect(options.queryFn()).resolves.toMatchObject({ authState: "authenticated" });
expect(searchForge).toHaveBeenCalledWith({ query: "issue", limit: 20 });
}); });
});
describe("buildForgeSearchQueryOptions", () => { it("uses the bound legacy GitHub method when the forge RPC is unavailable", async () => {
it("forwards kinds to the forge search request when specified", async () => { const searchForge = vi.fn();
const requests: unknown[] = []; const searchGitHub = vi.fn(async () => ({
const query = buildForgeSearchQueryOptions({ items: [],
client: { authState: "authenticated" as const,
async searchForge(options) { featuresEnabled: true,
requests.push(options); githubFeaturesEnabled: true,
return { error: null,
items: [], requestId: "search-legacy",
authState: "authenticated", }));
error: null, const options = buildForgeSearchQueryOptions({
requestId: "request-1", client: { ...target, searchForge, searchGitHub },
}; serverId: "server",
}, query: "42",
},
serverId: "server-1",
cwd: "/repo",
query: " 123 ",
kinds: ["change_request"], kinds: ["change_request"],
enabled: true, enabled: true,
}); });
await options.queryFn();
await query.queryFn(); expect(searchGitHub).toHaveBeenCalledWith({ query: "42", limit: 20, kinds: ["github-pr"] });
expect(searchForge).not.toHaveBeenCalled();
expect(requests).toEqual([
{ cwd: "/repo", query: "123", limit: 20, kinds: ["change_request"] },
]);
});
it("uses the legacy GitHub search request when forge search is unsupported", async () => {
const forgeRequests: unknown[] = [];
const githubRequests: unknown[] = [];
const query = buildForgeSearchQueryOptions({
client: {
async searchForge(options) {
forgeRequests.push(options);
return {
items: [],
authState: "authenticated",
error: null,
requestId: "forge-request",
};
},
async searchGitHub(options) {
githubRequests.push(options);
return {
items: [],
featuresEnabled: true,
authState: "authenticated",
githubFeaturesEnabled: true,
error: null,
requestId: "github-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: " 456 ",
kinds: ["issue"],
enabled: true,
supportsForgeSearch: false,
});
await query.queryFn();
expect(forgeRequests).toEqual([]);
expect(githubRequests).toEqual([
{ cwd: "/repo", query: "456", limit: 20, kinds: ["github-issue"] },
]);
});
it("normalizes legacy GitHub PR items into neutral change-request items", async () => {
const query = buildForgeSearchQueryOptions({
client: {
async searchForge() {
throw new Error("unexpected forge search");
},
async searchGitHub() {
return {
items: [
{
kind: "pr" as const,
number: 17,
title: "Fix search",
url: "https://github.com/acme/repo/pull/17",
state: "open",
body: null,
labels: ["bug"],
baseRefName: "main",
headRefName: "fix-search",
},
],
featuresEnabled: true,
authState: "authenticated" as const,
githubFeaturesEnabled: true,
error: null,
requestId: "github-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: " 456 ",
enabled: true,
supportsForgeSearch: false,
});
const result = await query.queryFn();
expect(result.items).toEqual([
{
kind: "change_request",
number: 17,
title: "Fix search",
url: "https://github.com/acme/repo/pull/17",
state: "open",
body: null,
labels: ["bug"],
baseRefName: "main",
headRefName: "fix-search",
},
]);
});
it("interprets modern search payloads at the query boundary", async () => {
const query = buildForgeSearchQueryOptions({
client: {
async searchForge() {
return {
items: [
{
kind: "issue",
number: 23,
title: "Keep this",
url: "https://gitlab.com/acme/repo/-/issues/23",
state: "open",
body: null,
labels: [],
},
{ kind: "future_kind", futureField: true },
],
authState: "future_auth_state",
error: null,
requestId: "forge-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: "23",
enabled: true,
supportsForgeSearch: true,
});
const result = await query.queryFn();
expect(result.items).toHaveLength(1);
expect(result.items[0]?.kind).toBe("issue");
expect(result.authState).toBe("unauthenticated");
});
it("derives legacy search auth from legacy feature flags", async () => {
const query = buildForgeSearchQueryOptions({
client: {
async searchForge() {
throw new Error("unexpected forge search");
},
async searchGitHub() {
return {
items: [],
githubFeaturesEnabled: false,
error: null,
requestId: "github-request",
};
},
},
serverId: "server-1",
cwd: "/repo",
query: "23",
enabled: true,
supportsForgeSearch: false,
});
expect((await query.queryFn()).authState).toBe("unauthenticated");
});
it("invokes forge search bound to the client so this-dependent methods work", async () => {
const client = new ThisDependentSearchClient();
const query = buildForgeSearchQueryOptions({
client,
serverId: "server-1",
cwd: "/repo",
query: " 789 ",
enabled: true,
supportsForgeSearch: true,
});
const result = await query.queryFn();
expect(result.requestId).toBe("forge.search.request");
expect(client.requests).toEqual([{ cwd: "/repo", query: "789", limit: 20 }]);
});
it("invokes the legacy GitHub search bound to the client", async () => {
const client = new ThisDependentSearchClient();
const query = buildForgeSearchQueryOptions({
client,
serverId: "server-1",
cwd: "/repo",
query: " 789 ",
enabled: true,
supportsForgeSearch: false,
});
const result = await query.queryFn();
expect(result.requestId).toBe("github_search_request");
expect(client.requests).toEqual([{ cwd: "/repo", query: "789", limit: 20 }]);
}); });
}); });
class ThisDependentSearchClient {
readonly requests: unknown[] = [];
private send(requestId: string, options: { cwd: string; query: string; limit?: number }) {
this.requests.push(options);
return Promise.resolve({
items: [],
featuresEnabled: true,
authState: "authenticated" as const,
githubFeaturesEnabled: true,
error: null,
requestId,
});
}
async searchForge(options: { cwd: string; query: string; limit?: number }) {
return this.send("forge.search.request", options);
}
async searchGitHub(options: { cwd: string; query: string; limit?: number }) {
return this.send("github_search_request", options);
}
}
+67 -21
View File
@@ -11,6 +11,8 @@ import {
import { i18n } from "@/i18n/i18next"; import { i18n } from "@/i18n/i18next";
import { useFetchQuery } from "@/data/query"; import { useFetchQuery } from "@/data/query";
import { parseForgeAuthState } from "@/git/forge"; import { parseForgeAuthState } from "@/git/forge";
import type { WorkspaceGitClient, WorkspaceGitReadClient } from "@/git/workspace-git";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
export const FORGE_SEARCH_STALE_TIME = 30_000; export const FORGE_SEARCH_STALE_TIME = 30_000;
@@ -22,36 +24,27 @@ export interface ForgeSearchPayload {
} }
interface ForgeSearchOptions { interface ForgeSearchOptions {
cwd: string;
query: string; query: string;
limit?: number; limit?: number;
kinds?: ForgeSearchKind[]; kinds?: ForgeSearchKind[];
} }
interface LegacyGitHubSearchOptions { interface LegacyGitHubSearchOptions {
cwd: string;
query: string; query: string;
limit?: number; limit?: number;
kinds?: LegacyGitHubSearchKind[]; kinds?: LegacyGitHubSearchKind[];
} }
export interface ForgeSearchClient { type ForgeSearchTarget = Pick<WorkspaceGitReadClient, "cwd" | "queryIdentity">;
searchForge: ( export type ForgeSearchClient = ForgeSearchTarget &
options: ForgeSearchOptions, Pick<WorkspaceGitClient, "searchForge"> &
requestId?: string, Partial<Pick<WorkspaceGitClient, "searchGitHub">>;
) => Promise<ForgeSearchResponse["payload"]>;
searchGitHub?: (
options: LegacyGitHubSearchOptions,
requestId?: string,
) => Promise<GitHubSearchResponse["payload"]>;
}
type LegacyGitHubSearchKind = "github-issue" | "github-pr"; type LegacyGitHubSearchKind = "github-issue" | "github-pr";
interface ForgeSearchQueryInput { interface ForgeSearchQueryInput {
client: ForgeSearchClient | null; client: ForgeSearchClient | null;
serverId: string; serverId: string;
cwd: string;
query: string; query: string;
kinds?: ForgeSearchKind[]; kinds?: ForgeSearchKind[];
enabled: boolean; enabled: boolean;
@@ -59,42 +52,53 @@ interface ForgeSearchQueryInput {
hostDisconnectedMessage?: string; hostDisconnectedMessage?: string;
} }
interface LegacyForgeSearchQueryInput extends Omit<ForgeSearchQueryInput, "client"> {
client: Pick<DaemonClient, "searchForge" | "searchGitHub"> | null;
cwd: string;
}
export function forgeSearchQueryKey( export function forgeSearchQueryKey(
serverId: string, serverId: string,
cwd: string, target: ForgeSearchTarget,
query: string, query: string,
kinds?: ForgeSearchKind[], kinds?: ForgeSearchKind[],
transport: "forge" | "github" = "forge", transport: "forge" | "github" = "forge",
) { ) {
const trimmedQuery = query.trim(); const trimmedQuery = query.trim();
const queryIdentity = forgeSearchClientIdentity(target);
if (!kinds) { if (!kinds) {
return ["forge-search", serverId, cwd, transport, trimmedQuery] as const; return ["forge-search", serverId, queryIdentity, target.cwd, transport, trimmedQuery] as const;
} }
return [ return [
"forge-search", "forge-search",
serverId, serverId,
cwd, queryIdentity,
target.cwd,
transport, transport,
trimmedQuery, trimmedQuery,
[...kinds].sort().join(","), [...kinds].sort().join(","),
] as const; ] as const;
} }
export function forgeSearchClientIdentity(client: ForgeSearchTarget) {
return client.queryIdentity;
}
export function buildForgeSearchQueryOptions(input: ForgeSearchQueryInput) { export function buildForgeSearchQueryOptions(input: ForgeSearchQueryInput) {
const query = input.query.trim(); const query = input.query.trim();
const transport = input.supportsForgeSearch === true ? "forge" : "github"; const transport = input.supportsForgeSearch === true ? "forge" : "github";
return { return {
queryKey: forgeSearchQueryKey(input.serverId, input.cwd, query, input.kinds, transport), queryKey: input.client
? forgeSearchQueryKey(input.serverId, input.client, query, input.kinds, transport)
: (["forge-search", input.serverId, "unbound", transport, query] as const),
queryFn: async (): Promise<ForgeSearchPayload> => { queryFn: async (): Promise<ForgeSearchPayload> => {
if (!input.client) { if (!input.client) {
throw new Error( throw new Error(
input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"), input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"),
); );
} }
const request = input.kinds const request = input.kinds ? { query, limit: 20, kinds: input.kinds } : { query, limit: 20 };
? { cwd: input.cwd, query, limit: 20, kinds: input.kinds }
: { cwd: input.cwd, query, limit: 20 };
// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once // COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once
// clients use forge.search.*. // clients use forge.search.*.
if (transport === "github" && input.client.searchGitHub) { if (transport === "github" && input.client.searchGitHub) {
@@ -153,7 +157,7 @@ function toLegacyGitHubSearchKind(kind: ForgeSearchKind): LegacyGitHubSearchKind
function toLegacyGitHubSearchRequest(request: ForgeSearchOptions): LegacyGitHubSearchOptions { function toLegacyGitHubSearchRequest(request: ForgeSearchOptions): LegacyGitHubSearchOptions {
if (!request.kinds) { if (!request.kinds) {
return { cwd: request.cwd, query: request.query, limit: request.limit }; return { query: request.query, limit: request.limit };
} }
return { return {
...request, ...request,
@@ -161,6 +165,48 @@ function toLegacyGitHubSearchRequest(request: ForgeSearchOptions): LegacyGitHubS
}; };
} }
export function buildLegacyForgeSearchQueryOptions(input: LegacyForgeSearchQueryInput) {
const query = input.query.trim();
const transport = input.supportsForgeSearch === true ? "forge" : "github";
return {
queryKey: ["legacy-forge-search", input.serverId, input.cwd, transport, query] as const,
queryFn: async (): Promise<ForgeSearchPayload> => {
if (!input.client) {
throw new Error(
input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"),
);
}
const request = input.kinds
? { cwd: input.cwd, query, limit: 20, kinds: input.kinds }
: { cwd: input.cwd, query, limit: 20 };
if (transport === "github") {
return normalizeLegacyGitHubSearchPayload(
await input.client.searchGitHub({
cwd: input.cwd,
query,
limit: 20,
kinds: input.kinds?.map(toLegacyGitHubSearchKind),
}),
);
}
return normalizeForgeSearchPayload(await input.client.searchForge(request));
},
enabled: input.enabled && Boolean(input.client),
dataShape: "list" as const,
staleTimeMs: FORGE_SEARCH_STALE_TIME,
};
}
export function useLegacyForgeSearchQuery(input: LegacyForgeSearchQueryInput) {
const { t } = useTranslation();
return useFetchQuery(
buildLegacyForgeSearchQueryOptions({
...input,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
);
}
export function useForgeSearchQuery(input: ForgeSearchQueryInput) { export function useForgeSearchQuery(input: ForgeSearchQueryInput) {
const { t } = useTranslation(); const { t } = useTranslation();
return useFetchQuery( return useFetchQuery(
+16 -14
View File
@@ -1,14 +1,14 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { checkoutPrStatusQueryKey } from "@/git/query-keys"; import { checkoutPrStatusQueryKey } from "@/git/query-keys";
import { normalizeForge } from "@/git/forge"; import { normalizeForge } from "@/git/forge";
import { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint"; import { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
import { useWorkspaceGit } from "@/git/workspace-git";
interface UseCheckoutPrStatusQueryOptions { interface UseCheckoutPrStatusQueryOptions {
serverId: string; serverId: string;
cwd: string;
enabled?: boolean; enabled?: boolean;
} }
@@ -21,22 +21,23 @@ function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null
export function useCheckoutPrStatusQuery({ export function useCheckoutPrStatusQuery({
serverId, serverId,
cwd,
enabled = true, enabled = true,
}: UseCheckoutPrStatusQueryOptions) { }: UseCheckoutPrStatusQueryOptions) {
const { t } = useTranslation(); const { t } = useTranslation();
const client = useHostRuntimeClient(serverId); const workspaceGit = useWorkspaceGit();
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery({ const query = useQuery({
queryKey: checkoutPrStatusQueryKey(serverId, cwd), queryKey: workspaceGit
? checkoutPrStatusQueryKey(serverId, workspaceGit)
: (["checkoutPrStatus", serverId, "unavailable"] as const),
queryFn: async () => { queryFn: async () => {
if (!client) { if (!workspaceGit) {
throw new Error(t("common.errors.daemonClientUnavailable")); throw new Error(t("common.errors.daemonClientUnavailable"));
} }
return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd)); return normalizeCheckoutPrStatusPayload(await workspaceGit.getPrStatus());
}, },
enabled: !!client && isConnected && !!cwd && enabled, enabled: isConnected && enabled && workspaceGit !== null,
staleTime: Infinity, staleTime: Infinity,
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see // Refetch on mount only after explicit invalidation (e.g. reconnect) — see
// useCheckoutStatusQuery for the rationale. // useCheckoutStatusQuery for the rationale.
@@ -63,22 +64,23 @@ export function useCheckoutPrStatusQuery({
export function useWorkspacePrHint({ export function useWorkspacePrHint({
serverId, serverId,
cwd,
enabled = true, enabled = true,
}: UseCheckoutPrStatusQueryOptions): PrHint | null { }: UseCheckoutPrStatusQueryOptions): PrHint | null {
const { t } = useTranslation(); const { t } = useTranslation();
const client = useHostRuntimeClient(serverId); const workspaceGit = useWorkspaceGit();
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({ const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
queryKey: checkoutPrStatusQueryKey(serverId, cwd), queryKey: workspaceGit
? checkoutPrStatusQueryKey(serverId, workspaceGit)
: (["checkoutPrStatus", serverId, "unavailable"] as const),
queryFn: async () => { queryFn: async () => {
if (!client) { if (!workspaceGit) {
throw new Error(t("common.errors.daemonClientUnavailable")); throw new Error(t("common.errors.daemonClientUnavailable"));
} }
return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd)); return normalizeCheckoutPrStatusPayload(await workspaceGit.getPrStatus());
}, },
enabled: !!client && isConnected && !!cwd && enabled, enabled: isConnected && enabled && workspaceGit !== null,
staleTime: Infinity, staleTime: Infinity,
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see // Refetch on mount only after explicit invalidation (e.g. reconnect) — see
// useCheckoutStatusQuery for the rationale. // useCheckoutStatusQuery for the rationale.
+47 -19
View File
@@ -1,8 +1,9 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { checkoutStatusQueryKey } from "@/git/query-keys"; import { checkoutStatusQueryKey, legacyCheckoutStatusQueryKey } from "@/git/query-keys";
import { fetchCheckoutStatus } from "./checkout-status-cache"; import { fetchCheckoutStatus } from "./checkout-status-cache";
import { useWorkspaceGit } from "./workspace-git";
export type { CheckoutStatusPayload } from "./checkout-status-cache"; export type { CheckoutStatusPayload } from "./checkout-status-cache";
@@ -10,23 +11,25 @@ export const CHECKOUT_STATUS_STALE_TIME = 15_000;
interface UseCheckoutStatusQueryOptions { interface UseCheckoutStatusQueryOptions {
serverId: string; serverId: string;
cwd: string;
} }
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { export function useCheckoutStatusQuery({ serverId }: UseCheckoutStatusQueryOptions) {
const { t } = useTranslation(); const workspaceGit = useWorkspaceGit();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery({ const query = useQuery({
queryKey: checkoutStatusQueryKey(serverId, cwd), queryKey: workspaceGit
? checkoutStatusQueryKey(serverId, workspaceGit)
: (["checkoutStatus", serverId, "unavailable"] as const),
queryFn: async () => { queryFn: async () => {
if (!client) { if (!workspaceGit) throw new Error("Workspace Git is unavailable");
throw new Error(t("common.errors.daemonClientUnavailable")); return await fetchCheckoutStatus({
} client: workspaceGit,
return await fetchCheckoutStatus({ client, serverId, cwd }); serverId,
target: workspaceGit,
});
}, },
enabled: !!client && isConnected && !!cwd, enabled: isConnected && workspaceGit !== null,
staleTime: Infinity, staleTime: Infinity,
// Freshness is push-driven (checkout_status_update applied globally); with // Freshness is push-driven (checkout_status_update applied globally); with
// staleTime: Infinity, refetchOnMount only fires after an explicit invalidation // staleTime: Infinity, refetchOnMount only fires after an explicit invalidation
@@ -50,19 +53,44 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery
* initiating a fetch. Useful for list rows where a parent component prefetches * initiating a fetch. Useful for list rows where a parent component prefetches
* only the visible agents. * only the visible agents.
*/ */
export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) { export function useCheckoutStatusCacheOnly({ serverId }: UseCheckoutStatusQueryOptions) {
const { t } = useTranslation(); const workspaceGit = useWorkspaceGit();
const client = useHostRuntimeClient(serverId);
return useQuery({ return useQuery({
queryKey: checkoutStatusQueryKey(serverId, cwd), queryKey: workspaceGit
? checkoutStatusQueryKey(serverId, workspaceGit)
: (["checkoutStatus", serverId, "unavailable"] as const),
queryFn: async () => { queryFn: async () => {
if (!client) { if (!workspaceGit) throw new Error("Workspace Git is unavailable");
throw new Error(t("common.errors.daemonClientUnavailable")); return await fetchCheckoutStatus({
} client: workspaceGit,
return await fetchCheckoutStatus({ client, serverId, cwd }); serverId,
target: workspaceGit,
});
}, },
enabled: false, enabled: false,
staleTime: CHECKOUT_STATUS_STALE_TIME, staleTime: CHECKOUT_STATUS_STALE_TIME,
}); });
} }
export function useLegacyCheckoutStatusQuery({ serverId, cwd }: { serverId: string; cwd: string }) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery({
queryKey: legacyCheckoutStatusQueryKey(serverId, cwd),
queryFn: async () => {
if (!client) throw new Error(t("common.errors.daemonClientUnavailable"));
return await client.getCheckoutStatus(cwd);
},
enabled: !!client && isConnected && !!cwd,
staleTime: Infinity,
});
return {
status: query.data ?? null,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
};
}
+2 -3
View File
@@ -16,7 +16,7 @@ import { useCheckoutStatusQuery } from "@/git/use-status-query";
interface UseWorkingDiffOptions { interface UseWorkingDiffOptions {
serverId: string; serverId: string;
workspaceId?: string; workspaceId: string;
cwd: string; cwd: string;
ignoreWhitespace: boolean; ignoreWhitespace: boolean;
enabled: boolean; enabled: boolean;
@@ -36,7 +36,7 @@ export function useWorkingDiff({
isLoading: isStatusLoading, isLoading: isStatusLoading,
isError: isStatusError, isError: isStatusError,
error: statusError, error: statusError,
} = useCheckoutStatusQuery({ serverId, cwd }); } = useCheckoutStatusQuery({ serverId });
const gitStatus = status && status.isGit ? status : null; const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus); const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error; const notGit = status !== null && !status.isGit && !status.error;
@@ -83,7 +83,6 @@ export function useWorkingDiff({
isLoading: isDiffLoading, isLoading: isDiffLoading,
} = useCheckoutDiffQuery({ } = useCheckoutDiffQuery({
serverId, serverId,
cwd,
mode: diffMode, mode: diffMode,
baseRef, baseRef,
ignoreWhitespace, ignoreWhitespace,
+7 -1
View File
@@ -4,11 +4,13 @@ import { GIT_ACTION_ICONS } from "@/git/action-icons";
interface WorkspaceActionsProps { interface WorkspaceActionsProps {
serverId: string; serverId: string;
workspaceId: string;
cwd: string; cwd: string;
isWorkspaceGitBound: boolean;
hideLabels?: boolean; hideLabels?: boolean;
} }
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) { function BoundWorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
const { gitActions } = useGitActions({ const { gitActions } = useGitActions({
serverId, serverId,
cwd, cwd,
@@ -17,3 +19,7 @@ export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActions
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />; return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
} }
export function WorkspaceActions(props: WorkspaceActionsProps) {
return props.isWorkspaceGitBound ? <BoundWorkspaceActions {...props} /> : null;
}
@@ -0,0 +1,79 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import React, { useCallback } from "react";
import { afterEach, expect, test } from "vitest";
import { PreWorkspaceComposerGitOwner, useWorkspaceGit } from "./workspace-git";
afterEach(cleanup);
function createLegacyGitPort() {
const statusCwds: string[] = [];
return {
client: {
async getCheckoutStatus(cwd: string) {
statusCwds.push(cwd);
return { cwd };
},
async checkoutPrStatus(cwd: string) {
return { cwd };
},
async searchForge() {
return { items: [], authState: "unknown", error: null };
},
async searchGitHub() {
return { items: [], error: null };
},
},
statusCwds,
};
}
function SharedComposerGitConsumer() {
const workspaceGit = useWorkspaceGit();
const fetchStatus = useCallback(() => {
void workspaceGit?.getStatus();
}, [workspaceGit]);
return (
<button type="button" onClick={fetchStatus}>
{workspaceGit?.cwd ?? "Git unavailable"}
</button>
);
}
test.each(["terminal", "chat"])(
"the new-workspace %s Composer production owner supplies its legacy cwd",
async () => {
const port = createLegacyGitPort();
render(
<PreWorkspaceComposerGitOwner client={port.client as never} cwd=" /draft/source ">
<SharedComposerGitConsumer />
</PreWorkspaceComposerGitOwner>,
);
fireEvent.click(screen.getByRole("button", { name: "/draft/source" }));
await waitFor(() => expect(port.statusCwds).toEqual(["/draft/source"]));
},
);
test("the new-workspace Composer production owner represents no cwd without crashing", () => {
const port = createLegacyGitPort();
render(
<PreWorkspaceComposerGitOwner client={port.client as never} cwd={null}>
<SharedComposerGitConsumer />
</PreWorkspaceComposerGitOwner>,
);
expect(screen.getByRole("button", { name: "Git unavailable" })).toBeVisible();
expect(port.statusCwds).toEqual([]);
});
test("the workspace-setup Composer production owner supplies its source directory", async () => {
const port = createLegacyGitPort();
render(
<PreWorkspaceComposerGitOwner client={port.client as never} cwd="/setup/source">
<SharedComposerGitConsumer />
</PreWorkspaceComposerGitOwner>,
);
fireEvent.click(screen.getByRole("button", { name: "/setup/source" }));
await waitFor(() => expect(port.statusCwds).toEqual(["/setup/source"]));
});
@@ -0,0 +1,71 @@
import { expect, test, vi } from "vitest";
import { bindLegacyWorkspaceGit, bindSelectedWorkspaceGit } from "./workspace-git";
test.each(["", " "])(
"the app selected Git boundary rejects workspaceId %j before binding a daemon client",
(workspaceId) => {
const bindWorkspaceGit = vi.fn();
expect(() =>
bindSelectedWorkspaceGit(
{ bindWorkspaceGit },
{ kind: "selected", workspaceId, cwd: "/shared" },
),
).toThrow("workspaceId is required for selected workspace Git");
expect(bindWorkspaceGit).not.toHaveBeenCalled();
},
);
test.each(["", " "])(
"the app selected Git boundary rejects cwd %j before binding a daemon client",
(cwd) => {
const bindWorkspaceGit = vi.fn();
expect(() =>
bindSelectedWorkspaceGit(
{ bindWorkspaceGit },
{ kind: "selected", workspaceId: "workspace-a", cwd },
),
).toThrow("cwd is required for selected workspace Git");
expect(bindWorkspaceGit).not.toHaveBeenCalled();
},
);
test("the app selected Git boundary normalizes its complete address once", () => {
const bindWorkspaceGit = vi.fn(() => ({ workspaceId: "workspace-a", cwd: "/shared" }));
const workspaceGit = bindSelectedWorkspaceGit({ bindWorkspaceGit } as never, {
kind: "selected",
workspaceId: " workspace-a ",
cwd: " /shared ",
});
expect(bindWorkspaceGit).toHaveBeenCalledWith({ workspaceId: "workspace-a", cwd: "/shared" });
expect(workspaceGit).toMatchObject({
workspaceId: "workspace-a",
cwd: "/shared",
queryIdentity: ["selected", "workspace-a"],
});
});
test("selected and legacy capabilities own structurally distinct query identities", () => {
const selected = bindSelectedWorkspaceGit(
{
bindWorkspaceGit: () => ({ workspaceId: "legacy:/shared", cwd: "/shared" }),
} as never,
{ kind: "selected", workspaceId: "legacy:/shared", cwd: "/shared" },
);
const legacy = bindLegacyWorkspaceGit(
{
checkoutPrStatus: vi.fn(),
getCheckoutStatus: vi.fn(),
searchForge: vi.fn(),
searchGitHub: vi.fn(),
},
{ kind: "legacy", cwd: "/shared" },
);
expect(selected.queryIdentity).toEqual(["selected", "legacy:/shared"]);
expect(legacy.queryIdentity).toEqual(["legacy", "/shared"]);
expect(selected.queryIdentity).not.toEqual(legacy.queryIdentity);
});
+168
View File
@@ -0,0 +1,168 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createContext, createElement, type ReactNode, useContext, useMemo } from "react";
export interface SelectedWorkspaceGitAddress {
readonly kind: "selected";
workspaceId: string;
cwd: string;
}
export interface LegacyWorkspaceGitAddress {
readonly kind: "legacy";
cwd: string;
}
export type WorkspaceGitQueryIdentity =
| readonly [kind: "selected", workspaceId: string]
| readonly [kind: "legacy", cwd: string];
export function workspaceGitQueryIdentitiesEqual(
left: WorkspaceGitQueryIdentity,
right: WorkspaceGitQueryIdentity,
): boolean {
return left[0] === right[0] && left[1] === right[1];
}
type WorkspaceGitBinder = Pick<DaemonClient, "bindWorkspaceGit">;
type LegacyWorkspaceGitBinder = Pick<
DaemonClient,
"checkoutPrStatus" | "getCheckoutStatus" | "searchForge" | "searchGitHub"
>;
export function bindSelectedWorkspaceGit(
client: WorkspaceGitBinder,
address: SelectedWorkspaceGitAddress,
) {
const workspaceId = address.workspaceId.trim();
if (workspaceId.length === 0) {
throw new Error("workspaceId is required for selected workspace Git");
}
const cwd = address.cwd.trim();
if (cwd.length === 0) {
throw new Error("cwd is required for selected workspace Git");
}
return Object.assign(client.bindWorkspaceGit({ workspaceId, cwd }), {
kind: "selected" as const,
workspaceId,
cwd,
queryIdentity: ["selected", workspaceId] as const,
});
}
export type WorkspaceGitClient = ReturnType<typeof bindSelectedWorkspaceGit>;
export type WorkspaceGitReadClient = Readonly<
Pick<WorkspaceGitClient, "cwd" | "getPrStatus" | "getStatus" | "searchForge" | "searchGitHub"> & {
queryIdentity: WorkspaceGitQueryIdentity;
}
>;
export type WorkspaceGitTarget = Readonly<
Pick<WorkspaceGitClient, "cwd" | "queryIdentity" | "workspaceId">
>;
export type WorkspaceGitStatusTarget = Readonly<
Pick<WorkspaceGitReadClient, "cwd" | "queryIdentity">
>;
export function bindLegacyWorkspaceGit(
client: LegacyWorkspaceGitBinder,
address: LegacyWorkspaceGitAddress,
): WorkspaceGitReadClient & { readonly kind: "legacy" } {
const cwd = address.cwd.trim();
if (cwd.length === 0) {
throw new Error("cwd is required for legacy workspace Git");
}
return {
kind: "legacy",
cwd,
queryIdentity: ["legacy", cwd],
getPrStatus: () => client.checkoutPrStatus(cwd),
getStatus: () => client.getCheckoutStatus(cwd),
searchForge: (options, requestId) => client.searchForge({ cwd, ...options }, requestId),
searchGitHub: (options, requestId) => client.searchGitHub({ cwd, ...options }, requestId),
};
}
export function PreWorkspaceComposerGitOwner({
client,
cwd,
children,
}: {
client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null;
cwd: string | null;
children?: ReactNode;
}) {
const address = useMemo(() => (cwd === null ? null : ({ kind: "legacy", cwd } as const)), [cwd]);
return createElement(WorkspaceGitOwner, { client, address }, children);
}
type LegacyWorkspaceGitClient = ReturnType<typeof bindLegacyWorkspaceGit>;
type WorkspaceGitCapability = WorkspaceGitClient | LegacyWorkspaceGitClient | null;
const MISSING_WORKSPACE_GIT_OWNER = Symbol("missing-workspace-git-owner");
const WorkspaceGitContext = createContext<
WorkspaceGitCapability | typeof MISSING_WORKSPACE_GIT_OWNER
>(MISSING_WORKSPACE_GIT_OWNER);
export function useBoundWorkspaceGit(
client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null,
address: SelectedWorkspaceGitAddress | null,
): WorkspaceGitClient | null;
export function useBoundWorkspaceGit(
client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null,
address: LegacyWorkspaceGitAddress | null,
): LegacyWorkspaceGitClient | null;
export function useBoundWorkspaceGit(
client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null,
address: SelectedWorkspaceGitAddress | LegacyWorkspaceGitAddress | null,
): WorkspaceGitCapability;
export function useBoundWorkspaceGit(
client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null,
address: SelectedWorkspaceGitAddress | LegacyWorkspaceGitAddress | null,
): WorkspaceGitCapability {
return useMemo(() => {
if (!client || !address) return null;
return address.kind === "selected"
? bindSelectedWorkspaceGit(client, address)
: bindLegacyWorkspaceGit(client, address);
}, [address, client]);
}
export function WorkspaceGitBoundary({
workspaceGit,
children,
}: {
workspaceGit: WorkspaceGitCapability;
children?: ReactNode;
}) {
return createElement(WorkspaceGitContext.Provider, { value: workspaceGit }, children);
}
export function WorkspaceGitOwner({
client,
address,
children,
}: {
client: (WorkspaceGitBinder & LegacyWorkspaceGitBinder) | null;
address: SelectedWorkspaceGitAddress | LegacyWorkspaceGitAddress | null;
children?: ReactNode;
}) {
const workspaceGit = useBoundWorkspaceGit(client, address);
return createElement(WorkspaceGitBoundary, { workspaceGit }, children);
}
export function useWorkspaceGit(): WorkspaceGitReadClient | null {
const workspaceGit = useContext(WorkspaceGitContext);
if (workspaceGit === MISSING_WORKSPACE_GIT_OWNER) {
throw new Error("Workspace Git owner is missing");
}
return workspaceGit;
}
export function useRequiredWorkspaceGit(): WorkspaceGitClient {
const workspaceGit = useContext(WorkspaceGitContext);
if (workspaceGit === MISSING_WORKSPACE_GIT_OWNER) {
throw new Error("Workspace Git owner is missing");
}
if (!workspaceGit || workspaceGit.kind !== "selected") {
throw new Error("Selected workspace Git is not bound");
}
return workspaceGit;
}
@@ -25,6 +25,7 @@ export interface SidebarWorkspacePlacement {
projectName: string; projectName: string;
projectRootPath?: string; projectRootPath?: string;
workspaceDirectory?: string; workspaceDirectory?: string;
hostVisiblePath?: string;
projectKind: WorkspaceStructureProject["projectKind"]; projectKind: WorkspaceStructureProject["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string; name: string;
@@ -159,6 +160,7 @@ export function createSidebarWorkspaceEntry(input: {
projectName: projectNameForWorkspace(input.workspace), projectName: projectNameForWorkspace(input.workspace),
projectRootPath: input.workspace.projectRootPath, projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory, workspaceDirectory: input.workspace.workspaceDirectory,
hostVisiblePath: input.workspace.hostVisiblePath,
workspaceDirectoryLabel: workspaceDirectoryLabel:
input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory), input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory),
projectKind: input.workspace.projectKind, projectKind: input.workspace.projectKind,
+11 -2
View File
@@ -43,6 +43,8 @@ import type { MaterializedAgentProfile } from "@/agent-profiles";
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form"; export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
export interface UseAgentFormStateOptions { export interface UseAgentFormStateOptions {
workspaceId?: string | null;
providerSnapshotCwd?: string | null;
initialServerId?: string | null; initialServerId?: string | null;
initialValues?: FormInitialValues; initialValues?: FormInitialValues;
isVisible?: boolean; isVisible?: boolean;
@@ -207,7 +209,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
initialValues, initialValues,
isVisible = true, isVisible = true,
isCreateFlow = true, isCreateFlow = true,
isTargetDaemonReady: _isTargetDaemonReady = true, isTargetDaemonReady = true,
onlineServerIds = [], onlineServerIds = [],
} = options; } = options;
@@ -269,7 +271,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
error: snapshotError, error: snapshotError,
refresh: refreshSnapshot, refresh: refreshSnapshot,
refetchIfStale: refetchSnapshotIfStale, refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(formState.serverId, { cwd: formState.workingDir }); } = useProvidersSnapshot(formState.serverId, {
enabled: isTargetDaemonReady,
cwd:
options.providerSnapshotCwd === undefined
? formState.workingDir
: options.providerSnapshotCwd,
workspaceId: options.workspaceId,
});
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo( const snapshotProviderDefinitions = useMemo(
+8 -20
View File
@@ -1,18 +1,15 @@
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo } from "react";
import { useQuery, type QueryClient } from "@tanstack/react-query"; import { useQuery, type QueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ComboboxOption } from "@/components/ui/combobox"; import type { ComboboxOption } from "@/components/ui/combobox";
import type { ToastApi } from "@/components/toast-host"; import type { ToastApi } from "@/components/toast-host";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { createBranchSwitcherOperations } from "@/git/branch-switcher-operations"; import { createBranchSwitcherOperations } from "@/git/branch-switcher-operations";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
interface UseBranchSwitcherInput { interface UseBranchSwitcherInput {
client: DaemonClient | null;
normalizedServerId: string; normalizedServerId: string;
normalizedWorkspaceId: string;
workspaceDirectory: string | null;
currentBranchName: string | null; currentBranchName: string | null;
isGitCheckout: boolean; isGitCheckout: boolean;
isConnected: boolean; isConnected: boolean;
@@ -29,10 +26,7 @@ interface UseBranchSwitcherResult {
} }
export function useBranchSwitcher({ export function useBranchSwitcher({
client,
normalizedServerId, normalizedServerId,
normalizedWorkspaceId,
workspaceDirectory,
currentBranchName, currentBranchName,
isGitCheckout, isGitCheckout,
isConnected, isConnected,
@@ -40,20 +34,15 @@ export function useBranchSwitcher({
queryClient, queryClient,
}: UseBranchSwitcherInput): UseBranchSwitcherResult { }: UseBranchSwitcherInput): UseBranchSwitcherResult {
const { t } = useTranslation(); const { t } = useTranslation();
const workspaceGit = useRequiredWorkspaceGit();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
// Git operations are bound to the workspace directory; the opaque workspace id is // Git operations are bound to the workspace directory; the opaque workspace id is
// used only for query cache identity below, never as a cwd. // used only for query cache identity below, never as a cwd.
const operations = useMemo( const operations = useMemo(() => createBranchSwitcherOperations(workspaceGit), [workspaceGit]);
() =>
client && workspaceDirectory
? createBranchSwitcherOperations(client, workspaceDirectory)
: null,
[client, workspaceDirectory],
);
const branchSuggestionsQuery = useQuery({ const branchSuggestionsQuery = useQuery({
queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId], queryKey: ["branchSuggestions", normalizedServerId, workspaceGit.workspaceId],
queryFn: async () => { queryFn: async () => {
if (!operations) { if (!operations) {
throw new Error(t("common.errors.daemonClientUnavailable")); throw new Error(t("common.errors.daemonClientUnavailable"));
@@ -75,20 +64,19 @@ export function useBranchSwitcher({
}, [branchSuggestionsQuery.data]); }, [branchSuggestionsQuery.data]);
const stashListQueryKey = useMemo( const stashListQueryKey = useMemo(
() => ["stashList", normalizedServerId, normalizedWorkspaceId] as const, () => ["stashList", normalizedServerId, workspaceGit.workspaceId] as const,
[normalizedServerId, normalizedWorkspaceId], [normalizedServerId, workspaceGit.workspaceId],
); );
const invalidateStashAndCheckout = useCallback(async () => { const invalidateStashAndCheckout = useCallback(async () => {
if (!workspaceDirectory) return;
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: stashListQueryKey }), queryClient.invalidateQueries({ queryKey: stashListQueryKey }),
invalidateCheckoutGitQueriesForClient(queryClient, { invalidateCheckoutGitQueriesForClient(queryClient, {
serverId: normalizedServerId, serverId: normalizedServerId,
cwd: workspaceDirectory, target: workspaceGit,
}), }),
]); ]);
}, [queryClient, stashListQueryKey, normalizedServerId, workspaceDirectory]); }, [queryClient, stashListQueryKey, normalizedServerId, workspaceGit]);
const maybeRestoreStashForBranch = useCallback( const maybeRestoreStashForBranch = useCallback(
async (branchId: string) => { async (branchId: string) => {
@@ -138,7 +138,12 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
} }
try { try {
const directory = await client.listDirectory(normalizedWorkspaceRoot, normalizedPath); const directory = await client.listDirectory(
normalizedWorkspaceRoot,
normalizedPath,
undefined,
workspaceId ?? undefined,
);
updateExplorerState((state) => { updateExplorerState((state) => {
const nextState: AgentFileExplorerState = { const nextState: AgentFileExplorerState = {
...state, ...state,
@@ -169,7 +174,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
return null; return null;
} }
}, },
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey], [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceId, workspaceStateKey],
); );
const requestFilePreview = useCallback( const requestFilePreview = useCallback(
@@ -206,7 +211,12 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
} }
try { try {
const file = await client.readFile(normalizedWorkspaceRoot, normalizedPath); const file = await client.readFile(
normalizedWorkspaceRoot,
normalizedPath,
undefined,
workspaceId ?? undefined,
);
updateExplorerState((state) => { updateExplorerState((state) => {
const nextState: AgentFileExplorerState = { const nextState: AgentFileExplorerState = {
...state, ...state,
@@ -233,7 +243,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
})); }));
} }
}, },
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey], [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceId, workspaceStateKey],
); );
const requestFileDownloadToken = useCallback( const requestFileDownloadToken = useCallback(
@@ -244,13 +254,18 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
if (!client) { if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected")); throw new Error(t("workspace.terminal.hostDisconnected"));
} }
const payload = await client.requestDownloadToken(normalizedWorkspaceRoot, path); const payload = await client.requestDownloadToken(
normalizedWorkspaceRoot,
path,
undefined,
workspaceId ?? undefined,
);
if (payload.error) { if (payload.error) {
throw new Error(payload.error); throw new Error(payload.error);
} }
return payload; return payload;
}, },
[client, normalizedWorkspaceRoot, t], [client, normalizedWorkspaceRoot, t, workspaceId],
); );
const createEntry = useCallback( const createEntry = useCallback(
@@ -185,7 +185,7 @@ describe("fetchProvidersSnapshot", () => {
expect(cache.writes).toEqual([ expect(cache.writes).toEqual([
{ {
serverId, serverId,
cwd: "/repo-a", scope: { type: "legacy-cwd", cwd: "/repo-a" },
hash: "next-hash", hash: "next-hash",
generatedAt: "2026-01-02T00:00:00.000Z", generatedAt: "2026-01-02T00:00:00.000Z",
compactSnapshot, compactSnapshot,
@@ -369,7 +369,7 @@ describe("applyProvidersSnapshotUpdate", () => {
expect(cache.writes).toEqual([ expect(cache.writes).toEqual([
{ {
serverId, serverId,
cwd: "/repo-a", scope: { type: "legacy-cwd", cwd: "/repo-a" },
hash: "push-hash", hash: "push-hash",
generatedAt: "2026-01-01T00:00:01.000Z", generatedAt: "2026-01-01T00:00:01.000Z",
compactSnapshot, compactSnapshot,

Some files were not shown because too many files have changed in this diff Show More