mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3910bdc0 | ||
|
|
b607bed49c | ||
|
|
ec12c5c35c | ||
|
|
1daccc4414 | ||
|
|
af140997a3 | ||
|
|
98bf569448 | ||
|
|
bdfa718c83 | ||
|
|
791939081b | ||
|
|
18ec41670f | ||
|
|
add858f3e8 | ||
|
|
92a1e3fa66 | ||
|
|
91f76c9494 | ||
|
|
d58230404b | ||
|
|
692d4777c6 | ||
|
|
3a8824ed99 | ||
|
|
6d16961d58 | ||
|
|
16e0967408 | ||
|
|
697a0002d9 | ||
|
|
1518eb09a9 | ||
|
|
3175a5ff10 | ||
|
|
eafa5f2b5c | ||
|
|
12c1a68956 | ||
|
|
cd354158f5 | ||
|
|
cf4d702a8e | ||
|
|
e5f3b3dc02 | ||
|
|
b500809229 | ||
|
|
642c303859 | ||
|
|
a67381795b | ||
|
|
8a37f2f5d3 | ||
|
|
54f0e636b9 | ||
|
|
cd429339a0 | ||
|
|
51193e7a92 | ||
|
|
d35c10a8b5 | ||
|
|
ff83ca4bdc | ||
|
|
accd2d7587 | ||
|
|
2501a46f3e | ||
|
|
e2ac23da70 | ||
|
|
7c32fcda98 | ||
|
|
36d8464068 | ||
|
|
cbb8295ceb | ||
|
|
33afa7283b | ||
|
|
527b252497 | ||
|
|
d03e4298ab | ||
|
|
359d5443a9 | ||
|
|
3a36c76733 | ||
|
|
6ca27a9b12 | ||
|
|
cbf59f4c96 | ||
|
|
36241cbd87 | ||
|
|
0520481966 | ||
|
|
b682f56bb9 | ||
|
|
d340365245 | ||
|
|
4898523351 | ||
|
|
a18d9ec3d8 |
@@ -35,6 +35,9 @@ hub:
|
||||
|
||||
server:
|
||||
- "packages/server/**"
|
||||
- "runtimes/fixture/**"
|
||||
- "packages/workspace-helper/**"
|
||||
- "packages/workspace-runtime-contract/**"
|
||||
- "packages/app/e2e/support/fixtures/recording.*"
|
||||
|
||||
desktop:
|
||||
|
||||
@@ -102,6 +102,9 @@ jobs:
|
||||
- name: 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:
|
||||
name: typecheck
|
||||
needs: changes
|
||||
@@ -129,6 +132,8 @@ jobs:
|
||||
- name: Verify public package contents
|
||||
run: |
|
||||
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/server
|
||||
|
||||
@@ -163,6 +168,18 @@ jobs:
|
||||
- name: Build server dependencies
|
||||
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
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
|
||||
@@ -16,6 +16,9 @@ on:
|
||||
- "packages/app/**"
|
||||
- "packages/cli/**"
|
||||
- "packages/client/**"
|
||||
- "packages/desktop/**"
|
||||
- "packages/workspace-helper/**"
|
||||
- "packages/workspace-runtime-contract/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
|
||||
+138
@@ -84,6 +84,144 @@
|
||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||
},
|
||||
"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}"],
|
||||
"rules": {
|
||||
|
||||
@@ -22,6 +22,8 @@ RUN set -eux; \
|
||||
npm pack --workspace=@getpaseo/highlight --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/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=@paseo/plugin --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||
|
||||
@@ -75,6 +75,8 @@ not retain non-Git directories.
|
||||
| `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/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/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;
|
||||
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
|
||||
|
||||
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
|
||||
|
||||
+26
-15
@@ -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
|
||||
are observed too.
|
||||
|
||||
The workspace registry model defines placement once: initial directory/worktree construction,
|
||||
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy
|
||||
preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding
|
||||
registry writes, so directory opens, agent imports, and worktree creation all enter through that
|
||||
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
|
||||
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive,
|
||||
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
|
||||
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
|
||||
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
|
||||
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
|
||||
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
|
||||
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
|
||||
The workspace registry model defines public placement once: initial directory/worktree
|
||||
construction, mutable reconciliation fields, and the persisted-to-wire projection.
|
||||
`WorkspaceProvisioningService` owns the corresponding registry writes, so directory opens, agent
|
||||
imports, and worktree creation do not construct records independently.
|
||||
|
||||
For a runtime-selected workspace, `runtime.runtimeId` is the only persisted runtime identity. `cwd`
|
||||
is required compatibility and presentation data: an adopted local directory, the actual host
|
||||
worktree path, or a runtime-local path such as Docker's `/workspace`. It is not globally unique and
|
||||
never selects a runtime. `hostVisiblePath` is the separate optional capability used by editor and
|
||||
file-manager actions. Private roots, revisions, execution domains, container and volume IDs,
|
||||
labels, supervisor state, and ownership metadata are recovered from driver `inspect(workspaceId)`
|
||||
and are never written to the workspace record.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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. |
|
||||
| `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 |
|
||||
| `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`". |
|
||||
@@ -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 |
|
||||
| `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` |
|
||||
| `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) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `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. |
|
||||
| `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
|
||||
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
|
||||
|
||||
+1
-1
@@ -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
|
||||
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
|
||||
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`
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ The image source lives in [`docker/`](../docker/).
|
||||
|
||||
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
|
||||
- listens on `0.0.0.0:6767` inside the container
|
||||
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
|
||||
|
||||
+5
-3
@@ -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.
|
||||
- **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.
|
||||
- **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).
|
||||
- **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`).
|
||||
- **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.
|
||||
- **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".
|
||||
- **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).
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
+5
-3
@@ -103,7 +103,9 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ Evidence is something someone else can look at:
|
||||
- A video of the interaction
|
||||
- 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.
|
||||
|
||||
## Does it work well
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM=
|
||||
sha256-YJpDgaoaBS1lCJIVhxBIfeNelyYRsJMcN/HgEO4KehM=
|
||||
|
||||
Generated
+65
-11
@@ -20,7 +20,10 @@
|
||||
"packages/relay",
|
||||
"packages/website",
|
||||
"packages/desktop",
|
||||
"packages/cli"
|
||||
"packages/cli",
|
||||
"packages/workspace-runtime-contract",
|
||||
"packages/workspace-helper",
|
||||
"runtimes/fixture"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.14",
|
||||
@@ -6256,6 +6259,10 @@
|
||||
"resolved": "packages/expo-two-way-audio",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/fixture-workspace-runtime": {
|
||||
"resolved": "runtimes/fixture",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/highlight": {
|
||||
"resolved": "packages/highlight",
|
||||
"link": true
|
||||
@@ -6276,6 +6283,14 @@
|
||||
"resolved": "packages/website",
|
||||
"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": {
|
||||
"version": "5.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.14.tgz",
|
||||
@@ -28470,6 +28485,16 @@
|
||||
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||
"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": {
|
||||
"version": "2.0.36",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||
@@ -38847,6 +38872,8 @@
|
||||
"@getpaseo/highlight": "0.4.0",
|
||||
"@getpaseo/protocol": "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",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39695,16 +39722,6 @@
|
||||
"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": {
|
||||
"version": "6.44.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz",
|
||||
@@ -41501,6 +41518,43 @@
|
||||
"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
@@ -31,7 +31,10 @@
|
||||
"packages/relay",
|
||||
"packages/website",
|
||||
"packages/desktop",
|
||||
"packages/cli"
|
||||
"packages/cli",
|
||||
"packages/workspace-runtime-contract",
|
||||
"packages/workspace-helper",
|
||||
"runtimes/fixture"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev:server",
|
||||
@@ -55,8 +58,11 @@
|
||||
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
|
||||
"build:client": "npm run build --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: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-runtime-contract": "npm run build --workspace=@getpaseo/workspace-runtime-contract",
|
||||
"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: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",
|
||||
@@ -72,6 +78,7 @@
|
||||
"format:check": "oxfmt --check .",
|
||||
"format:check:files": "oxfmt --check",
|
||||
"lint": "oxlint",
|
||||
"check:workspace-runtime-boundaries": "node scripts/check-workspace-runtime-boundaries.mjs",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"knip": "knip",
|
||||
"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:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||
"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: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: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": "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: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: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/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/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/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/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: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",
|
||||
|
||||
@@ -809,6 +809,7 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
|
||||
);
|
||||
});
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(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.";
|
||||
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||
await recordTurnFrames(page, prompt);
|
||||
await submitMessage(page, prompt);
|
||||
|
||||
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
|
||||
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);
|
||||
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
|
||||
gate.setServerMessageSuppressed("fetch_agent_timeline_response", false);
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", false);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
selectWorkspaceIsolation,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { openNewWorkspaceComposer, selectWorkspaceRuntime } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
@@ -177,7 +174,7 @@ test.describe("New Workspace dictation submit", () => {
|
||||
projectKey: seeded.projectKey,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "local");
|
||||
await selectWorkspaceRuntime(page, "local");
|
||||
|
||||
await dictateAndSend(page, harness.waitForAudio);
|
||||
await harness.waitForCreateRequest();
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectNewWorkspaceControlsEnabled,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
expectWorkspaceRuntimeChoices,
|
||||
expectWorkspaceRuntimeSelected,
|
||||
expectNewWorkspaceTriggerLabelsAligned,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openMissingProjectNewWorkspaceComposer,
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
// (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
|
||||
// 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) {
|
||||
return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
|
||||
@@ -134,10 +136,11 @@ test.describe("New workspace entry points", () => {
|
||||
sourceDirectory: "/tmp/missing-project",
|
||||
});
|
||||
|
||||
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible({ timeout: 30_000 });
|
||||
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-" });
|
||||
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
test("the Runtime control hides Git runtimes for a non-git project", async ({ page }) => {
|
||||
const gitProject: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-iso-git-" });
|
||||
const nonGitProject: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "entry-iso-nongit-",
|
||||
@@ -274,11 +275,10 @@ test.describe("New workspace entry points", () => {
|
||||
await nonGitOption.click();
|
||||
await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName);
|
||||
|
||||
// No git checkout means no worktree isolation choice: the Isolation row is
|
||||
// absent entirely.
|
||||
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
|
||||
await expectWorkspaceRuntimeSelected(page, "local");
|
||||
await expectWorkspaceRuntimeChoices(page, ["local"]);
|
||||
|
||||
// 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();
|
||||
const gitOption = page.getByTestId(
|
||||
`new-workspace-project-picker-option-${projectEquivalenceViewKey(gitProject.projectKey)}`,
|
||||
@@ -287,9 +287,8 @@ test.describe("New workspace entry points", () => {
|
||||
await gitOption.click();
|
||||
await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName);
|
||||
|
||||
await expect(page.getByTestId("workspace-create-isolation-trigger")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceRuntimeSelected(page, "local");
|
||||
await expectWorkspaceRuntimeChoices(page, ["local", "worktree"]);
|
||||
} finally {
|
||||
await gitProject.cleanup();
|
||||
await nonGitProject.cleanup();
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
archiveWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectWorkspaceIsolationSelected,
|
||||
expectWorkspaceRuntimeSelected,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
gotoNewWorkspaceForRuntime,
|
||||
seedGitProjectForRuntime,
|
||||
} from "../support/helpers/new-workspace-runtime";
|
||||
import { expectNoTruncation } from "../support/helpers/no-truncation";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
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
|
||||
// remembered." The isolation choice persists in the create-form preferences
|
||||
// (FormPreferences.isolation), so it must survive the create→reopen remount:
|
||||
// creating a worktree workspace navigates away from /new and unmounts it, and
|
||||
// reopening New Workspace has to still show "New worktree".
|
||||
test.describe("New workspace isolation memory", () => {
|
||||
// Regression for "the Local / Worktree runtime selection in New Workspace is not
|
||||
// remembered." The runtime choice persists in the create-form preferences, so it
|
||||
// must survive the create→reopen remount: creating a Worktree workspace
|
||||
// navigates away from /new and unmounts it.
|
||||
test.describe("New workspace runtime memory", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
@@ -33,45 +28,26 @@ test.describe("New workspace isolation memory", () => {
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (client) {
|
||||
for (const workspaceDirectory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of localWorkspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceDirectory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
localWorkspaceIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
|
||||
test("remembers the Worktree runtime after creating a workspace", async ({ page }) => {
|
||||
const project = await seedGitProjectForRuntime();
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
// First visit: the screen opens on Local, switch it to New worktree and create.
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
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");
|
||||
// First visit: the screen opens on Local, switch it to Worktree and create.
|
||||
await gotoNewWorkspaceForRuntime(page, project);
|
||||
await expectWorkspaceRuntimeSelected(page, "local");
|
||||
await page.getByRole("button", { name: "Runtime", exact: true }).click();
|
||||
const runtimePopup = page.getByRole("dialog").last();
|
||||
await expect(runtimePopup).toBeVisible({ timeout: 30_000 });
|
||||
await expectNoTruncation(runtimePopup);
|
||||
await runtimePopup.getByRole("button", { name: "Worktree", exact: true }).click();
|
||||
await expectWorkspaceRuntimeSelected(page, "worktree");
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
@@ -80,21 +56,21 @@ test.describe("New workspace isolation memory", () => {
|
||||
await createButton.click();
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
previousWorkspaceId: "",
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
});
|
||||
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, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
projectKey: project.projectKey,
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceIsolationSelected(page, "worktree");
|
||||
await expectWorkspaceRuntimeSelected(page, "worktree");
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,13 +21,13 @@ import {
|
||||
type TerminalProfileSeed,
|
||||
} from "../support/helpers/new-workspace-launch";
|
||||
|
||||
// `sleep` keeps the terminal alive long enough for the UI to attach and
|
||||
// render output before the process exits — see new-workspace-launch-terminal.spec.ts.
|
||||
// Loaded CI workers can take several seconds to materialize the runtime and attach the UI.
|
||||
// Keep the fixture bounded but alive through that assertion window.
|
||||
const PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-memory-profile",
|
||||
name: "Memory Profile",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"],
|
||||
args: ["-c", 'echo remembered: "$0"; sleep 60', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
function hasLeftNewWorkspaceRoute(url: URL): boolean {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
openNewWorkspaceComposer,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
selectWorkspaceIsolation,
|
||||
selectWorkspaceRuntime,
|
||||
} 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 { seedSavedSettingsHosts } from "../support/helpers/settings";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const LONG_HOST_NAME =
|
||||
"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", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
let project: SeededRuntimeProject;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "new-workspace-layout-",
|
||||
repo: { branches: [LONG_BRANCH_NAME] },
|
||||
});
|
||||
project = await seedGitProjectForRuntime({ branches: [LONG_BRANCH_NAME] });
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
await project?.cleanup();
|
||||
});
|
||||
|
||||
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 waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectKey,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await gotoNewWorkspaceForRuntime(page, project);
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, LONG_BRANCH_NAME);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
selectWorkspaceIsolation,
|
||||
selectWorkspaceRuntime,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
@@ -723,7 +723,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
|
||||
@@ -771,7 +771,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
return openedProject;
|
||||
}
|
||||
|
||||
@@ -905,7 +905,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
@@ -930,7 +930,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
@@ -960,7 +960,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectGitHubPrInPicker(page, pr.number);
|
||||
|
||||
@@ -996,7 +996,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "main");
|
||||
|
||||
@@ -1054,7 +1054,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
await pasteGithubPrUrl(page, context, pr.url);
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: pr.number,
|
||||
@@ -1102,7 +1102,7 @@ test.describe("New workspace flow", () => {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await selectWorkspaceRuntime(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectGitHubPrInPicker(page, pr.number);
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectWorkspaceIsolation,
|
||||
selectWorkspaceRuntime,
|
||||
submitNewWorkspaceEmpty,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
@@ -271,7 +271,7 @@ test.describe("Workspace model regressions", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await selectWorkspaceIsolation(page, "local");
|
||||
await selectWorkspaceRuntime(page, "local");
|
||||
await submitNewWorkspacePrompt(page, "Fix login bug");
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
@@ -356,7 +356,7 @@ test.describe("Workspace model regressions", () => {
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await selectWorkspaceIsolation(page, "local");
|
||||
await selectWorkspaceRuntime(page, "local");
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
@@ -472,7 +472,7 @@ test.describe("Workspace model regressions", () => {
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await selectWorkspaceIsolation(page, "local");
|
||||
await selectWorkspaceRuntime(page, "local");
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
selectWorkspaceIsolation,
|
||||
selectWorkspaceRuntime,
|
||||
submitNewWorkspaceEmpty,
|
||||
} from "../support/helpers/new-workspace";
|
||||
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 { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
// Model B reshape: a workspace is the unit, its isolation (local checkout or
|
||||
// worktree) is a CHOICE at creation, and creation NEVER dedupes by
|
||||
// Model B reshape: a workspace is the unit, its runtime (Local or Worktree) is
|
||||
// a CHOICE at creation, and creation NEVER dedupes by
|
||||
// directory. These specs drive the real creation UI (workspace-create-* test
|
||||
// IDs) to prove a single directory can back any number of workspaces.
|
||||
|
||||
@@ -39,17 +39,16 @@ async function createWorkspaceViaUi(
|
||||
page: Page,
|
||||
input: {
|
||||
project: { projectKey: string; projectDisplayName: string };
|
||||
// null when the project has no git checkout: there is no Isolation control to
|
||||
// touch, the isolation is implicitly local.
|
||||
isolation: "local" | "worktree" | null;
|
||||
// null when the project has no git checkout: Local is the only available runtime.
|
||||
runtime: "local" | "worktree" | null;
|
||||
previousWorkspaceId: string;
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
},
|
||||
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, input.project);
|
||||
if (input.isolation !== null) {
|
||||
await selectWorkspaceIsolation(page, input.isolation);
|
||||
if (input.runtime !== null) {
|
||||
await selectWorkspaceRuntime(page, input.runtime);
|
||||
}
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
|
||||
@@ -97,7 +96,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
const second = await createWorkspaceViaUi(page, {
|
||||
project,
|
||||
isolation: "local",
|
||||
runtime: "local",
|
||||
previousWorkspaceId: seeded.workspaceId,
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
test("the Worktree runtime creates a workspace in a distinct directory", async ({ page }) => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "multiplicity-worktree-",
|
||||
});
|
||||
@@ -149,7 +146,7 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
const worktree = await createWorkspaceViaUi(page, {
|
||||
project,
|
||||
isolation: "worktree",
|
||||
runtime: "worktree",
|
||||
previousWorkspaceId: seeded.workspaceId,
|
||||
client,
|
||||
});
|
||||
@@ -200,8 +197,8 @@ test.describe("Workspace multiplicity creation flow", () => {
|
||||
|
||||
const second = await createWorkspaceViaUi(page, {
|
||||
project,
|
||||
// Non-git project: no Isolation control, isolation is implicitly local.
|
||||
isolation: null,
|
||||
// Non-git project: Local is the only available runtime.
|
||||
runtime: null,
|
||||
previousWorkspaceId: seeded.workspaceId,
|
||||
client,
|
||||
});
|
||||
|
||||
@@ -59,15 +59,16 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const agents = await Promise.all(
|
||||
Array.from({ length: options.agentCount ?? 1 }, () =>
|
||||
createIdleAgent(client, {
|
||||
const agents = [];
|
||||
for (let index = 0; index < (options.agentCount ?? 1); index += 1) {
|
||||
agents.push(
|
||||
await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
const agent = agents[0];
|
||||
if (!agent) {
|
||||
throw new Error("Expected at least one archived-worktree agent");
|
||||
@@ -369,10 +370,7 @@ test.describe("Worktree restore", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText(
|
||||
"The archived workspace directory no longer exists and cannot be recreated.",
|
||||
{ exact: true },
|
||||
),
|
||||
page.getByText("The archived workspace runtime is missing.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 path from "node:path";
|
||||
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(
|
||||
workerIndex: number,
|
||||
options: { forkProviders?: string[] } = {},
|
||||
@@ -154,6 +223,7 @@ export async function startE2EWorker(
|
||||
|
||||
try {
|
||||
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
|
||||
await configureDesktopFixtureRuntime(paseoHome);
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
paseoHome,
|
||||
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> {
|
||||
await page.keyboard.press("Control+P");
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+P" : "Control+P");
|
||||
|
||||
const searchInput = page.getByPlaceholder("Search projects");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
@@ -319,35 +319,61 @@ export async function selectNewWorkspaceProject(
|
||||
await expectNewWorkspaceProjectSelected(page, input.projectDisplayName);
|
||||
}
|
||||
|
||||
// The isolation trigger renders the active isolation's label ("Local" / "New
|
||||
// worktree"), so asserting its text proves what the screen currently remembers.
|
||||
const ISOLATION_TRIGGER_LABEL: Record<"local" | "worktree", string> = {
|
||||
type WorkspaceRuntimeChoice = "local" | "worktree";
|
||||
|
||||
const WORKSPACE_RUNTIME_LABEL: Record<WorkspaceRuntimeChoice, string> = {
|
||||
local: "Local",
|
||||
worktree: "New worktree",
|
||||
worktree: "Worktree",
|
||||
};
|
||||
|
||||
export async function expectWorkspaceIsolationSelected(
|
||||
export async function expectWorkspaceRuntimeSelected(
|
||||
page: Page,
|
||||
isolation: "local" | "worktree",
|
||||
runtime: WorkspaceRuntimeChoice,
|
||||
): 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).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,
|
||||
isolation: "local" | "worktree",
|
||||
runtime: WorkspaceRuntimeChoice,
|
||||
): 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 trigger.click();
|
||||
|
||||
// Isolation options are derived from project capability. Wait for the option
|
||||
// so this helper also covers route-to-project reconciliation.
|
||||
const option = page.getByTestId(`workspace-create-isolation-${isolation}`);
|
||||
const runtimePicker = page.getByRole("dialog").last();
|
||||
await expect(runtimePicker).toBeVisible({ timeout: 30_000 });
|
||||
const option = runtimePicker.getByRole("button", {
|
||||
name: WORKSPACE_RUNTIME_LABEL[runtime],
|
||||
exact: true,
|
||||
});
|
||||
await expect(option).toBeVisible({ timeout: 30_000 });
|
||||
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> {
|
||||
@@ -464,7 +490,7 @@ export async function pasteGithubPrUrl(
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.evaluate((value) => navigator.clipboard.writeText(value), url);
|
||||
await composer.focus();
|
||||
await page.keyboard.press("Control+V");
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
|
||||
}
|
||||
|
||||
export async function assertNewWorkspaceSidebarAndHeader(
|
||||
@@ -476,6 +502,7 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
projectDisplayName: string;
|
||||
assertSidebarRow?: boolean;
|
||||
assertHeader?: boolean;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
|
||||
// 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);
|
||||
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
{ timeout: input.timeoutMs ?? 60_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
cancelAgent(agentId: string): Promise<void>;
|
||||
refreshAgent(agentId: string): Promise<unknown>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
@@ -173,6 +174,10 @@ export interface SeedDaemonClient {
|
||||
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||
): () => void;
|
||||
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?: {
|
||||
|
||||
@@ -650,6 +650,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaceId={context.workspaceId}
|
||||
serverId={resolvedServerId}
|
||||
client={client}
|
||||
spacing={layoutItem.assistantSpacing}
|
||||
@@ -658,7 +659,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
</AssistantFileLinkResolverProvider>
|
||||
);
|
||||
},
|
||||
[agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
|
||||
[
|
||||
agentId,
|
||||
client,
|
||||
context.workspaceId,
|
||||
handleInlinePathPress,
|
||||
resolvedServerId,
|
||||
toast,
|
||||
workspaceRoot,
|
||||
],
|
||||
);
|
||||
|
||||
const renderThoughtItem = useCallback(
|
||||
|
||||
@@ -94,6 +94,25 @@ describe("assistant image acquisition cache", () => {
|
||||
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", () => {
|
||||
const first = createAssistantImageFilePreviewAttachmentId({
|
||||
serverId: "server-1",
|
||||
|
||||
@@ -19,6 +19,7 @@ export function createAssistantImageOccurrenceKey(input: {
|
||||
|
||||
export function createAssistantImageFilePreviewAttachmentId(input: {
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
occurrenceKey: string;
|
||||
mimeType: string;
|
||||
path: string;
|
||||
@@ -32,17 +33,18 @@ export function createAssistantImageFilePreviewAttachmentId(input: {
|
||||
size: input.size,
|
||||
modifiedAt: input.modifiedAt,
|
||||
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: {
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
occurrenceKey: string;
|
||||
cwd: string;
|
||||
path: 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: {
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
} from "./file-acquisition";
|
||||
|
||||
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) {
|
||||
this.reads.push({ cwd, path });
|
||||
async readFile(cwd: string, path: string, workspaceId?: string) {
|
||||
this.reads.push({ cwd, path, ...(workspaceId ? { workspaceId } : {}) });
|
||||
return {
|
||||
kind: "image" as const,
|
||||
path,
|
||||
@@ -38,6 +38,7 @@ describe("assistant image file acquisition", () => {
|
||||
const common = {
|
||||
resolution: { kind: "file_rpc" as const, cwd: "/workspace", path: "reconnect.png" },
|
||||
serverId: "server",
|
||||
workspaceId: "workspace-selected",
|
||||
occurrenceKey: "agent:message:reconnect-image",
|
||||
unavailableMessage: "Image unavailable",
|
||||
};
|
||||
@@ -48,6 +49,23 @@ describe("assistant image file acquisition", () => {
|
||||
expect(disconnected?.key).toBe(connected?.key);
|
||||
await expect(disconnected?.locate()).rejects.toThrow("Image unavailable");
|
||||
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";
|
||||
|
||||
export interface AssistantImageFileAcquisitionPort {
|
||||
readFile(cwd: string, path: string): Promise<FileReadResult>;
|
||||
readFile(cwd: string, path: string, workspaceId?: string): Promise<FileReadResult>;
|
||||
persist(input: {
|
||||
id: string;
|
||||
bytes: Uint8Array;
|
||||
@@ -26,6 +26,7 @@ export function createAssistantImageFileAcquisition(input: {
|
||||
port: AssistantImageFileAcquisitionPort | null;
|
||||
resolution: AssistantImageSourceResolution | null;
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
occurrenceKey: string;
|
||||
unavailableMessage: string;
|
||||
}): AssistantImageAcquisition | null {
|
||||
@@ -36,6 +37,7 @@ export function createAssistantImageFileAcquisition(input: {
|
||||
return {
|
||||
key: createAssistantImageFileAcquisitionKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
occurrenceKey: input.occurrenceKey,
|
||||
cwd: resolution.cwd,
|
||||
path: resolution.path,
|
||||
@@ -44,13 +46,14 @@ export function createAssistantImageFileAcquisition(input: {
|
||||
if (!port) {
|
||||
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") {
|
||||
throw new Error(input.unavailableMessage);
|
||||
}
|
||||
return await port.persist({
|
||||
id: createAssistantImageFilePreviewAttachmentId({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
occurrenceKey: input.occurrenceKey,
|
||||
mimeType: file.mime,
|
||||
path: file.path || resolution.path,
|
||||
|
||||
@@ -82,6 +82,7 @@ interface UseAssistantImageInput {
|
||||
client?: DaemonClient | null;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
type PreviewUrlState =
|
||||
@@ -377,6 +378,7 @@ export function useAssistantImage({
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
workspaceId,
|
||||
}: UseAssistantImageInput): AssistantImageResult {
|
||||
const { t } = useTranslation();
|
||||
const resolution = useMemo(
|
||||
@@ -387,7 +389,8 @@ export function useAssistantImage({
|
||||
const fileAcquisition = useMemo(() => {
|
||||
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,
|
||||
}
|
||||
: null;
|
||||
@@ -395,10 +398,11 @@ export function useAssistantImage({
|
||||
port,
|
||||
resolution,
|
||||
serverId,
|
||||
workspaceId,
|
||||
occurrenceKey,
|
||||
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
|
||||
});
|
||||
}, [client, occurrenceKey, resolution, serverId, t]);
|
||||
}, [client, occurrenceKey, resolution, serverId, t, workspaceId]);
|
||||
const dataImageAcquisition = useMemo(
|
||||
() => createDataImageAcquisition({ source, dataImage }),
|
||||
[dataImage, source],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getIsElectron } from "@/constants/platform";
|
||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
|
||||
import { useBoundWorkspaceGit, WorkspaceGitBoundary } from "@/git/workspace-git";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import {
|
||||
resolveShortcutKeysForAction,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getCommandCenterIcon } from "./icon";
|
||||
@@ -107,6 +109,26 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
}
|
||||
|
||||
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();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,15 +6,13 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
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 { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||
|
||||
interface BranchSwitcherProps {
|
||||
currentBranchName: string | null;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
isGitCheckout: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -29,23 +27,17 @@ const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
export function BranchSwitcher({
|
||||
currentBranchName,
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
isGitCheckout,
|
||||
testID = "workspace-header-branch-switcher",
|
||||
}: BranchSwitcherProps) {
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
|
||||
client,
|
||||
normalizedServerId: serverId,
|
||||
normalizedWorkspaceId: workspaceId,
|
||||
workspaceDirectory,
|
||||
currentBranchName,
|
||||
isGitCheckout,
|
||||
isConnected,
|
||||
|
||||
@@ -11,6 +11,11 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
bindSelectedWorkspaceGit,
|
||||
WorkspaceGitBoundary,
|
||||
type WorkspaceGitClient,
|
||||
} from "@/git/workspace-git";
|
||||
import {
|
||||
resolveCompactExplorerSidebarHostModel,
|
||||
type CompactExplorerSidebarHostModel,
|
||||
@@ -24,6 +29,62 @@ interface CompactExplorerOpenGestureSurfaceProps {
|
||||
|
||||
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({
|
||||
children,
|
||||
enabled,
|
||||
@@ -41,9 +102,10 @@ function CompactExplorerOpenGestureSurface({
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveCompactExplorerSidebarModel(
|
||||
enabled: boolean,
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
function useActiveCompactExplorerSidebarModel(enabled: boolean): {
|
||||
model: CompactExplorerSidebarHostModel | null;
|
||||
workspaceGit: WorkspaceGitClient | null;
|
||||
} {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||
const isExplorerOpen = usePanelStore((state) =>
|
||||
@@ -52,44 +114,32 @@ function useActiveCompactExplorerSidebarModel(
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null);
|
||||
const { checkoutQuery } = useWorkspaceCheckoutStatus({
|
||||
const workspaceGit = useCompactWorkspaceGit(
|
||||
client,
|
||||
selection?.workspaceId,
|
||||
workspace?.workspaceDirectory,
|
||||
);
|
||||
const { checkoutQuery } = useWorkspaceCheckoutStatus({
|
||||
workspaceGit,
|
||||
isConnected,
|
||||
isRouteFocused: enabled && selection !== null,
|
||||
normalizedServerId: selection?.serverId ?? "",
|
||||
normalizedWorkspaceId: selection?.workspaceId ?? "",
|
||||
workspaceDirectory: workspace?.workspaceDirectory || null,
|
||||
});
|
||||
const resolvedModel = useMemo(
|
||||
() =>
|
||||
resolveCompactExplorerSidebarHostModel({
|
||||
previous: isExplorerOpen ? retainedModelRef.current : null,
|
||||
selection,
|
||||
workspace,
|
||||
isGit: checkoutQuery.data?.isGit ?? false,
|
||||
}),
|
||||
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
|
||||
);
|
||||
const model = useCompactExplorerModel({
|
||||
enabled,
|
||||
isExplorerOpen,
|
||||
selection,
|
||||
workspace,
|
||||
isGit: checkoutQuery.data?.isGit ?? false,
|
||||
showMobileAgent,
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
|
||||
return {
|
||||
model,
|
||||
workspaceGit,
|
||||
};
|
||||
}
|
||||
|
||||
interface CompactExplorerSidebarHostProps {
|
||||
@@ -98,7 +148,7 @@ interface CompactExplorerSidebarHostProps {
|
||||
}
|
||||
|
||||
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
|
||||
const model = useActiveCompactExplorerSidebarModel(enabled);
|
||||
const { model, workspaceGit } = useActiveCompactExplorerSidebarModel(enabled);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
@@ -137,20 +187,22 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
|
||||
return (
|
||||
<>
|
||||
<CompactExplorerOpenGestureSurface
|
||||
enabled={enabled && Boolean(model?.workspaceRoot)}
|
||||
enabled={enabled && Boolean(model?.workspaceRoot) && Boolean(workspaceGit)}
|
||||
onOpenExplorer={handleOpenExplorer}
|
||||
>
|
||||
{children}
|
||||
</CompactExplorerOpenGestureSurface>
|
||||
{enabled && model ? (
|
||||
<CompactExplorerSidebar
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
workspaceRoot={model.workspaceRoot}
|
||||
isGit={model.isGit}
|
||||
onOpenFile={handleOpenFile}
|
||||
/>
|
||||
) : null}
|
||||
<WorkspaceGitBoundary workspaceGit={workspaceGit}>
|
||||
{enabled && model && workspaceGit ? (
|
||||
<CompactExplorerSidebar
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
workspaceRoot={model.workspaceRoot}
|
||||
isGit={model.isGit}
|
||||
onOpenFile={handleOpenFile}
|
||||
/>
|
||||
) : null}
|
||||
</WorkspaceGitBoundary>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
usePrPaneData,
|
||||
} from "@/git/pull-request-panel";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
|
||||
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
|
||||
import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
@@ -320,10 +321,10 @@ function ExplorerSidebarContent({
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
|
||||
const workspaceGit = useRequiredWorkspaceGit();
|
||||
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||
const prPane = usePrPaneData({
|
||||
serverId,
|
||||
cwd: workspaceRoot,
|
||||
enabled: canQueryPullRequest && isOpen,
|
||||
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
|
||||
});
|
||||
@@ -335,10 +336,13 @@ function ExplorerSidebarContent({
|
||||
const prTabLabel = formatPrTabLabel(prPane.prNumber);
|
||||
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
|
||||
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"));
|
||||
});
|
||||
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
|
||||
}, [refreshGitActions, serverId, t, toast, workspaceGit]);
|
||||
const workspaceAttachmentScopeKey = useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
|
||||
[serverId, workspaceId, workspaceRoot],
|
||||
@@ -419,7 +423,7 @@ function ExplorerSidebarContent({
|
||||
{resolvedTab === "changes" && (
|
||||
<ChangedFilesPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceId={workspaceGit.workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
@@ -428,7 +432,7 @@ function ExplorerSidebarContent({
|
||||
{resolvedTab === "files" && (
|
||||
<FilesPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceId={workspaceGit.workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
@@ -436,6 +440,7 @@ function ExplorerSidebarContent({
|
||||
{resolvedTab === "pr" && (
|
||||
<PrTabContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceGit.workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
prPane={prPane}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
@@ -526,6 +531,7 @@ function FilesPane({
|
||||
|
||||
interface PrTabContentProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
prPane: UsePrPaneDataResult;
|
||||
workspaceAttachmentScopeKey: string;
|
||||
@@ -534,6 +540,7 @@ interface PrTabContentProps {
|
||||
|
||||
function PrTabContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
prPane,
|
||||
workspaceAttachmentScopeKey,
|
||||
@@ -543,6 +550,7 @@ function PrTabContent({
|
||||
return (
|
||||
<PullRequestPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={cwd}
|
||||
data={prPane.data}
|
||||
activityLoading={prPane.activityLoading}
|
||||
|
||||
@@ -68,10 +68,18 @@ function buildSessionsQueriesConfig(args: {
|
||||
visible: boolean;
|
||||
client: RecentProviderSessionsClient | null;
|
||||
cwd: string | null | undefined;
|
||||
workspaceId?: string | null;
|
||||
hostDisconnectedMessage?: string;
|
||||
}): SessionsQueryConfig[] {
|
||||
const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } =
|
||||
args;
|
||||
const {
|
||||
providersToFetch,
|
||||
sessionsQueryRoot,
|
||||
visible,
|
||||
client,
|
||||
cwd,
|
||||
workspaceId,
|
||||
hostDisconnectedMessage,
|
||||
} = args;
|
||||
if (providersToFetch === null) return [];
|
||||
const enabled = visible && Boolean(client);
|
||||
return providersToFetch.map((provider) => ({
|
||||
@@ -82,6 +90,7 @@ function buildSessionsQueriesConfig(args: {
|
||||
throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return await client.fetchRecentProviderSessions({
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
providers: [provider],
|
||||
limit: PER_PROVIDER_LIMIT,
|
||||
@@ -274,6 +283,7 @@ export function ImportSessionSheet({
|
||||
|
||||
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
|
||||
cwd,
|
||||
workspaceId,
|
||||
enabled: visible,
|
||||
});
|
||||
const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget");
|
||||
@@ -294,8 +304,8 @@ export function ImportSessionSheet({
|
||||
);
|
||||
|
||||
const sessionsQueryRoot = useMemo(
|
||||
() => ["recent-provider-sessions", cwd ?? null] as const,
|
||||
[cwd],
|
||||
() => ["recent-provider-sessions", workspaceId ?? null, cwd ?? null] as const,
|
||||
[cwd, workspaceId],
|
||||
);
|
||||
|
||||
const queriesConfig = useMemo(
|
||||
@@ -306,9 +316,10 @@ export function ImportSessionSheet({
|
||||
visible,
|
||||
client,
|
||||
cwd,
|
||||
workspaceId,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
}),
|
||||
[providersToFetch, sessionsQueryRoot, visible, client, cwd, t],
|
||||
[providersToFetch, sessionsQueryRoot, visible, client, cwd, workspaceId, t],
|
||||
);
|
||||
|
||||
const queries = useQueries({ queries: queriesConfig });
|
||||
|
||||
@@ -736,6 +736,7 @@ interface AssistantMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
workspaceRoot?: string;
|
||||
workspaceId?: string;
|
||||
serverId?: string;
|
||||
client?: DaemonClient | null;
|
||||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||
@@ -799,6 +800,7 @@ function AssistantMarkdownImage({
|
||||
hasLeadingContent,
|
||||
client,
|
||||
workspaceRoot,
|
||||
workspaceId,
|
||||
serverId,
|
||||
}: {
|
||||
source: string;
|
||||
@@ -807,6 +809,7 @@ function AssistantMarkdownImage({
|
||||
hasLeadingContent: boolean;
|
||||
client?: DaemonClient | null;
|
||||
workspaceRoot?: string;
|
||||
workspaceId?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
@@ -821,6 +824,7 @@ function AssistantMarkdownImage({
|
||||
occurrenceKey,
|
||||
client,
|
||||
workspaceRoot,
|
||||
workspaceId,
|
||||
serverId,
|
||||
});
|
||||
const binding = image.status === "failed" ? null : image.binding;
|
||||
@@ -1448,6 +1452,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
message,
|
||||
timestamp: _timestamp,
|
||||
workspaceRoot,
|
||||
workspaceId,
|
||||
serverId,
|
||||
client,
|
||||
spacing = "default",
|
||||
@@ -1885,12 +1890,22 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
hasLeadingContent={hasLeadingContent}
|
||||
client={client}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaceId={workspaceId}
|
||||
serverId={serverId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]);
|
||||
}, [
|
||||
client,
|
||||
fileLinkActions,
|
||||
markdownParser,
|
||||
occurrenceKey,
|
||||
phase,
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
]);
|
||||
|
||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||
const keyedBlocks = useMemo(
|
||||
|
||||
@@ -174,7 +174,7 @@ function updateSelectionPreferences(input: {
|
||||
...(model && thinkingOptionId ? { thinkingByModel: { [model]: thinkingOptionId } } : {}),
|
||||
},
|
||||
}),
|
||||
isolation: input.isolation,
|
||||
runtimeId: input.isolation,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -654,7 +654,7 @@ function WorkspaceRowRightGroup({
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}) {
|
||||
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
|
||||
const workspacePath = workspace.hostVisiblePath;
|
||||
const { t } = useTranslation();
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
@@ -1156,7 +1156,7 @@ function WorkspaceRowInner({
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
openInFileManagerPath={workspace.hostVisiblePath}
|
||||
disabled={isArchiving}
|
||||
aria-selected={selected}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -882,7 +882,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
openInFileManagerPath={workspace.hostVisiblePath}
|
||||
disabled={isArchiving}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
|
||||
@@ -338,7 +338,7 @@ function WorkspaceRowBody({
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
openInFileManagerPath={workspace.workspaceDirectory}
|
||||
openInFileManagerPath={workspace.hostVisiblePath}
|
||||
disabled={isArchiving}
|
||||
aria-selected={selected}
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -31,6 +31,7 @@ import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import type { MessagePayload } from "@/composer/types";
|
||||
import { PreWorkspaceComposerGitOwner } from "@/git/workspace-git";
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
@@ -429,27 +430,29 @@ export function WorkspaceSetupDialog() {
|
||||
testID="workspace-setup-dialog"
|
||||
desktopMaxWidth={640}
|
||||
>
|
||||
<FileDropZone style={styles.section}>
|
||||
<Composer
|
||||
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
||||
serverId={serverId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleCreateChatAgent}
|
||||
isSubmitLoading={pendingAction === "chat"}
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacementKey={chatDraft.textReplacementKey}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
inputWrapperStyle={styles.composerInputWrapper}
|
||||
/>
|
||||
</FileDropZone>
|
||||
<PreWorkspaceComposerGitOwner client={client} cwd={sourceDirectory}>
|
||||
<FileDropZone style={styles.section}>
|
||||
<Composer
|
||||
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
||||
serverId={serverId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleCreateChatAgent}
|
||||
isSubmitLoading={pendingAction === "chat"}
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacementKey={chatDraft.textReplacementKey}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
inputWrapperStyle={styles.composerInputWrapper}
|
||||
/>
|
||||
</FileDropZone>
|
||||
</PreWorkspaceComposerGitOwner>
|
||||
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -349,6 +349,7 @@ function pickDesktopModel({
|
||||
type AgentControlsSlice = {
|
||||
provider: string;
|
||||
cwd: string | null;
|
||||
workspaceId: string | undefined;
|
||||
runtimeModelId: string | null;
|
||||
model: string | null | undefined;
|
||||
features: AgentFeature[] | undefined;
|
||||
@@ -368,6 +369,7 @@ function selectAgentControlsSlice(
|
||||
return {
|
||||
provider: currentAgent.provider,
|
||||
cwd: currentAgent.cwd,
|
||||
workspaceId: currentAgent.workspaceId,
|
||||
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
||||
model: currentAgent.model,
|
||||
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(
|
||||
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"],
|
||||
agentProvider: string | undefined,
|
||||
@@ -564,7 +576,7 @@ function ControlledAgentControls({
|
||||
}
|
||||
}, [updateDensityForWidth]);
|
||||
|
||||
const modelDisabled = disabled;
|
||||
const modelDisabled = [disabled, isModelLoading].some(Boolean);
|
||||
|
||||
const comboboxProviderOptions = useMemo<ComboboxOption[]>(
|
||||
() => toComboboxOptions(providerOptions),
|
||||
@@ -1481,7 +1493,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
isRefreshing: snapshotIsRefreshing,
|
||||
refresh: refreshSnapshot,
|
||||
refetchIfStale: refetchSnapshotIfStale,
|
||||
} = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
|
||||
} = useProvidersSnapshot(serverId, providerSnapshotTarget(agent));
|
||||
|
||||
const snapshotSelectedEntry = useMemo(
|
||||
() => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider),
|
||||
|
||||
@@ -253,6 +253,7 @@ export function useLiveAgentModeControl(
|
||||
return {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
workspaceId: agent.workspaceId,
|
||||
currentModeId: agent.currentModeId,
|
||||
};
|
||||
}),
|
||||
@@ -265,7 +266,10 @@ export function useLiveAgentModeControl(
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const { updatePreferences } = useFormPreferences();
|
||||
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[]>(() => {
|
||||
if (!slice?.provider) return [];
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
useAgentFormState,
|
||||
type CreateAgentInitialValues,
|
||||
type UseAgentFormStateOptions,
|
||||
type UseAgentFormStateResult,
|
||||
} from "@/hooks/use-agent-form-state";
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
@@ -28,6 +29,9 @@ type AttachmentUpdater =
|
||||
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
|
||||
|
||||
interface AgentInputDraftComposerOptions {
|
||||
workspaceId?: string | null;
|
||||
providerSnapshotCwd?: string | null;
|
||||
isTargetDaemonReady?: boolean;
|
||||
initialServerId: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
initialFeatureValues?: Record<string, unknown>;
|
||||
@@ -63,15 +67,24 @@ export interface AgentInputDraft {
|
||||
composerState: DraftComposerState | null;
|
||||
}
|
||||
|
||||
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
|
||||
const composerOptions = input.composer ?? null;
|
||||
const formState = useAgentFormState({
|
||||
function buildAgentFormStateOptions(
|
||||
composerOptions: AgentInputDraftComposerOptions | null,
|
||||
): UseAgentFormStateOptions {
|
||||
return {
|
||||
initialServerId: composerOptions?.initialServerId ?? null,
|
||||
initialValues: composerOptions?.initialValues,
|
||||
isVisible: composerOptions?.isVisible ?? false,
|
||||
isCreateFlow: true,
|
||||
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(
|
||||
() =>
|
||||
resolveDraftKey({
|
||||
|
||||
@@ -365,6 +365,7 @@ export function WorkspaceDraftAgentTab({
|
||||
const draftInput = useAgentInputDraft({
|
||||
draftKey: draftStoreKey,
|
||||
composer: {
|
||||
workspaceId,
|
||||
initialServerId: serverId,
|
||||
initialValues: draftInitialValues,
|
||||
initialFeatureValues: draftSetup?.featureValues,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import React, { type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { ForgeSearchClient } from "@/git/use-forge-search-query";
|
||||
@@ -49,11 +49,12 @@ const issue202: ForgeSearchItem = {
|
||||
};
|
||||
|
||||
interface SearchCall {
|
||||
cwd: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
type TestSearchClient = Pick<ForgeSearchClient, "searchForge" | "searchGitHub">;
|
||||
|
||||
interface HarnessInput {
|
||||
initialAttachments?: UserComposerAttachment[];
|
||||
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[] = [];
|
||||
return {
|
||||
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 [searchClient, setSearchClient] = useState(client);
|
||||
const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd);
|
||||
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
|
||||
input.initialAttachments ?? [],
|
||||
);
|
||||
const boundSearchClient = useMemo(
|
||||
() => ({
|
||||
...searchClient,
|
||||
queryIdentity: ["selected", "workspace-1"] as const,
|
||||
cwd: workingDirectory,
|
||||
}),
|
||||
[searchClient, workingDirectory],
|
||||
);
|
||||
const autoAttach = useComposerGithubAutoAttach({
|
||||
text,
|
||||
remoteUrl: input.remote ?? remoteUrl,
|
||||
attachments,
|
||||
client: searchClient,
|
||||
client: boundSearchClient,
|
||||
isConnected: true,
|
||||
serverId: "server-1",
|
||||
cwd: workingDirectory,
|
||||
setAttachments,
|
||||
onPullRequestDetected: input.onPullRequestDetected,
|
||||
onPullRequestAdded: input.onPullRequestAdded,
|
||||
@@ -161,7 +169,7 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
|
||||
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -235,8 +243,8 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
{ kind: "forge_issue", item: issue202 },
|
||||
]);
|
||||
expect(client.calls).toEqual([
|
||||
{ cwd, query: "101", limit: 20 },
|
||||
{ cwd, query: "202", limit: 20 },
|
||||
{ query: "101", limit: 20 },
|
||||
{ query: "202", limit: 20 },
|
||||
]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -245,7 +253,7 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
vi.useFakeTimers();
|
||||
const firstLookup = deferred<ForgeSearchPayload>();
|
||||
const secondLookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
const client: TestSearchClient = {
|
||||
searchForge: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstLookup.promise)
|
||||
@@ -288,7 +296,7 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
vi.useFakeTimers();
|
||||
const firstLookup = deferred<ForgeSearchPayload>();
|
||||
const secondLookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
const client: TestSearchClient = {
|
||||
searchForge: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstLookup.promise)
|
||||
@@ -357,8 +365,8 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
|
||||
expect(onPullRequestAdded.mock.calls).toEqual([[pr202], [pr101]]);
|
||||
expect(client.calls).toEqual([
|
||||
{ cwd, query: "202", limit: 20 },
|
||||
{ cwd, query: "101", limit: 20 },
|
||||
{ query: "202", limit: 20 },
|
||||
{ query: "101", limit: 20 },
|
||||
]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -367,7 +375,7 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
vi.useFakeTimers();
|
||||
const firstLookup = deferred<ForgeSearchPayload>();
|
||||
const secondLookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
const client: TestSearchClient = {
|
||||
searchForge: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstLookup.promise)
|
||||
@@ -419,7 +427,7 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
vi.useFakeTimers();
|
||||
const firstLookup = deferred<ForgeSearchPayload>();
|
||||
const secondLookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
const client: TestSearchClient = {
|
||||
searchForge: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstLookup.promise)
|
||||
@@ -459,7 +467,7 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
it("keeps resolving when a pending pull request URL is removed and re-added", async () => {
|
||||
vi.useFakeTimers();
|
||||
const lookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
const client: TestSearchClient = {
|
||||
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||
};
|
||||
const onPullRequestAdded = vi.fn();
|
||||
@@ -500,7 +508,7 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
vi.useFakeTimers();
|
||||
const firstLookup = deferred<ForgeSearchPayload>();
|
||||
const secondLookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
const client: TestSearchClient = {
|
||||
searchForge: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstLookup.promise)
|
||||
@@ -545,7 +553,7 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
it("stays resolving when an unrelated attachment is added during lookup", async () => {
|
||||
vi.useFakeTimers();
|
||||
const lookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
const client: TestSearchClient = {
|
||||
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||
};
|
||||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
const lookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
const client: TestSearchClient = {
|
||||
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||
};
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
@@ -598,7 +606,7 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
it("ignores a lookup that finishes after the target changes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const lookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
const client: TestSearchClient = {
|
||||
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||
};
|
||||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
const lookup = deferred<ForgeSearchPayload>();
|
||||
const firstClient: ForgeSearchClient = {
|
||||
const firstClient: TestSearchClient = {
|
||||
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||
};
|
||||
const replacementClient = createSearchClient([pr101]);
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
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 type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions";
|
||||
@@ -24,7 +29,6 @@ interface ComposerGithubAutoAttachInput {
|
||||
client: ForgeSearchClient | null;
|
||||
isConnected: boolean;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
supportsForgeSearch?: boolean;
|
||||
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
||||
onPullRequestDetected?: () => void;
|
||||
@@ -50,7 +54,7 @@ export function useComposerGithubAutoAttach(
|
||||
const removedRefKeysRef = useRef(new Set<string>());
|
||||
const presentPullRequestKeysRef = useRef(new Set<string>());
|
||||
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>>(
|
||||
() => new Map(),
|
||||
);
|
||||
@@ -79,7 +83,7 @@ export function useComposerGithubAutoAttach(
|
||||
hasClient,
|
||||
params.isConnected,
|
||||
params.serverId,
|
||||
params.cwd,
|
||||
params.client,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -143,7 +147,7 @@ export function useComposerGithubAutoAttach(
|
||||
hasClient,
|
||||
params.isConnected,
|
||||
params.serverId,
|
||||
params.cwd,
|
||||
params.client,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
@@ -236,15 +240,25 @@ function suppressRefsCarriedAcrossTargets({
|
||||
removedRefKeys,
|
||||
}: {
|
||||
params: ComposerGithubAutoAttachInput;
|
||||
previousTargetRef: RefObject<{ serverId: string; cwd: string }>;
|
||||
previousTargetRef: RefObject<{
|
||||
serverId: string;
|
||||
client: ForgeSearchClient | null;
|
||||
}>;
|
||||
removedRefKeys: Set<string>;
|
||||
}): void {
|
||||
const previous = previousTargetRef.current;
|
||||
const targetChanged =
|
||||
previous.cwd.trim().length > 0 &&
|
||||
params.cwd.trim().length > 0 &&
|
||||
(previous.serverId !== params.serverId || previous.cwd !== params.cwd);
|
||||
previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd };
|
||||
previous.client !== null &&
|
||||
params.client !== null &&
|
||||
previous.client.cwd.trim().length > 0 &&
|
||||
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;
|
||||
|
||||
for (const ref of extractGithubRefs(params.text, params.remoteUrl)) {
|
||||
@@ -444,7 +458,7 @@ function refsReadyForLookup({
|
||||
removedRefKeys: Set<string>;
|
||||
activeLookups: ReadonlySet<ActiveGithubLookup>;
|
||||
}): GithubRef[] {
|
||||
if (!params.client || !params.isConnected || params.cwd.trim().length === 0) {
|
||||
if (!params.client || !params.isConnected || params.client.cwd.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -476,7 +490,6 @@ async function fetchGithubRefSearch({
|
||||
buildForgeSearchQueryOptions({
|
||||
client: snapshot.client,
|
||||
serverId: snapshot.serverId,
|
||||
cwd: snapshot.cwd,
|
||||
query: String(ref.number),
|
||||
supportsForgeSearch: snapshot.supportsForgeSearch,
|
||||
enabled: true,
|
||||
@@ -499,7 +512,13 @@ function isSameLookupTarget(
|
||||
): boolean {
|
||||
return (
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { useWorkspaceGit } from "@/git/workspace-git";
|
||||
import { getForgePresentation } from "@/git/forge";
|
||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
||||
@@ -855,6 +856,8 @@ interface ComposerProps {
|
||||
submitIcon?: "arrow" | "return";
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
/** Disables submission without locking draft editing or attachments. */
|
||||
isSubmitDisabled?: boolean;
|
||||
/** When true, waits for pasted GitHub links to resolve before enabling submit. */
|
||||
waitForGithubAutoAttachOnSubmit?: boolean;
|
||||
submitBehavior?: "clear" | "preserve-and-lock";
|
||||
@@ -1066,6 +1069,7 @@ export function Composer({
|
||||
submitButtonTestID,
|
||||
submitIcon = "arrow",
|
||||
isSubmitLoading = false,
|
||||
isSubmitDisabled = false,
|
||||
waitForGithubAutoAttachOnSubmit = false,
|
||||
submitBehavior = "clear",
|
||||
blurOnSubmit = false,
|
||||
@@ -1149,7 +1153,8 @@ export function Composer({
|
||||
onOpenWorkspaceAttachment,
|
||||
});
|
||||
const setSelectedAttachments = onChangeAttachments;
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId });
|
||||
const workspaceGitClient = useWorkspaceGit();
|
||||
const supportsForgeSearch = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
|
||||
);
|
||||
@@ -1157,10 +1162,9 @@ export function Composer({
|
||||
text: userInput,
|
||||
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
|
||||
attachments,
|
||||
client,
|
||||
client: workspaceGitClient,
|
||||
isConnected,
|
||||
serverId,
|
||||
cwd,
|
||||
supportsForgeSearch,
|
||||
setAttachments: setSelectedAttachments,
|
||||
onPullRequestDetected: onGithubPrDetected,
|
||||
@@ -1918,16 +1922,14 @@ export function Composer({
|
||||
// until the forge-specific picker or attachment presentation is visible.
|
||||
const { forge } = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment),
|
||||
});
|
||||
const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]);
|
||||
|
||||
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
||||
const githubSearchResultsQuery = useForgeSearchQuery({
|
||||
client,
|
||||
client: workspaceGitClient,
|
||||
serverId,
|
||||
cwd,
|
||||
query: githubSearchQueryTrimmed,
|
||||
supportsForgeSearch,
|
||||
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
|
||||
@@ -2145,8 +2147,10 @@ export function Composer({
|
||||
|
||||
const isSubmitLoadingVisible =
|
||||
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
|
||||
const isSubmitDisabled =
|
||||
isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
|
||||
const isSubmitActionDisabled =
|
||||
isSubmitDisabled ||
|
||||
isSubmitLoadingVisible ||
|
||||
(waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
|
||||
|
||||
// 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
|
||||
@@ -2205,7 +2209,7 @@ export function Composer({
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitDisabled}
|
||||
isSubmitDisabled={isSubmitActionDisabled}
|
||||
isSubmitLoading={isSubmitLoadingVisible}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
|
||||
@@ -5,26 +5,26 @@ describe("optimistic form preferences", () => {
|
||||
it("removes a rejected update while preserving later queued updates", () => {
|
||||
const preferences = new OptimisticFormPreferences({});
|
||||
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);
|
||||
expect(preferences.current()).toEqual({ isolation: "worktree" });
|
||||
expect(preferences.current()).toEqual({ runtimeId: "worktree" });
|
||||
|
||||
preferences.commit(pending, { isolation: "worktree" });
|
||||
expect(preferences.current()).toEqual({ isolation: "worktree" });
|
||||
preferences.commit(pending, { runtimeId: "worktree" });
|
||||
expect(preferences.current()).toEqual({ runtimeId: "worktree" });
|
||||
});
|
||||
|
||||
it("does not replace pending optimistic state with a stale external snapshot", () => {
|
||||
const preferences = new OptimisticFormPreferences({ provider: "codex" });
|
||||
const pending = preferences.begin({ provider: "claude" });
|
||||
|
||||
preferences.reconcile({ provider: "codex", isolation: "local" });
|
||||
preferences.reconcile({ provider: "codex", runtimeId: "local" });
|
||||
expect(preferences.current()).toEqual({ provider: "claude" });
|
||||
|
||||
preferences.commit(pending, { provider: "claude" });
|
||||
preferences.reconcile({ provider: "claude", isolation: "worktree" });
|
||||
expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" });
|
||||
preferences.reconcile({ provider: "claude", runtimeId: "worktree" });
|
||||
expect(preferences.current()).toEqual({ provider: "claude", runtimeId: "worktree" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,12 +61,12 @@ describe("create agent preferences", () => {
|
||||
|
||||
expect(await preferences.load()).toEqual({});
|
||||
|
||||
const successfulWrite = preferences.update({ isolation: "worktree" });
|
||||
const successfulWrite = preferences.update({ runtimeId: "worktree" });
|
||||
await storage.nextWrite();
|
||||
storage.finishOldestWrite();
|
||||
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 () => {
|
||||
@@ -212,36 +212,49 @@ describe("create agent preferences", () => {
|
||||
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 preferences = new CreateAgentPreferencesService(storage);
|
||||
|
||||
const save = preferences.update({ isolation: "worktree" });
|
||||
const save = preferences.update({ runtimeId: "fixture" });
|
||||
await storage.nextWrite();
|
||||
storage.finishOldestWrite();
|
||||
await save;
|
||||
|
||||
expect(storage.savedPreferences()).toEqual({ isolation: "worktree" });
|
||||
expect(storage.savedPreferences()).toEqual({ runtimeId: "fixture" });
|
||||
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 () => {
|
||||
const favoriteModels = [{ provider: "claude", modelId: "opus" }];
|
||||
const storage = new FakeCreateAgentPreferenceStorage({ stored: { favoriteModels } });
|
||||
const preferences = new CreateAgentPreferencesService(storage);
|
||||
|
||||
const save = preferences.update({ isolation: "worktree" });
|
||||
const save = preferences.update({ runtimeId: "worktree" });
|
||||
await storage.nextWrite();
|
||||
storage.finishOldestWrite();
|
||||
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", () => {
|
||||
expect(parseFormPreferences({ provider: "codex" }).isolation).toBeUndefined();
|
||||
it("treats stored preferences without a runtime choice as undefined", () => {
|
||||
expect(parseFormPreferences({ provider: "codex" }).runtimeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an unknown isolation value as invalid stored preferences", () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface FormPreferences {
|
||||
provider?: string;
|
||||
providerPreferences?: Record<string, ProviderPreferences>;
|
||||
favoriteModels?: Array<{ provider: string; modelId: string }>;
|
||||
isolation?: "local" | "worktree";
|
||||
runtimeId?: string;
|
||||
launchTarget?: LaunchTarget;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export const FormPreferencesSchema = z.strictObject({
|
||||
}),
|
||||
)
|
||||
.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
|
||||
// terminal profile. See `@/new-workspace-launch` for resolution/fallback.
|
||||
launchTarget: launchTargetSchema.optional(),
|
||||
@@ -58,6 +58,9 @@ const LegacyProviderPreferencesSchema = z.strictObject({
|
||||
thinkingOptionId: z.string().optional(),
|
||||
});
|
||||
|
||||
const CurrentStoredFormPreferencesSchema = FormPreferencesSchema.extend({
|
||||
isolation: z.enum(["local", "worktree"]).optional(),
|
||||
});
|
||||
const LegacyFormPreferencesSchema = z
|
||||
.strictObject({
|
||||
workingDir: z.string().optional(),
|
||||
@@ -86,7 +89,11 @@ const LegacyFormPreferencesSchema = z
|
||||
});
|
||||
|
||||
export const StoredFormPreferencesSchema: z.ZodType<FormPreferences> = z.union([
|
||||
FormPreferencesSchema,
|
||||
CurrentStoredFormPreferencesSchema.transform(({ isolation, ...preferences }) =>
|
||||
preferences.runtimeId !== undefined || isolation === undefined
|
||||
? preferences
|
||||
: { ...preferences, runtimeId: isolation },
|
||||
),
|
||||
LegacyFormPreferencesSchema,
|
||||
]);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
|
||||
import { z } from "zod";
|
||||
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_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() })),
|
||||
});
|
||||
|
||||
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) {
|
||||
const values = new Map<string, string>();
|
||||
const stats = { getAllKeysCalls: 0 };
|
||||
@@ -132,7 +141,7 @@ function writeSnapshot(
|
||||
): Promise<void> {
|
||||
return cache.write({
|
||||
serverId: "server-1",
|
||||
cwd: input.cwd,
|
||||
scope: legacyScope(input.cwd),
|
||||
hash: input.label,
|
||||
generatedAt: input.generatedAt,
|
||||
compactSnapshot: compactProviderSnapshot(snapshotEntries(input.label)),
|
||||
@@ -161,12 +170,12 @@ describe("provider snapshot cache", () => {
|
||||
|
||||
await cache.write({
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
scope: { type: "legacy-cwd", cwd: "/repo" },
|
||||
hash: "snapshot-hash",
|
||||
generatedAt: "2026-08-04T00:00:00.000Z",
|
||||
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[0]?.models?.[0]?.thinkingOptions).toBe(
|
||||
@@ -177,9 +186,12 @@ describe("provider snapshot cache", () => {
|
||||
it("discards an invalid cache record", async () => {
|
||||
const storage = createStorage();
|
||||
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(
|
||||
false,
|
||||
);
|
||||
@@ -193,29 +205,29 @@ describe("provider snapshot cache", () => {
|
||||
await Promise.all([
|
||||
cache.write({
|
||||
serverId: "server-1",
|
||||
cwd: "/oldest",
|
||||
scope: legacyScope("/oldest"),
|
||||
hash: "oldest",
|
||||
generatedAt: "2026-08-01T00:00:00.000Z",
|
||||
compactSnapshot: compactProviderSnapshot(snapshotEntries("oldest")),
|
||||
}),
|
||||
cache.write({
|
||||
serverId: "server-1",
|
||||
cwd: "/middle",
|
||||
scope: legacyScope("/middle"),
|
||||
hash: "middle",
|
||||
generatedAt: "2026-08-02T00:00:00.000Z",
|
||||
compactSnapshot: compactProviderSnapshot(snapshotEntries("middle")),
|
||||
}),
|
||||
cache.write({
|
||||
serverId: "server-1",
|
||||
cwd: "/newest",
|
||||
scope: legacyScope("/newest"),
|
||||
hash: "newest",
|
||||
generatedAt: "2026-08-03T00:00:00.000Z",
|
||||
compactSnapshot: compactProviderSnapshot(snapshotEntries("newest")),
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(cache.read("server-1", "/oldest")).resolves.toBeNull();
|
||||
await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull();
|
||||
await expect(cache.read("server-1", legacyScope("/oldest"))).resolves.toBeNull();
|
||||
await expect(cache.read("server-1", legacyScope("/newest"))).resolves.not.toBeNull();
|
||||
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
|
||||
expectIndexMatchesSnapshots(storage.values);
|
||||
expect(storage.stats.getAllKeysCalls).toBe(1);
|
||||
@@ -228,14 +240,14 @@ describe("provider snapshot cache", () => {
|
||||
|
||||
await cache.write({
|
||||
serverId: "server-1",
|
||||
cwd: "/current",
|
||||
scope: legacyScope("/current"),
|
||||
hash: "current",
|
||||
generatedAt: "2026-08-03T00:00:00.000Z",
|
||||
compactSnapshot: compactProviderSnapshot(snapshotEntries("current")),
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -244,7 +256,7 @@ describe("provider snapshot cache", () => {
|
||||
storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy");
|
||||
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);
|
||||
expectIndexMatchesSnapshots(storage.values);
|
||||
@@ -257,7 +269,7 @@ describe("provider snapshot cache", () => {
|
||||
const cache = createProviderSnapshotCache(storage);
|
||||
|
||||
await Promise.all([
|
||||
cache.read("server-1", "/legacy"),
|
||||
cache.read("server-1", legacyScope("/legacy")),
|
||||
writeSnapshot(cache, {
|
||||
cwd: "/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);
|
||||
expect(storage.stats.getAllKeysCalls).toBe(1);
|
||||
});
|
||||
@@ -286,7 +298,7 @@ describe("provider snapshot cache", () => {
|
||||
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",
|
||||
});
|
||||
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
|
||||
@@ -312,7 +324,7 @@ describe("provider snapshot cache", () => {
|
||||
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);
|
||||
expectIndexMatchesSnapshots(storage.values);
|
||||
});
|
||||
@@ -330,7 +342,7 @@ describe("provider snapshot cache", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
expect(storage.stats.getAllKeysCalls).toBe(1);
|
||||
});
|
||||
@@ -360,7 +372,7 @@ describe("provider snapshot cache", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -379,7 +391,7 @@ describe("provider snapshot cache", () => {
|
||||
await Promise.all(writes);
|
||||
|
||||
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);
|
||||
expect(storage.stats.getAllKeysCalls).toBe(1);
|
||||
});
|
||||
@@ -394,7 +406,7 @@ describe("provider snapshot cache", () => {
|
||||
label: "stable",
|
||||
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);
|
||||
|
||||
await writeSnapshot(cache, {
|
||||
@@ -409,8 +421,8 @@ describe("provider snapshot cache", () => {
|
||||
label: "next",
|
||||
generatedAt: "2026-08-03T00:00:00.000Z",
|
||||
});
|
||||
await expect(restarted.read("server-1", "/stable")).resolves.not.toBeNull();
|
||||
await expect(restarted.read("server-1", "/next")).resolves.not.toBeNull();
|
||||
await expect(restarted.read("server-1", legacyScope("/stable"))).resolves.not.toBeNull();
|
||||
await expect(restarted.read("server-1", legacyScope("/next"))).resolves.not.toBeNull();
|
||||
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(2_000);
|
||||
expectIndexMatchesSnapshots(storage.values);
|
||||
},
|
||||
@@ -436,7 +448,7 @@ describe("provider snapshot cache", () => {
|
||||
|
||||
const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 });
|
||||
await expect(
|
||||
restarted.read("server-1", "/orphan-after-index-failure"),
|
||||
restarted.read("server-1", legacyScope("/orphan-after-index-failure")),
|
||||
).resolves.not.toBeNull();
|
||||
expectIndexMatchesSnapshots(storage.values);
|
||||
expect(storage.stats.getAllKeysCalls).toBe(2);
|
||||
@@ -482,7 +494,7 @@ describe("provider snapshot cache", () => {
|
||||
expect(storage.values.get("@paseo/unrelated")).toBe("keep-me");
|
||||
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
|
||||
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 () => {
|
||||
@@ -506,7 +518,7 @@ describe("provider snapshot cache", () => {
|
||||
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);
|
||||
expectIndexMatchesSnapshots(storage.values);
|
||||
});
|
||||
@@ -515,12 +527,12 @@ describe("provider snapshot cache", () => {
|
||||
"recovers when invalid-record cleanup fails %s deletion",
|
||||
async (timing) => {
|
||||
const storage = createStorage();
|
||||
const key = `${SNAPSHOT_KEY_PREFIX}["server-1","/invalid"]`;
|
||||
const key = snapshotKey("server-1", "/invalid");
|
||||
storage.values.set(key, "invalid");
|
||||
storage.failNext("multiRemove", timing);
|
||||
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, {
|
||||
cwd: "/recovered",
|
||||
label: "recovered",
|
||||
@@ -528,8 +540,37 @@ describe("provider snapshot cache", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
export type ProviderSnapshotCacheScope =
|
||||
| { type: "workspace"; workspaceId: string }
|
||||
| { type: "legacy-cwd"; cwd: string | null };
|
||||
|
||||
export interface ProviderSnapshotCache {
|
||||
read(serverId: string, cwd: string | null): Promise<CachedProviderSnapshot | null>;
|
||||
read(serverId: string, scope: ProviderSnapshotCacheScope): Promise<CachedProviderSnapshot | null>;
|
||||
write(input: {
|
||||
serverId: string;
|
||||
cwd: string | null;
|
||||
scope: ProviderSnapshotCacheScope;
|
||||
hash: string;
|
||||
generatedAt: string;
|
||||
compactSnapshot: CompactProviderSnapshot;
|
||||
@@ -74,8 +78,8 @@ export class ProviderSnapshotCacheMissError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKey(serverId: string, cwd: string | null): string {
|
||||
return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`;
|
||||
function cacheKey(serverId: string, scope: ProviderSnapshotCacheScope): string {
|
||||
return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, scope])}`;
|
||||
}
|
||||
|
||||
function storedBytes(key: string, value: string): number {
|
||||
@@ -160,13 +164,13 @@ export function createProviderSnapshotCache(
|
||||
|
||||
async function writeSnapshot(input: {
|
||||
serverId: string;
|
||||
cwd: string | null;
|
||||
scope: ProviderSnapshotCacheScope;
|
||||
hash: string;
|
||||
generatedAt: string;
|
||||
compactSnapshot: CompactProviderSnapshot;
|
||||
}): Promise<void> {
|
||||
const currentIndex = await ensureCacheIndex();
|
||||
const key = cacheKey(input.serverId, input.cwd);
|
||||
const key = cacheKey(input.serverId, input.scope);
|
||||
const stored = StoredProviderSnapshotSchema.safeParse({
|
||||
version: CACHE_VERSION,
|
||||
hash: input.hash,
|
||||
@@ -206,11 +210,11 @@ export function createProviderSnapshotCache(
|
||||
}
|
||||
|
||||
return {
|
||||
async read(serverId, cwd) {
|
||||
async read(serverId, scope) {
|
||||
try {
|
||||
return await runSerialized(async () => {
|
||||
const currentIndex = await ensureCacheIndex();
|
||||
const key = cacheKey(serverId, cwd);
|
||||
const key = cacheKey(serverId, scope);
|
||||
const value = await storage.getItem(key);
|
||||
if (value === null) return null;
|
||||
try {
|
||||
|
||||
@@ -11,8 +11,15 @@ export function providersSnapshotQueryRoot(serverId: string | null) {
|
||||
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);
|
||||
if (workspaceId) {
|
||||
return [PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "workspace", workspaceId] as const;
|
||||
}
|
||||
return normalizedCwd
|
||||
? ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "cwd", normalizedCwd] as const)
|
||||
: ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "home"] as const);
|
||||
@@ -22,10 +29,12 @@ export function providersSnapshotRequestOptions(input: {
|
||||
cwd?: string | null;
|
||||
providers?: AgentProvider[];
|
||||
ifNoneMatch?: string;
|
||||
workspaceId?: string | null;
|
||||
}) {
|
||||
const normalizedCwd = normalizeProvidersSnapshotCwd(input.cwd);
|
||||
return {
|
||||
...(normalizedCwd ? { cwd: normalizedCwd } : {}),
|
||||
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
||||
...(input.providers ? { providers: input.providers } : {}),
|
||||
...(input.ifNoneMatch ? { ifNoneMatch: input.ifNoneMatch } : {}),
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
mountServerDataPushRouter,
|
||||
workspaceTerminalsPushRoute,
|
||||
} from "@/data/push-router";
|
||||
import type { WorkspaceGitClient } from "@/git/workspace-git";
|
||||
|
||||
type ProvidersSnapshotUpdateMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
@@ -56,6 +57,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
unsubscribeCheckoutDiffCalls: string[];
|
||||
subscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
|
||||
unsubscribeTerminalCalls: Array<{ cwd: string; workspaceId?: string }>;
|
||||
workspaceGit: (target: { workspaceId: string; cwd: string }) => WorkspaceGitClient;
|
||||
} {
|
||||
const handlers: Record<RouterMessageType, RouterHandler[]> = {
|
||||
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 {
|
||||
client: {
|
||||
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) {
|
||||
subscribeTerminalCalls.push(subscription);
|
||||
},
|
||||
@@ -129,6 +137,7 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
|
||||
unsubscribeCheckoutDiffCalls,
|
||||
subscribeTerminalCalls,
|
||||
unsubscribeTerminalCalls,
|
||||
workspaceGit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,7 +189,8 @@ describe("server data push router", () => {
|
||||
const fake = createFakeClient();
|
||||
const serverId = "server-1";
|
||||
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 observer = new QueryObserver(queryClient, {
|
||||
queryKey,
|
||||
@@ -192,7 +202,7 @@ describe("server data push router", () => {
|
||||
enabled: true,
|
||||
serverId,
|
||||
subscriptionId,
|
||||
cwd,
|
||||
workspaceGit: target,
|
||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||
}),
|
||||
});
|
||||
@@ -252,7 +262,8 @@ describe("server data push router", () => {
|
||||
const fake = createFakeClient({ rejectCheckoutDiffSubscribe: true });
|
||||
const serverId = "server-1";
|
||||
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 observer = new QueryObserver(queryClient, {
|
||||
queryKey,
|
||||
@@ -264,7 +275,7 @@ describe("server data push router", () => {
|
||||
enabled: true,
|
||||
serverId,
|
||||
subscriptionId,
|
||||
cwd,
|
||||
workspaceGit: target,
|
||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||
}),
|
||||
});
|
||||
@@ -339,7 +350,8 @@ describe("server data push router", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/repo";
|
||||
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 terminalKey = buildTerminalsQueryKey(serverId, cwd, workspaceId);
|
||||
const checkoutDiffObserver = new QueryObserver(queryClient, {
|
||||
@@ -352,7 +364,7 @@ describe("server data push router", () => {
|
||||
enabled: true,
|
||||
serverId,
|
||||
subscriptionId: checkoutDiffSubscriptionId,
|
||||
cwd,
|
||||
workspaceGit: target,
|
||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||
}),
|
||||
});
|
||||
@@ -508,7 +520,17 @@ describe("server data push router", () => {
|
||||
const providerKey = providersSnapshotQueryKey(serverId);
|
||||
const daemonConfigKey = daemonConfigQueryKey(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 otherProviderKey = providersSnapshotQueryKey(otherServerId);
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
providersSnapshotQueryKey,
|
||||
providersSnapshotQueryRoot,
|
||||
} from "@/data/providers-snapshot";
|
||||
import type { ProviderSnapshotCacheScope } from "@/data/provider-snapshot-cache";
|
||||
import type { WorkspaceGitClient } from "@/git/workspace-git";
|
||||
|
||||
type ProvidersSnapshotUpdateMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
@@ -47,7 +49,7 @@ interface CheckoutDiffRoute {
|
||||
enabled: boolean;
|
||||
serverId: string;
|
||||
subscriptionId: string;
|
||||
cwd: string;
|
||||
workspaceGit: WorkspaceGitClient;
|
||||
compare: CheckoutDiffCompare;
|
||||
}
|
||||
|
||||
@@ -72,12 +74,6 @@ interface ServerDataPushClient {
|
||||
type: TType,
|
||||
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => 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;
|
||||
unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void;
|
||||
}
|
||||
@@ -140,7 +136,7 @@ export function checkoutDiffPushRoute(input: {
|
||||
enabled: boolean;
|
||||
serverId: string;
|
||||
subscriptionId: string;
|
||||
cwd: string;
|
||||
workspaceGit: WorkspaceGitClient;
|
||||
compare: CheckoutDiffCompare;
|
||||
}): ServerDataQueryMeta {
|
||||
return {
|
||||
@@ -149,7 +145,7 @@ export function checkoutDiffPushRoute(input: {
|
||||
enabled: input.enabled,
|
||||
serverId: input.serverId,
|
||||
subscriptionId: input.subscriptionId,
|
||||
cwd: input.cwd,
|
||||
workspaceGit: input.workspaceGit,
|
||||
compare: input.compare,
|
||||
},
|
||||
};
|
||||
@@ -194,7 +190,11 @@ export function applyProvidersSnapshotUpdate(input: {
|
||||
if (input.message.type !== "providers_snapshot_update") {
|
||||
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, {
|
||||
entries: input.message.payload.entries,
|
||||
generatedAt: input.message.payload.generatedAt,
|
||||
@@ -204,7 +204,12 @@ export function applyProvidersSnapshotUpdate(input: {
|
||||
if (compactSnapshot && snapshotHash) {
|
||||
void (input.cache ?? providerSnapshotCache).write({
|
||||
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,
|
||||
generatedAt: input.message.payload.generatedAt,
|
||||
compactSnapshot,
|
||||
@@ -341,8 +346,8 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void {
|
||||
unsubscribeCheckoutDiffUpdate();
|
||||
unsubscribeCheckoutDiffResponse();
|
||||
unsubscribeTerminalsChanged();
|
||||
for (const subscriptionId of activeCheckoutDiffSubscriptions.keys()) {
|
||||
unsubscribeCheckoutDiff(input.client, subscriptionId);
|
||||
for (const route of activeCheckoutDiffSubscriptions.values()) {
|
||||
unsubscribeCheckoutDiff(route);
|
||||
}
|
||||
activeCheckoutDiffSubscriptions.clear();
|
||||
for (const route of activeTerminalSubscriptions.values()) {
|
||||
@@ -363,7 +368,7 @@ function reconcileCheckoutDiffSubscriptions(input: {
|
||||
if (desired && areCheckoutDiffRoutesEqual(current, desired)) {
|
||||
continue;
|
||||
}
|
||||
unsubscribeCheckoutDiff(input.client, subscriptionId);
|
||||
unsubscribeCheckoutDiff(current);
|
||||
input.active.delete(subscriptionId);
|
||||
}
|
||||
|
||||
@@ -372,8 +377,8 @@ function reconcileCheckoutDiffSubscriptions(input: {
|
||||
continue;
|
||||
}
|
||||
input.active.set(subscriptionId, desired);
|
||||
void input.client
|
||||
.subscribeCheckoutDiff(desired.cwd, desired.compare, {
|
||||
void desired.workspaceGit
|
||||
.subscribeDiff(desired.compare, {
|
||||
subscriptionId,
|
||||
requestId: `push-router:${input.serverId}:${subscriptionId}`,
|
||||
})
|
||||
@@ -383,7 +388,7 @@ function reconcileCheckoutDiffSubscriptions(input: {
|
||||
}
|
||||
console.error("[server-data] subscribeCheckoutDiff failed", {
|
||||
serverId: input.serverId,
|
||||
cwd: desired.cwd,
|
||||
cwd: desired.workspaceGit.cwd,
|
||||
error,
|
||||
});
|
||||
});
|
||||
@@ -652,15 +657,15 @@ function readServerDataRoute(value: Record<string, unknown>): ServerDataRoute |
|
||||
const domain = value.domain;
|
||||
const enabled = value.enabled;
|
||||
const serverId = value.serverId;
|
||||
const cwd = value.cwd;
|
||||
if (typeof enabled !== "boolean" || typeof serverId !== "string" || typeof cwd !== "string") {
|
||||
if (typeof enabled !== "boolean" || typeof serverId !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (domain === "checkoutDiff") {
|
||||
const subscriptionId = value.subscriptionId;
|
||||
const compare = readCheckoutDiffCompare(value.compare);
|
||||
if (typeof subscriptionId !== "string" || !compare) {
|
||||
const workspaceGit = readWorkspaceGitClient(value.workspaceGit);
|
||||
if (typeof subscriptionId !== "string" || !compare || !workspaceGit) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -668,12 +673,14 @@ function readServerDataRoute(value: Record<string, unknown>): ServerDataRoute |
|
||||
enabled,
|
||||
serverId,
|
||||
subscriptionId,
|
||||
cwd,
|
||||
workspaceGit,
|
||||
compare,
|
||||
};
|
||||
}
|
||||
|
||||
if (domain === "workspaceTerminals") {
|
||||
const cwd = value.cwd;
|
||||
if (typeof cwd !== "string") return null;
|
||||
const workspaceId = value.workspaceId;
|
||||
if (workspaceId !== undefined && typeof workspaceId !== "string") {
|
||||
return null;
|
||||
@@ -720,7 +727,7 @@ function areCheckoutDiffRoutesEqual(
|
||||
return (
|
||||
left?.serverId === right.serverId &&
|
||||
left.subscriptionId === right.subscriptionId &&
|
||||
left.cwd === right.cwd &&
|
||||
left.workspaceGit === right.workspaceGit &&
|
||||
left.compare.mode === right.compare.mode &&
|
||||
left.compare.baseRef === right.compare.baseRef &&
|
||||
left.compare.ignoreWhitespace === right.compare.ignoreWhitespace
|
||||
@@ -731,10 +738,13 @@ function isCheckoutDiffQueryKeyForRoute(queryKey: QueryKey, route: CheckoutDiffR
|
||||
return (
|
||||
queryKey[0] === "checkoutDiff" &&
|
||||
queryKey[1] === route.serverId &&
|
||||
queryKey[2] === route.cwd &&
|
||||
queryKey[3] === route.compare.mode &&
|
||||
queryKey[4] === (route.compare.baseRef ?? "") &&
|
||||
queryKey[5] === (route.compare.ignoreWhitespace === true)
|
||||
Array.isArray(queryKey[2]) &&
|
||||
queryKey[2][0] === route.workspaceGit.queryIdentity[0] &&
|
||||
queryKey[2][1] === route.workspaceGit.queryIdentity[1] &&
|
||||
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 {
|
||||
client.unsubscribeCheckoutDiff(subscriptionId);
|
||||
route.workspaceGit.unsubscribeDiff(route.subscriptionId);
|
||||
} catch {
|
||||
// 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 {
|
||||
return queryKey.length >= 2 && queryKey[0] === kind && queryKey[1] === serverId;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LiveFileModel, type LiveFileSession } from "./model";
|
||||
|
||||
export function useLiveFile(input: {
|
||||
client: DaemonClient | null;
|
||||
workspaceId: string;
|
||||
cwd: string | null;
|
||||
path: string | null;
|
||||
enabled: boolean;
|
||||
@@ -15,13 +16,13 @@ export function useLiveFile(input: {
|
||||
const client = input.client;
|
||||
return {
|
||||
subscribe(target, onVersion) {
|
||||
return client.subscribeFile(target, onVersion);
|
||||
return client.subscribeFile({ ...target, workspaceId: input.workspaceId }, onVersion);
|
||||
},
|
||||
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(() => {
|
||||
if (!input.enabled || !session || !input.cwd || !input.path) {
|
||||
|
||||
@@ -399,11 +399,13 @@ function FilePreviewBody({
|
||||
|
||||
export function FilePane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceRoot: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
@@ -447,6 +449,7 @@ export function FilePane({
|
||||
});
|
||||
const liveFile = useLiveFile({
|
||||
client,
|
||||
workspaceId,
|
||||
cwd: readTarget?.cwd ?? null,
|
||||
path: readTarget?.path ?? null,
|
||||
enabled,
|
||||
@@ -487,6 +490,7 @@ export function FilePane({
|
||||
<FilePanePresentation
|
||||
serverId={serverId}
|
||||
client={client}
|
||||
workspaceId={workspaceId}
|
||||
readTarget={readTarget}
|
||||
preview={preview}
|
||||
liveFile={liveFile.model}
|
||||
@@ -533,6 +537,7 @@ function isEditableTextFile(input: {
|
||||
|
||||
function FilePanePresentation({
|
||||
serverId,
|
||||
workspaceId,
|
||||
client,
|
||||
readTarget,
|
||||
preview,
|
||||
@@ -554,6 +559,7 @@ function FilePanePresentation({
|
||||
imagePreviewUri,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
client: DaemonClient | null;
|
||||
readTarget: { cwd: string; path: string } | null;
|
||||
preview: ExplorerFile | null;
|
||||
@@ -589,6 +595,7 @@ function FilePanePresentation({
|
||||
<EditableFilePane
|
||||
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
|
||||
client={client}
|
||||
workspaceId={workspaceId}
|
||||
cwd={readTarget.cwd}
|
||||
path={readTarget.path}
|
||||
preview={preview as TextExplorerFile}
|
||||
@@ -644,6 +651,7 @@ function FilePanePresentation({
|
||||
|
||||
function EditableFilePane({
|
||||
client,
|
||||
workspaceId,
|
||||
cwd,
|
||||
path,
|
||||
preview,
|
||||
@@ -659,6 +667,7 @@ function EditableFilePane({
|
||||
navigationRevision,
|
||||
}: {
|
||||
client: DaemonClient;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
path: string;
|
||||
preview: TextExplorerFile;
|
||||
@@ -680,10 +689,10 @@ function EditableFilePane({
|
||||
const session = useMemo(
|
||||
() => ({
|
||||
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(() => {
|
||||
return new FileEditorModel({
|
||||
|
||||
@@ -1,34 +1,26 @@
|
||||
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 { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
__resetCheckoutGitActionsStoreForTests,
|
||||
useCheckoutGitActionsStore,
|
||||
} from "@/git/actions-store";
|
||||
import type { WorkspaceGitClient } from "@/git/workspace-git";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined),
|
||||
removeItem: vi.fn(async () => undefined),
|
||||
},
|
||||
default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
}));
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
const serverId = "server-1";
|
||||
function capability(methods: Record<string, unknown>): WorkspaceGitClient {
|
||||
return {
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/tmp/repo/worktrees/feature",
|
||||
...methods,
|
||||
} as unknown as WorkspaceGitClient;
|
||||
}
|
||||
|
||||
describe("checkout-git-actions-store", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/tmp/repo/worktrees/feature";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
@@ -40,267 +32,101 @@ describe("checkout-git-actions-store", () => {
|
||||
vi.useRealTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
|
||||
it("shares pending state per checkout and de-dupes in-flight calls", async () => {
|
||||
const deferred = createDeferred<unknown>();
|
||||
const client = {
|
||||
checkoutCommit: vi.fn(() => deferred.promise),
|
||||
};
|
||||
|
||||
useSessionStore.setState((state) => ({
|
||||
...state,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
||||
},
|
||||
}));
|
||||
|
||||
it("binds once to workspace identity and de-duplicates an in-flight commit", async () => {
|
||||
let resolve!: (value: { error: null }) => void;
|
||||
const commit = vi.fn(() => new Promise<{ error: null }>((done) => (resolve = done)));
|
||||
const target = capability({ commit });
|
||||
const store = useCheckoutGitActionsStore.getState();
|
||||
|
||||
const first = store.commit({ serverId, cwd });
|
||||
const second = store.commit({ serverId, cwd });
|
||||
|
||||
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("pending");
|
||||
|
||||
deferred.resolve({});
|
||||
const first = store.commit({ serverId, target });
|
||||
const second = store.commit({ serverId, target });
|
||||
expect(store.getStatus({ serverId, target, actionId: "commit" })).toBe("pending");
|
||||
resolve({ error: null });
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("success");
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle");
|
||||
expect(commit).toHaveBeenCalledTimes(1);
|
||||
expect(store.getStatus({ serverId, target, actionId: "commit" })).toBe("success");
|
||||
});
|
||||
|
||||
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 client = {
|
||||
checkoutPull: vi.fn(async () => {
|
||||
const target = capability({
|
||||
pull: vi.fn(async () => {
|
||||
order.push("pull");
|
||||
return {};
|
||||
return { error: null };
|
||||
}),
|
||||
checkoutPush: vi.fn(async () => {
|
||||
push: vi.fn(async () => {
|
||||
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(
|
||||
useCheckoutGitActionsStore
|
||||
.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");
|
||||
});
|
||||
|
||||
for (const rpc of [
|
||||
{
|
||||
label: "forge",
|
||||
method: "checkoutForgeSetAutoMerge",
|
||||
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);
|
||||
it("fails auto-merge before invoking Git when the daemon lacks the feature", async () => {
|
||||
const setForgeAutoMerge = vi.fn();
|
||||
const target = capability({ setForgeAutoMerge });
|
||||
useSessionStore.getState().initializeSession(serverId, {} as never);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: {},
|
||||
});
|
||||
|
||||
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
|
||||
.getState()
|
||||
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
||||
).toBe("idle");
|
||||
.enablePrAutoMerge({ serverId, target, method: "merge" }),
|
||||
).rejects.toThrow("Update the host");
|
||||
expect(setForgeAutoMerge).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import { create } from "zustand";
|
||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
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;
|
||||
|
||||
@@ -30,17 +32,8 @@ export type CheckoutGitAsyncActionId =
|
||||
type CheckoutKey = string;
|
||||
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
|
||||
|
||||
function checkoutKey(serverId: string, cwd: string): CheckoutKey {
|
||||
return `${serverId}::${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;
|
||||
function checkoutKey(serverId: string, target: WorkspaceGitTarget): CheckoutKey {
|
||||
return `${serverId}::${target.workspaceId}::${target.cwd}`;
|
||||
}
|
||||
|
||||
type AutoMergeActionsRpc = "forge" | "github";
|
||||
@@ -81,8 +74,8 @@ function setStatus(
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
|
||||
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
|
||||
function invalidateCheckoutGitQueries(serverId: string, target: WorkspaceGitTarget) {
|
||||
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, target });
|
||||
}
|
||||
|
||||
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
@@ -97,44 +90,56 @@ interface CheckoutGitActionsStoreState {
|
||||
|
||||
getStatus: (params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
target: WorkspaceGitTarget;
|
||||
actionId: CheckoutGitAsyncActionId;
|
||||
}) => CheckoutGitActionStatus;
|
||||
|
||||
commit: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
push: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
refresh: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
commit: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
|
||||
pull: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
|
||||
push: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
|
||||
pullAndPush: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
|
||||
refresh: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
|
||||
createPr: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
|
||||
mergePr: (params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
target: WorkspaceGitTarget;
|
||||
method: CheckoutPrMergeMethod;
|
||||
}) => Promise<void>;
|
||||
enablePrAutoMerge: (params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
target: WorkspaceGitTarget;
|
||||
method: CheckoutPrMergeMethod;
|
||||
}) => Promise<void>;
|
||||
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
discardChanges: (params: { serverId: string; cwd: string; paths: string[] }) => Promise<void>;
|
||||
disablePrAutoMerge: (params: { serverId: string; target: WorkspaceGitTarget }) => Promise<void>;
|
||||
mergeBranch: (params: {
|
||||
serverId: string;
|
||||
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({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId,
|
||||
run,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
target: WorkspaceGitTarget;
|
||||
actionId: CheckoutGitAsyncActionId;
|
||||
run: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const key = checkoutKey(serverId, cwd);
|
||||
const key = checkoutKey(serverId, target);
|
||||
const inflightId = inFlightKey(key, actionId);
|
||||
|
||||
const existing = inFlight.get(inflightId);
|
||||
@@ -154,7 +159,7 @@ async function runCheckoutAction({
|
||||
const promise = (async () => {
|
||||
try {
|
||||
await run();
|
||||
await invalidateCheckoutGitQueries(serverId, cwd);
|
||||
await invalidateCheckoutGitQueries(serverId, target);
|
||||
setStatus(key, actionId, "success");
|
||||
const timer = setTimeout(() => {
|
||||
setStatus(key, actionId, "idle");
|
||||
@@ -176,19 +181,18 @@ async function runCheckoutAction({
|
||||
export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()((set, get) => ({
|
||||
statusByCheckout: {},
|
||||
|
||||
getStatus: ({ serverId, cwd, actionId }) => {
|
||||
const key = checkoutKey(serverId, cwd);
|
||||
getStatus: ({ serverId, target, actionId }) => {
|
||||
const key = checkoutKey(serverId, target);
|
||||
return get().statusByCheckout[key]?.[actionId] ?? "idle";
|
||||
},
|
||||
|
||||
commit: async ({ serverId, cwd }) => {
|
||||
commit: async ({ serverId, target }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId: "commit",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutCommit(cwd, { addAll: true });
|
||||
const payload = await target.commit({ addAll: true });
|
||||
if (payload.error) {
|
||||
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({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId: "pull",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutPull(cwd);
|
||||
const payload = await target.pull();
|
||||
if (payload.error) {
|
||||
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({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId: "push",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutPush(cwd);
|
||||
const payload = await target.push();
|
||||
if (payload.error) {
|
||||
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({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId: "refresh",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutRefresh(cwd);
|
||||
const payload = await target.refresh();
|
||||
if (payload.error) {
|
||||
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({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId: "pull-and-push",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const pullPayload = await client.checkoutPull(cwd);
|
||||
const pullPayload = await target.pull();
|
||||
if (pullPayload.error) {
|
||||
throw new Error(pullPayload.error.message);
|
||||
}
|
||||
const pushPayload = await client.checkoutPush(cwd);
|
||||
const pushPayload = await target.push();
|
||||
if (pushPayload.error) {
|
||||
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({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId: "create-pr",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutPrCreate(cwd, {});
|
||||
const payload = await target.createPr({});
|
||||
if (payload.error) {
|
||||
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({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId: `merge-pr-${method}`,
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutPrMerge(cwd, { method });
|
||||
const payload = await target.mergePr(method);
|
||||
if (payload.error) {
|
||||
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);
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId: `enable-pr-auto-merge-${method}`,
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// all supported clients use checkout.forge.set_auto_merge.*.
|
||||
const payload =
|
||||
rpc === "forge"
|
||||
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method })
|
||||
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
|
||||
? await target.setForgeAutoMerge({ enabled: true, method })
|
||||
: await target.setGithubAutoMerge({ enabled: true, method });
|
||||
if (payload.error) {
|
||||
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);
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId: "disable-pr-auto-merge",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// all supported clients use checkout.forge.set_auto_merge.*.
|
||||
const payload =
|
||||
rpc === "forge"
|
||||
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false })
|
||||
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
|
||||
? await target.setForgeAutoMerge({ enabled: false })
|
||||
: await target.setGithubAutoMerge({ enabled: false });
|
||||
if (payload.error) {
|
||||
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({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId: "merge-branch",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutMerge(cwd, {
|
||||
const payload = await target.merge({
|
||||
baseRef,
|
||||
strategy: "merge",
|
||||
requireCleanTarget: true,
|
||||
@@ -351,14 +346,13 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
});
|
||||
},
|
||||
|
||||
mergeFromBase: async ({ serverId, cwd, baseRef }) => {
|
||||
mergeFromBase: async ({ serverId, target, baseRef }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId: "merge-from-base",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutMergeFromBase(cwd, {
|
||||
const payload = await target.mergeFromBase({
|
||||
baseRef,
|
||||
requireCleanTarget: true,
|
||||
});
|
||||
@@ -369,18 +363,15 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
});
|
||||
},
|
||||
|
||||
discardChanges: async ({ serverId, cwd, paths }) => {
|
||||
discardChanges: async ({ serverId, target, paths }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
actionId: "discard-changes",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutDiscardChanges(cwd, { paths });
|
||||
const payload = await target.discardChanges({ paths });
|
||||
if (!payload.success) {
|
||||
throw new Error(
|
||||
payload.error?.message ?? i18n.t("workspace.fileActions.confirmRevert.failed"),
|
||||
);
|
||||
throw new Error(payload.error?.message ?? "Failed to discard changes");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,54 +1,32 @@
|
||||
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";
|
||||
|
||||
function createRecordingClient() {
|
||||
const cwds: string[] = [];
|
||||
const calls: string[] = [];
|
||||
const client = {
|
||||
getBranchSuggestions: async (options: { cwd: string; limit?: number }) => {
|
||||
cwds.push(options.cwd);
|
||||
return { branches: [], error: null };
|
||||
},
|
||||
stashList: async (cwd: string) => {
|
||||
cwds.push(cwd);
|
||||
return { entries: [] };
|
||||
},
|
||||
stashSave: async (cwd: string) => {
|
||||
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 };
|
||||
workspaceId: "wks_3f9a2b1c",
|
||||
cwd: "/Users/dev/project",
|
||||
getBranchSuggestions: async () => (calls.push("suggest"), { branches: [], error: null }),
|
||||
stashList: async () => (calls.push("list"), { entries: [] }),
|
||||
stashSave: async () => (calls.push("save"), { error: null }),
|
||||
stashPop: async () => (calls.push("pop"), { error: null }),
|
||||
switchBranch: async () => (calls.push("switch"), { error: null }),
|
||||
} as unknown as WorkspaceGitClient;
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
describe("createBranchSwitcherOperations", () => {
|
||||
it("sends the workspace directory as cwd to every git operation, never the workspace id", async () => {
|
||||
const workspaceDirectory = "/Users/dev/project";
|
||||
const workspaceId = "wks_3f9a2b1c";
|
||||
const { client, cwds } = createRecordingClient();
|
||||
it("binds every operation to the selected workspace identity", async () => {
|
||||
const { client, calls } = createRecordingClient();
|
||||
|
||||
const operations = createBranchSwitcherOperations(client, workspaceDirectory);
|
||||
const operations = createBranchSwitcherOperations(client);
|
||||
await operations.getBranchSuggestions(200);
|
||||
await operations.listPaseoStashes();
|
||||
await operations.saveStash("main");
|
||||
await operations.popStash(0);
|
||||
await operations.switchBranch("feature");
|
||||
|
||||
expect(cwds).toEqual([
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
workspaceDirectory,
|
||||
]);
|
||||
expect(cwds).not.toContain(workspaceId);
|
||||
expect(calls).toEqual(["suggest", "list", "save", "pop", "switch"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// workspace id can never be passed where a cwd is expected. `cwd` is set once here;
|
||||
// callers choose the operation, never the directory.
|
||||
export function createBranchSwitcherOperations(client: DaemonClient, cwd: string) {
|
||||
export function createBranchSwitcherOperations(workspaceGit: WorkspaceGitClient) {
|
||||
return {
|
||||
getBranchSuggestions: (limit: number) => client.getBranchSuggestions({ cwd, limit }),
|
||||
listPaseoStashes: () => client.stashList(cwd, { paseoOnly: true }),
|
||||
saveStash: (branch: string | undefined) => client.stashSave(cwd, { branch }),
|
||||
popStash: (stashIndex: number) => client.stashPop(cwd, stashIndex),
|
||||
switchBranch: (branch: string) => client.checkoutSwitchBranch(cwd, branch),
|
||||
getBranchSuggestions: (limit: number) => workspaceGit.getBranchSuggestions({ limit }),
|
||||
listPaseoStashes: () => workspaceGit.stashList({ paseoOnly: true }),
|
||||
saveStash: (branch: string | undefined) => workspaceGit.stashSave({ branch }),
|
||||
popStash: (stashIndex: number) => workspaceGit.stashPop(stashIndex),
|
||||
switchBranch: (branch: string) => workspaceGit.switchBranch(branch),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,332 +2,129 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
checkoutCommitsQueryKey,
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
} from "@/git/query-keys";
|
||||
import {
|
||||
prPanePipelineQueryKey,
|
||||
prPaneTimelineQueryKey,
|
||||
} from "@/git/pull-request-panel/query-keys";
|
||||
import { checkoutCommitsQueryKey, checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
|
||||
import {
|
||||
applyCheckoutStatusUpdateFromEvent,
|
||||
ensureCheckoutStatus,
|
||||
type CheckoutPrStatusPayload,
|
||||
type CheckoutStatusPayload,
|
||||
fetchCheckoutStatus,
|
||||
type CheckoutStatusPayload,
|
||||
} from "./checkout-status-cache";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined),
|
||||
removeItem: vi.fn(async () => undefined),
|
||||
},
|
||||
default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
}));
|
||||
|
||||
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 {
|
||||
cwd,
|
||||
workspaceId: target.workspaceId,
|
||||
cwd: target.cwd,
|
||||
error: null,
|
||||
requestId: "checkout-status-1",
|
||||
requestId: `status-${target.workspaceId}`,
|
||||
isGit: true,
|
||||
isPaseoOwnedWorktree: false,
|
||||
repoRoot: cwd,
|
||||
currentBranch: "main",
|
||||
isDirty: false,
|
||||
repoRoot: target.cwd,
|
||||
mainRepoRoot: null,
|
||||
currentBranch: branch,
|
||||
isDirty,
|
||||
baseRef: "origin/main",
|
||||
aheadBehind: { ahead: 0, behind: 0 },
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
hasRemote: true,
|
||||
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
||||
...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,
|
||||
remoteUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
function checkoutStatusUpdate(
|
||||
payload: CheckoutStatusPayload,
|
||||
extraPrStatus?: NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>,
|
||||
): CheckoutStatusUpdate {
|
||||
return {
|
||||
type: "checkout_status_update",
|
||||
payload: extraPrStatus ? { ...payload, prStatus: extraPrStatus } : payload,
|
||||
};
|
||||
function update(payload: CheckoutStatusPayload): CheckoutStatusUpdate {
|
||||
return { type: "checkout_status_update", payload };
|
||||
}
|
||||
|
||||
function setDiffModeOverride(isDirtyAtSelection: boolean): void {
|
||||
useReviewDraftStore.getState().setDiffModeOverride({
|
||||
scopeKey: "review:scope",
|
||||
override: { serverId, cwd, mode: "base", isDirtyAtSelection },
|
||||
});
|
||||
}
|
||||
beforeEach(resetReviewDraftStore);
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetReviewDraftStore();
|
||||
});
|
||||
|
||||
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);
|
||||
describe("selected checkout status cache", () => {
|
||||
it("fetches through the bound workspace capability", async () => {
|
||||
const getStatus = vi.fn(async () => status());
|
||||
await expect(
|
||||
fetchCheckoutStatus({ client: { getStatus }, serverId, target: targetA }),
|
||||
).resolves.toEqual(status());
|
||||
expect(getStatus).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("expires a manual diff-mode override when the fetched dirty state flipped", async () => {
|
||||
setDiffModeOverride(true);
|
||||
const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) };
|
||||
|
||||
await fetchCheckoutStatus({ client, serverId, cwd });
|
||||
|
||||
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
it("deduplicates a bound status fetch by workspace identity", async () => {
|
||||
const client = new QueryClient();
|
||||
const getStatus = vi.fn(async () => status());
|
||||
const first = ensureCheckoutStatus({
|
||||
queryClient: client,
|
||||
client: { getStatus },
|
||||
serverId,
|
||||
target: targetA,
|
||||
});
|
||||
const fetched = checkoutStatus({ currentBranch: "feature/current" });
|
||||
const client = { getCheckoutStatus: vi.fn(async () => fetched) };
|
||||
|
||||
const result = await ensureCheckoutStatus({ queryClient, client, serverId, cwd });
|
||||
|
||||
expect(result.currentBranch).toBe("feature/current");
|
||||
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
|
||||
const second = ensureCheckoutStatus({
|
||||
queryClient: client,
|
||||
client: { getStatus },
|
||||
serverId,
|
||||
target: targetA,
|
||||
});
|
||||
await Promise.all([first, second]);
|
||||
expect(getStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
it("writes the checkout status to the cache using the cwd from the payload", () => {
|
||||
const queryClient = createQueryClient();
|
||||
const pushed = checkoutStatus({ requestId: "push-1", isDirty: true });
|
||||
it("keeps same-cwd selected status and invalidation isolated", () => {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(checkoutStatusQueryKey(serverId, targetA), status(targetA, "a"));
|
||||
client.setQueryData(checkoutStatusQueryKey(serverId, targetB), status(targetB, "b"));
|
||||
client.setQueryData(checkoutCommitsQueryKey(serverId, targetA), { commits: [] });
|
||||
client.setQueryData(checkoutCommitsQueryKey(serverId, targetB), { commits: [] });
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
queryClient: client,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(pushed),
|
||||
message: update(status(targetA, "a-next", true)),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
|
||||
});
|
||||
|
||||
it("invalidates recent commits when checkout status is pushed", () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] });
|
||||
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, "/repo2"), { commits: [] });
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus()),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryState(checkoutCommitsQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
expect(
|
||||
client.getQueryData<CheckoutStatusPayload>(checkoutStatusQueryKey(serverId, targetA))
|
||||
?.currentBranch,
|
||||
).toBe("a-next");
|
||||
expect(
|
||||
client.getQueryData<CheckoutStatusPayload>(checkoutStatusQueryKey(serverId, targetB))
|
||||
?.currentBranch,
|
||||
).toBe("b");
|
||||
expect(client.getQueryState(checkoutCommitsQueryKey(serverId, targetA))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/repo2"))?.isInvalidated,
|
||||
).toBe(false);
|
||||
expect(client.getQueryState(checkoutCommitsQueryKey(serverId, targetB))?.isInvalidated).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => {
|
||||
const queryClient = createQueryClient();
|
||||
const pushedPr = prStatus({ requestId: "pr-1" });
|
||||
|
||||
it("expires a diff-mode override when a bound update changes dirty state", () => {
|
||||
useReviewDraftStore.getState().setDiffModeOverride({
|
||||
scopeKey: "review:scope",
|
||||
override: { serverId, cwd: targetA.cwd, mode: "base", isDirtyAtSelection: false },
|
||||
});
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
queryClient: new QueryClient(),
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,15 +5,21 @@ import {
|
||||
checkoutCommitsQueryKey,
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
legacyCheckoutStatusQueryKey,
|
||||
invalidatePrPaneTimelineForCheckout,
|
||||
} from "@/git/query-keys";
|
||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
import { expireStaleDiffModeOverrides } from "@/review/store";
|
||||
import type { WorkspaceGitClient, WorkspaceGitStatusTarget } from "./workspace-git";
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
|
||||
export interface CheckoutStatusClient {
|
||||
getStatus: WorkspaceGitClient["getStatus"];
|
||||
}
|
||||
|
||||
export interface LegacyCheckoutStatusClient {
|
||||
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
|
||||
}
|
||||
|
||||
@@ -24,14 +30,18 @@ export interface CheckoutStatusClient {
|
||||
export async function fetchCheckoutStatus({
|
||||
client,
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
}: {
|
||||
client: CheckoutStatusClient;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
target: WorkspaceGitStatusTarget;
|
||||
}): Promise<CheckoutStatusPayload> {
|
||||
const payload = await client.getCheckoutStatus(cwd);
|
||||
expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
|
||||
const payload = await client.getStatus();
|
||||
expireStaleDiffModeOverrides({
|
||||
serverId,
|
||||
cwd: target.cwd,
|
||||
isDirty: payload.isGit && payload.isDirty,
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -39,16 +49,38 @@ export async function ensureCheckoutStatus({
|
||||
queryClient,
|
||||
client,
|
||||
serverId,
|
||||
cwd,
|
||||
target,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
client: CheckoutStatusClient;
|
||||
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;
|
||||
}): Promise<CheckoutStatusPayload> {
|
||||
return await queryClient.fetchQuery({
|
||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||
queryFn: () => fetchCheckoutStatus({ client, serverId, cwd }),
|
||||
queryKey: legacyCheckoutStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
const payload = await client.getCheckoutStatus(cwd);
|
||||
expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
|
||||
return payload;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -67,9 +99,15 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
? normalizeCheckoutPrStatusPayload(payload.prStatus)
|
||||
: undefined;
|
||||
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({
|
||||
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
|
||||
queryKey: checkoutCommitsQueryKey(serverId, target),
|
||||
});
|
||||
expireStaleDiffModeOverrides({
|
||||
serverId,
|
||||
@@ -82,14 +120,14 @@ export function applyCheckoutStatusUpdateFromEvent({
|
||||
}
|
||||
|
||||
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
|
||||
// meaningfully changed. Active panes refetch immediately, evicted ones on next mount.
|
||||
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 {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
/** Change request (PR/MR) number, so a section can address its head pipeline. */
|
||||
changeRequestNumber: number;
|
||||
|
||||
@@ -11,7 +11,6 @@ import { CommitRow } from "./commit-row";
|
||||
|
||||
interface CommitsSectionProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
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 { preferences, updatePreferences } = useChangesPreferences();
|
||||
const isPanelActive = useRetainedPanelActive();
|
||||
@@ -91,7 +90,6 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
|
||||
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
|
||||
const query = useCheckoutCommitsQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: !collapsed,
|
||||
});
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git
|
||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
@@ -241,15 +242,14 @@ function noopStartComment(): void {}
|
||||
|
||||
function useDiscardChangesAction({
|
||||
serverId,
|
||||
cwd,
|
||||
diffMode,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
diffMode: "uncommitted" | "base";
|
||||
}): ((path: string, oldPath?: string) => void) | undefined {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const workspaceGit = useRequiredWorkspaceGit();
|
||||
const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges);
|
||||
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
|
||||
const discardSupported = useSessionStore(
|
||||
@@ -270,7 +270,7 @@ function useDiscardChangesAction({
|
||||
try {
|
||||
await discardChanges({
|
||||
serverId,
|
||||
cwd,
|
||||
target: workspaceGit,
|
||||
paths: oldPath ? [path, oldPath] : [path],
|
||||
});
|
||||
} catch (cause) {
|
||||
@@ -279,7 +279,7 @@ function useDiscardChangesAction({
|
||||
);
|
||||
}
|
||||
},
|
||||
[cwd, discardChanges, serverId, t, toast],
|
||||
[discardChanges, serverId, t, toast, workspaceGit],
|
||||
);
|
||||
const handleDiscardPath = useCallback(
|
||||
(path: string, oldPath?: string) => {
|
||||
@@ -2848,6 +2848,7 @@ export function GitDiffPane({
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
}: GitDiffPaneProps) {
|
||||
const workspaceGit = useRequiredWorkspaceGit();
|
||||
const { settings: appSettings } = useAppSettings();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -2909,17 +2910,22 @@ export function GitDiffPane({
|
||||
);
|
||||
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
|
||||
const isRefreshing =
|
||||
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
|
||||
"pending";
|
||||
useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({
|
||||
serverId,
|
||||
target: workspaceGit,
|
||||
actionId: "refresh",
|
||||
}),
|
||||
) === "pending";
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (isRefreshing) {
|
||||
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"));
|
||||
});
|
||||
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
|
||||
}, [isRefreshing, runRefresh, serverId, t, toast, workspaceGit]);
|
||||
|
||||
const {
|
||||
status,
|
||||
@@ -2940,7 +2946,7 @@ export function GitDiffPane({
|
||||
reviewAttachment,
|
||||
} = useWorkingDiff({
|
||||
serverId,
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
workspaceId: workspaceGit.workspaceId,
|
||||
cwd,
|
||||
ignoreWhitespace: changesPreferences.hideWhitespace,
|
||||
enabled: enabled !== false,
|
||||
@@ -2959,7 +2965,6 @@ export function GitDiffPane({
|
||||
payloadError: prPayloadError,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isGit,
|
||||
});
|
||||
const forgeProvidersSupported = useSessionStore(
|
||||
@@ -2987,7 +2992,7 @@ export function GitDiffPane({
|
||||
[updateChangesPreferences],
|
||||
);
|
||||
const changesTree = useChangesTreeState({
|
||||
workspaceId,
|
||||
workspaceId: workspaceGit.workspaceId,
|
||||
cwd,
|
||||
files,
|
||||
viewMode,
|
||||
@@ -3056,7 +3061,7 @@ export function GitDiffPane({
|
||||
},
|
||||
[client, cwd, t, toast],
|
||||
);
|
||||
const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode });
|
||||
const onRevertPath = useDiscardChangesAction({ serverId, diffMode });
|
||||
const workingTreeMode = useMemo(
|
||||
() => ({
|
||||
kind: "working_tree" as const,
|
||||
@@ -3161,8 +3166,6 @@ export function GitDiffPane({
|
||||
<BranchSwitcher
|
||||
currentBranchName={currentBranchName}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId ?? cwd}
|
||||
workspaceDirectory={cwd}
|
||||
isGitCheckout={isGit}
|
||||
testID="changes-branch-switcher"
|
||||
/>
|
||||
@@ -3236,7 +3239,7 @@ export function GitDiffPane({
|
||||
|
||||
<View style={styles.diffContainer}>{bodyContent}</View>
|
||||
|
||||
<CommitsSection serverId={serverId} cwd={cwd} onCommitPress={handleCommitPress} />
|
||||
<CommitsSection serverId={serverId} onCommitPress={handleCommitPress} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotC
|
||||
return (
|
||||
<GitLabPipelineSection
|
||||
serverId={ctx.serverId}
|
||||
cwd={ctx.cwd}
|
||||
changeRequestNumber={ctx.changeRequestNumber}
|
||||
summary={summary}
|
||||
open={ctx.open}
|
||||
@@ -118,7 +117,6 @@ function countPipelineJobs(jobs: CheckoutPipelineJob[]): PipelineJobCounts {
|
||||
|
||||
function GitLabPipelineSection({
|
||||
serverId,
|
||||
cwd,
|
||||
changeRequestNumber,
|
||||
summary,
|
||||
open,
|
||||
@@ -126,7 +124,6 @@ function GitLabPipelineSection({
|
||||
canFetchCheckDetails,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
changeRequestNumber: number;
|
||||
summary: GitlabPipelineSummary;
|
||||
open: boolean;
|
||||
@@ -136,7 +133,6 @@ function GitLabPipelineSection({
|
||||
const { t } = useTranslation();
|
||||
const { pipeline, isLoading, isPlaceholderData, error } = useGitLabPipeline({
|
||||
serverId,
|
||||
cwd,
|
||||
pipelineId: summary.id,
|
||||
changeRequestNumber,
|
||||
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 { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||||
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
canAddPullRequestCheckLogsToChat,
|
||||
} from "./context-attachment";
|
||||
import { getActivityVerb, getStateLabel } from "./data";
|
||||
import { fetchBoundPullRequestCheckDetails } from "./check-details";
|
||||
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
|
||||
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
|
||||
import {
|
||||
@@ -205,12 +206,14 @@ function removeLoadingCheck(current: ReadonlySet<string>, checkKey: string): Rea
|
||||
|
||||
export function PullRequestPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
data,
|
||||
activityLoading,
|
||||
workspaceAttachmentScopeKey,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
data: PrPaneData;
|
||||
activityLoading: boolean;
|
||||
@@ -218,7 +221,7 @@ export function PullRequestPane({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const daemonClient = useHostRuntimeClient(serverId);
|
||||
const workspaceGit = useRequiredWorkspaceGit();
|
||||
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// all supported clients use checkout.forge.get_check_details.*.
|
||||
const canFetchGitHubCheckDetails = useSessionStore(
|
||||
@@ -248,17 +251,17 @@ export function PullRequestPane({
|
||||
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
|
||||
const isRefreshing =
|
||||
useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "refresh" }),
|
||||
state.getStatus({ serverId, target: workspaceGit, actionId: "refresh" }),
|
||||
) === "pending";
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (isRefreshing) {
|
||||
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"));
|
||||
});
|
||||
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
|
||||
}, [isRefreshing, runRefresh, serverId, t, toast, workspaceGit]);
|
||||
|
||||
const handleToggleChecks = useCallback(() => {
|
||||
setChecksOpen((open) => !open);
|
||||
@@ -399,29 +402,21 @@ export function PullRequestPane({
|
||||
canFetchForgeCheckDetails || (check.provider === "github" && canFetchGitHubCheckDetails);
|
||||
if (
|
||||
canFetchDetail &&
|
||||
daemonClient &&
|
||||
(ref?.checkRunId !== undefined || ref?.workflowRunId !== undefined) &&
|
||||
data.repoOwner &&
|
||||
data.repoName
|
||||
) {
|
||||
try {
|
||||
const request = {
|
||||
cwd,
|
||||
repoOwner: data.repoOwner,
|
||||
repoName: data.repoName,
|
||||
checkRunId: ref.checkRunId,
|
||||
workflowRunId: ref.workflowRunId,
|
||||
changeRequestNumber: data.number,
|
||||
};
|
||||
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// 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;
|
||||
}
|
||||
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// all supported clients use checkout.forge.get_check_details.*.
|
||||
details = await fetchBoundPullRequestCheckDetails({
|
||||
workspaceGit,
|
||||
transport: canFetchForgeCheckDetails ? "forge" : "github",
|
||||
repoOwner: data.repoOwner,
|
||||
repoName: data.repoName,
|
||||
checkRunId: ref.checkRunId,
|
||||
workflowRunId: ref.workflowRunId,
|
||||
changeRequestNumber: data.number,
|
||||
});
|
||||
}
|
||||
const attachment = buildPullRequestCheckContextAttachment({
|
||||
provider: data.provider,
|
||||
@@ -444,8 +439,6 @@ export function PullRequestPane({
|
||||
addWorkspaceAttachment,
|
||||
canFetchForgeCheckDetails,
|
||||
canFetchGitHubCheckDetails,
|
||||
cwd,
|
||||
daemonClient,
|
||||
data.forge,
|
||||
data.number,
|
||||
data.provider,
|
||||
@@ -454,6 +447,7 @@ export function PullRequestPane({
|
||||
data.title,
|
||||
data.url,
|
||||
workspaceAttachmentScopeKey,
|
||||
workspaceGit,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -483,6 +477,7 @@ export function PullRequestPane({
|
||||
const nativeChecksSection = data.forgeSpecific
|
||||
? nativeContribution?.renderChecksSection(data.forgeSpecific, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
changeRequestNumber: data.number,
|
||||
open: checksOpen,
|
||||
|
||||
@@ -2,29 +2,40 @@ export const prPaneTimelineQueryKind = "prPaneTimeline";
|
||||
|
||||
export function prPaneTimelineQueryKey({
|
||||
serverId,
|
||||
queryIdentity,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
queryIdentity: readonly ["selected", string];
|
||||
cwd: string;
|
||||
prNumber: number | null;
|
||||
}) {
|
||||
return [prPaneTimelineQueryKind, serverId, cwd, prNumber] as const;
|
||||
return [prPaneTimelineQueryKind, serverId, queryIdentity, cwd, prNumber] as const;
|
||||
}
|
||||
|
||||
export const prPanePipelineQueryKind = "prPanePipeline";
|
||||
|
||||
export function prPanePipelineQueryKey({
|
||||
serverId,
|
||||
queryIdentity,
|
||||
cwd,
|
||||
pipelineId,
|
||||
changeRequestNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
queryIdentity: readonly ["selected", string];
|
||||
cwd: string;
|
||||
pipelineId: number | null;
|
||||
/** MR iid the pipeline is fetched by; part of the key since the fetch routes by it. */
|
||||
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 PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
||||
type PullRequestTimelineInput = Parameters<PrPaneTimelineClient["pullRequestTimeline"]>[0];
|
||||
type PullRequestTimelineInput = Parameters<PrPaneTimelineClient["getPullRequestTimeline"]>[0];
|
||||
const workspaceId = "workspace-1";
|
||||
|
||||
const githubStatus: CheckoutPrStatus["github"] = {
|
||||
mergeStateStatus: null,
|
||||
@@ -82,7 +83,7 @@ function createTimelineClient(
|
||||
const calls: PullRequestTimelineInput[] = [];
|
||||
return {
|
||||
calls,
|
||||
pullRequestTimeline: async (input) => {
|
||||
getPullRequestTimeline: async (input) => {
|
||||
calls.push(input);
|
||||
return respond(input);
|
||||
},
|
||||
@@ -181,7 +182,12 @@ describe("shouldFetchTimelineFrom", () => {
|
||||
describe("createInMemoryUnsupportedTimelineRegistry", () => {
|
||||
it("remembers added keys and answers has() against them", () => {
|
||||
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);
|
||||
registry.add(key);
|
||||
@@ -189,14 +195,20 @@ describe("createInMemoryUnsupportedTimelineRegistry", () => {
|
||||
});
|
||||
|
||||
it("uses serverId, cwd, and prNumber to scope each key", () => {
|
||||
expect(unsupportedTimelineKey({ serverId: "host-a", cwd: "/repo", prNumber: 1 })).not.toEqual(
|
||||
unsupportedTimelineKey({ serverId: "host-b", cwd: "/repo", prNumber: 1 }),
|
||||
expect(
|
||||
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(
|
||||
unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 1 }),
|
||||
expect(
|
||||
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(
|
||||
unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 2 }),
|
||||
expect(
|
||||
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,
|
||||
registry,
|
||||
serverId: "host",
|
||||
workspaceId,
|
||||
cwd: "/repo",
|
||||
prNumber: 42,
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
});
|
||||
|
||||
expect(client.calls).toEqual([
|
||||
{ cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" },
|
||||
]);
|
||||
expect(client.calls).toEqual([{ prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" }]);
|
||||
});
|
||||
|
||||
it("returns the daemon's timeline payload on success", async () => {
|
||||
@@ -241,6 +252,7 @@ describe("fetchPrPaneTimelinePage", () => {
|
||||
client,
|
||||
registry,
|
||||
serverId: "host",
|
||||
workspaceId,
|
||||
cwd: "/repo",
|
||||
prNumber: 42,
|
||||
repoOwner: "getpaseo",
|
||||
@@ -262,6 +274,7 @@ describe("fetchPrPaneTimelinePage", () => {
|
||||
client,
|
||||
registry,
|
||||
serverId: "host",
|
||||
workspaceId,
|
||||
cwd: "/repo",
|
||||
prNumber: 99,
|
||||
repoOwner: "getpaseo",
|
||||
@@ -270,7 +283,9 @@ describe("fetchPrPaneTimelinePage", () => {
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(
|
||||
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })),
|
||||
registry.has(
|
||||
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 99 }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -286,6 +301,7 @@ describe("fetchPrPaneTimelinePage", () => {
|
||||
client,
|
||||
registry,
|
||||
serverId: "host",
|
||||
workspaceId,
|
||||
cwd: "/repo",
|
||||
prNumber: 99,
|
||||
repoOwner: "getpaseo",
|
||||
@@ -294,16 +310,18 @@ describe("fetchPrPaneTimelinePage", () => {
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(
|
||||
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })),
|
||||
registry.has(
|
||||
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 99 }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes recorded tuples per serverId+cwd+prNumber so other PRs can still be tried", async () => {
|
||||
const client = createTimelineClient(async (input) => {
|
||||
if (input.cwd === "/repo-a") {
|
||||
if (input.prNumber === 1) {
|
||||
throw unsupportedTimelineError();
|
||||
}
|
||||
return timelinePayload({ cwd: input.cwd, prNumber: input.prNumber });
|
||||
return timelinePayload({ cwd: "/repo-b", prNumber: input.prNumber });
|
||||
});
|
||||
const registry = createInMemoryUnsupportedTimelineRegistry();
|
||||
|
||||
@@ -312,6 +330,7 @@ describe("fetchPrPaneTimelinePage", () => {
|
||||
client,
|
||||
registry,
|
||||
serverId: "host",
|
||||
workspaceId,
|
||||
cwd: "/repo-a",
|
||||
prNumber: 1,
|
||||
repoOwner: "getpaseo",
|
||||
@@ -323,6 +342,7 @@ describe("fetchPrPaneTimelinePage", () => {
|
||||
client,
|
||||
registry,
|
||||
serverId: "host",
|
||||
workspaceId,
|
||||
cwd: "/repo-b",
|
||||
prNumber: 2,
|
||||
repoOwner: "getpaseo",
|
||||
@@ -331,10 +351,14 @@ describe("fetchPrPaneTimelinePage", () => {
|
||||
|
||||
expect(result.prNumber).toBe(2);
|
||||
expect(
|
||||
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })),
|
||||
registry.has(
|
||||
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-a", prNumber: 1 }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 2 })),
|
||||
registry.has(
|
||||
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-b", prNumber: 2 }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
} 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 type { Forge } from "@/git/forge";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
@@ -19,7 +19,6 @@ type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
|
||||
export interface UsePrPaneDataOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
enabled?: 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 {
|
||||
has(key: string): boolean;
|
||||
@@ -89,14 +88,16 @@ export interface UnsupportedTimelineRegistry {
|
||||
|
||||
export function unsupportedTimelineKey({
|
||||
serverId,
|
||||
workspaceId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
prNumber: number;
|
||||
}): string {
|
||||
return `${serverId}\0${cwd}\0${prNumber}`;
|
||||
return `${serverId}\0${workspaceId}\0${cwd}\0${prNumber}`;
|
||||
}
|
||||
|
||||
export function createInMemoryUnsupportedTimelineRegistry(): UnsupportedTimelineRegistry {
|
||||
@@ -115,6 +116,7 @@ export interface FetchPrPaneTimelinePageInput {
|
||||
client: PrPaneTimelineClient;
|
||||
registry: UnsupportedTimelineRegistry;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
prNumber: number;
|
||||
repoOwner: string;
|
||||
@@ -125,8 +127,7 @@ export async function fetchPrPaneTimelinePage(
|
||||
input: FetchPrPaneTimelinePageInput,
|
||||
): Promise<PullRequestTimeline> {
|
||||
try {
|
||||
return await input.client.pullRequestTimeline({
|
||||
cwd: input.cwd,
|
||||
return await input.client.getPullRequestTimeline({
|
||||
prNumber: input.prNumber,
|
||||
repoOwner: input.repoOwner,
|
||||
repoName: input.repoName,
|
||||
@@ -136,6 +137,7 @@ export async function fetchPrPaneTimelinePage(
|
||||
input.registry.add(
|
||||
unsupportedTimelineKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
cwd: input.cwd,
|
||||
prNumber: input.prNumber,
|
||||
}),
|
||||
@@ -197,14 +199,13 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
|
||||
|
||||
export function usePrPaneData({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled = true,
|
||||
timelineEnabled = enabled,
|
||||
}: UsePrPaneDataOptions): UsePrPaneDataResult {
|
||||
const { t } = useTranslation();
|
||||
const daemonClient = useHostRuntimeClient(serverId);
|
||||
const workspaceGit = useRequiredWorkspaceGit();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled });
|
||||
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, enabled });
|
||||
const status = checkoutPrStatus.status;
|
||||
const identity = extractPrRepoIdentity(status);
|
||||
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled;
|
||||
@@ -212,26 +213,37 @@ export function usePrPaneData({
|
||||
const unsupportedKey =
|
||||
identity.prNumber === 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 shouldFetchTimeline = shouldFetchTimelineFrom({
|
||||
hasClient: !!daemonClient,
|
||||
hasClient: !!workspaceGit,
|
||||
isConnected,
|
||||
timelineEnabled,
|
||||
githubFeaturesEnabled,
|
||||
cwd,
|
||||
cwd: workspaceGit.cwd,
|
||||
identity,
|
||||
timelineUnsupported,
|
||||
});
|
||||
|
||||
const timelineQuery = useQuery<PullRequestTimeline>({
|
||||
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 () => {
|
||||
if (
|
||||
!daemonClient ||
|
||||
!workspaceGit ||
|
||||
identity.prNumber === null ||
|
||||
identity.repoOwner === null ||
|
||||
identity.repoName === null
|
||||
@@ -239,10 +251,11 @@ export function usePrPaneData({
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
return fetchPrPaneTimelinePage({
|
||||
client: daemonClient,
|
||||
client: workspaceGit,
|
||||
registry,
|
||||
serverId,
|
||||
cwd,
|
||||
workspaceId: workspaceGit.workspaceId,
|
||||
cwd: workspaceGit.cwd,
|
||||
prNumber: identity.prNumber,
|
||||
repoOwner: identity.repoOwner,
|
||||
repoName: identity.repoName,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CheckoutPipeline } from "@getpaseo/protocol/messages";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { prPanePipelineQueryKey } from "./query-keys";
|
||||
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
|
||||
|
||||
/** Poll cadence for an in-progress pipeline; finished pipelines are immutable. */
|
||||
const LIVE_PIPELINE_REFETCH_MS = 15_000;
|
||||
@@ -10,7 +11,6 @@ const FINISHED_PIPELINE_STALE_MS = 24 * 60 * 60 * 1_000;
|
||||
|
||||
export interface UseGitLabPipelineOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
pipelineId: number | null;
|
||||
/** MR iid, so the fetch resolves a fork/detached head pipeline correctly. */
|
||||
changeRequestNumber: number;
|
||||
@@ -38,27 +38,32 @@ export interface UseGitLabPipelineResult {
|
||||
*/
|
||||
export function useGitLabPipeline({
|
||||
serverId,
|
||||
cwd,
|
||||
pipelineId,
|
||||
changeRequestNumber,
|
||||
enabled,
|
||||
live,
|
||||
}: UseGitLabPipelineOptions): UseGitLabPipelineResult {
|
||||
const daemonClient = useHostRuntimeClient(serverId);
|
||||
const workspaceGit = useRequiredWorkspaceGit();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const shouldFetch = enabled && !!daemonClient && isConnected && !!cwd && pipelineId !== null;
|
||||
const shouldFetch = enabled && !!workspaceGit && isConnected && pipelineId !== null;
|
||||
|
||||
const query = useFetchQuery<CheckoutPipeline | null>({
|
||||
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 () => {
|
||||
if (!daemonClient || pipelineId === null) {
|
||||
if (!workspaceGit || pipelineId === null) {
|
||||
return null;
|
||||
}
|
||||
const payload = await daemonClient.checkoutForgeGetCheckDetails({
|
||||
cwd,
|
||||
const payload = await workspaceGit.getForgeCheckDetails({
|
||||
checkRunId: pipelineId,
|
||||
changeRequestNumber,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
checkoutDiffQueryKey,
|
||||
checkoutCommitsQueryKey,
|
||||
checkoutDiffQueryKey,
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
invalidateCheckoutGitQueriesForClient,
|
||||
@@ -15,153 +15,156 @@ import {
|
||||
|
||||
describe("checkout query keys", () => {
|
||||
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 () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true });
|
||||
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), {
|
||||
files: [],
|
||||
});
|
||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
|
||||
queryClient.setQueryData(checkoutCommitsQueryKey(serverId, cwd), { commits: [] });
|
||||
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: [],
|
||||
},
|
||||
it("uses workspace identity before the compatibility cwd", () => {
|
||||
expect(checkoutStatusQueryKey(serverId, target)).toEqual([
|
||||
"checkoutStatus",
|
||||
serverId,
|
||||
target.queryIdentity,
|
||||
target.cwd,
|
||||
]);
|
||||
expect(checkoutStatusQueryKey(serverId, sibling)).not.toEqual(
|
||||
checkoutStatusQueryKey(serverId, target),
|
||||
);
|
||||
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 () => {
|
||||
const queryClient = new QueryClient();
|
||||
const otherServerId = "server-2";
|
||||
const otherCwd = "/tmp/repo-2";
|
||||
it("keeps selected and legacy identity distinct across keys and invalidation", async () => {
|
||||
const selected = {
|
||||
...target,
|
||||
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 });
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), { isGit: true });
|
||||
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: [],
|
||||
},
|
||||
expect(checkoutStatusQueryKey(serverId, selected)).not.toEqual(
|
||||
checkoutStatusQueryKey(serverId, legacy),
|
||||
);
|
||||
// Subscription-fed diff queries are deliberately not part of the server-wide sweep.
|
||||
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), {
|
||||
expect(checkoutPrStatusQueryKey(serverId, selected)).not.toEqual(
|
||||
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: [],
|
||||
});
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(otherServerId, cwd), { isGit: true });
|
||||
queryClient.setQueryData(checkoutStatusQueryKey("server-2", target), { isGit: true });
|
||||
|
||||
await invalidateCheckoutGitQueriesForServer(queryClient, serverId);
|
||||
|
||||
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, target))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(checkoutStatusQueryKey(serverId, otherCwd))?.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(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))
|
||||
queryClient.getQueryState(checkoutDiffQueryKey(serverId, target, "base", "main", true))
|
||||
?.isInvalidated,
|
||||
).toBe(false);
|
||||
expect(
|
||||
queryClient.getQueryState(checkoutStatusQueryKey(otherServerId, cwd))?.isInvalidated,
|
||||
queryClient.getQueryState(checkoutStatusQueryKey("server-2", target))?.isInvalidated,
|
||||
).toBe(false);
|
||||
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
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";
|
||||
|
||||
interface CheckoutQueryIdentity {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
target: WorkspaceGitTarget;
|
||||
}
|
||||
|
||||
interface CheckoutQueryScope {
|
||||
serverId: string;
|
||||
cwd?: string;
|
||||
target?: WorkspaceGitTarget;
|
||||
}
|
||||
|
||||
type CheckoutQueryKey = readonly unknown[];
|
||||
@@ -17,35 +23,47 @@ type CheckoutQueryKey = readonly unknown[];
|
||||
// can share the same long-lived cache policy.
|
||||
export const COMMIT_FILE_DIFF_STALE_TIME = 5 * 60_000;
|
||||
|
||||
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutStatus", serverId, cwd] as const;
|
||||
export function checkoutStatusQueryKey(serverId: string, target: WorkspaceGitStatusTarget) {
|
||||
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(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
target: WorkspaceGitTarget,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
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) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
export function checkoutPrStatusQueryKey(serverId: string, target: WorkspaceGitStatusTarget) {
|
||||
return ["checkoutPrStatus", serverId, target.queryIdentity, target.cwd] as const;
|
||||
}
|
||||
|
||||
export function checkoutCommitsQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutCommits", serverId, cwd] as const;
|
||||
export function checkoutCommitsQueryKey(serverId: string, target: WorkspaceGitTarget) {
|
||||
return ["checkoutCommits", serverId, target.queryIdentity, target.cwd] as const;
|
||||
}
|
||||
|
||||
export function checkoutCommitFileDiffQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
target: WorkspaceGitTarget,
|
||||
sha: 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(
|
||||
@@ -54,7 +72,7 @@ export async function invalidateCheckoutGitQueriesForClient(
|
||||
) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: checkoutStatusQueryKey(identity.serverId, identity.cwd),
|
||||
queryKey: checkoutStatusQueryKey(identity.serverId, identity.target),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("checkoutDiff", identity),
|
||||
@@ -63,7 +81,7 @@ export async function invalidateCheckoutGitQueriesForClient(
|
||||
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: checkoutCommitsQueryKey(identity.serverId, identity.cwd),
|
||||
queryKey: checkoutCommitsQueryKey(identity.serverId, identity.target),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
|
||||
@@ -119,16 +137,28 @@ function checkoutQueryPredicate(
|
||||
isCheckoutQueryKey(key) &&
|
||||
key[0] === queryKind &&
|
||||
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 {
|
||||
return (
|
||||
key.length >= 3 &&
|
||||
key.length >= 4 &&
|
||||
typeof key[0] === "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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar
|
||||
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
|
||||
import { readValidatedString } from "@/storage/validated-storage";
|
||||
|
||||
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 [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 isGit = Boolean(gitStatus);
|
||||
const notGit = status !== null && !status.isGit && !status.error;
|
||||
@@ -333,7 +335,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
forge,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isGit,
|
||||
});
|
||||
const prIcon = useMemo(() => renderForgePrIcon(forge), [forge]);
|
||||
@@ -397,50 +398,50 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
}, [cwd]);
|
||||
|
||||
const commitStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "commit" }),
|
||||
s.getStatus({ serverId, target, actionId: "commit" }),
|
||||
);
|
||||
const pullStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "pull" }),
|
||||
s.getStatus({ serverId, target, actionId: "pull" }),
|
||||
);
|
||||
const pushStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "push" }),
|
||||
s.getStatus({ serverId, target, actionId: "push" }),
|
||||
);
|
||||
const pullAndPushStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
|
||||
s.getStatus({ serverId, target, actionId: "pull-and-push" }),
|
||||
);
|
||||
const prCreateStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "create-pr" }),
|
||||
s.getStatus({ serverId, target, actionId: "create-pr" }),
|
||||
);
|
||||
const mergePrStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
|
||||
squash: useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "merge-pr-squash" }),
|
||||
s.getStatus({ serverId, target, actionId: "merge-pr-squash" }),
|
||||
),
|
||||
merge: useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "merge-pr-merge" }),
|
||||
s.getStatus({ serverId, target, actionId: "merge-pr-merge" }),
|
||||
),
|
||||
rebase: useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }),
|
||||
s.getStatus({ serverId, target, actionId: "merge-pr-rebase" }),
|
||||
),
|
||||
};
|
||||
const enablePrAutoMergeStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
|
||||
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) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
||||
s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-merge" }),
|
||||
),
|
||||
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) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
|
||||
s.getStatus({ serverId, target, actionId: "disable-pr-auto-merge" }),
|
||||
);
|
||||
const mergeStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "merge-branch" }),
|
||||
s.getStatus({ serverId, target, actionId: "merge-branch" }),
|
||||
);
|
||||
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);
|
||||
@@ -476,7 +477,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
|
||||
// Handlers
|
||||
const handleCommit = useCallback(() => {
|
||||
void runCommit({ serverId, cwd })
|
||||
void runCommit({ serverId, target })
|
||||
.then(() => {
|
||||
toastActionSuccess(t("workspace.git.actions.commit.success"));
|
||||
return;
|
||||
@@ -484,10 +485,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
.catch((err) => {
|
||||
toastActionError(err, t("workspace.git.actions.toasts.failedCommit"));
|
||||
});
|
||||
}, [cwd, runCommit, serverId, t, toastActionError, toastActionSuccess]);
|
||||
}, [runCommit, serverId, t, target, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handlePull = useCallback(() => {
|
||||
void runPull({ serverId, cwd })
|
||||
void runPull({ serverId, target })
|
||||
.then(() => {
|
||||
toastActionSuccess(t("workspace.git.actions.pull.success"));
|
||||
return;
|
||||
@@ -495,10 +496,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
.catch((err) => {
|
||||
toastActionError(err, t("workspace.git.actions.toasts.failedPull"));
|
||||
});
|
||||
}, [cwd, runPull, serverId, t, toastActionError, toastActionSuccess]);
|
||||
}, [runPull, serverId, t, target, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handlePush = useCallback(() => {
|
||||
void runPush({ serverId, cwd })
|
||||
void runPush({ serverId, target })
|
||||
.then(() => {
|
||||
toastActionSuccess(t("workspace.git.actions.push.success"));
|
||||
return;
|
||||
@@ -506,10 +507,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
.catch((err) => {
|
||||
toastActionError(err, t("workspace.git.actions.toasts.failedPush"));
|
||||
});
|
||||
}, [cwd, runPush, serverId, t, toastActionError, toastActionSuccess]);
|
||||
}, [runPush, serverId, t, target, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handlePullAndPush = useCallback(() => {
|
||||
void runPullAndPush({ serverId, cwd })
|
||||
void runPullAndPush({ serverId, target })
|
||||
.then(() => {
|
||||
toastActionSuccess(t("workspace.git.actions.pullAndPush.success"));
|
||||
return;
|
||||
@@ -517,11 +518,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
.catch((err) => {
|
||||
toastActionError(err, t("workspace.git.actions.toasts.failedPullAndPush"));
|
||||
});
|
||||
}, [cwd, runPullAndPush, serverId, t, toastActionError, toastActionSuccess]);
|
||||
}, [runPullAndPush, serverId, t, target, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleCreatePr = useCallback(() => {
|
||||
void persistShipDefault("pr");
|
||||
void runCreatePr({ serverId, cwd })
|
||||
void runCreatePr({ serverId, target })
|
||||
.then(() => {
|
||||
toastActionSuccess(t("workspace.git.actions.createPr.success", forgeVocabulary(forge)));
|
||||
return;
|
||||
@@ -530,12 +531,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
toastActionError(err, t("workspace.git.actions.toasts.failedCreatePr"));
|
||||
});
|
||||
}, [
|
||||
cwd,
|
||||
forge,
|
||||
persistShipDefault,
|
||||
runCreatePr,
|
||||
serverId,
|
||||
t,
|
||||
target,
|
||||
toastActionError,
|
||||
toastActionSuccess,
|
||||
]);
|
||||
@@ -543,7 +544,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
const handleMergePr = useCallback(
|
||||
(method: CheckoutPrMergeMethod) => {
|
||||
void persistShipDefault("pr");
|
||||
void runMergePr({ serverId, cwd, method })
|
||||
void runMergePr({ serverId, target, method })
|
||||
.then(() => {
|
||||
setPostShipArchiveSuggested(true);
|
||||
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"));
|
||||
});
|
||||
},
|
||||
[cwd, forge, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess],
|
||||
[
|
||||
forge,
|
||||
persistShipDefault,
|
||||
runMergePr,
|
||||
serverId,
|
||||
t,
|
||||
target,
|
||||
toastActionError,
|
||||
toastActionSuccess,
|
||||
],
|
||||
);
|
||||
|
||||
const handleEnablePrAutoMerge = useCallback(
|
||||
(method: CheckoutPrMergeMethod) => {
|
||||
void persistShipDefault("pr");
|
||||
void runEnablePrAutoMerge({ serverId, cwd, method })
|
||||
void runEnablePrAutoMerge({ serverId, target, method })
|
||||
.then(() => {
|
||||
toastActionSuccess(t("workspace.git.actions.autoMerge.enabled"));
|
||||
return;
|
||||
@@ -569,18 +579,18 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
});
|
||||
},
|
||||
[
|
||||
cwd,
|
||||
persistShipDefault,
|
||||
runEnablePrAutoMerge,
|
||||
serverId,
|
||||
t,
|
||||
target,
|
||||
toastActionError,
|
||||
toastActionSuccess,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDisablePrAutoMerge = useCallback(() => {
|
||||
void runDisablePrAutoMerge({ serverId, cwd })
|
||||
void runDisablePrAutoMerge({ serverId, target })
|
||||
.then(() => {
|
||||
toastActionSuccess(t("workspace.git.actions.autoMerge.disabled"));
|
||||
return;
|
||||
@@ -588,7 +598,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
.catch((err) => {
|
||||
toastActionError(err, t("workspace.git.actions.toasts.failedDisableAutoMerge"));
|
||||
});
|
||||
}, [cwd, runDisablePrAutoMerge, serverId, t, toastActionError, toastActionSuccess]);
|
||||
}, [runDisablePrAutoMerge, serverId, t, target, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleMergeBranch = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
@@ -596,7 +606,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
return;
|
||||
}
|
||||
void persistShipDefault("merge");
|
||||
void runMergeBranch({ serverId, cwd, baseRef })
|
||||
void runMergeBranch({ serverId, target, baseRef })
|
||||
.then(() => {
|
||||
setPostShipArchiveSuggested(true);
|
||||
toastActionSuccess(t("workspace.git.actions.mergeBranch.success"));
|
||||
@@ -607,11 +617,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
});
|
||||
}, [
|
||||
baseRef,
|
||||
cwd,
|
||||
persistShipDefault,
|
||||
runMergeBranch,
|
||||
serverId,
|
||||
t,
|
||||
target,
|
||||
toast,
|
||||
toastActionError,
|
||||
toastActionSuccess,
|
||||
@@ -622,7 +632,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
toast.error(t("workspace.git.actions.toasts.baseRefUnavailable"));
|
||||
return;
|
||||
}
|
||||
void runMergeFromBase({ serverId, cwd, baseRef })
|
||||
void runMergeFromBase({ serverId, target, baseRef })
|
||||
.then(() => {
|
||||
toastActionSuccess(t("workspace.git.actions.mergeFromBase.success"));
|
||||
return;
|
||||
@@ -630,7 +640,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
.catch((err) => {
|
||||
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({
|
||||
serverId,
|
||||
|
||||
@@ -3,8 +3,9 @@ import invariant from "tiny-invariant";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
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 { useRequiredWorkspaceGit } from "@/git/workspace-git";
|
||||
|
||||
// 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.
|
||||
@@ -12,7 +13,6 @@ const CHECKOUT_COMMITS_STALE_TIME = 30_000;
|
||||
|
||||
interface UseCheckoutCommitsQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -70,12 +70,11 @@ export function resolveCheckoutCommitsQueryResult({
|
||||
|
||||
export function useCheckoutCommitsQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled = true,
|
||||
}: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult {
|
||||
const retainedPanelActive = useRetainedPanelActive();
|
||||
const queryEnabledByCaller = enabled && retainedPanelActive;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const workspaceGit = useRequiredWorkspaceGit();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
// COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16.
|
||||
// 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,
|
||||
);
|
||||
|
||||
const canFetch = Boolean(cwd) && Boolean(client) && isConnected;
|
||||
const canFetch = Boolean(workspaceGit) && isConnected;
|
||||
const queryEnabled = queryEnabledByCaller && capabilityPresent && canFetch;
|
||||
|
||||
const query = useFetchQuery<CheckoutCommitsData>({
|
||||
queryKey: checkoutCommitsQueryKey(serverId, cwd),
|
||||
queryKey: checkoutCommitsQueryKey(serverId, workspaceGit),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
if (!workspaceGit) {
|
||||
throw new Error("Host disconnected");
|
||||
}
|
||||
const data = await client.listCheckoutCommits(cwd);
|
||||
const data = await workspaceGit.listCommits();
|
||||
const commits = data.commits.map((commit) => {
|
||||
invariant(commit.isOnBase !== undefined, "Host omitted commit base classification");
|
||||
return { ...commit, isOnBase: commit.isOnBase };
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useFetchQueries } from "@/data/query";
|
||||
import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys";
|
||||
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
|
||||
@@ -13,7 +14,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
||||
*/
|
||||
export interface CommitDiffFilesContext {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
sha: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -57,12 +57,12 @@ export function resolveCommitDiffFiles(
|
||||
}
|
||||
|
||||
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 queryEnabled = enabled && retainedPanelActive;
|
||||
const client = useHostRuntimeClient(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 commitFiles = useMemo(() => {
|
||||
if (!sha || !commitsData) {
|
||||
@@ -74,18 +74,17 @@ export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFiles
|
||||
const fileDiffsEnabled =
|
||||
queryEnabled &&
|
||||
commitsQuery.status === "loaded" &&
|
||||
Boolean(cwd) &&
|
||||
Boolean(workspaceGit) &&
|
||||
Boolean(sha) &&
|
||||
Boolean(client) &&
|
||||
isConnected;
|
||||
const fileDiffResults = useFetchQueries(
|
||||
commitFiles.map((file) => ({
|
||||
queryKey: checkoutCommitFileDiffQueryKey(serverId, cwd, sha, file.path),
|
||||
queryKey: checkoutCommitFileDiffQueryKey(serverId, workspaceGit, sha, file.path),
|
||||
queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => {
|
||||
if (!client) {
|
||||
if (!workspaceGit) {
|
||||
throw new Error("Host disconnected");
|
||||
}
|
||||
return client.getCommitFileDiff(cwd, sha, file.path);
|
||||
return workspaceGit.getCommitFileDiff(sha, file.path);
|
||||
},
|
||||
enabled: fileDiffsEnabled,
|
||||
staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME,
|
||||
|
||||
@@ -5,10 +5,10 @@ import { checkoutDiffPushRoute } from "@/data/push-router";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
import { checkoutDiffQueryKey } from "@/git/query-keys";
|
||||
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
@@ -41,13 +41,13 @@ function normalizeCheckoutDiffCompare(compare: {
|
||||
|
||||
export function useCheckoutDiffQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled = true,
|
||||
queryScope,
|
||||
}: UseCheckoutDiffQueryOptions) {
|
||||
const workspaceGit = useRequiredWorkspaceGit();
|
||||
const retainedPanelActive = useRetainedPanelActive();
|
||||
const queryEnabled = enabled && retainedPanelActive;
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -61,16 +61,16 @@ export function useCheckoutDiffQuery({
|
||||
const queryKey = useMemo(() => {
|
||||
const comparisonKey = checkoutDiffQueryKey(
|
||||
serverId,
|
||||
cwd,
|
||||
workspaceGit,
|
||||
compareMode,
|
||||
compareBaseRef,
|
||||
compareIgnoreWhitespace,
|
||||
);
|
||||
const normalizedScope = queryScope?.trim();
|
||||
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 routeEnabled = Boolean(queryEnabled && isConnected && cwd);
|
||||
const routeEnabled = Boolean(queryEnabled && isConnected && workspaceGit);
|
||||
|
||||
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
|
||||
queryKey,
|
||||
@@ -80,7 +80,7 @@ export function useCheckoutDiffQuery({
|
||||
enabled: routeEnabled,
|
||||
serverId,
|
||||
subscriptionId,
|
||||
cwd,
|
||||
workspaceGit,
|
||||
compare: {
|
||||
mode: compareMode,
|
||||
...(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";
|
||||
|
||||
describe("forgeSearchQueryKey", () => {
|
||||
it("keeps the shared cache key shape for no-kinds searches", () => {
|
||||
expect(forgeSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([
|
||||
"forge-search",
|
||||
"server-1",
|
||||
"/repo",
|
||||
"forge",
|
||||
"123",
|
||||
]);
|
||||
const target = {
|
||||
cwd: "/workspace",
|
||||
queryIdentity: ["selected", "workspace-a"] as const,
|
||||
};
|
||||
|
||||
describe("forge search query", () => {
|
||||
it("keys same-cwd workspaces independently", () => {
|
||||
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", () => {
|
||||
expect(forgeSearchQueryKey("server-1", "/repo", "123", ["change_request", "issue"])).toEqual([
|
||||
"forge-search",
|
||||
"server-1",
|
||||
"/repo",
|
||||
"forge",
|
||||
"123",
|
||||
"change_request,issue",
|
||||
]);
|
||||
it("does not alias a selected id that resembles a legacy address", () => {
|
||||
expect(
|
||||
forgeSearchQueryKey(
|
||||
"server",
|
||||
{ cwd: "/shared", queryIdentity: ["selected", "legacy:/shared"] },
|
||||
"issue",
|
||||
),
|
||||
).not.toEqual(
|
||||
forgeSearchQueryKey(
|
||||
"server",
|
||||
{ cwd: "/shared", queryIdentity: ["legacy", "/shared"] },
|
||||
"issue",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("separates legacy GitHub fallback results from forge search results", () => {
|
||||
expect(forgeSearchQueryKey("server-1", "/repo", "123", undefined, "github")).toEqual([
|
||||
"forge-search",
|
||||
"server-1",
|
||||
"/repo",
|
||||
"github",
|
||||
"123",
|
||||
]);
|
||||
it("searches through the already-bound workspace capability", async () => {
|
||||
const searchForge = vi.fn(async () => ({
|
||||
items: [],
|
||||
authState: "authenticated" as const,
|
||||
error: null,
|
||||
requestId: "search-1",
|
||||
}));
|
||||
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("forwards kinds to the forge search request when specified", async () => {
|
||||
const requests: unknown[] = [];
|
||||
const query = buildForgeSearchQueryOptions({
|
||||
client: {
|
||||
async searchForge(options) {
|
||||
requests.push(options);
|
||||
return {
|
||||
items: [],
|
||||
authState: "authenticated",
|
||||
error: null,
|
||||
requestId: "request-1",
|
||||
};
|
||||
},
|
||||
},
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
query: " 123 ",
|
||||
it("uses the bound legacy GitHub method when the forge RPC is unavailable", async () => {
|
||||
const searchForge = vi.fn();
|
||||
const searchGitHub = vi.fn(async () => ({
|
||||
items: [],
|
||||
authState: "authenticated" as const,
|
||||
featuresEnabled: true,
|
||||
githubFeaturesEnabled: true,
|
||||
error: null,
|
||||
requestId: "search-legacy",
|
||||
}));
|
||||
const options = buildForgeSearchQueryOptions({
|
||||
client: { ...target, searchForge, searchGitHub },
|
||||
serverId: "server",
|
||||
query: "42",
|
||||
kinds: ["change_request"],
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
await query.queryFn();
|
||||
|
||||
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 }]);
|
||||
await options.queryFn();
|
||||
expect(searchGitHub).toHaveBeenCalledWith({ query: "42", limit: 20, kinds: ["github-pr"] });
|
||||
expect(searchForge).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
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;
|
||||
|
||||
@@ -22,36 +24,27 @@ export interface ForgeSearchPayload {
|
||||
}
|
||||
|
||||
interface ForgeSearchOptions {
|
||||
cwd: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
kinds?: ForgeSearchKind[];
|
||||
}
|
||||
|
||||
interface LegacyGitHubSearchOptions {
|
||||
cwd: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
kinds?: LegacyGitHubSearchKind[];
|
||||
}
|
||||
|
||||
export interface ForgeSearchClient {
|
||||
searchForge: (
|
||||
options: ForgeSearchOptions,
|
||||
requestId?: string,
|
||||
) => Promise<ForgeSearchResponse["payload"]>;
|
||||
searchGitHub?: (
|
||||
options: LegacyGitHubSearchOptions,
|
||||
requestId?: string,
|
||||
) => Promise<GitHubSearchResponse["payload"]>;
|
||||
}
|
||||
type ForgeSearchTarget = Pick<WorkspaceGitReadClient, "cwd" | "queryIdentity">;
|
||||
export type ForgeSearchClient = ForgeSearchTarget &
|
||||
Pick<WorkspaceGitClient, "searchForge"> &
|
||||
Partial<Pick<WorkspaceGitClient, "searchGitHub">>;
|
||||
|
||||
type LegacyGitHubSearchKind = "github-issue" | "github-pr";
|
||||
|
||||
interface ForgeSearchQueryInput {
|
||||
client: ForgeSearchClient | null;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
query: string;
|
||||
kinds?: ForgeSearchKind[];
|
||||
enabled: boolean;
|
||||
@@ -59,42 +52,53 @@ interface ForgeSearchQueryInput {
|
||||
hostDisconnectedMessage?: string;
|
||||
}
|
||||
|
||||
interface LegacyForgeSearchQueryInput extends Omit<ForgeSearchQueryInput, "client"> {
|
||||
client: Pick<DaemonClient, "searchForge" | "searchGitHub"> | null;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function forgeSearchQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
target: ForgeSearchTarget,
|
||||
query: string,
|
||||
kinds?: ForgeSearchKind[],
|
||||
transport: "forge" | "github" = "forge",
|
||||
) {
|
||||
const trimmedQuery = query.trim();
|
||||
const queryIdentity = forgeSearchClientIdentity(target);
|
||||
if (!kinds) {
|
||||
return ["forge-search", serverId, cwd, transport, trimmedQuery] as const;
|
||||
return ["forge-search", serverId, queryIdentity, target.cwd, transport, trimmedQuery] as const;
|
||||
}
|
||||
return [
|
||||
"forge-search",
|
||||
serverId,
|
||||
cwd,
|
||||
queryIdentity,
|
||||
target.cwd,
|
||||
transport,
|
||||
trimmedQuery,
|
||||
[...kinds].sort().join(","),
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function forgeSearchClientIdentity(client: ForgeSearchTarget) {
|
||||
return client.queryIdentity;
|
||||
}
|
||||
|
||||
export function buildForgeSearchQueryOptions(input: ForgeSearchQueryInput) {
|
||||
const query = input.query.trim();
|
||||
const transport = input.supportsForgeSearch === true ? "forge" : "github";
|
||||
|
||||
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> => {
|
||||
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 };
|
||||
const request = input.kinds ? { query, limit: 20, kinds: input.kinds } : { query, limit: 20 };
|
||||
// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||
// clients use forge.search.*.
|
||||
if (transport === "github" && input.client.searchGitHub) {
|
||||
@@ -153,7 +157,7 @@ function toLegacyGitHubSearchKind(kind: ForgeSearchKind): LegacyGitHubSearchKind
|
||||
|
||||
function toLegacyGitHubSearchRequest(request: ForgeSearchOptions): LegacyGitHubSearchOptions {
|
||||
if (!request.kinds) {
|
||||
return { cwd: request.cwd, query: request.query, limit: request.limit };
|
||||
return { query: request.query, limit: request.limit };
|
||||
}
|
||||
return {
|
||||
...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) {
|
||||
const { t } = useTranslation();
|
||||
return useFetchQuery(
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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 { normalizeForge } from "@/git/forge";
|
||||
import { selectPrHintFromStatus, type PrHint } from "@/git/pr-hint";
|
||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||
import { useWorkspaceGit } from "@/git/workspace-git";
|
||||
|
||||
interface UseCheckoutPrStatusQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -21,22 +21,23 @@ function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null
|
||||
|
||||
export function useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled = true,
|
||||
}: UseCheckoutPrStatusQueryOptions) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const workspaceGit = useWorkspaceGit();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||
queryKey: workspaceGit
|
||||
? checkoutPrStatusQueryKey(serverId, workspaceGit)
|
||||
: (["checkoutPrStatus", serverId, "unavailable"] as const),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
if (!workspaceGit) {
|
||||
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,
|
||||
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see
|
||||
// useCheckoutStatusQuery for the rationale.
|
||||
@@ -63,22 +64,23 @@ export function useCheckoutPrStatusQuery({
|
||||
|
||||
export function useWorkspacePrHint({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled = true,
|
||||
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const workspaceGit = useWorkspaceGit();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
|
||||
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||
queryKey: workspaceGit
|
||||
? checkoutPrStatusQueryKey(serverId, workspaceGit)
|
||||
: (["checkoutPrStatus", serverId, "unavailable"] as const),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
if (!workspaceGit) {
|
||||
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,
|
||||
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see
|
||||
// useCheckoutStatusQuery for the rationale.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { useWorkspaceGit } from "./workspace-git";
|
||||
|
||||
export type { CheckoutStatusPayload } from "./checkout-status-cache";
|
||||
|
||||
@@ -10,23 +11,25 @@ export const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
||||
|
||||
interface UseCheckoutStatusQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
export function useCheckoutStatusQuery({ serverId }: UseCheckoutStatusQueryOptions) {
|
||||
const workspaceGit = useWorkspaceGit();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||
queryKey: workspaceGit
|
||||
? checkoutStatusQueryKey(serverId, workspaceGit)
|
||||
: (["checkoutStatus", serverId, "unavailable"] as const),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
return await fetchCheckoutStatus({ client, serverId, cwd });
|
||||
if (!workspaceGit) throw new Error("Workspace Git is unavailable");
|
||||
return await fetchCheckoutStatus({
|
||||
client: workspaceGit,
|
||||
serverId,
|
||||
target: workspaceGit,
|
||||
});
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd,
|
||||
enabled: isConnected && workspaceGit !== null,
|
||||
staleTime: Infinity,
|
||||
// Freshness is push-driven (checkout_status_update applied globally); with
|
||||
// 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
|
||||
* only the visible agents.
|
||||
*/
|
||||
export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
export function useCheckoutStatusCacheOnly({ serverId }: UseCheckoutStatusQueryOptions) {
|
||||
const workspaceGit = useWorkspaceGit();
|
||||
|
||||
return useQuery({
|
||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||
queryKey: workspaceGit
|
||||
? checkoutStatusQueryKey(serverId, workspaceGit)
|
||||
: (["checkoutStatus", serverId, "unavailable"] as const),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
return await fetchCheckoutStatus({ client, serverId, cwd });
|
||||
if (!workspaceGit) throw new Error("Workspace Git is unavailable");
|
||||
return await fetchCheckoutStatus({
|
||||
client: workspaceGit,
|
||||
serverId,
|
||||
target: workspaceGit,
|
||||
});
|
||||
},
|
||||
enabled: false,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
|
||||
interface UseWorkingDiffOptions {
|
||||
serverId: string;
|
||||
workspaceId?: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
ignoreWhitespace: boolean;
|
||||
enabled: boolean;
|
||||
@@ -36,7 +36,7 @@ export function useWorkingDiff({
|
||||
isLoading: isStatusLoading,
|
||||
isError: isStatusError,
|
||||
error: statusError,
|
||||
} = useCheckoutStatusQuery({ serverId, cwd });
|
||||
} = useCheckoutStatusQuery({ serverId });
|
||||
const gitStatus = status && status.isGit ? status : null;
|
||||
const isGit = Boolean(gitStatus);
|
||||
const notGit = status !== null && !status.isGit && !status.error;
|
||||
@@ -83,7 +83,6 @@ export function useWorkingDiff({
|
||||
isLoading: isDiffLoading,
|
||||
} = useCheckoutDiffQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
mode: diffMode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
|
||||
@@ -4,11 +4,13 @@ import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
|
||||
interface WorkspaceActionsProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
isWorkspaceGitBound: boolean;
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
|
||||
function BoundWorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
|
||||
const { gitActions } = useGitActions({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -17,3 +19,7 @@ export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActions
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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;
|
||||
projectRootPath?: string;
|
||||
workspaceDirectory?: string;
|
||||
hostVisiblePath?: string;
|
||||
projectKind: WorkspaceStructureProject["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
name: string;
|
||||
@@ -159,6 +160,7 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
projectName: projectNameForWorkspace(input.workspace),
|
||||
projectRootPath: input.workspace.projectRootPath,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
hostVisiblePath: input.workspace.hostVisiblePath,
|
||||
workspaceDirectoryLabel:
|
||||
input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory),
|
||||
projectKind: input.workspace.projectKind,
|
||||
|
||||
@@ -43,6 +43,8 @@ import type { MaterializedAgentProfile } from "@/agent-profiles";
|
||||
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
export interface UseAgentFormStateOptions {
|
||||
workspaceId?: string | null;
|
||||
providerSnapshotCwd?: string | null;
|
||||
initialServerId?: string | null;
|
||||
initialValues?: FormInitialValues;
|
||||
isVisible?: boolean;
|
||||
@@ -207,7 +209,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
initialValues,
|
||||
isVisible = true,
|
||||
isCreateFlow = true,
|
||||
isTargetDaemonReady: _isTargetDaemonReady = true,
|
||||
isTargetDaemonReady = true,
|
||||
onlineServerIds = [],
|
||||
} = options;
|
||||
|
||||
@@ -269,7 +271,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
error: snapshotError,
|
||||
refresh: refreshSnapshot,
|
||||
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 snapshotProviderDefinitions = useMemo(
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useQuery, type QueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ComboboxOption } from "@/components/ui/combobox";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { createBranchSwitcherOperations } from "@/git/branch-switcher-operations";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
|
||||
|
||||
interface UseBranchSwitcherInput {
|
||||
client: DaemonClient | null;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
currentBranchName: string | null;
|
||||
isGitCheckout: boolean;
|
||||
isConnected: boolean;
|
||||
@@ -29,10 +26,7 @@ interface UseBranchSwitcherResult {
|
||||
}
|
||||
|
||||
export function useBranchSwitcher({
|
||||
client,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
currentBranchName,
|
||||
isGitCheckout,
|
||||
isConnected,
|
||||
@@ -40,20 +34,15 @@ export function useBranchSwitcher({
|
||||
queryClient,
|
||||
}: UseBranchSwitcherInput): UseBranchSwitcherResult {
|
||||
const { t } = useTranslation();
|
||||
const workspaceGit = useRequiredWorkspaceGit();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Git operations are bound to the workspace directory; the opaque workspace id is
|
||||
// used only for query cache identity below, never as a cwd.
|
||||
const operations = useMemo(
|
||||
() =>
|
||||
client && workspaceDirectory
|
||||
? createBranchSwitcherOperations(client, workspaceDirectory)
|
||||
: null,
|
||||
[client, workspaceDirectory],
|
||||
);
|
||||
const operations = useMemo(() => createBranchSwitcherOperations(workspaceGit), [workspaceGit]);
|
||||
|
||||
const branchSuggestionsQuery = useQuery({
|
||||
queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId],
|
||||
queryKey: ["branchSuggestions", normalizedServerId, workspaceGit.workspaceId],
|
||||
queryFn: async () => {
|
||||
if (!operations) {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
@@ -75,20 +64,19 @@ export function useBranchSwitcher({
|
||||
}, [branchSuggestionsQuery.data]);
|
||||
|
||||
const stashListQueryKey = useMemo(
|
||||
() => ["stashList", normalizedServerId, normalizedWorkspaceId] as const,
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
() => ["stashList", normalizedServerId, workspaceGit.workspaceId] as const,
|
||||
[normalizedServerId, workspaceGit.workspaceId],
|
||||
);
|
||||
|
||||
const invalidateStashAndCheckout = useCallback(async () => {
|
||||
if (!workspaceDirectory) return;
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: stashListQueryKey }),
|
||||
invalidateCheckoutGitQueriesForClient(queryClient, {
|
||||
serverId: normalizedServerId,
|
||||
cwd: workspaceDirectory,
|
||||
target: workspaceGit,
|
||||
}),
|
||||
]);
|
||||
}, [queryClient, stashListQueryKey, normalizedServerId, workspaceDirectory]);
|
||||
}, [queryClient, stashListQueryKey, normalizedServerId, workspaceGit]);
|
||||
|
||||
const maybeRestoreStashForBranch = useCallback(
|
||||
async (branchId: string) => {
|
||||
|
||||
@@ -138,7 +138,12 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
}
|
||||
|
||||
try {
|
||||
const directory = await client.listDirectory(normalizedWorkspaceRoot, normalizedPath);
|
||||
const directory = await client.listDirectory(
|
||||
normalizedWorkspaceRoot,
|
||||
normalizedPath,
|
||||
undefined,
|
||||
workspaceId ?? undefined,
|
||||
);
|
||||
updateExplorerState((state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
@@ -169,7 +174,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
|
||||
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceId, workspaceStateKey],
|
||||
);
|
||||
|
||||
const requestFilePreview = useCallback(
|
||||
@@ -206,7 +211,12 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await client.readFile(normalizedWorkspaceRoot, normalizedPath);
|
||||
const file = await client.readFile(
|
||||
normalizedWorkspaceRoot,
|
||||
normalizedPath,
|
||||
undefined,
|
||||
workspaceId ?? undefined,
|
||||
);
|
||||
updateExplorerState((state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...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(
|
||||
@@ -244,13 +254,18 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
if (!client) {
|
||||
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) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, t],
|
||||
[client, normalizedWorkspaceRoot, t, workspaceId],
|
||||
);
|
||||
|
||||
const createEntry = useCallback(
|
||||
|
||||
@@ -185,7 +185,7 @@ describe("fetchProvidersSnapshot", () => {
|
||||
expect(cache.writes).toEqual([
|
||||
{
|
||||
serverId,
|
||||
cwd: "/repo-a",
|
||||
scope: { type: "legacy-cwd", cwd: "/repo-a" },
|
||||
hash: "next-hash",
|
||||
generatedAt: "2026-01-02T00:00:00.000Z",
|
||||
compactSnapshot,
|
||||
@@ -369,7 +369,7 @@ describe("applyProvidersSnapshotUpdate", () => {
|
||||
expect(cache.writes).toEqual([
|
||||
{
|
||||
serverId,
|
||||
cwd: "/repo-a",
|
||||
scope: { type: "legacy-cwd", cwd: "/repo-a" },
|
||||
hash: "push-hash",
|
||||
generatedAt: "2026-01-01T00:00:01.000Z",
|
||||
compactSnapshot,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user