Compare commits

..
Author SHA1 Message Date
Mohamed Boudra cdce65dfd5 test(app): stabilize archive descriptor assertions
The test rebuilt expected provider rows with fresh timestamps, so its result depended on both fixture calls landing in the same millisecond. Assert the preserved descriptor identities instead, matching the behavior under test without clock dependence.
2026-08-16 07:48:03 +00:00
Mohamed Boudra 7f8a485a77 Keep large diff reviews responsive (#3422)
* perf(app): bound large diff row mounting

Keep the existing vertical and horizontal scroll owners so virtualization does not change gesture or scrolling behavior. Reserve exact offscreen row heights while mounting only the visible window, and retain the full renderer when wrapping or editing reviews requires dynamic geometry.

* perf(app): keep large diff reviews responsive

Opening an inline editor changed the whole review action object, reconciled large syntax-token trees, and disabled row virtualization. Keep the expensive token subtree stable and preserve deterministic row geometry while the editor is open.\n\nBound server-side highlighting for pathological single lines and remove the unused client highlighter so the actual diff pipeline owns the limit.

* test(app): check mounted diff typography

Row virtualization can unmount a fixed diff row after scrolling. Assert the configured font size against a currently mounted row so the test continues to verify rendered typography without depending on window position.

* test(app): stabilize Mermaid streaming coverage
2026-08-16 15:38:12 +08:00
349 changed files with 5058 additions and 31212 deletions
-3
View File
@@ -35,9 +35,6 @@ hub:
server:
- "packages/server/**"
- "runtimes/fixture/**"
- "packages/workspace-helper/**"
- "packages/workspace-runtime-contract/**"
- "packages/app/e2e/support/fixtures/recording.*"
desktop:
-17
View File
@@ -102,9 +102,6 @@ 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
@@ -132,8 +129,6 @@ 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
@@ -168,18 +163,6 @@ 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
-3
View File
@@ -16,9 +16,6 @@ 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
View File
@@ -84,144 +84,6 @@
"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": {
-2
View File
@@ -22,8 +22,6 @@ 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; \
-15
View File
@@ -75,8 +75,6 @@ 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 |
@@ -87,19 +85,6 @@ 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`
+15 -26
View File
@@ -16,23 +16,19 @@ 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 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.
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`.
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.
@@ -427,8 +423,7 @@ 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` | 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. |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `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`". |
@@ -437,19 +432,13 @@ 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. 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.
> **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.
`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
View File
@@ -304,7 +304,7 @@ Worktrees inherit committed Git state only; uncommitted source-checkout changes
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
For runtime-selected workspaces, lifecycle commands run inside the selected runtime through a stable script shell. Legacy worktrees use `bash`
Lifecycle commands run in the worktree through a stable script shell: `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
View File
@@ -10,7 +10,7 @@ The image source lives in [`docker/`](../docker/).
The official image:
- builds `@getpaseo/server`, `@getpaseo/cli`, and their public workspace packages from source-built workspace tarballs
- builds `@getpaseo/server` and `@getpaseo/cli` 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`
+3 -5
View File
@@ -5,10 +5,8 @@ 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 **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.
- **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`).
- **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).
@@ -19,7 +17,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** — 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.
- **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.
- **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).
+3 -5
View File
@@ -103,9 +103,7 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
## Provider Snapshot Refresh Contract
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.
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.
`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
@@ -116,13 +114,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 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.
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.
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: 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.
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.
---
-2
View File
@@ -21,8 +21,6 @@ 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
View File
@@ -1 +1 @@
sha256-YJpDgaoaBS1lCJIVhxBIfeNelyYRsJMcN/HgEO4KehM=
sha256-MgMvQC+EGJ8gGosqel8oC88pMzf2Mtetd2D8Yg5VFAM=
+11 -65
View File
@@ -20,10 +20,7 @@
"packages/relay",
"packages/website",
"packages/desktop",
"packages/cli",
"packages/workspace-runtime-contract",
"packages/workspace-helper",
"runtimes/fixture"
"packages/cli"
],
"devDependencies": {
"@types/ws": "^8.5.14",
@@ -6259,10 +6256,6 @@
"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
@@ -6283,14 +6276,6 @@
"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",
@@ -28485,16 +28470,6 @@
"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",
@@ -38872,8 +38847,6 @@
"@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",
@@ -39722,6 +39695,16 @@
"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",
@@ -41518,43 +41501,6 @@
"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"
}
}
}
}
+8 -15
View File
@@ -31,10 +31,7 @@
"packages/relay",
"packages/website",
"packages/desktop",
"packages/cli",
"packages/workspace-runtime-contract",
"packages/workspace-helper",
"runtimes/fixture"
"packages/cli"
],
"scripts": {
"dev": "npm run dev:server",
@@ -58,11 +55,8 @@
"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: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-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: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",
@@ -78,7 +72,6 @@
"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",
@@ -109,11 +102,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 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: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: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,7 +809,6 @@ 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);
@@ -817,20 +816,14 @@ 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.waitForHeldServerMessage("fetch_agent_timeline_response");
gate.truncateHeldTimelineAfterLast("user_message");
gate.setServerMessageSuppressed("fetch_agent_timeline_response", true);
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
await gate.waitForServerMessage("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();
}
@@ -669,11 +669,13 @@ async function expectFlatFileList(page: Page): Promise<void> {
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
const visibleCodeText = page
.getByTestId(/^diff-code-text-/)
.filter({ visible: true })
.first();
await expect
.poll(async () => {
return page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
return visibleCodeText.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
})
.toBe(fontSize);
}
@@ -0,0 +1,184 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { CDPSession } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const RUN_DIFF_PERF = process.env.PASEO_DIFF_PERF_E2E === "1";
const diffPerfDescribe = RUN_DIFF_PERF ? test.describe : test.describe.skip;
const LINE_COUNT = 2_400;
const CPU_SLOWDOWN = Number(process.env.PASEO_DIFF_PERF_CPU_SLOWDOWN ?? 6);
const MAX_MOUNTED_CODE_ROWS = 220;
const MAX_EXPANSION_MS = 6_000;
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
diffPerfDescribe("Diff rendering performance", () => {
test("large unwrapped diffs keep mounting bounded under CPU slowdown", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
const repo = await createTempGitRepo("diff-perf-", {
files: [{ path: "src/large.ts", content: "export const baseline = 0;\n" }],
});
const client = await connectSeedClient();
let cdp: CDPSession | null = null;
try {
const generatedLines = Array.from(
{ length: LINE_COUNT },
(_, index) => `export const generated_${index} = "${index}-${"wide".repeat(16)}";`,
).join("\n");
await writeFile(
path.join(repo.path, "src/large.ts"),
`export const baseline = 1;\n${generatedLines}\n`,
);
const created = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
if (!created.workspace) {
throw new Error(created.error ?? "Failed to create diff performance workspace");
}
await page.addInitScript(
({ preferencesKey }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
}),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByRole("button", { name: "Open explorer" }).click();
const fileHeader = page.getByTestId("diff-file-0");
await expect(fileHeader).toContainText("large.ts", { timeout: 30_000 });
cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU_SLOWDOWN });
const expansionStart = await page.evaluate(() => performance.now());
await fileHeader.click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
const expansionMs = (await page.evaluate(() => performance.now())) - expansionStart;
const scroll = page.getByTestId("git-diff-scroll");
const initial = await readDiffMountReport(page);
expect(initial.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
expect(initial.mountedGutterRows).toBe(initial.mountedCodeRows);
await page.getByTestId("diff-gutter-row-1").hover();
await page.getByTestId("diff-gutter-action-1").click();
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
const withEditor = await readDiffMountReport(page);
expect(withEditor.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
expect(withEditor.mountedGutterRows).toBe(withEditor.mountedCodeRows);
expect(await readDiffRowAlignment(page, 2)).toBeCloseTo(0, 0);
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
await page.getByTestId("inline-review-editor-cancel").click();
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await scroll.evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect
.poll(async () => (await readDiffMountReport(page)).highestMountedRow)
.toBeGreaterThan(LINE_COUNT - 100);
const afterScroll = await readDiffMountReport(page);
expect(afterScroll.mountedCodeRows).toBeLessThanOrEqual(MAX_MOUNTED_CODE_ROWS);
expect(afterScroll.mountedGutterRows).toBe(afterScroll.mountedCodeRows);
const report = {
cpuSlowdown: CPU_SLOWDOWN,
sourceLines: LINE_COUNT,
expansionMs: Math.round(expansionMs),
initial,
withEditor,
afterScroll,
};
await testInfo.attach("diff-performance", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(`[perf] Diff rendering: ${JSON.stringify(report)}`);
expect(expansionMs).toBeLessThan(MAX_EXPANSION_MS);
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 });
await scroll.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await page.getByTestId("changes-toggle-layout").click();
const oldLine = page.getByText("export const baseline = 0;", { exact: true });
const newLine = page.getByText("export const baseline = 1;", { exact: true });
await expect(oldLine).toBeVisible();
await expect(newLine).toBeVisible();
const [bodyBox, oldBox, newBox] = await Promise.all([
page.getByTestId("diff-file-0-body").boundingBox(),
oldLine.boundingBox(),
newLine.boundingBox(),
]);
expect(bodyBox).not.toBeNull();
expect(oldBox).not.toBeNull();
expect(newBox).not.toBeNull();
expect(oldBox!.x).toBeLessThan(newBox!.x);
expect(oldBox!.x).toBeGreaterThanOrEqual(bodyBox!.x);
expect(newBox!.x).toBeGreaterThan(bodyBox!.x + bodyBox!.width / 2);
expect(newBox!.x).toBeLessThan(bodyBox!.x + bodyBox!.width);
} finally {
await cdp?.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => undefined);
await cdp?.detach().catch(() => undefined);
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
});
async function readDiffRowAlignment(
page: import("@playwright/test").Page,
rowIndex: number,
): Promise<number> {
const gutterTop = await page
.getByTestId(`diff-gutter-row-${rowIndex}`)
.evaluate((row) => row.getBoundingClientRect().top);
const codeTop = await page
.getByTestId(`diff-code-row-${rowIndex}`)
.evaluate((row) => row.getBoundingClientRect().top);
return gutterTop - codeTop;
}
async function readDiffMountReport(page: import("@playwright/test").Page): Promise<{
mountedCodeRows: number;
mountedGutterRows: number;
highestMountedRow: number;
bodyDomElements: number;
}> {
return page.getByTestId("diff-file-0-body").evaluate((body) => {
const codeRows = Array.from(
body.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'),
);
const gutterRows = body.querySelectorAll('[data-testid^="diff-gutter-row-"]');
return {
mountedCodeRows: codeRows.length,
mountedGutterRows: gutterRows.length,
highestMountedRow: Math.max(
-1,
...codeRows.map((row) =>
Number((row.getAttribute("data-testid") ?? "").slice("diff-code-row-".length)),
),
),
bodyDomElements: body.querySelectorAll("*").length,
};
});
}
@@ -34,6 +34,8 @@ const STREAMED_MERMAID = [
test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({
page,
}) => {
test.setTimeout(90_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-streaming-",
title: "Streaming Mermaid",
@@ -1,7 +1,10 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { openNewWorkspaceComposer, selectWorkspaceRuntime } from "../support/helpers/new-workspace";
import {
openNewWorkspaceComposer,
selectWorkspaceIsolation,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
@@ -174,7 +177,7 @@ test.describe("New Workspace dictation submit", () => {
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
await selectWorkspaceRuntime(page, "local");
await selectWorkspaceIsolation(page, "local");
await dictateAndSend(page, harness.waitForAudio);
await harness.waitForCreateRequest();
@@ -4,8 +4,6 @@ import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceControlsEnabled,
expectNewWorkspaceProjectSelected,
expectWorkspaceRuntimeChoices,
expectWorkspaceRuntimeSelected,
expectNewWorkspaceTriggerLabelsAligned,
openGlobalNewWorkspaceComposer,
openMissingProjectNewWorkspaceComposer,
@@ -31,7 +29,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 offer only the Local runtime.
// screen, and non-git projects never offer the worktree Isolation control.
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
@@ -136,11 +134,10 @@ 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("the project shortcut opens the project picker with search focused", async ({ page }) => {
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
try {
@@ -249,7 +246,9 @@ test.describe("New workspace entry points", () => {
}
});
test("the Runtime control hides Git runtimes for a non-git project", async ({ page }) => {
test("the Isolation control is hidden for a non-git project and shown for a git project", async ({
page,
}) => {
const gitProject: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-iso-git-" });
const nonGitProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "entry-iso-nongit-",
@@ -275,10 +274,11 @@ test.describe("New workspace entry points", () => {
await nonGitOption.click();
await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName);
await expectWorkspaceRuntimeSelected(page, "local");
await expectWorkspaceRuntimeChoices(page, ["local"]);
// No git checkout means no worktree isolation choice: the Isolation row is
// absent entirely.
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
// Switching to the git project on the same screen adds Worktree.
// Switching to the git project on the same screen reveals the Isolation row.
await trigger.click();
const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${projectEquivalenceViewKey(gitProject.projectKey)}`,
@@ -287,8 +287,9 @@ test.describe("New workspace entry points", () => {
await gitOption.click();
await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName);
await expectWorkspaceRuntimeSelected(page, "local");
await expectWorkspaceRuntimeChoices(page, ["local", "worktree"]);
await expect(page.getByTestId("workspace-create-isolation-trigger")).toBeVisible({
timeout: 30_000,
});
} finally {
await gitProject.cleanup();
await nonGitProject.cleanup();
@@ -1,24 +1,29 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
expectWorkspaceRuntimeSelected,
expectWorkspaceIsolationSelected,
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 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", () => {
// 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", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 240_000 });
@@ -28,26 +33,45 @@ test.describe("New workspace runtime memory", () => {
});
test.afterEach(async () => {
for (const workspaceDirectory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
if (client) {
for (const workspaceDirectory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
}
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
}
createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
await client?.close().catch(() => undefined);
});
test("remembers the Worktree runtime after creating a workspace", async ({ page }) => {
const project = await seedGitProjectForRuntime();
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
try {
// 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 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");
const createButton = page
.getByTestId("message-input-root")
@@ -56,21 +80,21 @@ test.describe("New workspace runtime memory", () => {
await createButton.click();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
serverId,
client,
previousWorkspaceId: "",
projectDisplayName: project.projectDisplayName,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
// Second visit (fresh mount of /new): the runtime choice must stick.
// Second visit (fresh mount of /new): the worktree choice must stick.
await openNewWorkspaceComposer(page, {
projectKey: project.projectKey,
projectDisplayName: project.projectDisplayName,
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceRuntimeSelected(page, "worktree");
await expectWorkspaceIsolationSelected(page, "worktree");
} finally {
await project.cleanup();
await tempRepo.cleanup();
}
});
});
@@ -21,13 +21,13 @@ import {
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// 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.
// `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.
const PROMPT_PROFILE: TerminalProfile = {
id: "e2e-memory-profile",
name: "Memory Profile",
command: "/bin/sh",
args: ["-c", 'echo remembered: "$0"; sleep 60', "{{{prompt}}}"],
args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"],
};
function hasLeftNewWorkspaceRoute(url: URL): boolean {
@@ -1,17 +1,16 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
openNewWorkspaceComposer,
openStartingRefPicker,
selectBranchInPicker,
selectWorkspaceRuntime,
selectWorkspaceIsolation,
} from "../support/helpers/new-workspace";
import {
gotoNewWorkspaceForRuntime,
seedGitProjectForRuntime,
type SeededRuntimeProject,
} from "../support/helpers/new-workspace-runtime";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
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";
@@ -28,14 +27,17 @@ function measureControlRightEdges(controls: HTMLElement[]) {
}
test.describe("New workspace metadata row layout", () => {
let project: SeededRuntimeProject;
let workspace: SeededWorkspace;
test.beforeEach(async () => {
project = await seedGitProjectForRuntime({ branches: [LONG_BRANCH_NAME] });
workspace = await seedWorkspace({
repoPrefix: "new-workspace-layout-",
repo: { branches: [LONG_BRANCH_NAME] },
});
});
test.afterEach(async () => {
await project?.cleanup();
await workspace?.cleanup();
});
test("long host and branch names stay inside the composer's right rail", async ({ page }) => {
@@ -53,8 +55,13 @@ test.describe("New workspace metadata row layout", () => {
},
]);
await gotoNewWorkspaceForRuntime(page, project);
await selectWorkspaceRuntime(page, "worktree");
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, LONG_BRANCH_NAME);
@@ -32,7 +32,7 @@ import {
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
selectWorkspaceRuntime,
selectWorkspaceIsolation,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import {
@@ -723,7 +723,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceRuntime(page, "worktree");
await selectWorkspaceIsolation(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 selectWorkspaceRuntime(page, "worktree");
await selectWorkspaceIsolation(page, "worktree");
return openedProject;
}
@@ -905,7 +905,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceRuntime(page, "worktree");
await selectWorkspaceIsolation(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
@@ -930,7 +930,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceRuntime(page, "worktree");
await selectWorkspaceIsolation(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
@@ -960,7 +960,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceRuntime(page, "worktree");
await selectWorkspaceIsolation(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 selectWorkspaceRuntime(page, "worktree");
await selectWorkspaceIsolation(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 selectWorkspaceRuntime(page, "worktree");
await selectWorkspaceIsolation(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 selectWorkspaceRuntime(page, "worktree");
await selectWorkspaceIsolation(page, "worktree");
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, pr.number);
await submitNewWorkspaceWithoutPrompt(page);
@@ -11,7 +11,7 @@ import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectWorkspaceRuntime,
selectWorkspaceIsolation,
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 selectWorkspaceRuntime(page, "local");
await selectWorkspaceIsolation(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 selectWorkspaceRuntime(page, "local");
await selectWorkspaceIsolation(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 selectWorkspaceRuntime(page, "local");
await selectWorkspaceIsolation(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -6,7 +6,7 @@ import {
connectNewWorkspaceDaemonClient,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
selectWorkspaceRuntime,
selectWorkspaceIsolation,
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 runtime (Local or Worktree) is
// a CHOICE at creation, and creation NEVER dedupes by
// Model B reshape: a workspace is the unit, its isolation (local checkout 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,16 +39,17 @@ async function createWorkspaceViaUi(
page: Page,
input: {
project: { projectKey: string; projectDisplayName: string };
// null when the project has no git checkout: Local is the only available runtime.
runtime: "local" | "worktree" | null;
// null when the project has no git checkout: there is no Isolation control to
// touch, the isolation is implicitly local.
isolation: "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.runtime !== null) {
await selectWorkspaceRuntime(page, input.runtime);
if (input.isolation !== null) {
await selectWorkspaceIsolation(page, input.isolation);
}
await submitNewWorkspaceEmpty(page);
@@ -96,7 +97,7 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, {
project,
runtime: "local",
isolation: "local",
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -127,7 +128,9 @@ test.describe("Workspace multiplicity creation flow", () => {
}
});
test("the Worktree runtime creates a workspace in a distinct directory", async ({ page }) => {
test("New worktree isolation creates a worktree-backed workspace in a distinct directory", async ({
page,
}) => {
const seeded: SeededWorkspace = await seedWorkspace({
repoPrefix: "multiplicity-worktree-",
});
@@ -146,7 +149,7 @@ test.describe("Workspace multiplicity creation flow", () => {
const worktree = await createWorkspaceViaUi(page, {
project,
runtime: "worktree",
isolation: "worktree",
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -197,8 +200,8 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, {
project,
// Non-git project: Local is the only available runtime.
runtime: null,
// Non-git project: no Isolation control, isolation is implicitly local.
isolation: null,
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -59,16 +59,15 @@ test.describe("Worktree restore", () => {
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agents = [];
for (let index = 0; index < (options.agentCount ?? 1); index += 1) {
agents.push(
await createIdleAgent(client, {
const agents = await Promise.all(
Array.from({ length: options.agentCount ?? 1 }, () =>
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");
@@ -370,7 +369,10 @@ test.describe("Worktree restore", () => {
timeout: 30_000,
});
await expect(
page.getByText("The archived workspace runtime is missing.", { exact: true }),
page.getByText(
"The archived workspace directory no longer exists and cannot be recreated.",
{ exact: true },
),
).toBeVisible();
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
} finally {
+2 -2
View File
@@ -30,7 +30,7 @@ export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Pr
const samples = 80;
for (let sample = 0; sample < samples; sample += 1) {
await expect(diagram).toBeVisible({ timeout: 100 });
await expect(svg).toBeVisible({ timeout: 100 });
await expect(svg).toBeVisible({ timeout: 500 });
await page.waitForTimeout(25);
}
}
@@ -44,5 +44,5 @@ export async function expectCompletedDiagram(page: Page, labels: readonly string
}
export async function reloadConversation(page: Page): Promise<void> {
await page.reload();
await page.reload({ waitUntil: "domcontentloaded" });
}
+1 -71
View File
@@ -1,6 +1,5 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { chmod, 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";
@@ -140,74 +139,6 @@ 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[] } = {},
@@ -223,7 +154,6 @@ export async function startE2EWorker(
try {
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
await configureDesktopFixtureRuntime(paseoHome);
const daemon = await startIsolatedHostDaemon(serverId, {
paseoHome,
preserveHome,
@@ -1,330 +0,0 @@
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(process.platform === "darwin" ? "Meta+P" : "Control+P");
await page.keyboard.press("Control+P");
const searchInput = page.getByPlaceholder("Search projects");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
@@ -319,61 +319,35 @@ export async function selectNewWorkspaceProject(
await expectNewWorkspaceProjectSelected(page, input.projectDisplayName);
}
type WorkspaceRuntimeChoice = "local" | "worktree";
const WORKSPACE_RUNTIME_LABEL: Record<WorkspaceRuntimeChoice, string> = {
// 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> = {
local: "Local",
worktree: "Worktree",
worktree: "New worktree",
};
export async function expectWorkspaceRuntimeSelected(
export async function expectWorkspaceIsolationSelected(
page: Page,
runtime: WorkspaceRuntimeChoice,
isolation: "local" | "worktree",
): Promise<void> {
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
const trigger = page.getByRole("button", { name: "Workspace isolation" });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await expect(trigger.getByText(WORKSPACE_RUNTIME_LABEL[runtime], { exact: true })).toBeVisible();
await expect(trigger).toContainText(ISOLATION_TRIGGER_LABEL[isolation]);
}
export async function selectWorkspaceRuntime(
export async function selectWorkspaceIsolation(
page: Page,
runtime: WorkspaceRuntimeChoice,
isolation: "local" | "worktree",
): Promise<void> {
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
const trigger = page.getByTestId("workspace-create-isolation-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
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,
});
// 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}`);
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> {
@@ -490,7 +464,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(process.platform === "darwin" ? "Meta+V" : "Control+V");
await page.keyboard.press("Control+V");
}
export async function assertNewWorkspaceSidebarAndHeader(
@@ -502,7 +476,6 @@ 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.
@@ -512,7 +485,7 @@ export async function assertNewWorkspaceSidebarAndHeader(
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
},
{ timeout: input.timeoutMs ?? 60_000 },
{ timeout: 60_000 },
)
.not.toBeNull();
@@ -143,7 +143,6 @@ 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;
@@ -174,10 +173,6 @@ 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?: {
+1 -10
View File
@@ -650,7 +650,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
message={item.text}
timestamp={item.timestamp.getTime()}
workspaceRoot={workspaceRoot}
workspaceId={context.workspaceId}
serverId={resolvedServerId}
client={client}
spacing={layoutItem.assistantSpacing}
@@ -659,15 +658,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
</AssistantFileLinkResolverProvider>
);
},
[
agentId,
client,
context.workspaceId,
handleInlinePathPress,
resolvedServerId,
toast,
workspaceRoot,
],
[agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
);
const renderThoughtItem = useCallback(
@@ -94,25 +94,6 @@ 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,7 +19,6 @@ export function createAssistantImageOccurrenceKey(input: {
export function createAssistantImageFilePreviewAttachmentId(input: {
serverId?: string;
workspaceId?: string;
occurrenceKey: string;
mimeType: string;
path: string;
@@ -33,18 +32,17 @@ export function createAssistantImageFilePreviewAttachmentId(input: {
size: input.size,
modifiedAt: input.modifiedAt,
contentLength: input.contentLength,
contentKey: `${input.serverId ?? "unknown-server"}:${input.workspaceId ?? "legacy-workspace"}:${input.occurrenceKey}`,
contentKey: `${input.serverId ?? "unknown-server"}:${input.occurrenceKey}`,
});
}
export function createAssistantImageFileAcquisitionKey(input: {
serverId?: string;
workspaceId?: string;
occurrenceKey: string;
cwd: string;
path: string;
}): string {
return `file:${input.serverId ?? "unknown-server"}:${input.workspaceId ?? "legacy-workspace"}:${input.occurrenceKey}:${input.cwd}:${input.path}`;
return `file:${input.serverId ?? "unknown-server"}:${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; workspaceId?: string }> = [];
readonly reads: Array<{ cwd: string; path: string }> = [];
async readFile(cwd: string, path: string, workspaceId?: string) {
this.reads.push({ cwd, path, ...(workspaceId ? { workspaceId } : {}) });
async readFile(cwd: string, path: string) {
this.reads.push({ cwd, path });
return {
kind: "image" as const,
path,
@@ -38,7 +38,6 @@ 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",
};
@@ -49,23 +48,6 @@ 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", 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" }]);
expect(connectedPort.reads).toEqual([{ cwd: "/workspace", path: "reconnect.png" }]);
});
});
@@ -8,7 +8,7 @@ import {
} from "./acquisition-cache";
export interface AssistantImageFileAcquisitionPort {
readFile(cwd: string, path: string, workspaceId?: string): Promise<FileReadResult>;
readFile(cwd: string, path: string): Promise<FileReadResult>;
persist(input: {
id: string;
bytes: Uint8Array;
@@ -26,7 +26,6 @@ export function createAssistantImageFileAcquisition(input: {
port: AssistantImageFileAcquisitionPort | null;
resolution: AssistantImageSourceResolution | null;
serverId?: string;
workspaceId?: string;
occurrenceKey: string;
unavailableMessage: string;
}): AssistantImageAcquisition | null {
@@ -37,7 +36,6 @@ export function createAssistantImageFileAcquisition(input: {
return {
key: createAssistantImageFileAcquisitionKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
occurrenceKey: input.occurrenceKey,
cwd: resolution.cwd,
path: resolution.path,
@@ -46,14 +44,13 @@ export function createAssistantImageFileAcquisition(input: {
if (!port) {
throw new Error(input.unavailableMessage);
}
const file = await port.readFile(resolution.cwd, resolution.path, input.workspaceId);
const file = await port.readFile(resolution.cwd, resolution.path);
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,7 +82,6 @@ interface UseAssistantImageInput {
client?: DaemonClient | null;
workspaceRoot?: string;
serverId?: string;
workspaceId?: string;
}
type PreviewUrlState =
@@ -378,7 +377,6 @@ export function useAssistantImage({
client,
workspaceRoot,
serverId,
workspaceId,
}: UseAssistantImageInput): AssistantImageResult {
const { t } = useTranslation();
const resolution = useMemo(
@@ -389,8 +387,7 @@ export function useAssistantImage({
const fileAcquisition = useMemo(() => {
const port: AssistantImageFileAcquisitionPort | null = client
? {
readFile: async (cwd, path, selectedWorkspaceId) =>
await client.readFile(cwd, path, undefined, selectedWorkspaceId),
readFile: async (cwd, path) => await client.readFile(cwd, path),
persist: persistAttachmentFromBytes,
}
: null;
@@ -398,11 +395,10 @@ export function useAssistantImage({
port,
resolution,
serverId,
workspaceId,
occurrenceKey,
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
});
}, [client, occurrenceKey, resolution, serverId, t, workspaceId]);
}, [client, occurrenceKey, resolution, serverId, t]);
const dataImageAcquisition = useMemo(
() => createDataImageAcquisition({ source, dataImage }),
[dataImage, source],
@@ -5,7 +5,6 @@ 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,
@@ -14,7 +13,6 @@ 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";
@@ -109,26 +107,6 @@ 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,13 +6,15 @@ 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 { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostRuntimeClient, 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;
}
@@ -27,17 +29,23 @@ 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,11 +11,6 @@ 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,
@@ -29,62 +24,6 @@ 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,
@@ -102,10 +41,9 @@ function CompactExplorerOpenGestureSurface({
);
}
function useActiveCompactExplorerSidebarModel(enabled: boolean): {
model: CompactExplorerSidebarHostModel | null;
workspaceGit: WorkspaceGitClient | null;
} {
function useActiveCompactExplorerSidebarModel(
enabled: boolean,
): CompactExplorerSidebarHostModel | null {
const selection = useActiveWorkspaceSelection();
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
const isExplorerOpen = usePanelStore((state) =>
@@ -114,32 +52,44 @@ function useActiveCompactExplorerSidebarModel(enabled: boolean): {
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const client = useHostRuntimeClient(selection?.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
const workspaceGit = useCompactWorkspaceGit(
client,
selection?.workspaceId,
workspace?.workspaceDirectory,
);
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null);
const { checkoutQuery } = useWorkspaceCheckoutStatus({
workspaceGit,
client,
isConnected,
isRouteFocused: enabled && selection !== null,
normalizedServerId: selection?.serverId ?? "",
normalizedWorkspaceId: selection?.workspaceId ?? "",
workspaceDirectory: workspace?.workspaceDirectory || null,
});
const model = useCompactExplorerModel({
enabled,
isExplorerOpen,
selection,
workspace,
isGit: checkoutQuery.data?.isGit ?? false,
showMobileAgent,
});
const resolvedModel = useMemo(
() =>
resolveCompactExplorerSidebarHostModel({
previous: isExplorerOpen ? retainedModelRef.current : null,
selection,
workspace,
isGit: checkoutQuery.data?.isGit ?? false,
}),
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
);
return {
model,
workspaceGit,
};
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;
}
interface CompactExplorerSidebarHostProps {
@@ -148,7 +98,7 @@ interface CompactExplorerSidebarHostProps {
}
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
const { model, workspaceGit } = useActiveCompactExplorerSidebarModel(enabled);
const model = useActiveCompactExplorerSidebarModel(enabled);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
@@ -187,22 +137,20 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
return (
<>
<CompactExplorerOpenGestureSurface
enabled={enabled && Boolean(model?.workspaceRoot) && Boolean(workspaceGit)}
enabled={enabled && Boolean(model?.workspaceRoot)}
onOpenExplorer={handleOpenExplorer}
>
{children}
</CompactExplorerOpenGestureSurface>
<WorkspaceGitBoundary workspaceGit={workspaceGit}>
{enabled && model && workspaceGit ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
</WorkspaceGitBoundary>
{enabled && model ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
</>
);
}
@@ -22,7 +22,6 @@ 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";
@@ -321,10 +320,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,
});
@@ -336,13 +335,10 @@ function ExplorerSidebarContent({
const prTabLabel = formatPrTabLabel(prPane.prNumber);
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
const handlePrRetry = useCallback(() => {
refreshGitActions({
serverId,
target: workspaceGit,
}).catch((error) => {
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [refreshGitActions, serverId, t, toast, workspaceGit]);
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
const workspaceAttachmentScopeKey = useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
[serverId, workspaceId, workspaceRoot],
@@ -423,7 +419,7 @@ function ExplorerSidebarContent({
{resolvedTab === "changes" && (
<ChangedFilesPane
serverId={serverId}
workspaceId={workspaceGit.workspaceId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isOpen={isOpen}
onOpenFile={onOpenFile}
@@ -432,7 +428,7 @@ function ExplorerSidebarContent({
{resolvedTab === "files" && (
<FilesPane
serverId={serverId}
workspaceId={workspaceGit.workspaceId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
/>
@@ -440,7 +436,6 @@ function ExplorerSidebarContent({
{resolvedTab === "pr" && (
<PrTabContent
serverId={serverId}
workspaceId={workspaceGit.workspaceId}
cwd={workspaceRoot}
prPane={prPane}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
@@ -531,7 +526,6 @@ function FilesPane({
interface PrTabContentProps {
serverId: string;
workspaceId: string;
cwd: string;
prPane: UsePrPaneDataResult;
workspaceAttachmentScopeKey: string;
@@ -540,7 +534,6 @@ interface PrTabContentProps {
function PrTabContent({
serverId,
workspaceId,
cwd,
prPane,
workspaceAttachmentScopeKey,
@@ -550,7 +543,6 @@ function PrTabContent({
return (
<PullRequestPane
serverId={serverId}
workspaceId={workspaceId}
cwd={cwd}
data={prPane.data}
activityLoading={prPane.activityLoading}
@@ -68,18 +68,10 @@ function buildSessionsQueriesConfig(args: {
visible: boolean;
client: RecentProviderSessionsClient | null;
cwd: string | null | undefined;
workspaceId?: string | null;
hostDisconnectedMessage?: string;
}): SessionsQueryConfig[] {
const {
providersToFetch,
sessionsQueryRoot,
visible,
client,
cwd,
workspaceId,
hostDisconnectedMessage,
} = args;
const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } =
args;
if (providersToFetch === null) return [];
const enabled = visible && Boolean(client);
return providersToFetch.map((provider) => ({
@@ -90,7 +82,6 @@ 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,
@@ -283,7 +274,6 @@ export function ImportSessionSheet({
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
cwd,
workspaceId,
enabled: visible,
});
const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget");
@@ -304,8 +294,8 @@ export function ImportSessionSheet({
);
const sessionsQueryRoot = useMemo(
() => ["recent-provider-sessions", workspaceId ?? null, cwd ?? null] as const,
[cwd, workspaceId],
() => ["recent-provider-sessions", cwd ?? null] as const,
[cwd],
);
const queriesConfig = useMemo(
@@ -316,10 +306,9 @@ export function ImportSessionSheet({
visible,
client,
cwd,
workspaceId,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[providersToFetch, sessionsQueryRoot, visible, client, cwd, workspaceId, t],
[providersToFetch, sessionsQueryRoot, visible, client, cwd, t],
);
const queries = useQueries({ queries: queriesConfig });
+1 -16
View File
@@ -736,7 +736,6 @@ interface AssistantMessageProps {
message: string;
timestamp: number;
workspaceRoot?: string;
workspaceId?: string;
serverId?: string;
client?: DaemonClient | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
@@ -800,7 +799,6 @@ function AssistantMarkdownImage({
hasLeadingContent,
client,
workspaceRoot,
workspaceId,
serverId,
}: {
source: string;
@@ -809,7 +807,6 @@ function AssistantMarkdownImage({
hasLeadingContent: boolean;
client?: DaemonClient | null;
workspaceRoot?: string;
workspaceId?: string;
serverId?: string;
}) {
const containerStyle = useMemo<StyleProp<ViewStyle>>(
@@ -824,7 +821,6 @@ function AssistantMarkdownImage({
occurrenceKey,
client,
workspaceRoot,
workspaceId,
serverId,
});
const binding = image.status === "failed" ? null : image.binding;
@@ -1452,7 +1448,6 @@ export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp: _timestamp,
workspaceRoot,
workspaceId,
serverId,
client,
spacing = "default",
@@ -1890,22 +1885,12 @@ export const AssistantMessage = memo(function AssistantMessage({
hasLeadingContent={hasLeadingContent}
client={client}
workspaceRoot={workspaceRoot}
workspaceId={workspaceId}
serverId={serverId}
/>
);
},
};
}, [
client,
fileLinkActions,
markdownParser,
occurrenceKey,
phase,
serverId,
workspaceId,
workspaceRoot,
]);
}, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo(
@@ -174,7 +174,7 @@ function updateSelectionPreferences(input: {
...(model && thinkingOptionId ? { thinkingByModel: { [model]: thinkingOptionId } } : {}),
},
}),
runtimeId: input.isolation,
isolation: input.isolation,
};
}
@@ -654,7 +654,7 @@ function WorkspaceRowRightGroup({
isPinned?: boolean;
onTogglePin?: () => void;
}) {
const workspacePath = workspace.hostVisiblePath;
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
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.hostVisiblePath}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
@@ -882,7 +882,7 @@ function StatusWorkspaceRowInnerContent({
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.hostVisiblePath}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
accessibilityRole="button"
accessibilityState={accessibilityState}
@@ -338,7 +338,7 @@ function WorkspaceRowBody({
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
openInFileManagerPath={workspace.hostVisiblePath}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
@@ -31,7 +31,6 @@ 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) {
@@ -430,29 +429,27 @@ export function WorkspaceSetupDialog() {
testID="workspace-setup-dialog"
desktopMaxWidth={640}
>
<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>
<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>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</AdaptiveModalSheet>
@@ -349,7 +349,6 @@ function pickDesktopModel({
type AgentControlsSlice = {
provider: string;
cwd: string | null;
workspaceId: string | undefined;
runtimeModelId: string | null;
model: string | null | undefined;
features: AgentFeature[] | undefined;
@@ -369,7 +368,6 @@ function selectAgentControlsSlice(
return {
provider: currentAgent.provider,
cwd: currentAgent.cwd,
workspaceId: currentAgent.workspaceId,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model,
features: currentAgent.features,
@@ -378,16 +376,6 @@ 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,
@@ -576,7 +564,7 @@ function ControlledAgentControls({
}
}, [updateDensityForWidth]);
const modelDisabled = [disabled, isModelLoading].some(Boolean);
const modelDisabled = disabled;
const comboboxProviderOptions = useMemo<ComboboxOption[]>(
() => toComboboxOptions(providerOptions),
@@ -1493,7 +1481,7 @@ export const AgentControls = memo(function AgentControls({
isRefreshing: snapshotIsRefreshing,
refresh: refreshSnapshot,
refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, providerSnapshotTarget(agent));
} = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
const snapshotSelectedEntry = useMemo(
() => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider),
@@ -253,7 +253,6 @@ export function useLiveAgentModeControl(
return {
provider: agent.provider,
cwd: agent.cwd,
workspaceId: agent.workspaceId,
currentModeId: agent.currentModeId,
};
}),
@@ -266,10 +265,7 @@ 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,
workspaceId: slice?.workspaceId,
});
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd });
const providerDefinitions = useMemo<AgentProviderDefinition[]>(() => {
if (!slice?.provider) return [];
+4 -17
View File
@@ -5,7 +5,6 @@ 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";
@@ -29,9 +28,6 @@ type AttachmentUpdater =
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
interface AgentInputDraftComposerOptions {
workspaceId?: string | null;
providerSnapshotCwd?: string | null;
isTargetDaemonReady?: boolean;
initialServerId: string | null;
initialValues?: CreateAgentInitialValues;
initialFeatureValues?: Record<string, unknown>;
@@ -67,24 +63,15 @@ export interface AgentInputDraft {
composerState: DraftComposerState | null;
}
function buildAgentFormStateOptions(
composerOptions: AgentInputDraftComposerOptions | null,
): UseAgentFormStateOptions {
return {
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
const composerOptions = input.composer ?? null;
const formState = useAgentFormState({
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,7 +365,6 @@ 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 { useMemo, useState } from "react";
import { 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,12 +49,11 @@ const issue202: ForgeSearchItem = {
};
interface SearchCall {
cwd: string;
query: string;
limit?: number;
}
type TestSearchClient = Pick<ForgeSearchClient, "searchForge" | "searchGitHub">;
interface HarnessInput {
initialAttachments?: UserComposerAttachment[];
initialCwd?: string;
@@ -73,7 +72,7 @@ function githubPayload(items: ForgeSearchItem[], requestId: string): ForgeSearch
};
}
function createSearchClient(items: ForgeSearchItem[]): TestSearchClient & { calls: SearchCall[] } {
function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { calls: SearchCall[] } {
const calls: SearchCall[] = [];
return {
calls,
@@ -105,28 +104,21 @@ function createWrapper() {
};
}
function useHarness(client: TestSearchClient, input: HarnessInput = {}) {
function useHarness(client: ForgeSearchClient, 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: boundSearchClient,
client: searchClient,
isConnected: true,
serverId: "server-1",
cwd: workingDirectory,
setAttachments,
onPullRequestDetected: input.onPullRequestDetected,
onPullRequestAdded: input.onPullRequestAdded,
@@ -169,7 +161,7 @@ describe("useComposerGithubAutoAttach", () => {
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
expect(result.current.isResolving).toBe(false);
expect(client.calls).toEqual([{ query: "101", limit: 20 }]);
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
vi.useRealTimers();
});
@@ -243,8 +235,8 @@ describe("useComposerGithubAutoAttach", () => {
{ kind: "forge_issue", item: issue202 },
]);
expect(client.calls).toEqual([
{ query: "101", limit: 20 },
{ query: "202", limit: 20 },
{ cwd, query: "101", limit: 20 },
{ cwd, query: "202", limit: 20 },
]);
vi.useRealTimers();
});
@@ -253,7 +245,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: TestSearchClient = {
const client: ForgeSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
@@ -296,7 +288,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: TestSearchClient = {
const client: ForgeSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
@@ -365,8 +357,8 @@ describe("useComposerGithubAutoAttach", () => {
expect(onPullRequestAdded.mock.calls).toEqual([[pr202], [pr101]]);
expect(client.calls).toEqual([
{ query: "202", limit: 20 },
{ query: "101", limit: 20 },
{ cwd, query: "202", limit: 20 },
{ cwd, query: "101", limit: 20 },
]);
vi.useRealTimers();
});
@@ -375,7 +367,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: TestSearchClient = {
const client: ForgeSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
@@ -427,7 +419,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: TestSearchClient = {
const client: ForgeSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
@@ -467,7 +459,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: TestSearchClient = {
const client: ForgeSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const onPullRequestAdded = vi.fn();
@@ -508,7 +500,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: TestSearchClient = {
const client: ForgeSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
@@ -553,7 +545,7 @@ describe("useComposerGithubAutoAttach", () => {
it("stays resolving when an unrelated attachment is added during lookup", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const client: TestSearchClient = {
const client: ForgeSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
@@ -586,7 +578,7 @@ describe("useComposerGithubAutoAttach", () => {
it("stops resolving when an in-flight PR URL is removed", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const client: TestSearchClient = {
const client: ForgeSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
@@ -606,7 +598,7 @@ describe("useComposerGithubAutoAttach", () => {
it("ignores a lookup that finishes after the target changes", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const client: TestSearchClient = {
const client: ForgeSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
@@ -633,7 +625,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: TestSearchClient = {
const firstClient: ForgeSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const replacementClient = createSearchClient([pr101]);
+13 -32
View File
@@ -10,12 +10,7 @@ import {
type SetStateAction,
} from "react";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import {
buildForgeSearchQueryOptions,
forgeSearchClientIdentity,
type ForgeSearchClient,
} from "@/git/use-forge-search-query";
import { workspaceGitQueryIdentitiesEqual } from "@/git/workspace-git";
import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query";
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions";
@@ -29,6 +24,7 @@ interface ComposerGithubAutoAttachInput {
client: ForgeSearchClient | null;
isConnected: boolean;
serverId: string;
cwd: string;
supportsForgeSearch?: boolean;
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
onPullRequestDetected?: () => void;
@@ -54,7 +50,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, client: params.client });
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd });
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
() => new Map(),
);
@@ -83,7 +79,7 @@ export function useComposerGithubAutoAttach(
hasClient,
params.isConnected,
params.serverId,
params.client,
params.cwd,
]);
useEffect(() => {
@@ -147,7 +143,7 @@ export function useComposerGithubAutoAttach(
hasClient,
params.isConnected,
params.serverId,
params.client,
params.cwd,
queryClient,
]);
@@ -240,25 +236,15 @@ function suppressRefsCarriedAcrossTargets({
removedRefKeys,
}: {
params: ComposerGithubAutoAttachInput;
previousTargetRef: RefObject<{
serverId: string;
client: ForgeSearchClient | null;
}>;
previousTargetRef: RefObject<{ serverId: string; cwd: string }>;
removedRefKeys: Set<string>;
}): void {
const previous = previousTargetRef.current;
const targetChanged =
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 };
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 };
if (!targetChanged) return;
for (const ref of extractGithubRefs(params.text, params.remoteUrl)) {
@@ -458,7 +444,7 @@ function refsReadyForLookup({
removedRefKeys: Set<string>;
activeLookups: ReadonlySet<ActiveGithubLookup>;
}): GithubRef[] {
if (!params.client || !params.isConnected || params.client.cwd.trim().length === 0) {
if (!params.client || !params.isConnected || params.cwd.trim().length === 0) {
return [];
}
@@ -490,6 +476,7 @@ async function fetchGithubRefSearch({
buildForgeSearchQueryOptions({
client: snapshot.client,
serverId: snapshot.serverId,
cwd: snapshot.cwd,
query: String(ref.number),
supportsForgeSearch: snapshot.supportsForgeSearch,
enabled: true,
@@ -512,13 +499,7 @@ function isSameLookupTarget(
): boolean {
return (
initial.serverId === current.serverId &&
(initial.client === null || current.client === null
? initial.client === current.client
: workspaceGitQueryIdentitiesEqual(
forgeSearchClientIdentity(initial.client),
forgeSearchClientIdentity(current.client),
)) &&
initial.client?.cwd === current.client?.cwd &&
initial.cwd === current.cwd &&
initial.remoteUrl === current.remoteUrl
);
}
+9 -13
View File
@@ -121,7 +121,6 @@ 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";
@@ -856,8 +855,6 @@ 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";
@@ -1069,7 +1066,6 @@ export function Composer({
submitButtonTestID,
submitIcon = "arrow",
isSubmitLoading = false,
isSubmitDisabled = false,
waitForGithubAutoAttachOnSubmit = false,
submitBehavior = "clear",
blurOnSubmit = false,
@@ -1153,8 +1149,7 @@ export function Composer({
onOpenWorkspaceAttachment,
});
const setSelectedAttachments = onChangeAttachments;
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId });
const workspaceGitClient = useWorkspaceGit();
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
const supportsForgeSearch = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
);
@@ -1162,9 +1157,10 @@ export function Composer({
text: userInput,
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
attachments,
client: workspaceGitClient,
client,
isConnected,
serverId,
cwd,
supportsForgeSearch,
setAttachments: setSelectedAttachments,
onPullRequestDetected: onGithubPrDetected,
@@ -1922,14 +1918,16 @@ 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: workspaceGitClient,
client,
serverId,
cwd,
query: githubSearchQueryTrimmed,
supportsForgeSearch,
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
@@ -2147,10 +2145,8 @@ export function Composer({
const isSubmitLoadingVisible =
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
const isSubmitActionDisabled =
isSubmitDisabled ||
isSubmitLoadingVisible ||
(waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
const 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
@@ -2209,7 +2205,7 @@ export function Composer({
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitActionDisabled}
isSubmitDisabled={isSubmitDisabled}
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({ runtimeId: "worktree" });
const pending = preferences.begin({ isolation: "worktree" });
expect(preferences.current()).toEqual({ provider: "claude", runtimeId: "worktree" });
expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" });
preferences.reject(failed);
expect(preferences.current()).toEqual({ runtimeId: "worktree" });
expect(preferences.current()).toEqual({ isolation: "worktree" });
preferences.commit(pending, { runtimeId: "worktree" });
expect(preferences.current()).toEqual({ runtimeId: "worktree" });
preferences.commit(pending, { isolation: "worktree" });
expect(preferences.current()).toEqual({ isolation: "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", runtimeId: "local" });
preferences.reconcile({ provider: "codex", isolation: "local" });
expect(preferences.current()).toEqual({ provider: "claude" });
preferences.commit(pending, { provider: "claude" });
preferences.reconcile({ provider: "claude", runtimeId: "worktree" });
expect(preferences.current()).toEqual({ provider: "claude", runtimeId: "worktree" });
preferences.reconcile({ provider: "claude", isolation: "worktree" });
expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" });
});
});
@@ -61,12 +61,12 @@ describe("create agent preferences", () => {
expect(await preferences.load()).toEqual({});
const successfulWrite = preferences.update({ runtimeId: "worktree" });
const successfulWrite = preferences.update({ isolation: "worktree" });
await storage.nextWrite();
storage.finishOldestWrite();
await successfulWrite;
expect(storage.savedPreferences()).toEqual({ runtimeId: "worktree" });
expect(storage.savedPreferences()).toEqual({ isolation: "worktree" });
});
it("flushes the full create-agent selection into provider preferences", async () => {
@@ -212,49 +212,36 @@ describe("create agent preferences", () => {
expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({});
});
it("persists and reloads the workspace runtime choice", async () => {
it("persists and reloads the workspace isolation choice", async () => {
const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage);
const save = preferences.update({ runtimeId: "fixture" });
const save = preferences.update({ isolation: "worktree" });
await storage.nextWrite();
storage.finishOldestWrite();
await save;
expect(storage.savedPreferences()).toEqual({ runtimeId: "fixture" });
expect(storage.savedPreferences()).toEqual({ isolation: "worktree" });
expect(await new CreateAgentPreferencesService(storage).load()).toEqual({
runtimeId: "fixture",
isolation: "worktree",
});
});
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({ runtimeId: "worktree" });
const save = preferences.update({ isolation: "worktree" });
await storage.nextWrite();
storage.finishOldestWrite();
await save;
expect(storage.savedPreferences()).toEqual({ favoriteModels, runtimeId: "worktree" });
expect(storage.savedPreferences()).toEqual({ favoriteModels, isolation: "worktree" });
});
it("treats stored preferences without a runtime choice as undefined", () => {
expect(parseFormPreferences({ provider: "codex" }).runtimeId).toBeUndefined();
it("treats stored preferences without an isolation choice as undefined", () => {
expect(parseFormPreferences({ provider: "codex" }).isolation).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 }>;
runtimeId?: string;
isolation?: "local" | "worktree";
launchTarget?: LaunchTarget;
}
@@ -46,7 +46,7 @@ export const FormPreferencesSchema = z.strictObject({
}),
)
.optional(),
runtimeId: z.string().min(1).optional(),
isolation: z.enum(["local", "worktree"]).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,9 +58,6 @@ 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(),
@@ -89,11 +86,7 @@ const LegacyFormPreferencesSchema = z
});
export const StoredFormPreferencesSchema: z.ZodType<FormPreferences> = z.union([
CurrentStoredFormPreferencesSchema.transform(({ isolation, ...preferences }) =>
preferences.runtimeId !== undefined || isolation === undefined
? preferences
: { ...preferences, runtimeId: isolation },
),
FormPreferencesSchema,
LegacyFormPreferencesSchema,
]);
@@ -4,7 +4,6 @@ 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";
@@ -13,14 +12,6 @@ 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 };
@@ -141,7 +132,7 @@ function writeSnapshot(
): Promise<void> {
return cache.write({
serverId: "server-1",
scope: legacyScope(input.cwd),
cwd: input.cwd,
hash: input.label,
generatedAt: input.generatedAt,
compactSnapshot: compactProviderSnapshot(snapshotEntries(input.label)),
@@ -170,12 +161,12 @@ describe("provider snapshot cache", () => {
await cache.write({
serverId: "server-1",
scope: { type: "legacy-cwd", cwd: "/repo" },
cwd: "/repo",
hash: "snapshot-hash",
generatedAt: "2026-08-04T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(entries),
});
const cached = await cache.read("server-1", { type: "legacy-cwd", cwd: "/repo" });
const cached = await cache.read("server-1", "/repo");
expect(cached?.entries).toEqual(entries);
expect(cached?.entries[0]?.models?.[0]?.thinkingOptions).toBe(
@@ -186,12 +177,9 @@ 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",{"type":"legacy-cwd","cwd":"/repo"}]',
"not json",
);
storage.values.set('@paseo/provider-snapshot/v1:["server-1","/repo"]', "not json");
await expect(cache.read("server-1", { type: "legacy-cwd", cwd: "/repo" })).resolves.toBeNull();
await expect(cache.read("server-1", "/repo")).resolves.toBeNull();
expect([...storage.values.keys()].some((key) => key.startsWith(SNAPSHOT_KEY_PREFIX))).toBe(
false,
);
@@ -205,29 +193,29 @@ describe("provider snapshot cache", () => {
await Promise.all([
cache.write({
serverId: "server-1",
scope: legacyScope("/oldest"),
cwd: "/oldest",
hash: "oldest",
generatedAt: "2026-08-01T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(snapshotEntries("oldest")),
}),
cache.write({
serverId: "server-1",
scope: legacyScope("/middle"),
cwd: "/middle",
hash: "middle",
generatedAt: "2026-08-02T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(snapshotEntries("middle")),
}),
cache.write({
serverId: "server-1",
scope: legacyScope("/newest"),
cwd: "/newest",
hash: "newest",
generatedAt: "2026-08-03T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(snapshotEntries("newest")),
}),
]);
await expect(cache.read("server-1", legacyScope("/oldest"))).resolves.toBeNull();
await expect(cache.read("server-1", legacyScope("/newest"))).resolves.not.toBeNull();
await expect(cache.read("server-1", "/oldest")).resolves.toBeNull();
await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull();
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1);
@@ -240,14 +228,14 @@ describe("provider snapshot cache", () => {
await cache.write({
serverId: "server-1",
scope: legacyScope("/current"),
cwd: "/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", legacyScope("/current"))).resolves.not.toBeNull();
await expect(cache.read("server-1", "/current")).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values);
});
@@ -256,7 +244,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", legacyScope("/legacy"))).resolves.toBeNull();
await expect(cache.read("server-1", "/legacy")).resolves.toBeNull();
expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false);
expectIndexMatchesSnapshots(storage.values);
@@ -269,7 +257,7 @@ describe("provider snapshot cache", () => {
const cache = createProviderSnapshotCache(storage);
await Promise.all([
cache.read("server-1", legacyScope("/legacy")),
cache.read("server-1", "/legacy"),
writeSnapshot(cache, {
cwd: "/current",
label: "current",
@@ -277,7 +265,7 @@ describe("provider snapshot cache", () => {
}),
]);
await expect(cache.read("server-1", legacyScope("/current"))).resolves.not.toBeNull();
await expect(cache.read("server-1", "/current")).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1);
});
@@ -298,7 +286,7 @@ describe("provider snapshot cache", () => {
generatedAt: "2026-08-02T00:00:00.000Z",
});
await expect(cache.read("server-1", legacyScope("/repo"))).resolves.toMatchObject({
await expect(cache.read("server-1", "/repo")).resolves.toMatchObject({
hash: "replacement-is-larger",
});
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
@@ -324,7 +312,7 @@ describe("provider snapshot cache", () => {
generatedAt: "2026-08-02T00:00:00.000Z",
});
await expect(tinyCache.read("server-1", legacyScope("/repo"))).resolves.toBeNull();
await expect(tinyCache.read("server-1", "/repo")).resolves.toBeNull();
expect(snapshotBytes(storage.values)).toBe(0);
expectIndexMatchesSnapshots(storage.values);
});
@@ -342,7 +330,7 @@ describe("provider snapshot cache", () => {
});
expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}legacy`)).toBe(false);
await expect(cache.read("server-1", legacyScope("/current"))).resolves.not.toBeNull();
await expect(cache.read("server-1", "/current")).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1);
});
@@ -372,7 +360,7 @@ describe("provider snapshot cache", () => {
generatedAt: "2026-08-02T00:00:00.000Z",
});
await expect(cache.read("server-1", legacyScope("/emoji"))).resolves.toBeNull();
await expect(cache.read("server-1", "/emoji")).resolves.toBeNull();
expectIndexMatchesSnapshots(storage.values);
});
@@ -391,7 +379,7 @@ describe("provider snapshot cache", () => {
await Promise.all(writes);
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
await expect(cache.read("server-1", legacyScope("/repo-249"))).resolves.not.toBeNull();
await expect(cache.read("server-1", "/repo-249")).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1);
});
@@ -406,7 +394,7 @@ describe("provider snapshot cache", () => {
label: "stable",
generatedAt: "2026-08-01T00:00:00.000Z",
});
const failedKey = snapshotKey("server-1", "/failed");
const failedKey = `${SNAPSHOT_KEY_PREFIX}["server-1","/failed"]`;
storage.failNext("setItem", timing, failedKey);
await writeSnapshot(cache, {
@@ -421,8 +409,8 @@ describe("provider snapshot cache", () => {
label: "next",
generatedAt: "2026-08-03T00:00:00.000Z",
});
await expect(restarted.read("server-1", legacyScope("/stable"))).resolves.not.toBeNull();
await expect(restarted.read("server-1", legacyScope("/next"))).resolves.not.toBeNull();
await expect(restarted.read("server-1", "/stable")).resolves.not.toBeNull();
await expect(restarted.read("server-1", "/next")).resolves.not.toBeNull();
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(2_000);
expectIndexMatchesSnapshots(storage.values);
},
@@ -448,7 +436,7 @@ describe("provider snapshot cache", () => {
const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 });
await expect(
restarted.read("server-1", legacyScope("/orphan-after-index-failure")),
restarted.read("server-1", "/orphan-after-index-failure"),
).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(2);
@@ -494,7 +482,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", legacyScope("/final"))).resolves.not.toBeNull();
await expect(restarted.read("server-1", "/final")).resolves.not.toBeNull();
});
it("evicts before writing when storage has no temporary headroom", async () => {
@@ -518,7 +506,7 @@ describe("provider snapshot cache", () => {
generatedAt: "2026-08-03T00:00:00.000Z",
});
await expect(cache.read("server-1", legacyScope("/newest"))).resolves.not.toBeNull();
await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull();
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
expectIndexMatchesSnapshots(storage.values);
});
@@ -527,12 +515,12 @@ describe("provider snapshot cache", () => {
"recovers when invalid-record cleanup fails %s deletion",
async (timing) => {
const storage = createStorage();
const key = snapshotKey("server-1", "/invalid");
const key = `${SNAPSHOT_KEY_PREFIX}["server-1","/invalid"]`;
storage.values.set(key, "invalid");
storage.failNext("multiRemove", timing);
const cache = createProviderSnapshotCache(storage);
await expect(cache.read("server-1", legacyScope("/invalid"))).resolves.toBeNull();
await expect(cache.read("server-1", "/invalid")).resolves.toBeNull();
await writeSnapshot(cache, {
cwd: "/recovered",
label: "recovered",
@@ -540,37 +528,8 @@ describe("provider snapshot cache", () => {
});
expect(storage.values.has(key)).toBe(false);
await expect(cache.read("server-1", legacyScope("/recovered"))).resolves.not.toBeNull();
await expect(cache.read("server-1", "/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,15 +56,11 @@ 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, scope: ProviderSnapshotCacheScope): Promise<CachedProviderSnapshot | null>;
read(serverId: string, cwd: string | null): Promise<CachedProviderSnapshot | null>;
write(input: {
serverId: string;
scope: ProviderSnapshotCacheScope;
cwd: string | null;
hash: string;
generatedAt: string;
compactSnapshot: CompactProviderSnapshot;
@@ -78,8 +74,8 @@ export class ProviderSnapshotCacheMissError extends Error {
}
}
function cacheKey(serverId: string, scope: ProviderSnapshotCacheScope): string {
return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, scope])}`;
function cacheKey(serverId: string, cwd: string | null): string {
return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`;
}
function storedBytes(key: string, value: string): number {
@@ -164,13 +160,13 @@ export function createProviderSnapshotCache(
async function writeSnapshot(input: {
serverId: string;
scope: ProviderSnapshotCacheScope;
cwd: string | null;
hash: string;
generatedAt: string;
compactSnapshot: CompactProviderSnapshot;
}): Promise<void> {
const currentIndex = await ensureCacheIndex();
const key = cacheKey(input.serverId, input.scope);
const key = cacheKey(input.serverId, input.cwd);
const stored = StoredProviderSnapshotSchema.safeParse({
version: CACHE_VERSION,
hash: input.hash,
@@ -210,11 +206,11 @@ export function createProviderSnapshotCache(
}
return {
async read(serverId, scope) {
async read(serverId, cwd) {
try {
return await runSerialized(async () => {
const currentIndex = await ensureCacheIndex();
const key = cacheKey(serverId, scope);
const key = cacheKey(serverId, cwd);
const value = await storage.getItem(key);
if (value === null) return null;
try {
+1 -10
View File
@@ -11,15 +11,8 @@ export function providersSnapshotQueryRoot(serverId: string | null) {
return [PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId] as const;
}
export function providersSnapshotQueryKey(
serverId: string | null,
cwd?: string | null,
workspaceId?: string | null,
) {
export function providersSnapshotQueryKey(serverId: string | null, cwd?: 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);
@@ -29,12 +22,10 @@ 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 } : {}),
};
+27 -49
View File
@@ -12,7 +12,6 @@ import {
mountServerDataPushRouter,
workspaceTerminalsPushRoute,
} from "@/data/push-router";
import type { WorkspaceGitClient } from "@/git/workspace-git";
type ProvidersSnapshotUpdateMessage = Extract<
SessionOutboundMessage,
@@ -57,7 +56,6 @@ 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: [],
@@ -96,35 +94,29 @@ 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);
},
@@ -137,7 +129,6 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {}
unsubscribeCheckoutDiffCalls,
subscribeTerminalCalls,
unsubscribeTerminalCalls,
workspaceGit,
};
}
@@ -189,8 +180,7 @@ describe("server data push router", () => {
const fake = createFakeClient();
const serverId = "server-1";
const cwd = "/repo";
const target = fake.workspaceGit({ workspaceId: "workspace-a", cwd });
const queryKey = checkoutDiffQueryKey(serverId, target, "base", "main", true);
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
const observer = new QueryObserver(queryClient, {
queryKey,
@@ -202,7 +192,7 @@ describe("server data push router", () => {
enabled: true,
serverId,
subscriptionId,
workspaceGit: target,
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
}),
});
@@ -262,8 +252,7 @@ describe("server data push router", () => {
const fake = createFakeClient({ rejectCheckoutDiffSubscribe: true });
const serverId = "server-1";
const cwd = "/repo";
const target = fake.workspaceGit({ workspaceId: "workspace-a", cwd });
const queryKey = checkoutDiffQueryKey(serverId, target, "base", "main", true);
const queryKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
const subscriptionId = `checkoutDiff:${JSON.stringify(queryKey)}`;
const observer = new QueryObserver(queryClient, {
queryKey,
@@ -275,7 +264,7 @@ describe("server data push router", () => {
enabled: true,
serverId,
subscriptionId,
workspaceGit: target,
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
}),
});
@@ -350,8 +339,7 @@ describe("server data push router", () => {
const serverId = "server-1";
const cwd = "/repo";
const workspaceId = "workspace-a";
const target = fake.workspaceGit({ workspaceId, cwd });
const checkoutDiffKey = checkoutDiffQueryKey(serverId, target, "base", "main", true);
const checkoutDiffKey = checkoutDiffQueryKey(serverId, cwd, "base", "main", true);
const checkoutDiffSubscriptionId = `checkoutDiff:${JSON.stringify(checkoutDiffKey)}`;
const terminalKey = buildTerminalsQueryKey(serverId, cwd, workspaceId);
const checkoutDiffObserver = new QueryObserver(queryClient, {
@@ -364,7 +352,7 @@ describe("server data push router", () => {
enabled: true,
serverId,
subscriptionId: checkoutDiffSubscriptionId,
workspaceGit: target,
cwd,
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
}),
});
@@ -520,17 +508,7 @@ describe("server data push router", () => {
const providerKey = providersSnapshotQueryKey(serverId);
const daemonConfigKey = daemonConfigQueryKey(serverId);
const pairingOfferKey = daemonPairingOfferQueryKey(serverId);
const diffKey = checkoutDiffQueryKey(
serverId,
{
workspaceId: "workspace-a",
cwd: "/repo",
queryIdentity: ["selected", "workspace-a"],
},
"uncommitted",
undefined,
false,
);
const diffKey = checkoutDiffQueryKey(serverId, "/repo", "uncommitted", undefined, false);
const terminalKey = buildTerminalsQueryKey(serverId, "/repo", "workspace-a");
const otherProviderKey = providersSnapshotQueryKey(otherServerId);
+28 -49
View File
@@ -14,8 +14,6 @@ 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,
@@ -49,7 +47,7 @@ interface CheckoutDiffRoute {
enabled: boolean;
serverId: string;
subscriptionId: string;
workspaceGit: WorkspaceGitClient;
cwd: string;
compare: CheckoutDiffCompare;
}
@@ -74,6 +72,12 @@ 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;
}
@@ -136,7 +140,7 @@ export function checkoutDiffPushRoute(input: {
enabled: boolean;
serverId: string;
subscriptionId: string;
workspaceGit: WorkspaceGitClient;
cwd: string;
compare: CheckoutDiffCompare;
}): ServerDataQueryMeta {
return {
@@ -145,7 +149,7 @@ export function checkoutDiffPushRoute(input: {
enabled: input.enabled,
serverId: input.serverId,
subscriptionId: input.subscriptionId,
workspaceGit: input.workspaceGit,
cwd: input.cwd,
compare: input.compare,
},
};
@@ -190,11 +194,7 @@ export function applyProvidersSnapshotUpdate(input: {
if (input.message.type !== "providers_snapshot_update") {
return;
}
const queryKey = providersSnapshotQueryKey(
input.serverId,
input.message.payload.cwd,
input.message.payload.workspaceId,
);
const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd);
input.queryClient.setQueryData(queryKey, {
entries: input.message.payload.entries,
generatedAt: input.message.payload.generatedAt,
@@ -204,12 +204,7 @@ export function applyProvidersSnapshotUpdate(input: {
if (compactSnapshot && snapshotHash) {
void (input.cache ?? providerSnapshotCache).write({
serverId: input.serverId,
scope: input.message.payload.workspaceId
? { type: "workspace", workspaceId: input.message.payload.workspaceId }
: ({
type: "legacy-cwd",
cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd),
} satisfies ProviderSnapshotCacheScope),
cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd),
hash: snapshotHash,
generatedAt: input.message.payload.generatedAt,
compactSnapshot,
@@ -346,8 +341,8 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void {
unsubscribeCheckoutDiffUpdate();
unsubscribeCheckoutDiffResponse();
unsubscribeTerminalsChanged();
for (const route of activeCheckoutDiffSubscriptions.values()) {
unsubscribeCheckoutDiff(route);
for (const subscriptionId of activeCheckoutDiffSubscriptions.keys()) {
unsubscribeCheckoutDiff(input.client, subscriptionId);
}
activeCheckoutDiffSubscriptions.clear();
for (const route of activeTerminalSubscriptions.values()) {
@@ -368,7 +363,7 @@ function reconcileCheckoutDiffSubscriptions(input: {
if (desired && areCheckoutDiffRoutesEqual(current, desired)) {
continue;
}
unsubscribeCheckoutDiff(current);
unsubscribeCheckoutDiff(input.client, subscriptionId);
input.active.delete(subscriptionId);
}
@@ -377,8 +372,8 @@ function reconcileCheckoutDiffSubscriptions(input: {
continue;
}
input.active.set(subscriptionId, desired);
void desired.workspaceGit
.subscribeDiff(desired.compare, {
void input.client
.subscribeCheckoutDiff(desired.cwd, desired.compare, {
subscriptionId,
requestId: `push-router:${input.serverId}:${subscriptionId}`,
})
@@ -388,7 +383,7 @@ function reconcileCheckoutDiffSubscriptions(input: {
}
console.error("[server-data] subscribeCheckoutDiff failed", {
serverId: input.serverId,
cwd: desired.workspaceGit.cwd,
cwd: desired.cwd,
error,
});
});
@@ -657,15 +652,15 @@ function readServerDataRoute(value: Record<string, unknown>): ServerDataRoute |
const domain = value.domain;
const enabled = value.enabled;
const serverId = value.serverId;
if (typeof enabled !== "boolean" || typeof serverId !== "string") {
const cwd = value.cwd;
if (typeof enabled !== "boolean" || typeof serverId !== "string" || typeof cwd !== "string") {
return null;
}
if (domain === "checkoutDiff") {
const subscriptionId = value.subscriptionId;
const compare = readCheckoutDiffCompare(value.compare);
const workspaceGit = readWorkspaceGitClient(value.workspaceGit);
if (typeof subscriptionId !== "string" || !compare || !workspaceGit) {
if (typeof subscriptionId !== "string" || !compare) {
return null;
}
return {
@@ -673,14 +668,12 @@ function readServerDataRoute(value: Record<string, unknown>): ServerDataRoute |
enabled,
serverId,
subscriptionId,
workspaceGit,
cwd,
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;
@@ -727,7 +720,7 @@ function areCheckoutDiffRoutesEqual(
return (
left?.serverId === right.serverId &&
left.subscriptionId === right.subscriptionId &&
left.workspaceGit === right.workspaceGit &&
left.cwd === right.cwd &&
left.compare.mode === right.compare.mode &&
left.compare.baseRef === right.compare.baseRef &&
left.compare.ignoreWhitespace === right.compare.ignoreWhitespace
@@ -738,13 +731,10 @@ function isCheckoutDiffQueryKeyForRoute(queryKey: QueryKey, route: CheckoutDiffR
return (
queryKey[0] === "checkoutDiff" &&
queryKey[1] === route.serverId &&
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)
queryKey[2] === route.cwd &&
queryKey[3] === route.compare.mode &&
queryKey[4] === (route.compare.baseRef ?? "") &&
queryKey[5] === (route.compare.ignoreWhitespace === true)
);
}
@@ -773,25 +763,14 @@ function workspaceTerminalSubscriptionInput(route: WorkspaceTerminalsRoute): {
};
}
function unsubscribeCheckoutDiff(route: CheckoutDiffRoute): void {
function unsubscribeCheckoutDiff(client: ServerDataPushClient, subscriptionId: string): void {
try {
route.workspaceGit.unsubscribeDiff(route.subscriptionId);
client.unsubscribeCheckoutDiff(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;
}
+3 -4
View File
@@ -4,7 +4,6 @@ import { LiveFileModel, type LiveFileSession } from "./model";
export function useLiveFile(input: {
client: DaemonClient | null;
workspaceId: string;
cwd: string | null;
path: string | null;
enabled: boolean;
@@ -16,13 +15,13 @@ export function useLiveFile(input: {
const client = input.client;
return {
subscribe(target, onVersion) {
return client.subscribeFile({ ...target, workspaceId: input.workspaceId }, onVersion);
return client.subscribeFile(target, onVersion);
},
read(target) {
return client.readFile(target.cwd, target.path, undefined, input.workspaceId);
return client.readFile(target.cwd, target.path);
},
};
}, [input.client, input.workspaceId]);
}, [input.client]);
useEffect(() => {
if (!input.enabled || !session || !input.cwd || !input.path) {
+2 -11
View File
@@ -399,13 +399,11 @@ function FilePreviewBody({
export function FilePane({
serverId,
workspaceId,
workspaceRoot,
location,
navigationRevision,
}: {
serverId: string;
workspaceId: string;
workspaceRoot: string;
location: WorkspaceFileLocation;
navigationRevision: number;
@@ -449,7 +447,6 @@ export function FilePane({
});
const liveFile = useLiveFile({
client,
workspaceId,
cwd: readTarget?.cwd ?? null,
path: readTarget?.path ?? null,
enabled,
@@ -490,7 +487,6 @@ export function FilePane({
<FilePanePresentation
serverId={serverId}
client={client}
workspaceId={workspaceId}
readTarget={readTarget}
preview={preview}
liveFile={liveFile.model}
@@ -537,7 +533,6 @@ function isEditableTextFile(input: {
function FilePanePresentation({
serverId,
workspaceId,
client,
readTarget,
preview,
@@ -559,7 +554,6 @@ function FilePanePresentation({
imagePreviewUri,
}: {
serverId: string;
workspaceId: string;
client: DaemonClient | null;
readTarget: { cwd: string; path: string } | null;
preview: ExplorerFile | null;
@@ -595,7 +589,6 @@ function FilePanePresentation({
<EditableFilePane
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
client={client}
workspaceId={workspaceId}
cwd={readTarget.cwd}
path={readTarget.path}
preview={preview as TextExplorerFile}
@@ -651,7 +644,6 @@ function FilePanePresentation({
function EditableFilePane({
client,
workspaceId,
cwd,
path,
preview,
@@ -667,7 +659,6 @@ function EditableFilePane({
navigationRevision,
}: {
client: DaemonClient;
workspaceId: string;
cwd: string;
path: string;
preview: TextExplorerFile;
@@ -689,10 +680,10 @@ function EditableFilePane({
const session = useMemo(
() => ({
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
return client.writeFile({ cwd, path, workspaceId, ...input });
return client.writeFile({ cwd, path, ...input });
},
}),
[client, cwd, path, workspaceId],
[client, cwd, path],
);
const [model] = useState(() => {
return new FileEditorModel({
+252 -78
View File
@@ -1,26 +1,34 @@
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(), removeItem: vi.fn() },
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined),
},
}));
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;
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 };
}
describe("checkout-git-actions-store", () => {
const serverId = "server-1";
const cwd = "/tmp/repo/worktrees/feature";
beforeEach(() => {
vi.useFakeTimers();
__resetCheckoutGitActionsStoreForTests();
@@ -32,101 +40,267 @@ describe("checkout-git-actions-store", () => {
vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests();
appQueryClient.clear();
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
});
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 });
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],
},
}));
const store = useCheckoutGitActionsStore.getState();
const first = store.commit({ serverId, target });
const second = store.commit({ serverId, target });
expect(store.getStatus({ serverId, target, actionId: "commit" })).toBe("pending");
resolve({ error: null });
const first = store.commit({ serverId, cwd });
const second = store.commit({ serverId, cwd });
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("pending");
deferred.resolve({});
await Promise.all([first, second]);
expect(commit).toHaveBeenCalledTimes(1);
expect(store.getStatus({ serverId, target, actionId: "commit" })).toBe("success");
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("success");
vi.advanceTimersByTime(1000);
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle");
});
it("runs pull then push sequentially through the same bound capability", async () => {
it("runs pull then push sequentially for pull-and-push", async () => {
const order: string[] = [];
const target = capability({
pull: vi.fn(async () => {
const client = {
checkoutPull: vi.fn(async () => {
order.push("pull");
return { error: null };
return {};
}),
push: vi.fn(async () => {
checkoutPush: vi.fn(async () => {
order.push("push");
return { error: null };
return {};
}),
});
await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, target });
};
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"]);
});
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, 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" }),
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
).toBe("success");
});
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);
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"],
});
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, 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);
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()
.enablePrAutoMerge({ serverId, target, method: "merge" }),
).rejects.toThrow("Update the host");
expect(setForgeAutoMerge).not.toHaveBeenCalled();
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
).toBe("idle");
});
});
+87 -78
View File
@@ -3,9 +3,7 @@ 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 type { WorkspaceGitClient } from "@/git/workspace-git";
type WorkspaceGitTarget = WorkspaceGitClient;
import { i18n } from "@/i18n/i18next";
const SUCCESS_DISPLAY_MS = 1000;
@@ -32,8 +30,17 @@ export type CheckoutGitAsyncActionId =
type CheckoutKey = string;
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
function checkoutKey(serverId: string, target: WorkspaceGitTarget): CheckoutKey {
return `${serverId}::${target.workspaceId}::${target.cwd}`;
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;
}
type AutoMergeActionsRpc = "forge" | "github";
@@ -74,8 +81,8 @@ function setStatus(
});
}
function invalidateCheckoutGitQueries(serverId: string, target: WorkspaceGitTarget) {
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, target });
function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
}
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -90,56 +97,44 @@ interface CheckoutGitActionsStoreState {
getStatus: (params: {
serverId: string;
target: WorkspaceGitTarget;
cwd: string;
actionId: CheckoutGitAsyncActionId;
}) => CheckoutGitActionStatus;
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>;
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>;
mergePr: (params: {
serverId: string;
target: WorkspaceGitTarget;
cwd: string;
method: CheckoutPrMergeMethod;
}) => Promise<void>;
enablePrAutoMerge: (params: {
serverId: string;
target: WorkspaceGitTarget;
cwd: string;
method: CheckoutPrMergeMethod;
}) => 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>;
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>;
}
async function runCheckoutAction({
serverId,
target,
cwd,
actionId,
run,
}: {
serverId: string;
target: WorkspaceGitTarget;
cwd: string;
actionId: CheckoutGitAsyncActionId;
run: () => Promise<void>;
}): Promise<void> {
const key = checkoutKey(serverId, target);
const key = checkoutKey(serverId, cwd);
const inflightId = inFlightKey(key, actionId);
const existing = inFlight.get(inflightId);
@@ -159,7 +154,7 @@ async function runCheckoutAction({
const promise = (async () => {
try {
await run();
await invalidateCheckoutGitQueries(serverId, target);
await invalidateCheckoutGitQueries(serverId, cwd);
setStatus(key, actionId, "success");
const timer = setTimeout(() => {
setStatus(key, actionId, "idle");
@@ -181,18 +176,19 @@ async function runCheckoutAction({
export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()((set, get) => ({
statusByCheckout: {},
getStatus: ({ serverId, target, actionId }) => {
const key = checkoutKey(serverId, target);
getStatus: ({ serverId, cwd, actionId }) => {
const key = checkoutKey(serverId, cwd);
return get().statusByCheckout[key]?.[actionId] ?? "idle";
},
commit: async ({ serverId, target }) => {
commit: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
target,
cwd,
actionId: "commit",
run: async () => {
const payload = await target.commit({ addAll: true });
const client = resolveClient(serverId);
const payload = await client.checkoutCommit(cwd, { addAll: true });
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -200,13 +196,14 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
pull: async ({ serverId, target }) => {
pull: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
target,
cwd,
actionId: "pull",
run: async () => {
const payload = await target.pull();
const client = resolveClient(serverId);
const payload = await client.checkoutPull(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -214,13 +211,14 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
push: async ({ serverId, target }) => {
push: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
target,
cwd,
actionId: "push",
run: async () => {
const payload = await target.push();
const client = resolveClient(serverId);
const payload = await client.checkoutPush(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -228,13 +226,14 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
refresh: async ({ serverId, target }) => {
refresh: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
target,
cwd,
actionId: "refresh",
run: async () => {
const payload = await target.refresh();
const client = resolveClient(serverId);
const payload = await client.checkoutRefresh(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -242,17 +241,18 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
pullAndPush: async ({ serverId, target }) => {
pullAndPush: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
target,
cwd,
actionId: "pull-and-push",
run: async () => {
const pullPayload = await target.pull();
const client = resolveClient(serverId);
const pullPayload = await client.checkoutPull(cwd);
if (pullPayload.error) {
throw new Error(pullPayload.error.message);
}
const pushPayload = await target.push();
const pushPayload = await client.checkoutPush(cwd);
if (pushPayload.error) {
throw new Error(pushPayload.error.message);
}
@@ -260,13 +260,14 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
createPr: async ({ serverId, target }) => {
createPr: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
target,
cwd,
actionId: "create-pr",
run: async () => {
const payload = await target.createPr({});
const client = resolveClient(serverId);
const payload = await client.checkoutPrCreate(cwd, {});
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -274,13 +275,14 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
mergePr: async ({ serverId, target, method }) => {
mergePr: async ({ serverId, cwd, method }) => {
await runCheckoutAction({
serverId,
target,
cwd,
actionId: `merge-pr-${method}`,
run: async () => {
const payload = await target.mergePr(method);
const client = resolveClient(serverId);
const payload = await client.checkoutPrMerge(cwd, { method });
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -288,19 +290,20 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
enablePrAutoMerge: async ({ serverId, target, method }) => {
enablePrAutoMerge: async ({ serverId, cwd, method }) => {
const rpc = resolveAutoMergeActionsRpc(serverId);
await runCheckoutAction({
serverId,
target,
cwd,
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 target.setForgeAutoMerge({ enabled: true, method })
: await target.setGithubAutoMerge({ enabled: true, method });
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method })
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -308,19 +311,20 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
disablePrAutoMerge: async ({ serverId, target }) => {
disablePrAutoMerge: async ({ serverId, cwd }) => {
const rpc = resolveAutoMergeActionsRpc(serverId);
await runCheckoutAction({
serverId,
target,
cwd,
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 target.setForgeAutoMerge({ enabled: false })
: await target.setGithubAutoMerge({ enabled: false });
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false })
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -328,13 +332,14 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
mergeBranch: async ({ serverId, target, baseRef }) => {
mergeBranch: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,
target,
cwd,
actionId: "merge-branch",
run: async () => {
const payload = await target.merge({
const client = resolveClient(serverId);
const payload = await client.checkoutMerge(cwd, {
baseRef,
strategy: "merge",
requireCleanTarget: true,
@@ -346,13 +351,14 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
mergeFromBase: async ({ serverId, target, baseRef }) => {
mergeFromBase: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,
target,
cwd,
actionId: "merge-from-base",
run: async () => {
const payload = await target.mergeFromBase({
const client = resolveClient(serverId);
const payload = await client.checkoutMergeFromBase(cwd, {
baseRef,
requireCleanTarget: true,
});
@@ -363,15 +369,18 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
discardChanges: async ({ serverId, target, paths }) => {
discardChanges: async ({ serverId, cwd, paths }) => {
await runCheckoutAction({
serverId,
target,
cwd,
actionId: "discard-changes",
run: async () => {
const payload = await target.discardChanges({ paths });
const client = resolveClient(serverId);
const payload = await client.checkoutDiscardChanges(cwd, { paths });
if (!payload.success) {
throw new Error(payload.error?.message ?? "Failed to discard changes");
throw new Error(
payload.error?.message ?? i18n.t("workspace.fileActions.confirmRevert.failed"),
);
}
},
});
@@ -1,32 +1,54 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceGitClient } from "./workspace-git";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createBranchSwitcherOperations } from "./branch-switcher-operations";
function createRecordingClient() {
const calls: string[] = [];
const cwds: string[] = [];
const client = {
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 };
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 };
}
describe("createBranchSwitcherOperations", () => {
it("binds every operation to the selected workspace identity", async () => {
const { client, calls } = createRecordingClient();
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();
const operations = createBranchSwitcherOperations(client);
const operations = createBranchSwitcherOperations(client, workspaceDirectory);
await operations.getBranchSuggestions(200);
await operations.listPaseoStashes();
await operations.saveStash("main");
await operations.popStash(0);
await operations.switchBranch("feature");
expect(calls).toEqual(["suggest", "list", "save", "pop", "switch"]);
expect(cwds).toEqual([
workspaceDirectory,
workspaceDirectory,
workspaceDirectory,
workspaceDirectory,
workspaceDirectory,
]);
expect(cwds).not.toContain(workspaceId);
});
});
@@ -1,15 +1,15 @@
import type { WorkspaceGitClient } from "./workspace-git";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
// 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(workspaceGit: WorkspaceGitClient) {
export function createBranchSwitcherOperations(client: DaemonClient, cwd: string) {
return {
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),
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),
};
}
@@ -2,129 +2,332 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
import { checkoutCommitsQueryKey, checkoutStatusQueryKey } from "@/git/query-keys";
import {
checkoutCommitsQueryKey,
checkoutPrStatusQueryKey,
checkoutStatusQueryKey,
} from "@/git/query-keys";
import {
prPanePipelineQueryKey,
prPaneTimelineQueryKey,
} from "@/git/pull-request-panel/query-keys";
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
import {
applyCheckoutStatusUpdateFromEvent,
ensureCheckoutStatus,
fetchCheckoutStatus,
type CheckoutPrStatusPayload,
type CheckoutStatusPayload,
fetchCheckoutStatus,
} from "./checkout-status-cache";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() },
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined),
},
}));
const serverId = "server-1";
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,
};
const cwd = "/repo";
function status(
target: { workspaceId: string; cwd: string } = targetA,
branch = "main",
isDirty = false,
): CheckoutStatusPayload {
function checkoutStatus(overrides: Partial<CheckoutStatusPayload> = {}): CheckoutStatusPayload {
return {
workspaceId: target.workspaceId,
cwd: target.cwd,
cwd,
error: null,
requestId: `status-${target.workspaceId}`,
requestId: "checkout-status-1",
isGit: true,
isPaseoOwnedWorktree: false,
repoRoot: target.cwd,
mainRepoRoot: null,
currentBranch: branch,
isDirty,
repoRoot: cwd,
currentBranch: "main",
isDirty: false,
baseRef: "origin/main",
aheadBehind: { ahead: 0, behind: 0 },
aheadOfOrigin: 0,
behindOfOrigin: 0,
hasRemote: true,
remoteUrl: null,
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,
};
}
function update(payload: CheckoutStatusPayload): CheckoutStatusUpdate {
return { type: "checkout_status_update", payload };
function checkoutStatusUpdate(
payload: CheckoutStatusPayload,
extraPrStatus?: NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>,
): CheckoutStatusUpdate {
return {
type: "checkout_status_update",
payload: extraPrStatus ? { ...payload, prStatus: extraPrStatus } : payload,
};
}
beforeEach(resetReviewDraftStore);
function setDiffModeOverride(isDirtyAtSelection: boolean): void {
useReviewDraftStore.getState().setDiffModeOverride({
scopeKey: "review:scope",
override: { serverId, cwd, mode: "base", isDirtyAtSelection },
});
}
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();
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);
});
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 second = ensureCheckoutStatus({
queryClient: client,
client: { getStatus },
serverId,
target: targetA,
});
await Promise.all([first, second]);
expect(getStatus).toHaveBeenCalledTimes(1);
});
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 })) };
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: [] });
await fetchCheckoutStatus({ client, serverId, cwd });
applyCheckoutStatusUpdateFromEvent({
queryClient: client,
serverId,
message: update(status(targetA, "a-next", true)),
});
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(client.getQueryState(checkoutCommitsQueryKey(serverId, targetB))?.isInvalidated).toBe(
false,
);
});
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: new QueryClient(),
serverId,
message: update(status(targetA, "main", true)),
});
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",
});
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);
});
});
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 });
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(pushed),
});
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(
true,
);
expect(
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, "/repo2"))?.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" });
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus(), pushedPr),
});
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);
});
});
+12 -50
View File
@@ -5,21 +5,15 @@ 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>;
}
@@ -30,18 +24,14 @@ export interface LegacyCheckoutStatusClient {
export async function fetchCheckoutStatus({
client,
serverId,
target,
cwd,
}: {
client: CheckoutStatusClient;
serverId: string;
target: WorkspaceGitStatusTarget;
cwd: string;
}): Promise<CheckoutStatusPayload> {
const payload = await client.getStatus();
expireStaleDiffModeOverrides({
serverId,
cwd: target.cwd,
isDirty: payload.isGit && payload.isDirty,
});
const payload = await client.getCheckoutStatus(cwd);
expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
return payload;
}
@@ -49,38 +39,16 @@ export async function ensureCheckoutStatus({
queryClient,
client,
serverId,
target,
cwd,
}: {
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: legacyCheckoutStatusQueryKey(serverId, cwd),
queryFn: async () => {
const payload = await client.getCheckoutStatus(cwd);
expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
return payload;
},
queryKey: checkoutStatusQueryKey(serverId, cwd),
queryFn: () => fetchCheckoutStatus({ client, serverId, cwd }),
staleTime: Infinity,
});
}
@@ -99,15 +67,9 @@ export function applyCheckoutStatusUpdateFromEvent({
? normalizeCheckoutPrStatusPayload(payload.prStatus)
: undefined;
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
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);
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
void queryClient.invalidateQueries({
queryKey: checkoutCommitsQueryKey(serverId, target),
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
});
expireStaleDiffModeOverrides({
serverId,
@@ -120,14 +82,14 @@ export function applyCheckoutStatusUpdateFromEvent({
}
const previous = queryClient.getQueryData<CheckoutPrStatusPayload>(
checkoutPrStatusQueryKey(serverId, target),
checkoutPrStatusQueryKey(serverId, prStatus.cwd),
);
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, target), prStatus);
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, prStatus.cwd), 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, target });
void invalidatePrPaneTimelineForCheckout(queryClient, { serverId, cwd: prStatus.cwd });
}
}
@@ -66,7 +66,6 @@ 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,6 +11,7 @@ import { CommitRow } from "./commit-row";
interface CommitsSectionProps {
serverId: string;
cwd: string;
onCommitPress: (sha: string) => void;
}
@@ -81,7 +82,7 @@ function CommitsSectionContent({
);
}
export function CommitsSection({ serverId, onCommitPress }: CommitsSectionProps) {
export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) {
const { t } = useTranslation();
const { preferences, updatePreferences } = useChangesPreferences();
const isPanelActive = useRetainedPanelActive();
@@ -90,6 +91,7 @@ export function CommitsSection({ serverId, onCommitPress }: CommitsSectionProps)
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
const query = useCheckoutCommitsQuery({
serverId,
cwd,
enabled: !collapsed,
});
+227 -75
View File
@@ -4,6 +4,7 @@ import {
useEffect,
useMemo,
useRef,
useSyncExternalStore,
memo,
type ReactElement,
type ReactNode,
@@ -43,6 +44,12 @@ import {
} from "lucide-react-native";
import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query";
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items";
import {
createDiffRowWindow,
createDiffViewport,
type DiffRowWindow,
type DiffViewport,
} from "@/git/diff-virtualization";
import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree";
import { DiffFolderRow } from "@/git/diff-folder-row";
import {
@@ -93,7 +100,6 @@ 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";
@@ -105,6 +111,7 @@ import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layo
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import {
compactHighlightTokens,
formatDiffContentText,
formatDiffGutterText,
hasVisibleDiffTokens,
@@ -181,7 +188,7 @@ function HighlightedToken({ token }: { token: HighlightToken }) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
}
function HighlightedText({
const HighlightedText = memo(function HighlightedText({
tokens,
textMetricsStyle,
wrapLines = false,
@@ -197,10 +204,14 @@ function HighlightedText({
[textMetricsStyle, wrapLines],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
);
const keyedTokens = useMemo(() => {
let characterOffset = 0;
return compactHighlightTokens(tokens).map((token) => {
const keyedToken = { key: `${characterOffset}-${token.style ?? "base"}`, token };
characterOffset += token.text.length;
return keyedToken;
});
}, [tokens]);
return (
<Text style={containerStyle} testID={testID}>
@@ -209,7 +220,7 @@ function HighlightedText({
))}
</Text>
);
}
});
interface DiffFileSectionProps {
file: ParsedDiffFile;
@@ -242,14 +253,15 @@ 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 +282,7 @@ function useDiscardChangesAction({
try {
await discardChanges({
serverId,
target: workspaceGit,
cwd,
paths: oldPath ? [path, oldPath] : [path],
});
} catch (cause) {
@@ -279,7 +291,7 @@ function useDiscardChangesAction({
);
}
},
[discardChanges, serverId, t, toast, workspaceGit],
[cwd, discardChanges, serverId, t, toast],
);
const handleDiscardPath = useCallback(
(path: string, oldPath?: string) => {
@@ -804,8 +816,53 @@ function InlineReviewRow({
);
}
const DIFF_ROW_OVERSCAN = 900;
const FULL_DIFF_VIEWPORT = { top: 0, height: Number.MAX_SAFE_INTEGER };
const subscribeToNothing = () => () => {};
const getFullDiffViewport = () => FULL_DIFF_VIEWPORT;
function useDiffRowWindow({
viewport,
bodyOffset,
rowHeights,
enabled,
}: {
viewport?: DiffViewport;
bodyOffset: number;
rowHeights: readonly number[];
enabled: boolean;
}): DiffRowWindow {
const viewportSnapshot = useSyncExternalStore(
viewport?.subscribe ?? subscribeToNothing,
viewport?.getSnapshot ?? getFullDiffViewport,
viewport?.getSnapshot ?? getFullDiffViewport,
);
return useMemo(() => {
if (!enabled) {
return createDiffRowWindow(rowHeights, {
viewportTop: 0,
viewportHeight: Number.MAX_SAFE_INTEGER,
overscan: 0,
});
}
return createDiffRowWindow(rowHeights, {
viewportTop: viewportSnapshot.top - bodyOffset - BORDER_WIDTH[1],
viewportHeight: viewportSnapshot.height,
overscan: DIFF_ROW_OVERSCAN,
});
}, [bodyOffset, enabled, rowHeights, viewportSnapshot.height, viewportSnapshot.top]);
}
function DiffRowSpacer({ height }: { height: number }) {
if (height <= 0) {
return null;
}
return <View style={inlineUnistylesStyle({ height })} />;
}
function SplitDiffColumn({
rows,
rowWindow,
side,
gutterWidth,
wrapLines,
@@ -814,6 +871,7 @@ function SplitDiffColumn({
showDivider = false,
}: {
rows: SplitDiffRow[];
rowWindow: DiffRowWindow;
side: "left" | "right";
gutterWidth: number;
wrapLines: boolean;
@@ -844,12 +902,20 @@ function SplitDiffColumn({
[textMetricsStyle],
);
const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]);
const keyedRows = useMemo(
() =>
rows.slice(rowWindow.startIndex, rowWindow.endIndex).map((row, index) => ({
key: `row-${rowWindow.startIndex + index}`,
row,
})),
[rowWindow.endIndex, rowWindow.startIndex, rows],
);
if (wrapLines) {
return (
<View style={wrapCellStyle}>
<View style={styles.linesContainer}>
<DiffRowSpacer height={rowWindow.beforeHeight} />
{keyedRows.map(({ key, row }) => {
if (row.kind === "header") {
return (
@@ -882,6 +948,7 @@ function SplitDiffColumn({
</View>
);
})}
<DiffRowSpacer height={rowWindow.afterHeight} />
</View>
</View>
);
@@ -890,6 +957,7 @@ function SplitDiffColumn({
return (
<View style={rowCellStyle}>
<View style={styles.gutterColumn}>
<DiffRowSpacer height={rowWindow.beforeHeight} />
{keyedRows.map(({ key, row }) => {
if (row.kind === "header") {
return (
@@ -931,6 +999,7 @@ function SplitDiffColumn({
</View>
);
})}
<DiffRowSpacer height={rowWindow.afterHeight} />
</View>
<DiffScroll
scrollViewWidth={scrollWidth}
@@ -939,6 +1008,7 @@ function SplitDiffColumn({
contentContainerStyle={styles.diffContentInner}
>
<View style={linesContainerRowStyle}>
<DiffRowSpacer height={rowWindow.beforeHeight} />
{keyedRows.map(({ key, row }) => {
if (row.kind === "header") {
return (
@@ -974,6 +1044,7 @@ function SplitDiffColumn({
</View>
);
})}
<DiffRowSpacer height={rowWindow.afterHeight} />
</View>
</DiffScroll>
</View>
@@ -1255,6 +1326,8 @@ const DiffFileHeader = memo(function DiffFileHeader({
export function DiffFileBody({
file,
viewport,
bodyOffset = 0,
layout,
wrapLines,
codeFontSize,
@@ -1264,6 +1337,8 @@ export function DiffFileBody({
testID,
}: {
file: ParsedDiffFile;
viewport?: DiffViewport;
bodyOffset?: number;
layout: "unified" | "split";
wrapLines: boolean;
codeFontSize: number;
@@ -1276,6 +1351,50 @@ export function DiffFileBody({
const [bodyWidth, setBodyWidth] = useState(0);
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
const { t } = useTranslation();
const canRenderRows = file.status !== "too_large" && file.status !== "binary";
const splitRows = useMemo(
() => (canRenderRows && layout === "split" ? getCachedSplitDiffRows(file) : []),
[canRenderRows, file, layout],
);
const unifiedLines = useMemo(
() => (canRenderRows && layout === "unified" ? getCachedUnifiedDiffLines(file) : []),
[canRenderRows, file, layout],
);
const rowHeights = useMemo(() => {
const lineHeight = getNumericLineHeight(textMetricsStyle) ?? Math.round(codeFontSize * 1.5);
if (layout === "split") {
return splitRows.map((row) => {
if (row.kind === "header") {
return lineHeight;
}
return (
lineHeight +
(getSplitInlineReviewThreadState({
left: row.left?.reviewTarget,
right: row.right?.reviewTarget,
reviewActions,
})?.height ?? 0)
);
});
}
return unifiedLines.map(
({ reviewTarget }) =>
lineHeight + (getInlineReviewThreadState({ reviewTarget, reviewActions })?.height ?? 0),
);
}, [codeFontSize, layout, reviewActions, splitRows, textMetricsStyle, unifiedLines]);
const rowWindow = useDiffRowWindow({
viewport,
bodyOffset,
rowHeights,
enabled: Boolean(viewport) && canRenderRows && !wrapLines,
});
const visibleUnifiedLines = useMemo(
() =>
unifiedLines
.slice(rowWindow.startIndex, rowWindow.endIndex)
.map((entry, index) => ({ entry, index: rowWindow.startIndex + index })),
[rowWindow.endIndex, rowWindow.startIndex, unifiedLines],
);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
@@ -1324,11 +1443,11 @@ export function DiffFileBody({
const gutterWidth = lineNumberGutterWidth(maxLineNo, codeFontSize);
if (layout === "split") {
const rows = buildSplitDiffRows(file);
return (
<View style={[styles.diffContent, styles.splitRow]} dataSet={CODE_SURFACE_DATASET}>
<SplitDiffColumn
rows={rows}
rows={splitRows}
rowWindow={rowWindow}
side="left"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
@@ -1336,7 +1455,8 @@ export function DiffFileBody({
reviewActions={reviewActions}
/>
<SplitDiffColumn
rows={rows}
rows={splitRows}
rowWindow={rowWindow}
side="right"
gutterWidth={gutterWidth}
wrapLines={wrapLines}
@@ -1348,30 +1468,32 @@ export function DiffFileBody({
);
}
const computedLines = buildUnifiedDiffLines(file);
if (wrapLines) {
return (
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.linesContainer}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => (
<View key={key} testID={`diff-wrapped-row-${index}`}>
<DiffLineView
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
/>
<InlineReviewRow
reviewTarget={reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
/>
</View>
))}
<DiffRowSpacer height={rowWindow.beforeHeight} />
{visibleUnifiedLines.map(
({ entry: { line, lineNumber, key, reviewTarget }, index }) => (
<View key={key} testID={`diff-wrapped-row-${index}`}>
<DiffLineView
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
/>
<InlineReviewRow
reviewTarget={reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
/>
</View>
),
)}
<DiffRowSpacer height={rowWindow.afterHeight} />
</View>
</View>
);
@@ -1382,28 +1504,33 @@ export function DiffFileBody({
return (
<View style={[styles.diffContent, styles.diffContentRow]} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.gutterColumn}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => (
<View key={key} testID={`diff-gutter-row-${index}`}>
<DiffGutterCell
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
isLineHovered={
reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key
}
textTestID={`diff-gutter-text-${index}`}
actionTestID={`diff-gutter-action-${index}`}
/>
<InlineReviewGutterSpacer
reviewTarget={reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
/>
</View>
))}
<DiffRowSpacer height={rowWindow.beforeHeight} />
{visibleUnifiedLines.map(
({ entry: { line, lineNumber, key, reviewTarget }, index }) => (
<View key={key} testID={`diff-gutter-row-${index}`}>
<DiffGutterCell
lineNumber={lineNumber}
type={line.type}
gutterWidth={gutterWidth}
textMetricsStyle={textMetricsStyle}
reviewTarget={reviewTarget}
reviewActions={reviewActions}
isLineHovered={
reviewTarget?.key !== undefined &&
hoveredReviewTargetKey === reviewTarget.key
}
textTestID={`diff-gutter-text-${index}`}
actionTestID={`diff-gutter-action-${index}`}
/>
<InlineReviewGutterSpacer
reviewTarget={reviewTarget}
reviewActions={reviewActions}
gutterWidth={gutterWidth}
/>
</View>
),
)}
<DiffRowSpacer height={rowWindow.afterHeight} />
</View>
<DiffScroll
scrollViewWidth={scrollViewWidth}
@@ -1412,7 +1539,8 @@ export function DiffFileBody({
contentContainerStyle={styles.diffContentInner}
>
<View style={linesContainerRowStyle}>
{computedLines.map(({ line, key, reviewTarget }, index) => (
<DiffRowSpacer height={rowWindow.beforeHeight} />
{visibleUnifiedLines.map(({ entry: { line, key, reviewTarget }, index }) => (
<View key={key} testID={`diff-code-row-${index}`}>
<DiffTextLine
line={line}
@@ -1432,6 +1560,7 @@ export function DiffFileBody({
/>
</View>
))}
<DiffRowSpacer height={rowWindow.afterHeight} />
</View>
</DiffScroll>
</View>
@@ -1828,7 +1957,9 @@ const EMPTY_PATH_LIST: string[] = [];
interface DiffFileMetrics {
contentLength: number;
splitRows?: SplitDiffRow[];
splitLineCount?: number;
unifiedLines?: ReturnType<typeof buildUnifiedDiffLines>;
unifiedLineCount: number;
}
@@ -1855,11 +1986,23 @@ function getDiffFileMetrics(file: ParsedDiffFile): DiffFileMetrics {
function getSplitDiffLineCount(file: ParsedDiffFile): number {
const metrics = getDiffFileMetrics(file);
if (metrics.splitLineCount === undefined) {
metrics.splitLineCount = buildSplitDiffRows(file).length;
metrics.splitLineCount = getCachedSplitDiffRows(file).length;
}
return metrics.splitLineCount;
}
function getCachedSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
const metrics = getDiffFileMetrics(file);
metrics.splitRows ??= buildSplitDiffRows(file);
return metrics.splitRows;
}
function getCachedUnifiedDiffLines(file: ParsedDiffFile): ReturnType<typeof buildUnifiedDiffLines> {
const metrics = getDiffFileMetrics(file);
metrics.unifiedLines ??= buildUnifiedDiffLines(file);
return metrics.unifiedLines;
}
function computeEmptyMessage(
hideWhitespace: boolean,
diffMode: "uncommitted" | "base",
@@ -2062,6 +2205,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
[allFolderPathSet, collapsedFolders],
);
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const diffViewport = useMemo(() => createDiffViewport(), []);
useEffect(() => () => diffViewport.dispose(), [diffViewport]);
const scrollbar = useOverlayFlatListScrollbar(diffListRef, { enabled: !isCompact });
const { onLayout: updateScrollbarLayout, onScroll: updateScrollbarOffset } = scrollbar;
const consumedFocusRequestRef = useRef<string | null>(null);
@@ -2216,9 +2361,13 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
const handleDiffListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
diffViewport.update({
top: diffListScrollOffsetRef.current,
height: diffListViewportHeightRef.current,
});
updateScrollbarOffset(event);
},
[updateScrollbarOffset],
[diffViewport, updateScrollbarOffset],
);
const handleDiffListLayout = useCallback(
@@ -2228,9 +2377,10 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
return;
}
diffListViewportHeightRef.current = height;
diffViewport.update({ top: diffListScrollOffsetRef.current, height });
updateScrollbarLayout(event);
},
[updateScrollbarLayout],
[diffViewport, updateScrollbarLayout],
);
const computeItemOffset = useCallback(
@@ -2387,7 +2537,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
({ item, index }: { item: DiffFlatItem; index: number }) => {
if (item.type === "folder") {
return (
<DiffFolderRow
@@ -2441,6 +2591,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
return (
<DiffFileBody
file={item.file}
viewport={diffViewport}
bodyOffset={sumHeightsBefore(flatItems, index, getFlatItemHeight)}
layout={layout}
wrapLines={wrapLines}
codeFontSize={codeFontSize}
@@ -2453,6 +2605,9 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
},
[
codeFontSize,
diffViewport,
flatItems,
getFlatItemHeight,
handleBodyHeightChange,
handleFolderRowHeightChange,
handleHeaderHeightChange,
@@ -2848,7 +3003,6 @@ export function GitDiffPane({
onOpenFile,
onAddToChat,
}: GitDiffPaneProps) {
const workspaceGit = useRequiredWorkspaceGit();
const { settings: appSettings } = useAppSettings();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
@@ -2910,22 +3064,17 @@ export function GitDiffPane({
);
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
const isRefreshing =
useCheckoutGitActionsStore((s) =>
s.getStatus({
serverId,
target: workspaceGit,
actionId: "refresh",
}),
) === "pending";
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
"pending";
const handleRefresh = useCallback(() => {
if (isRefreshing) {
return;
}
void runRefresh({ serverId, target: workspaceGit }).catch((error) => {
void runRefresh({ serverId, cwd }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [isRefreshing, runRefresh, serverId, t, toast, workspaceGit]);
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
const {
status,
@@ -2946,7 +3095,7 @@ export function GitDiffPane({
reviewAttachment,
} = useWorkingDiff({
serverId,
workspaceId: workspaceGit.workspaceId,
workspaceId: workspaceId ?? undefined,
cwd,
ignoreWhitespace: changesPreferences.hideWhitespace,
enabled: enabled !== false,
@@ -2965,6 +3114,7 @@ export function GitDiffPane({
payloadError: prPayloadError,
} = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
});
const forgeProvidersSupported = useSessionStore(
@@ -2992,7 +3142,7 @@ export function GitDiffPane({
[updateChangesPreferences],
);
const changesTree = useChangesTreeState({
workspaceId: workspaceGit.workspaceId,
workspaceId,
cwd,
files,
viewMode,
@@ -3061,7 +3211,7 @@ export function GitDiffPane({
},
[client, cwd, t, toast],
);
const onRevertPath = useDiscardChangesAction({ serverId, diffMode });
const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode });
const workingTreeMode = useMemo(
() => ({
kind: "working_tree" as const,
@@ -3166,6 +3316,8 @@ export function GitDiffPane({
<BranchSwitcher
currentBranchName={currentBranchName}
serverId={serverId}
workspaceId={workspaceId ?? cwd}
workspaceDirectory={cwd}
isGitCheckout={isGit}
testID="changes-branch-switcher"
/>
@@ -3239,7 +3391,7 @@ export function GitDiffPane({
<View style={styles.diffContainer}>{bodyContent}</View>
<CommitsSection serverId={serverId} onCommitPress={handleCommitPress} />
<CommitsSection serverId={serverId} cwd={cwd} onCommitPress={handleCommitPress} />
</View>
);
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { createDiffRowWindow } from "./diff-virtualization";
describe("createDiffRowWindow", () => {
it("renders only the viewport and overscan while preserving the full height", () => {
const rows = Array.from({ length: 1_200 }, () => 18);
const window = createDiffRowWindow(rows, {
viewportTop: 0,
viewportHeight: 900,
overscan: 900,
});
expect(window.startIndex).toBe(0);
expect(window.endIndex).toBe(100);
expect(window.beforeHeight).toBe(0);
expect(window.afterHeight).toBe(19_800);
expect(window.beforeHeight + window.renderedHeight + window.afterHeight).toBe(21_600);
});
it("finds a deep variable-height window without replaying earlier rows", () => {
const rows = [18, 18, 90, 18, 150, 18, 18];
const window = createDiffRowWindow(rows, {
viewportTop: 120,
viewportHeight: 60,
overscan: 0,
});
expect(window).toEqual({
startIndex: 2,
endIndex: 5,
beforeHeight: 36,
renderedHeight: 258,
afterHeight: 36,
totalHeight: 330,
});
});
it("returns an empty row window when an overscanned body is still offscreen", () => {
const window = createDiffRowWindow([18, 18, 18], {
viewportTop: -2_000,
viewportHeight: 800,
overscan: 600,
});
expect(window).toEqual({
startIndex: 0,
endIndex: 0,
beforeHeight: 0,
renderedHeight: 0,
afterHeight: 54,
totalHeight: 54,
});
});
});
+143
View File
@@ -0,0 +1,143 @@
export interface DiffViewportWindow {
viewportTop: number;
viewportHeight: number;
overscan: number;
}
export interface DiffRowWindow {
startIndex: number;
endIndex: number;
beforeHeight: number;
renderedHeight: number;
afterHeight: number;
totalHeight: number;
}
export interface DiffViewportSnapshot {
top: number;
height: number;
}
export interface DiffViewport {
getSnapshot: () => DiffViewportSnapshot;
subscribe: (listener: () => void) => () => void;
update: (snapshot: DiffViewportSnapshot) => void;
dispose: () => void;
}
/** Coalesce native/web scroll events so diff bodies select rows at most once per frame. */
export function createDiffViewport(): DiffViewport {
let snapshot: DiffViewportSnapshot = { top: 0, height: 0 };
let pending = snapshot;
let frame: number | null = null;
const listeners = new Set<() => void>();
const flush = () => {
frame = null;
if (pending.top === snapshot.top && pending.height === snapshot.height) {
return;
}
snapshot = pending;
for (const listener of listeners) {
listener();
}
};
return {
getSnapshot: () => snapshot,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
update(nextSnapshot) {
pending = nextSnapshot;
frame ??= requestAnimationFrame(flush);
},
dispose() {
if (frame !== null) {
cancelAnimationFrame(frame);
}
frame = null;
listeners.clear();
},
};
}
/**
* Select the contiguous diff rows that intersect a viewport while reserving
* exact spacer heights for everything outside it.
*/
export function createDiffRowWindow(
rowHeights: readonly number[],
viewport: DiffViewportWindow,
): DiffRowWindow {
const offsets = Array.from({ length: rowHeights.length + 1 }, () => 0);
offsets[0] = 0;
for (let index = 0; index < rowHeights.length; index += 1) {
offsets[index + 1] = offsets[index] + rowHeights[index];
}
const totalHeight = offsets[rowHeights.length];
const visibleTop = viewport.viewportTop - viewport.overscan;
const visibleBottom = viewport.viewportTop + viewport.viewportHeight + viewport.overscan;
if (visibleBottom <= 0) {
return emptyWindow(0, 0, totalHeight);
}
if (visibleTop >= totalHeight) {
return emptyWindow(rowHeights.length, totalHeight, 0);
}
const startIndex = firstIndexWhoseEndExceeds(offsets, Math.max(0, visibleTop));
const endIndex = firstIndexWhoseStartReaches(offsets, Math.min(totalHeight, visibleBottom));
const beforeHeight = offsets[startIndex];
const renderedHeight = offsets[endIndex] - beforeHeight;
return {
startIndex,
endIndex,
beforeHeight,
renderedHeight,
afterHeight: totalHeight - offsets[endIndex],
totalHeight,
};
}
function emptyWindow(index: number, beforeHeight: number, afterHeight: number): DiffRowWindow {
return {
startIndex: index,
endIndex: index,
beforeHeight,
renderedHeight: 0,
afterHeight,
totalHeight: beforeHeight + afterHeight,
};
}
function firstIndexWhoseEndExceeds(offsets: readonly number[], target: number): number {
let low = 1;
let high = offsets.length;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if ((offsets[middle] ?? Number.POSITIVE_INFINITY) > target) {
high = middle;
} else {
low = middle + 1;
}
}
return Math.min(offsets.length - 1, low - 1);
}
function firstIndexWhoseStartReaches(offsets: readonly number[], target: number): number {
let low = 0;
let high = offsets.length - 1;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (offsets[middle] >= target) {
high = middle;
} else {
low = middle + 1;
}
}
return low;
}
@@ -54,6 +54,7 @@ function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotC
return (
<GitLabPipelineSection
serverId={ctx.serverId}
cwd={ctx.cwd}
changeRequestNumber={ctx.changeRequestNumber}
summary={summary}
open={ctx.open}
@@ -117,6 +118,7 @@ function countPipelineJobs(jobs: CheckoutPipelineJob[]): PipelineJobCounts {
function GitLabPipelineSection({
serverId,
cwd,
changeRequestNumber,
summary,
open,
@@ -124,6 +126,7 @@ function GitLabPipelineSection({
canFetchCheckDetails,
}: {
serverId: string;
cwd: string;
changeRequestNumber: number;
summary: GitlabPipelineSummary;
open: boolean;
@@ -133,6 +136,7 @@ function GitLabPipelineSection({
const { t } = useTranslation();
const { pipeline, isLoading, isPlaceholderData, error } = useGitLabPipeline({
serverId,
cwd,
pipelineId: summary.id,
changeRequestNumber,
enabled: open && canFetchCheckDetails,
@@ -1,44 +0,0 @@
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,
});
});
@@ -1,32 +0,0 @@
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,7 +67,6 @@ 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 {
@@ -206,14 +205,12 @@ 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;
@@ -221,7 +218,7 @@ export function PullRequestPane({
}) {
const { t } = useTranslation();
const toast = useToast();
const workspaceGit = useRequiredWorkspaceGit();
const daemonClient = useHostRuntimeClient(serverId);
// 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(
@@ -251,17 +248,17 @@ export function PullRequestPane({
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
const isRefreshing =
useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, target: workspaceGit, actionId: "refresh" }),
state.getStatus({ serverId, cwd, actionId: "refresh" }),
) === "pending";
const handleRefresh = useCallback(() => {
if (isRefreshing) {
return;
}
void runRefresh({ serverId, target: workspaceGit }).catch((error) => {
void runRefresh({ serverId, cwd }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [isRefreshing, runRefresh, serverId, t, toast, workspaceGit]);
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
const handleToggleChecks = useCallback(() => {
setChecksOpen((open) => !open);
@@ -402,21 +399,29 @@ export function PullRequestPane({
canFetchForgeCheckDetails || (check.provider === "github" && canFetchGitHubCheckDetails);
if (
canFetchDetail &&
daemonClient &&
(ref?.checkRunId !== undefined || ref?.workflowRunId !== undefined) &&
data.repoOwner &&
data.repoName
) {
// 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,
});
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;
}
}
const attachment = buildPullRequestCheckContextAttachment({
provider: data.provider,
@@ -439,6 +444,8 @@ export function PullRequestPane({
addWorkspaceAttachment,
canFetchForgeCheckDetails,
canFetchGitHubCheckDetails,
cwd,
daemonClient,
data.forge,
data.number,
data.provider,
@@ -447,7 +454,6 @@ export function PullRequestPane({
data.title,
data.url,
workspaceAttachmentScopeKey,
workspaceGit,
],
);
@@ -477,7 +483,6 @@ export function PullRequestPane({
const nativeChecksSection = data.forgeSpecific
? nativeContribution?.renderChecksSection(data.forgeSpecific, {
serverId,
workspaceId,
cwd,
changeRequestNumber: data.number,
open: checksOpen,
@@ -2,40 +2,29 @@ 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, queryIdentity, cwd, prNumber] as const;
return [prPaneTimelineQueryKind, serverId, 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,
queryIdentity,
cwd,
pipelineId,
changeRequestNumber,
] as const;
return [prPanePipelineQueryKind, serverId, cwd, pipelineId, changeRequestNumber] as const;
}
@@ -16,8 +16,7 @@ import {
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
type PullRequestTimelineInput = Parameters<PrPaneTimelineClient["getPullRequestTimeline"]>[0];
const workspaceId = "workspace-1";
type PullRequestTimelineInput = Parameters<PrPaneTimelineClient["pullRequestTimeline"]>[0];
const githubStatus: CheckoutPrStatus["github"] = {
mergeStateStatus: null,
@@ -83,7 +82,7 @@ function createTimelineClient(
const calls: PullRequestTimelineInput[] = [];
return {
calls,
getPullRequestTimeline: async (input) => {
pullRequestTimeline: async (input) => {
calls.push(input);
return respond(input);
},
@@ -182,12 +181,7 @@ describe("shouldFetchTimelineFrom", () => {
describe("createInMemoryUnsupportedTimelineRegistry", () => {
it("remembers added keys and answers has() against them", () => {
const registry = createInMemoryUnsupportedTimelineRegistry();
const key = unsupportedTimelineKey({
serverId: "host",
workspaceId,
cwd: "/repo",
prNumber: 99,
});
const key = unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 });
expect(registry.has(key)).toBe(false);
registry.add(key);
@@ -195,20 +189,14 @@ describe("createInMemoryUnsupportedTimelineRegistry", () => {
});
it("uses serverId, cwd, and prNumber to scope each key", () => {
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-a", cwd: "/repo", prNumber: 1 })).not.toEqual(
unsupportedTimelineKey({ serverId: "host-b", cwd: "/repo", 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-a", prNumber: 1 })).not.toEqual(
unsupportedTimelineKey({ serverId: "host", cwd: "/repo-b", prNumber: 1 }),
);
expect(
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 1 }),
).not.toEqual(
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 2 }),
expect(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 1 })).not.toEqual(
unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 2 }),
);
});
});
@@ -222,14 +210,15 @@ describe("fetchPrPaneTimelinePage", () => {
client,
registry,
serverId: "host",
workspaceId,
cwd: "/repo",
prNumber: 42,
repoOwner: "getpaseo",
repoName: "paseo",
});
expect(client.calls).toEqual([{ prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" }]);
expect(client.calls).toEqual([
{ cwd: "/repo", prNumber: 42, repoOwner: "getpaseo", repoName: "paseo" },
]);
});
it("returns the daemon's timeline payload on success", async () => {
@@ -252,7 +241,6 @@ describe("fetchPrPaneTimelinePage", () => {
client,
registry,
serverId: "host",
workspaceId,
cwd: "/repo",
prNumber: 42,
repoOwner: "getpaseo",
@@ -274,7 +262,6 @@ describe("fetchPrPaneTimelinePage", () => {
client,
registry,
serverId: "host",
workspaceId,
cwd: "/repo",
prNumber: 99,
repoOwner: "getpaseo",
@@ -283,9 +270,7 @@ describe("fetchPrPaneTimelinePage", () => {
).rejects.toBe(error);
expect(
registry.has(
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 99 }),
),
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo", prNumber: 99 })),
).toBe(true);
});
@@ -301,7 +286,6 @@ describe("fetchPrPaneTimelinePage", () => {
client,
registry,
serverId: "host",
workspaceId,
cwd: "/repo",
prNumber: 99,
repoOwner: "getpaseo",
@@ -310,18 +294,16 @@ describe("fetchPrPaneTimelinePage", () => {
).rejects.toBe(error);
expect(
registry.has(
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo", prNumber: 99 }),
),
registry.has(unsupportedTimelineKey({ serverId: "host", 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.prNumber === 1) {
if (input.cwd === "/repo-a") {
throw unsupportedTimelineError();
}
return timelinePayload({ cwd: "/repo-b", prNumber: input.prNumber });
return timelinePayload({ cwd: input.cwd, prNumber: input.prNumber });
});
const registry = createInMemoryUnsupportedTimelineRegistry();
@@ -330,7 +312,6 @@ describe("fetchPrPaneTimelinePage", () => {
client,
registry,
serverId: "host",
workspaceId,
cwd: "/repo-a",
prNumber: 1,
repoOwner: "getpaseo",
@@ -342,7 +323,6 @@ describe("fetchPrPaneTimelinePage", () => {
client,
registry,
serverId: "host",
workspaceId,
cwd: "/repo-b",
prNumber: 2,
repoOwner: "getpaseo",
@@ -351,14 +331,10 @@ describe("fetchPrPaneTimelinePage", () => {
expect(result.prNumber).toBe(2);
expect(
registry.has(
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-a", prNumber: 1 }),
),
registry.has(unsupportedTimelineKey({ serverId: "host", cwd: "/repo-a", prNumber: 1 })),
).toBe(true);
expect(
registry.has(
unsupportedTimelineKey({ serverId: "host", workspaceId, cwd: "/repo-b", prNumber: 2 }),
),
registry.has(unsupportedTimelineKey({ serverId: "host", 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 { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@getpaseo/protocol/messages";
import { type WorkspaceGitClient, useRequiredWorkspaceGit } from "@/git/workspace-git";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import type { Forge } from "@/git/forge";
import { i18n } from "@/i18n/i18next";
@@ -19,6 +19,7 @@ type PullRequestTimeline = PullRequestTimelineResponse["payload"];
export interface UsePrPaneDataOptions {
serverId: string;
cwd: string;
enabled?: boolean;
timelineEnabled?: boolean;
}
@@ -79,7 +80,7 @@ export function shouldFetchTimelineFrom({
);
}
export type PrPaneTimelineClient = Pick<WorkspaceGitClient, "getPullRequestTimeline">;
export type PrPaneTimelineClient = Pick<DaemonClient, "pullRequestTimeline">;
export interface UnsupportedTimelineRegistry {
has(key: string): boolean;
@@ -88,16 +89,14 @@ export interface UnsupportedTimelineRegistry {
export function unsupportedTimelineKey({
serverId,
workspaceId,
cwd,
prNumber,
}: {
serverId: string;
workspaceId: string;
cwd: string;
prNumber: number;
}): string {
return `${serverId}\0${workspaceId}\0${cwd}\0${prNumber}`;
return `${serverId}\0${cwd}\0${prNumber}`;
}
export function createInMemoryUnsupportedTimelineRegistry(): UnsupportedTimelineRegistry {
@@ -116,7 +115,6 @@ export interface FetchPrPaneTimelinePageInput {
client: PrPaneTimelineClient;
registry: UnsupportedTimelineRegistry;
serverId: string;
workspaceId: string;
cwd: string;
prNumber: number;
repoOwner: string;
@@ -127,7 +125,8 @@ export async function fetchPrPaneTimelinePage(
input: FetchPrPaneTimelinePageInput,
): Promise<PullRequestTimeline> {
try {
return await input.client.getPullRequestTimeline({
return await input.client.pullRequestTimeline({
cwd: input.cwd,
prNumber: input.prNumber,
repoOwner: input.repoOwner,
repoName: input.repoName,
@@ -137,7 +136,6 @@ export async function fetchPrPaneTimelinePage(
input.registry.add(
unsupportedTimelineKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
cwd: input.cwd,
prNumber: input.prNumber,
}),
@@ -199,13 +197,14 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
export function usePrPaneData({
serverId,
cwd,
enabled = true,
timelineEnabled = enabled,
}: UsePrPaneDataOptions): UsePrPaneDataResult {
const { t } = useTranslation();
const workspaceGit = useRequiredWorkspaceGit();
const daemonClient = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, enabled });
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled });
const status = checkoutPrStatus.status;
const identity = extractPrRepoIdentity(status);
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled;
@@ -213,37 +212,26 @@ export function usePrPaneData({
const unsupportedKey =
identity.prNumber === null
? null
: unsupportedTimelineKey({
serverId,
workspaceId: workspaceGit.workspaceId,
cwd: workspaceGit.cwd,
prNumber: identity.prNumber,
});
: unsupportedTimelineKey({ serverId, cwd, prNumber: identity.prNumber });
const timelineUnsupported = unsupportedKey ? registry.has(unsupportedKey) : false;
const shouldFetchTimeline = shouldFetchTimelineFrom({
hasClient: !!workspaceGit,
hasClient: !!daemonClient,
isConnected,
timelineEnabled,
githubFeaturesEnabled,
cwd: workspaceGit.cwd,
cwd,
identity,
timelineUnsupported,
});
const timelineQuery = useQuery<PullRequestTimeline>({
queryKey: useMemo(
() =>
prPaneTimelineQueryKey({
serverId,
queryIdentity: workspaceGit.queryIdentity,
cwd: workspaceGit.cwd,
prNumber: identity.prNumber,
}),
[serverId, workspaceGit, identity.prNumber],
() => prPaneTimelineQueryKey({ serverId, cwd, prNumber: identity.prNumber }),
[serverId, cwd, identity.prNumber],
),
queryFn: async () => {
if (
!workspaceGit ||
!daemonClient ||
identity.prNumber === null ||
identity.repoOwner === null ||
identity.repoName === null
@@ -251,11 +239,10 @@ export function usePrPaneData({
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return fetchPrPaneTimelinePage({
client: workspaceGit,
client: daemonClient,
registry,
serverId,
workspaceId: workspaceGit.workspaceId,
cwd: workspaceGit.cwd,
cwd,
prNumber: identity.prNumber,
repoOwner: identity.repoOwner,
repoName: identity.repoName,
@@ -1,9 +1,8 @@
import { useMemo } from "react";
import type { CheckoutPipeline } from "@getpaseo/protocol/messages";
import { useFetchQuery } from "@/data/query";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostRuntimeClient, 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;
@@ -11,6 +10,7 @@ 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,32 +38,27 @@ export interface UseGitLabPipelineResult {
*/
export function useGitLabPipeline({
serverId,
cwd,
pipelineId,
changeRequestNumber,
enabled,
live,
}: UseGitLabPipelineOptions): UseGitLabPipelineResult {
const workspaceGit = useRequiredWorkspaceGit();
const daemonClient = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const shouldFetch = enabled && !!workspaceGit && isConnected && pipelineId !== null;
const shouldFetch = enabled && !!daemonClient && isConnected && !!cwd && pipelineId !== null;
const query = useFetchQuery<CheckoutPipeline | null>({
queryKey: useMemo(
() =>
prPanePipelineQueryKey({
serverId,
queryIdentity: workspaceGit.queryIdentity,
cwd: workspaceGit.cwd,
pipelineId,
changeRequestNumber,
}),
[serverId, workspaceGit, pipelineId, changeRequestNumber],
() => prPanePipelineQueryKey({ serverId, cwd, pipelineId, changeRequestNumber }),
[serverId, cwd, pipelineId, changeRequestNumber],
),
queryFn: async () => {
if (!workspaceGit || pipelineId === null) {
if (!daemonClient || pipelineId === null) {
return null;
}
const payload = await workspaceGit.getForgeCheckDetails({
const payload = await daemonClient.checkoutForgeGetCheckDetails({
cwd,
checkRunId: pipelineId,
changeRequestNumber,
});
+112 -115
View File
@@ -1,8 +1,8 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import {
checkoutCommitsQueryKey,
checkoutDiffQueryKey,
checkoutCommitsQueryKey,
checkoutPrStatusQueryKey,
checkoutStatusQueryKey,
invalidateCheckoutGitQueriesForClient,
@@ -15,156 +15,153 @@ import {
describe("checkout query keys", () => {
const serverId = "server-1";
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("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),
);
});
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,
};
expect(checkoutStatusQueryKey(serverId, selected)).not.toEqual(
checkoutStatusQueryKey(serverId, legacy),
);
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 cwd = "/tmp/repo";
it("invalidates every query for a checkout without touching other checkouts", async () => {
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,
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: [],
},
);
queryClient.setQueryData(
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
{ items: [] },
);
queryClient.setQueryData(
prPanePipelineQueryKey({
serverId,
cwd: "/tmp/other",
pipelineId: 9001,
changeRequestNumber: 1,
}),
{ stages: [] },
);
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, cwd });
await invalidateCheckoutGitQueriesForClient(queryClient, { serverId, target });
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, target))?.isInvalidated).toBe(
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(checkoutDiffQueryKey(serverId, target, "base", "main", true))
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(checkoutPrStatusQueryKey(serverId, target))?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, target))?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, ...target, prNumber: 12 }))
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 13 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(
prPanePipelineQueryKey({ serverId, ...target, pipelineId: 9, changeRequestNumber: 12 }),
prPanePipelineQueryKey({ serverId, cwd, pipelineId: 9001, changeRequestNumber: 1 }),
)?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(checkoutStatusQueryKey(serverId, sibling))?.isInvalidated,
queryClient.getQueryState(
prPaneTimelineQueryKey({ serverId, cwd: "/tmp/other", prNumber: 12 }),
)?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(checkoutCommitsQueryKey(serverId, sibling))?.isInvalidated,
queryClient.getQueryState(
prPanePipelineQueryKey({
serverId,
cwd: "/tmp/other",
pipelineId: 9001,
changeRequestNumber: 1,
}),
)?.isInvalidated,
).toBe(false);
queryClient.clear();
});
it("invalidates fetch-based selected queries server-wide but leaves diffs subscription-fed", async () => {
it("invalidates fetch-based checkout queries server-wide without touching other servers", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(checkoutStatusQueryKey(serverId, target), { isGit: true });
queryClient.setQueryData(checkoutDiffQueryKey(serverId, target, "base", "main", true), {
const otherServerId = "server-2";
const otherCwd = "/tmp/repo-2";
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: [],
},
);
// Subscription-fed diff queries are deliberately not part of the server-wide sweep.
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), {
files: [],
});
queryClient.setQueryData(checkoutStatusQueryKey("server-2", target), { isGit: true });
queryClient.setQueryData(checkoutStatusQueryKey(otherServerId, cwd), { isGit: true });
await invalidateCheckoutGitQueriesForServer(queryClient, serverId);
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, target))?.isInvalidated).toBe(
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(checkoutDiffQueryKey(serverId, target, "base", "main", true))
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))
?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(checkoutStatusQueryKey("server-2", target))?.isInvalidated,
queryClient.getQueryState(checkoutStatusQueryKey(otherServerId, cwd))?.isInvalidated,
).toBe(false);
queryClient.clear();
});
});
+17 -47
View File
@@ -1,20 +1,14 @@
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;
target: WorkspaceGitTarget;
cwd: string;
}
interface CheckoutQueryScope {
serverId: string;
target?: WorkspaceGitTarget;
cwd?: string;
}
type CheckoutQueryKey = readonly unknown[];
@@ -23,47 +17,35 @@ 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, 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 checkoutStatusQueryKey(serverId: string, cwd: string) {
return ["checkoutStatus", serverId, cwd] as const;
}
export function checkoutDiffQueryKey(
serverId: string,
target: WorkspaceGitTarget,
cwd: string,
mode: "uncommitted" | "base",
baseRef?: string,
ignoreWhitespace?: boolean,
) {
return [
"checkoutDiff",
serverId,
target.queryIdentity,
target.cwd,
mode,
baseRef ?? "",
ignoreWhitespace === true,
] as const;
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
}
export function checkoutPrStatusQueryKey(serverId: string, target: WorkspaceGitStatusTarget) {
return ["checkoutPrStatus", serverId, target.queryIdentity, target.cwd] as const;
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
return ["checkoutPrStatus", serverId, cwd] as const;
}
export function checkoutCommitsQueryKey(serverId: string, target: WorkspaceGitTarget) {
return ["checkoutCommits", serverId, target.queryIdentity, target.cwd] as const;
export function checkoutCommitsQueryKey(serverId: string, cwd: string) {
return ["checkoutCommits", serverId, cwd] as const;
}
export function checkoutCommitFileDiffQueryKey(
serverId: string,
target: WorkspaceGitTarget,
cwd: string,
sha: string,
path: string,
) {
return ["checkoutCommitFileDiff", serverId, target.queryIdentity, target.cwd, sha, path] as const;
return ["checkoutCommitFileDiff", serverId, cwd, sha, path] as const;
}
export async function invalidateCheckoutGitQueriesForClient(
@@ -72,7 +54,7 @@ export async function invalidateCheckoutGitQueriesForClient(
) {
await Promise.all([
queryClient.invalidateQueries({
queryKey: checkoutStatusQueryKey(identity.serverId, identity.target),
queryKey: checkoutStatusQueryKey(identity.serverId, identity.cwd),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate("checkoutDiff", identity),
@@ -81,7 +63,7 @@ export async function invalidateCheckoutGitQueriesForClient(
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
}),
queryClient.invalidateQueries({
queryKey: checkoutCommitsQueryKey(identity.serverId, identity.target),
queryKey: checkoutCommitsQueryKey(identity.serverId, identity.cwd),
}),
queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
@@ -137,28 +119,16 @@ function checkoutQueryPredicate(
isCheckoutQueryKey(key) &&
key[0] === queryKind &&
key[1] === scope.serverId &&
(scope.target === undefined ||
(isWorkspaceGitQueryIdentity(key[2]) &&
workspaceGitQueryIdentitiesEqual(key[2], scope.target.queryIdentity) &&
key[3] === scope.target.cwd))
(scope.cwd === undefined || key[2] === scope.cwd)
);
};
}
function isCheckoutQueryKey(key: readonly unknown[]): key is CheckoutQueryKey {
return (
key.length >= 4 &&
key.length >= 3 &&
typeof key[0] === "string" &&
typeof key[1] === "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"
typeof key[2] === "string"
);
}
+36 -46
View File
@@ -30,7 +30,6 @@ 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";
@@ -320,8 +319,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr");
const target = useRequiredWorkspaceGit();
const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId });
const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error;
@@ -335,6 +333,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
forge,
} = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
});
const prIcon = useMemo(() => renderForgePrIcon(forge), [forge]);
@@ -398,50 +397,50 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}, [cwd]);
const commitStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "commit" }),
s.getStatus({ serverId, cwd, actionId: "commit" }),
);
const pullStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "pull" }),
s.getStatus({ serverId, cwd, actionId: "pull" }),
);
const pushStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "push" }),
s.getStatus({ serverId, cwd, actionId: "push" }),
);
const pullAndPushStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "pull-and-push" }),
s.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "create-pr" }),
s.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
const mergePrStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
squash: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "merge-pr-squash" }),
s.getStatus({ serverId, cwd, actionId: "merge-pr-squash" }),
),
merge: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "merge-pr-merge" }),
s.getStatus({ serverId, cwd, actionId: "merge-pr-merge" }),
),
rebase: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "merge-pr-rebase" }),
s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }),
),
};
const enablePrAutoMergeStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
squash: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-squash" }),
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
),
merge: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-merge" }),
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
),
rebase: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "enable-pr-auto-merge-rebase" }),
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-rebase" }),
),
};
const disablePrAutoMergeStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "disable-pr-auto-merge" }),
s.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
);
const mergeStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "merge-branch" }),
s.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, target, actionId: "merge-from-base" }),
s.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const runCommit = useCheckoutGitActionsStore((s) => s.commit);
@@ -477,7 +476,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
// Handlers
const handleCommit = useCallback(() => {
void runCommit({ serverId, target })
void runCommit({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.commit.success"));
return;
@@ -485,10 +484,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedCommit"));
});
}, [runCommit, serverId, t, target, toastActionError, toastActionSuccess]);
}, [cwd, runCommit, serverId, t, toastActionError, toastActionSuccess]);
const handlePull = useCallback(() => {
void runPull({ serverId, target })
void runPull({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.pull.success"));
return;
@@ -496,10 +495,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedPull"));
});
}, [runPull, serverId, t, target, toastActionError, toastActionSuccess]);
}, [cwd, runPull, serverId, t, toastActionError, toastActionSuccess]);
const handlePush = useCallback(() => {
void runPush({ serverId, target })
void runPush({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.push.success"));
return;
@@ -507,10 +506,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedPush"));
});
}, [runPush, serverId, t, target, toastActionError, toastActionSuccess]);
}, [cwd, runPush, serverId, t, toastActionError, toastActionSuccess]);
const handlePullAndPush = useCallback(() => {
void runPullAndPush({ serverId, target })
void runPullAndPush({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.pullAndPush.success"));
return;
@@ -518,11 +517,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedPullAndPush"));
});
}, [runPullAndPush, serverId, t, target, toastActionError, toastActionSuccess]);
}, [cwd, runPullAndPush, serverId, t, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
void runCreatePr({ serverId, target })
void runCreatePr({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.createPr.success", forgeVocabulary(forge)));
return;
@@ -531,12 +530,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,
]);
@@ -544,7 +543,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const handleMergePr = useCallback(
(method: CheckoutPrMergeMethod) => {
void persistShipDefault("pr");
void runMergePr({ serverId, target, method })
void runMergePr({ serverId, cwd, method })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess(t("workspace.git.actions.mergePr.success", forgeVocabulary(forge)));
@@ -554,22 +553,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
toastActionError(err, t("workspace.git.actions.toasts.failedMergePr"));
});
},
[
forge,
persistShipDefault,
runMergePr,
serverId,
t,
target,
toastActionError,
toastActionSuccess,
],
[cwd, forge, persistShipDefault, runMergePr, serverId, t, toastActionError, toastActionSuccess],
);
const handleEnablePrAutoMerge = useCallback(
(method: CheckoutPrMergeMethod) => {
void persistShipDefault("pr");
void runEnablePrAutoMerge({ serverId, target, method })
void runEnablePrAutoMerge({ serverId, cwd, method })
.then(() => {
toastActionSuccess(t("workspace.git.actions.autoMerge.enabled"));
return;
@@ -579,18 +569,18 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
});
},
[
cwd,
persistShipDefault,
runEnablePrAutoMerge,
serverId,
t,
target,
toastActionError,
toastActionSuccess,
],
);
const handleDisablePrAutoMerge = useCallback(() => {
void runDisablePrAutoMerge({ serverId, target })
void runDisablePrAutoMerge({ serverId, cwd })
.then(() => {
toastActionSuccess(t("workspace.git.actions.autoMerge.disabled"));
return;
@@ -598,7 +588,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedDisableAutoMerge"));
});
}, [runDisablePrAutoMerge, serverId, t, target, toastActionError, toastActionSuccess]);
}, [cwd, runDisablePrAutoMerge, serverId, t, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
@@ -606,7 +596,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
return;
}
void persistShipDefault("merge");
void runMergeBranch({ serverId, target, baseRef })
void runMergeBranch({ serverId, cwd, baseRef })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess(t("workspace.git.actions.mergeBranch.success"));
@@ -617,11 +607,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
});
}, [
baseRef,
cwd,
persistShipDefault,
runMergeBranch,
serverId,
t,
target,
toast,
toastActionError,
toastActionSuccess,
@@ -632,7 +622,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
toast.error(t("workspace.git.actions.toasts.baseRefUnavailable"));
return;
}
void runMergeFromBase({ serverId, target, baseRef })
void runMergeFromBase({ serverId, cwd, baseRef })
.then(() => {
toastActionSuccess(t("workspace.git.actions.mergeFromBase.success"));
return;
@@ -640,7 +630,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
.catch((err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedMergeFromBase"));
});
}, [baseRef, runMergeFromBase, serverId, t, target, toast, toastActionError, toastActionSuccess]);
}, [baseRef, cwd, runMergeFromBase, serverId, t, toast, toastActionError, toastActionSuccess]);
const archiveController = useWorkspaceScreenArchiveController({
serverId,
+8 -7
View File
@@ -3,9 +3,8 @@ import invariant from "tiny-invariant";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useFetchQuery } from "@/data/query";
import { checkoutCommitsQueryKey } from "@/git/query-keys";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostRuntimeClient, 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.
@@ -13,6 +12,7 @@ const CHECKOUT_COMMITS_STALE_TIME = 30_000;
interface UseCheckoutCommitsQueryOptions {
serverId: string;
cwd: string;
enabled?: boolean;
}
@@ -70,11 +70,12 @@ export function resolveCheckoutCommitsQueryResult({
export function useCheckoutCommitsQuery({
serverId,
cwd,
enabled = true,
}: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult {
const retainedPanelActive = useRetainedPanelActive();
const queryEnabledByCaller = enabled && retainedPanelActive;
const workspaceGit = useRequiredWorkspaceGit();
const client = useHostRuntimeClient(serverId);
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.
@@ -85,16 +86,16 @@ export function useCheckoutCommitsQuery({
state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true,
);
const canFetch = Boolean(workspaceGit) && isConnected;
const canFetch = Boolean(cwd) && Boolean(client) && isConnected;
const queryEnabled = queryEnabledByCaller && capabilityPresent && canFetch;
const query = useFetchQuery<CheckoutCommitsData>({
queryKey: checkoutCommitsQueryKey(serverId, workspaceGit),
queryKey: checkoutCommitsQueryKey(serverId, cwd),
queryFn: async () => {
if (!workspaceGit) {
if (!client) {
throw new Error("Host disconnected");
}
const data = await workspaceGit.listCommits();
const data = await client.listCheckoutCommits(cwd);
const commits = data.commits.map((commit) => {
invariant(commit.isOnBase !== undefined, "Host omitted commit base classification");
return { ...commit, isOnBase: commit.isOnBase };
+10 -9
View File
@@ -4,8 +4,7 @@ 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 { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
/**
* Context needed to resolve a commit diff against a host: which daemon
@@ -14,6 +13,7 @@ import { useRequiredWorkspaceGit } from "@/git/workspace-git";
*/
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, sha, enabled = true } = ctx;
const workspaceGit = useRequiredWorkspaceGit();
const { serverId, cwd, sha, enabled = true } = ctx;
const retainedPanelActive = useRetainedPanelActive();
const queryEnabled = enabled && retainedPanelActive;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const commitsQuery = useCheckoutCommitsQuery({ serverId, enabled: queryEnabled });
const commitsQuery = useCheckoutCommitsQuery({ serverId, cwd, enabled: queryEnabled });
const commitsData = commitsQuery.status === "loaded" ? commitsQuery.data : null;
const commitFiles = useMemo(() => {
if (!sha || !commitsData) {
@@ -74,17 +74,18 @@ export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFiles
const fileDiffsEnabled =
queryEnabled &&
commitsQuery.status === "loaded" &&
Boolean(workspaceGit) &&
Boolean(cwd) &&
Boolean(sha) &&
Boolean(client) &&
isConnected;
const fileDiffResults = useFetchQueries(
commitFiles.map((file) => ({
queryKey: checkoutCommitFileDiffQueryKey(serverId, workspaceGit, sha, file.path),
queryKey: checkoutCommitFileDiffQueryKey(serverId, cwd, sha, file.path),
queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => {
if (!workspaceGit) {
if (!client) {
throw new Error("Host disconnected");
}
return workspaceGit.getCommitFileDiff(sha, file.path);
return client.getCommitFileDiff(cwd, sha, file.path);
},
enabled: fileDiffsEnabled,
staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME,
+6 -6
View File
@@ -5,10 +5,10 @@ import { checkoutDiffPushRoute } from "@/data/push-router";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import 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,
workspaceGit,
cwd,
compareMode,
compareBaseRef,
compareIgnoreWhitespace,
);
const normalizedScope = queryScope?.trim();
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
}, [serverId, workspaceGit, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
const routeEnabled = Boolean(queryEnabled && isConnected && workspaceGit);
const routeEnabled = Boolean(queryEnabled && isConnected && cwd);
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
queryKey,
@@ -80,7 +80,7 @@ export function useCheckoutDiffQuery({
enabled: routeEnabled,
serverId,
subscriptionId,
workspaceGit,
cwd,
compare: {
mode: compareMode,
...(compareBaseRef ? { baseRef: compareBaseRef } : {}),
@@ -1,75 +1,282 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { buildForgeSearchQueryOptions, forgeSearchQueryKey } from "./use-forge-search-query";
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",
),
);
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",
]);
});
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("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("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 });
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("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",
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 ",
kinds: ["change_request"],
enabled: true,
});
await options.queryFn();
expect(searchGitHub).toHaveBeenCalledWith({ query: "42", limit: 20, kinds: ["github-pr"] });
expect(searchForge).not.toHaveBeenCalled();
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 }]);
});
});
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);
}
}
+21 -67
View File
@@ -11,8 +11,6 @@ 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;
@@ -24,27 +22,36 @@ export interface ForgeSearchPayload {
}
interface ForgeSearchOptions {
cwd: string;
query: string;
limit?: number;
kinds?: ForgeSearchKind[];
}
interface LegacyGitHubSearchOptions {
cwd: string;
query: string;
limit?: number;
kinds?: LegacyGitHubSearchKind[];
}
type ForgeSearchTarget = Pick<WorkspaceGitReadClient, "cwd" | "queryIdentity">;
export type ForgeSearchClient = ForgeSearchTarget &
Pick<WorkspaceGitClient, "searchForge"> &
Partial<Pick<WorkspaceGitClient, "searchGitHub">>;
export interface ForgeSearchClient {
searchForge: (
options: ForgeSearchOptions,
requestId?: string,
) => Promise<ForgeSearchResponse["payload"]>;
searchGitHub?: (
options: LegacyGitHubSearchOptions,
requestId?: string,
) => Promise<GitHubSearchResponse["payload"]>;
}
type LegacyGitHubSearchKind = "github-issue" | "github-pr";
interface ForgeSearchQueryInput {
client: ForgeSearchClient | null;
serverId: string;
cwd: string;
query: string;
kinds?: ForgeSearchKind[];
enabled: boolean;
@@ -52,53 +59,42 @@ interface ForgeSearchQueryInput {
hostDisconnectedMessage?: string;
}
interface LegacyForgeSearchQueryInput extends Omit<ForgeSearchQueryInput, "client"> {
client: Pick<DaemonClient, "searchForge" | "searchGitHub"> | null;
cwd: string;
}
export function forgeSearchQueryKey(
serverId: string,
target: ForgeSearchTarget,
cwd: string,
query: string,
kinds?: ForgeSearchKind[],
transport: "forge" | "github" = "forge",
) {
const trimmedQuery = query.trim();
const queryIdentity = forgeSearchClientIdentity(target);
if (!kinds) {
return ["forge-search", serverId, queryIdentity, target.cwd, transport, trimmedQuery] as const;
return ["forge-search", serverId, cwd, transport, trimmedQuery] as const;
}
return [
"forge-search",
serverId,
queryIdentity,
target.cwd,
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: input.client
? forgeSearchQueryKey(input.serverId, input.client, query, input.kinds, transport)
: (["forge-search", input.serverId, "unbound", transport, query] as const),
queryKey: forgeSearchQueryKey(input.serverId, input.cwd, query, input.kinds, transport),
queryFn: async (): Promise<ForgeSearchPayload> => {
if (!input.client) {
throw new Error(
input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"),
);
}
const request = input.kinds ? { query, limit: 20, kinds: input.kinds } : { query, limit: 20 };
const request = input.kinds
? { cwd: input.cwd, query, limit: 20, kinds: input.kinds }
: { cwd: input.cwd, query, limit: 20 };
// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once
// clients use forge.search.*.
if (transport === "github" && input.client.searchGitHub) {
@@ -157,7 +153,7 @@ function toLegacyGitHubSearchKind(kind: ForgeSearchKind): LegacyGitHubSearchKind
function toLegacyGitHubSearchRequest(request: ForgeSearchOptions): LegacyGitHubSearchOptions {
if (!request.kinds) {
return { query: request.query, limit: request.limit };
return { cwd: request.cwd, query: request.query, limit: request.limit };
}
return {
...request,
@@ -165,48 +161,6 @@ 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(
+14 -16
View File
@@ -1,14 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostRuntimeClient, 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,23 +21,22 @@ function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null
export function useCheckoutPrStatusQuery({
serverId,
cwd,
enabled = true,
}: UseCheckoutPrStatusQueryOptions) {
const { t } = useTranslation();
const workspaceGit = useWorkspaceGit();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery({
queryKey: workspaceGit
? checkoutPrStatusQueryKey(serverId, workspaceGit)
: (["checkoutPrStatus", serverId, "unavailable"] as const),
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
queryFn: async () => {
if (!workspaceGit) {
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return normalizeCheckoutPrStatusPayload(await workspaceGit.getPrStatus());
return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd));
},
enabled: isConnected && enabled && workspaceGit !== null,
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: Infinity,
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see
// useCheckoutStatusQuery for the rationale.
@@ -64,23 +63,22 @@ export function useCheckoutPrStatusQuery({
export function useWorkspacePrHint({
serverId,
cwd,
enabled = true,
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
const { t } = useTranslation();
const workspaceGit = useWorkspaceGit();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
queryKey: workspaceGit
? checkoutPrStatusQueryKey(serverId, workspaceGit)
: (["checkoutPrStatus", serverId, "unavailable"] as const),
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
queryFn: async () => {
if (!workspaceGit) {
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return normalizeCheckoutPrStatusPayload(await workspaceGit.getPrStatus());
return normalizeCheckoutPrStatusPayload(await client.checkoutPrStatus(cwd));
},
enabled: isConnected && enabled && workspaceGit !== null,
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: Infinity,
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see
// useCheckoutStatusQuery for the rationale.
+19 -47
View File
@@ -1,9 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { checkoutStatusQueryKey, legacyCheckoutStatusQueryKey } from "@/git/query-keys";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import { fetchCheckoutStatus } from "./checkout-status-cache";
import { useWorkspaceGit } from "./workspace-git";
export type { CheckoutStatusPayload } from "./checkout-status-cache";
@@ -11,25 +10,23 @@ export const CHECKOUT_STATUS_STALE_TIME = 15_000;
interface UseCheckoutStatusQueryOptions {
serverId: string;
cwd: string;
}
export function useCheckoutStatusQuery({ serverId }: UseCheckoutStatusQueryOptions) {
const workspaceGit = useWorkspaceGit();
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery({
queryKey: workspaceGit
? checkoutStatusQueryKey(serverId, workspaceGit)
: (["checkoutStatus", serverId, "unavailable"] as const),
queryKey: checkoutStatusQueryKey(serverId, cwd),
queryFn: async () => {
if (!workspaceGit) throw new Error("Workspace Git is unavailable");
return await fetchCheckoutStatus({
client: workspaceGit,
serverId,
target: workspaceGit,
});
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await fetchCheckoutStatus({ client, serverId, cwd });
},
enabled: isConnected && workspaceGit !== null,
enabled: !!client && isConnected && !!cwd,
staleTime: Infinity,
// Freshness is push-driven (checkout_status_update applied globally); with
// staleTime: Infinity, refetchOnMount only fires after an explicit invalidation
@@ -53,44 +50,19 @@ export function useCheckoutStatusQuery({ serverId }: UseCheckoutStatusQueryOptio
* initiating a fetch. Useful for list rows where a parent component prefetches
* only the visible agents.
*/
export function useCheckoutStatusCacheOnly({ serverId }: UseCheckoutStatusQueryOptions) {
const workspaceGit = useWorkspaceGit();
export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
return useQuery({
queryKey: workspaceGit
? checkoutStatusQueryKey(serverId, workspaceGit)
: (["checkoutStatus", serverId, "unavailable"] as const),
queryKey: checkoutStatusQueryKey(serverId, cwd),
queryFn: async () => {
if (!workspaceGit) throw new Error("Workspace Git is unavailable");
return await fetchCheckoutStatus({
client: workspaceGit,
serverId,
target: workspaceGit,
});
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await fetchCheckoutStatus({ client, serverId, cwd });
},
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,
};
}
+3 -2
View File
@@ -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 });
} = useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error;
@@ -83,6 +83,7 @@ export function useWorkingDiff({
isLoading: isDiffLoading,
} = useCheckoutDiffQuery({
serverId,
cwd,
mode: diffMode,
baseRef,
ignoreWhitespace,
+1 -7
View File
@@ -4,13 +4,11 @@ import { GIT_ACTION_ICONS } from "@/git/action-icons";
interface WorkspaceActionsProps {
serverId: string;
workspaceId: string;
cwd: string;
isWorkspaceGitBound: boolean;
hideLabels?: boolean;
}
function BoundWorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
const { gitActions } = useGitActions({
serverId,
cwd,
@@ -19,7 +17,3 @@ function BoundWorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsPr
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
}
export function WorkspaceActions(props: WorkspaceActionsProps) {
return props.isWorkspaceGitBound ? <BoundWorkspaceActions {...props} /> : null;
}
@@ -1,79 +0,0 @@
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"]));
});
@@ -1,71 +0,0 @@
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);
});

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