Compare commits

...
Author SHA1 Message Date
Mohamed Boudra bf3910bdc0 test(workspaces): keep launch fixture alive 2026-08-15 12:30:17 +00:00
Mohamed Boudra b607bed49c test(workspaces): isolate runtime observation routing 2026-08-15 11:25:15 +00:00
Mohamed Boudra ec12c5c35c fix(workspaces): resolve runtime Git observation command 2026-08-15 11:01:11 +00:00
Mohamed Boudra 1daccc4414 test(workspaces): isolate runtime observation routing 2026-08-15 10:25:55 +00:00
Mohamed Boudra af140997a3 fix(workspaces): invalidate watches after helper writes 2026-08-15 10:03:14 +00:00
Mohamed Boudra 98bf569448 test(workspaces): focus runtime observation isolation 2026-08-15 09:35:32 +00:00
Mohamed Boudra bdfa718c83 test(workspaces): budget command runtime observation 2026-08-15 09:13:03 +00:00
Mohamed Boudra 791939081b fix(workspaces): await Git watcher teardown 2026-08-15 08:48:58 +00:00
Mohamed Boudra 18ec41670f test(workspaces): bound runtime Git integration waits 2026-08-15 08:19:28 +00:00
Mohamed Boudra add858f3e8 test(workspaces): isolate command runtime Git contracts 2026-08-15 07:54:55 +00:00
Mohamed Boudra 92a1e3fa66 fix(workspaces): release runtime resources on shutdown 2026-08-15 07:22:01 +00:00
Mohamed Boudra 91f76c9494 test(workspaces): isolate host Git observer seam 2026-08-15 00:51:17 +02:00
Mohamed Boudra d58230404b test(workspaces): expect helper protocol framing 2026-08-15 00:40:51 +02:00
Mohamed Boudra 692d4777c6 style(workspaces): format Windows helper test 2026-08-15 00:34:42 +02:00
Mohamed Boudra 3a8824ed99 fix(workspaces): keep helper executable standalone 2026-08-15 00:31:22 +02:00
Mohamed Boudra 6d16961d58 fix(workspaces): resolve Windows runtime commands 2026-08-15 00:24:34 +02:00
Mohamed Boudra 16e0967408 test(workspaces): make Git isolation deterministic 2026-08-14 23:58:52 +02:00
Mohamed Boudra 697a0002d9 test(workspaces): isolate sibling Git fanout 2026-08-14 23:43:21 +02:00
Mohamed Boudra 1518eb09a9 test(workspaces): isolate Git watcher readiness 2026-08-14 23:26:28 +02:00
Mohamed Boudra 3175a5ff10 chore: merge main into workspace runtime branch 2026-08-14 23:10:24 +02:00
Mohamed Boudra eafa5f2b5c fix(workspaces): resolve runtime Git portably 2026-08-14 22:54:59 +02:00
Mohamed Boudra 4748aad103 Reload daemon configuration without restarting (#3365)
* feat(daemon): reload configuration without restarting

Keep config validation, reloadability classification, override handling, and atomic runtime application inside the daemon so local and remote clients share one authoritative workflow.

* fix(daemon): preserve reload resource ownership

Keep every materialized provider client under daemon-lifetime ownership and classify speech overrides from resolved providers. Restore the complete WebSocket config-store test contract exposed by CI.
2026-08-14 22:46:32 +02:00
Mohamed Boudra 12c1a68956 test(workspaces): await runtime teardown 2026-08-14 22:23:22 +02:00
Mohamed Boudra cd354158f5 test(desktop): combine dev prerequisite build states 2026-08-14 21:59:24 +02:00
Mohamed Boudra cf4d702a8e fix(workspaces): release archived runtime backing 2026-08-14 21:44:48 +02:00
Mohamed Boudra e5f3b3dc02 fix(workspaces): preserve portable runtime readiness 2026-08-14 20:13:04 +02:00
Mohamed Boudra b500809229 test: assert worktree backing release 2026-08-14 19:31:04 +02:00
Mohamed Boudra 642c303859 test: remove provider descriptor clock race 2026-08-14 19:12:40 +02:00
Mohamed Boudra a67381795b test: include plugin in isolated build fixtures 2026-08-14 19:01:33 +02:00
Mohamed Boudra 8a37f2f5d3 fix: update Nix dependency hash 2026-08-14 18:49:40 +02:00
Mohamed Boudra 54f0e636b9 Merge remote-tracking branch 'origin/main' into workspace-runtime-poc
# Conflicts:
#	nix/npm-deps.hash
#	package.json
#	packages/client/src/daemon-client.ts
#	packages/server/src/server/bootstrap.ts
#	packages/server/src/server/websocket-server.ts
2026-08-14 18:43:58 +02:00
Mohamed Boudra cd429339a0 fix(workspaces): preserve runtime lifecycle boundaries 2026-08-14 18:35:07 +02:00
Mohamed Boudra 75bbca4a1b Archive finished subagents across the whole track (#3368)
* feat(app): archive finished subagents together

Make the track action clean up finished managed and provider-owned children as one operation while preserving running work. Managed archives remain sequential and provider-owned dismissal stays client-local.

* test(app): clarify subagent archive assertions
2026-08-14 18:16:22 +02:00
Mohamed Boudra 23853a1515 Add managed local plugin lifecycle (#3222)
* feat(plugins): add local plugin surfaces and RPCs

Keep plugin execution scoped to its daemon while allowing one local TypeScript entry point to contribute validated RPCs and native client surfaces. Paseo owns host selection, navigation chrome, and per-installation query caches.

* feat(plugins): add Linear attachment source

* fix(nix): refresh npm dependency hash

* test(server): exclude internal sessions from Hub counts

* test(server): distinguish Hub execution sessions

* feat(plugins): make local plugins explicitly opt-in

Keep plugin loading dormant until configuration enables it, and collect unsupported examples under one directory.

* refactor(plugins): establish host boundaries

* fix(nix): refresh plugin dependency hash

* fix(plugins): complete packaging and startup rollback

* feat(plugins): add managed local lifecycle

Make trusted local plugins recoverable and authorable through daemon-owned lifecycle operations, host settings, and a capability-gated CLI. Reloads fully stop the previous plugin before starting new code, while generated projects provide strict TSX checking without changing runtime dependency ownership.

* fix(nix): refresh plugin dependency hash
2026-08-14 17:12:49 +02:00
Mohamed Boudra 51193e7a92 fix(workspaces): stabilize runtime CI paths 2026-08-14 16:49:05 +02:00
Mohamed Boudra d35c10a8b5 test(workspaces): invoke npm portably on Windows 2026-08-14 14:34:04 +02:00
Mohamed Boudra ff83ca4bdc fix(workspaces): restore clean runtime test paths 2026-08-14 14:11:12 +02:00
Mohamed Boudra accd2d7587 fix(build): update Nix dependency hash 2026-08-14 13:44:12 +02:00
Mohamed Boudra 2501a46f3e fix(build): restore package lock metadata 2026-08-14 13:39:02 +02:00
Mohamed Boudra e2ac23da70 chore(merge): integrate main into workspace runtimes 2026-08-14 13:28:32 +02:00
Mohamed Boudra f0bd2c8483 Resume cached timelines without replaying their tail (#3329)
* perf(timeline): resume cached history from its exact range

Warm launches were repainting cached history and then downloading the same bounded tail to rediscover that nothing changed. Persist synchronization authority only when the complete current canonical window can be encoded losslessly; otherwise retain the existing display-only fallback.

* test(timeline): hide replica setup behind E2E helper

* fix(timeline): bound persisted resume overflow

* test(timeline): cover workspace eviction catch-up order

* test(timeline): read persisted range from IndexedDB
2026-08-14 12:57:44 +02:00
Mohamed Boudra 7c32fcda98 refactor(workspaces): finalize runtime package boundaries
Keep the daemon dependent only on the public runtime contract and helper, while optional runtime implementations remain external command registrations. Workspace setup continues asynchronously through the bound runtime without coupling production packages to an implementation.
2026-08-14 12:48:35 +02:00
Mohamed BoudraandJimmy Lee 30195d3bd1 fix(app): make composer input IME-safe and paste-aware (#3343)
* fix(app): preserve native composer input semantics

Native text was round-tripped through application state on every keystroke, which could overwrite an input method's in-flight edits. Keep native text ownership inside the input and reserve application-driven replacement for explicit draft mutations.\n\nRoute native clipboard images through the existing persisted attachment pipeline so keyboard and system paste actions behave like other image sources.

* fix(nix): refresh dependency hash

The native composer dependency changes the fixed npm dependency closure used by desktop builds.

* fix(app): keep composer locked across image pastes

Count concurrent native image persistence operations so submit remains disabled until every pasted image is attached.

* fix(app): replace composer text through the DOM on web

Web TextInput refs are DOM elements, so application-driven draft replacement must update their value and selection without native setNativeProps.

* fix(app): preserve web IME composition

Carry the Chinese IME fix from #2990 into the web composer input boundary.

Co-authored-by: Jimmy Lee <jimersylee+github@gmail.com>

---------

Co-authored-by: Jimmy Lee <jimersylee+github@gmail.com>
2026-08-14 11:52:24 +02:00
770b87a15c fix(desktop): keep element selectors available on loaded pages (#3187)
* fix(desktop): keep element selectors available on loaded pages

Annotate element and Screenshot element returned early when a cached load signal said the guest was not ready, even if the current document was complete and executable. Let executeJavaScript own the readiness boundary and keep selector cleanup available in the same stale-signal state.

Extend the real Electron browser regression to reproduce the complete-document/stale-signal mismatch through both toolbar actions.

* test(desktop): await browser selector injection

Poll the guest selector state through the browser bridge instead of assuming injection completes within 250 ms. This keeps the Electron regression deterministic on slower CI hosts.

* Harden browser selector session lifecycle

* Keep selector E2E fixtures independent

* fix(desktop): harden selector and annotation lifecycle

* fix(desktop): keep selector PR focused

* refactor(desktop): own element selector lifecycle

---------

Co-authored-by: Daniel G. Kang <180234654+dgk-dev@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-14 15:51:42 +08:00
Mohamed Boudra b03dc804eb feat(app): make pinned workspaces sortable (#3341)
Reuse the existing workspace drag interaction for pinned rows and persist their local order across grouping modes. Keep pressed and dragged row scrims aligned with the active backdrop, and preserve memoized row rendering on web.
2026-08-13 23:48:01 +02:00
Mohamed Boudra 373a98c64d Restore host directories instantly before reconnect (#3259)
* feat(sync): restore host directories before reconnect

Persist complete project, workspace, and active-agent replicas so every registered host can paint immediately, including offline hosts. Reconcile those replicas through monotonic per-entity cursors on the existing directory RPCs without retaining an event journal.

* fix(sync): preserve active turn identity during catch-up

Own Agent-to-wire projection beside the existing snapshot normalizer so replica persistence does not reach through directory-sync internals. This also keeps identified active turns intact when unchanged agents are folded into an incremental directory response.

* test(e2e): inspect the real IndexedDB replica

Keep cache setup, reload, and hydration coverage on the production browser storage backend after the replica moved off localStorage. Measure writes at the actual IndexedDB put boundary as well.

* fix warm reconnect cache paths

* feat(sync): persist project icons and deepen directory sync

* fix(sync): preserve project update order

* fix(cache): preserve validated replica persistence

* fix(sync): retain cached agents during catch-up
2026-08-13 23:10:44 +02:00
Mohamed Boudra 599846650c Stabilize live relay status coverage (#3337)
* test(cli): stabilize live relay status coverage

The regression fixture deleted the supervisor's active PID lock to model a reachable daemon without local process state. Under CI load, worker readiness could race that deletion and make the supervisor tear down the daemon while status and pairing were probing it.\n\nLaunch the real worker directly so the no-PID state exists naturally, then wait on the CLI-visible live relay state before asserting behavior.

* test(cli): wait for complete relay probe state

Status readiness alone does not guarantee the next pairing RPC or the foreign-home identity probe will finish inside the CLI's per-call timeout under CI contention. Retry each exact observable outcome within the fixture deadline and resolve the real worker entry through the server package.

* test(cli): name retry mechanics once

Keep the relay scenarios expressed as CLI probes and exact outcomes while one file-local helper owns the shared deadline, worker-liveness check, and retry cadence.
2026-08-13 23:10:28 +02:00
Mohamed Boudra 36d8464068 refactor(workspaces): make Docker a generic runtime
The server build, typecheck, and dry-run package pass with the Docker implementation deleted from the tree. paseo-workspace-helper remains a mandatory generic runtime contract requirement.
2026-08-13 21:59:13 +02:00
Mohamed Boudra cbb8295ceb fix(workspaces): unblock desktop runtime lifecycle
Electron-hosted Local helpers bypassed the sanitized self-Node launch policy, so they lacked ELECTRON_RUN_AS_NODE and provider probes hung.

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

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

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

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

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

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

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

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

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

Run commands and observation through runtime capabilities while preserving explicit cwd-bound behavior for legacy records.
2026-08-11 07:40:20 +02:00
Mohamed Boudra b682f56bb9 feat(workspaces): route file access through runtimes
Keep browsing, streaming, editing, downloads, and live file observation inside the selected workspace runtime. Helper processes own cancellation and teardown so remote workspaces never fall back to host paths.
2026-08-11 01:55:27 +02:00
Mohamed Boudra d340365245 feat(workspaces): route interactive terminals through runtimes
Keep terminal state in Paseo while allocating PTYs and running terminal commands in the selected workspace runtime. Runtime exit and cleanup remain authoritative across worker and wrapper failures.
2026-08-11 00:47:09 +02:00
Mohamed Boudra 4898523351 feat(workspaces): introduce runtime execution boundary
Establish the pipe-mode vertical slice before migrating terminals, files, Git, and providers. Runtime selection is immutable per workspace, and external runtimes remain unavailable to path-based daemon surfaces until those capabilities move behind the boundary.
2026-08-10 22:57:54 +02:00
Mohamed Boudra a18d9ec3d8 test(workspaces): characterize runtime boundaries 2026-08-10 20:30:45 +02:00
534 changed files with 46628 additions and 5614 deletions
+3
View File
@@ -35,6 +35,9 @@ hub:
server:
- "packages/server/**"
- "runtimes/fixture/**"
- "packages/workspace-helper/**"
- "packages/workspace-runtime-contract/**"
- "packages/app/e2e/support/fixtures/recording.*"
desktop:
+17
View File
@@ -102,6 +102,9 @@ jobs:
- name: Lint
run: npm run lint
- name: Enforce workspace runtime ownership boundaries
run: npm run check:workspace-runtime-boundaries && node --test scripts/workspace-runtime-boundaries.test.mjs
typecheck:
name: typecheck
needs: changes
@@ -129,6 +132,8 @@ jobs:
- name: Verify public package contents
run: |
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/workspace-runtime-contract
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/workspace-helper
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
@@ -163,6 +168,18 @@ jobs:
- name: Build server dependencies
run: npm run build:server-deps
- name: Test workspace runtime command contract package
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm test --workspace=@getpaseo/workspace-runtime-contract && npm test --workspace=@getpaseo/workspace-helper
- name: Prove the command runtime fixture is independently installable
if: ${{ runner.os == 'Linux' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
run: node --test scripts/workspace-runtime-standalone.test.mjs
- name: Prove clean server builds own public prerequisites
if: ${{ runner.os == 'Linux' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
run: node --test scripts/server-clean-build.test.mjs
- name: Run server tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/server
+4
View File
@@ -16,8 +16,12 @@ 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/**"
- "packages/protocol/**"
- "packages/relay/**"
- "packages/server/**"
+1
View File
@@ -10,6 +10,7 @@ on:
- "package.json"
- "package-lock.json"
- "packages/highlight/**"
- "packages/plugin/**"
- "packages/server/**"
- "packages/relay/**"
- "packages/cli/**"
+1
View File
@@ -12,6 +12,7 @@ on:
- "packages/app/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/plugin/**"
- "packages/protocol/**"
- "packages/client/**"
- "packages/server/**"
+138
View File
@@ -84,6 +84,144 @@
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
{
"files": ["packages/server/src/**/*.{ts,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"**/workspace-runtime/internal/**",
"**/workspace-runtime/command/internal/**"
],
"message": "Workspace runtime internals are owned by their public entry points."
},
{
"group": ["**/workspace-helper/internal/**"],
"message": "Normal callers consume WorkspaceFiles from workspace-helper/index. Only the workspace-runtime parent integration and workspace-helper tests may assemble helper clients."
},
{
"group": [
"**/workspace-runtime/integration/**",
"**/workspace-runtime/git-observation/internal/**"
],
"message": "Git callers consume workspace-runtime/git-observation/index. Only runtime internals may assemble shared observation identity, placement, helpers, and lifecycle."
}
]
}
]
}
},
{
"files": [
"packages/server/src/server/workspace-runtime/index.ts",
"packages/server/src/server/workspace-runtime/command/index.ts",
"packages/server/src/server/workspace-runtime/command/internal/**/*.{ts,tsx}",
"packages/server/src/server/workspace-runtime/internal/**/*.{ts,tsx}",
"packages/server/src/server/workspace-helper/**/*.{ts,tsx}",
"packages/server/src/server/workspace-runtime/git-observation/**/*.{ts,tsx}",
"packages/server/src/server/workspace-runtime/internal/service.ts"
],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": [
"packages/server/src/server/session/files/**/*.{ts,tsx}",
"packages/server/src/server/session/git-mutation/**/*.{ts,tsx}",
"packages/server/src/server/session/provider/**/*.{ts,tsx}",
"packages/server/src/server/session/workspace-git-observer/**/*.{ts,tsx}",
"packages/server/src/server/session/workspace-provisioning/**/*.{ts,tsx}",
"packages/server/src/server/session/workspace-scripts/**/*.{ts,tsx}"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{ "name": "child_process", "message": "Use the bound workspace runtime." },
{ "name": "node:child_process", "message": "Use the bound workspace runtime." },
{ "name": "fs", "message": "Use the bound workspace runtime." },
{ "name": "node:fs", "message": "Use the bound workspace runtime." },
{ "name": "fs/promises", "message": "Use the bound workspace runtime." },
{ "name": "node:fs/promises", "message": "Use the bound workspace runtime." },
{ "name": "module", "message": "Dynamic loaders bypass workspace ownership." },
{
"name": "node:module",
"message": "Dynamic loaders bypass workspace ownership."
},
{ "name": "which", "message": "Use the bound workspace runtime." }
],
"patterns": [
{
"group": ["**/utils/run-git-command.{js,ts}"],
"message": "Use the bound workspace runtime."
},
{
"group": [
"**/workspace-runtime/internal/**",
"**/workspace-runtime/command/internal/**",
"**/workspace-runtime/git-observation/internal/**",
"**/workspace-helper/internal/**"
],
"message": "Use the owning public entry point."
}
]
}
]
}
},
{
"files": [
"packages/server/src/server/session/files/**/*.test.{ts,tsx}",
"packages/server/src/server/session/git-mutation/**/*.test.{ts,tsx}",
"packages/server/src/server/session/provider/**/*.test.{ts,tsx}",
"packages/server/src/server/session/workspace-git-observer/**/*.test.{ts,tsx}",
"packages/server/src/server/session/workspace-provisioning/**/*.test.{ts,tsx}",
"packages/server/src/server/session/workspace-scripts/**/*.test.{ts,tsx}"
],
"rules": {
"no-restricted-imports": "off"
}
},
{
"files": ["packages/fixture-workspace-runtime/**/*.{js,mjs,ts,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "module",
"message": "The external runtime may not construct dynamic module loaders."
},
{
"name": "node:module",
"message": "The external runtime may not construct dynamic module loaders."
},
{
"name": "@getpaseo/server",
"message": "The external runtime may consume only the published command contract."
}
],
"patterns": [
{
"group": [
"@getpaseo/server/**",
"**/packages/server/**",
"**/workspace-runtime/**/internal/**",
"**/workspace-helper/**/internal/**"
],
"message": "The external runtime may consume only the published command contract."
}
]
}
]
}
},
{
"files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": {
+1
View File
@@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
+3
View File
@@ -22,7 +22,10 @@ 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; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
+2 -2
View File
@@ -165,7 +165,7 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
The track header's **Archive finished** action covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
@@ -185,7 +185,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway
### Subagent accumulation under long-lived parents
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts.
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.
### Cross-client tab dismissal
+31 -7
View File
@@ -69,11 +69,14 @@ not retain non-Git directories.
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `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 |
@@ -84,6 +87,19 @@ agent timeline types, provider config schemas, and other values shared by daemon
and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package;
it does not depend on the server.
### Workspace runtime packages
`packages/workspace-runtime-contract` owns the versioned command-runtime lifecycle and exec wire
contract. `packages/workspace-helper` owns the official confined files/watch/command-resolution
executable and its typed binding. The daemon depends on those two packages and registers command
runtimes without importing an implementation.
`runtimes/fixture` is a private generic contract fixture. Runtime implementations live in their own
repositories and depend on published versions of the two public workspace packages. CLI, Desktop,
server, release workflows, and the daemon image do not depend on, bundle, publish, or register an
implementation. Tests invoke registered runtimes through their public command, never through source
imports.
### `packages/client` — Daemon client library and SDK facade
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
@@ -97,19 +113,27 @@ Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `runtime/replica-cache` keeps a non-authoritative per-host display replica in AsyncStorage: only the last focused agent, its workspace, and a short timeline tail. It restores before navigation becomes ready, leaves remote hydration flags false, and is atomically replaced by the normal snapshot-plus-delta synchronization path.
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false.
- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
- Voice features: dictation (STT) and voice agent (realtime)
The replica cache exists only to paint stale data immediately while the host connects. It does not
own mutations, infer deletions, or replace daemon reconciliation. Pending permission requests are
not restored from it. AsyncStorage is not encrypted, so the cached timeline tail may contain source
code, prompts, and tool output; encrypted-at-rest storage is a separate product/security decision.
Its serialized payload has a 1 MiB byte budget and evicts whole host snapshots in least-recently-
written order; a single oversized host is omitted rather than partially restored.
The replica cache paints stale data immediately while the host connects. Directory cursors are
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole
host snapshots in least-recently-written order; a single oversized host is omitted rather than
partially restored. Browser and Electron builds store it in IndexedDB. Native builds use
AsyncStorage, and Android reserves 64 MiB for that database.
The three directory entity types have independent monotonic sequences and share one daemon
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are
independent records; a project with no workspaces does not need a workspace placeholder.
### `packages/cli` — Command-line client
+36 -18
View File
@@ -16,19 +16,23 @@ names, and workspace foreign keys. Attached workspaces are independently refresh
from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects
are observed too.
The workspace registry model defines placement once: initial directory/worktree construction,
mutable reconciliation fields, and the persisted-to-wire checkout projection. Its update policy
preserves `displayName` and `baseBranch`. `WorkspaceProvisioningService` owns the corresponding
registry writes, so directory opens, agent imports, and worktree creation all enter through that
service instead of constructing records independently. The workspace record is then the durable
placement authority: `cwd` is the exact execution directory, while `worktreeRoot` is the backing
checkout root. They intentionally differ for an exact subproject inside a worktree. Archive,
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
The workspace registry model defines public placement once: initial directory/worktree
construction, mutable reconciliation fields, and the persisted-to-wire projection.
`WorkspaceProvisioningService` owns the corresponding registry writes, so directory opens, agent
imports, and worktree creation do not construct records independently.
For a runtime-selected workspace, `runtime.runtimeId` is the only persisted runtime identity. `cwd`
is required compatibility and presentation data: an adopted local directory, the actual host
worktree path, or a runtime-local path such as Docker's `/workspace`. It is not globally unique and
never selects a runtime. `hostVisiblePath` is the separate optional capability used by editor and
file-manager actions. Private roots, revisions, execution domains, container and volume IDs,
labels, supervisor state, and ownership metadata are recovered from driver `inspect(workspaceId)`
and are never written to the workspace record.
Records without `runtime` remain the narrow legacy placement boundary. Their `cwd`, `worktreeRoot`,
and `mainRepoRoot` continue to support old local/worktree behavior until classified. Selected
worktree records retain user-visible worktree placement metadata, but lifecycle and deletion are
owned by the runtime driver.
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
@@ -175,6 +179,13 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId`
Single file, validated with `PersistedConfigSchema`.
`paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution,
classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe
fields and their removal/default semantics; session handlers and the CLI only relay the structured
result. Normal config patches persist only the requested fields, so launch overrides and resolved
defaults never leak into the file. Startup-only fields remain compared with the daemon's launch
snapshot so a mixed edit can apply its live subset and still name the paths that require restart.
```
{
version: 1,
@@ -278,9 +289,9 @@ Environment variables override `config.json`:
| `PASEO_GIT_MAX_PROCESS_CONCURRENCY` | `maxProcessConcurrency` |
| `PASEO_GIT_CONCURRENCY` | Legacy concurrency alias |
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Restart the
daemon after changing the file or environment. Run `paseo daemon restart` for a standalone daemon.
For a desktop-managed daemon, fully quit and reopen Paseo Desktop.
`PASEO_GIT_MAX_PROCESS_CONCURRENCY` wins when it and the legacy alias are both set. Run `paseo reload`
after changing `config.json`. Environment changes require a daemon restart; the launch environment
remains authoritative during reload.
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
@@ -416,7 +427,8 @@ Array of workspace records. A workspace is a specific working directory within a
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `cwd` | `string` | Client compatibility projection. Runtime-selected server operations address the workspace by `workspaceId`; the driver owns its execution root. |
| `hostVisiblePath` | `string \| null` | Explicit host-path capability for editor and file-manager integrations. Null for Docker and remote placement. |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
@@ -425,13 +437,19 @@ Array of workspace records. A workspace is a specific working directory within a
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `runtime` | `{ runtimeId: string } \| undefined` | Immutable selected workspace runtime. Runtime roots, revisions, execution domains, and resource identifiers are never stored in the workspace record. |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Runtime-selected filesystem, Git, terminal, provider, and script operations use it as their server-side address. `cwd` remains on the wire for compatibility and display. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
For a runtime-selected workspace, archive pauses the runtime and sets `archivedAt`; it does not remove
the runtime's files. Restore resumes the same runtime before clearing `archivedAt`. Permanent project
removal destroys each selected runtime before removing its workspace record. Records without
`runtime` remain on the legacy local/worktree lifecycle path until they are explicitly re-created.
`projectId` is still a real FK: workspace records should have a matching project record. Read-only
history surfaces tolerate transient orphaned workspaces by omitting those rows so one bad FK cannot
+3 -3
View File
@@ -266,8 +266,8 @@ the daemon-global Git process limits in `$PASEO_HOME/config.json`:
}
```
Restart the daemon with `paseo daemon restart`. If Paseo Desktop manages the daemon, fully quit and
reopen the desktop app. Lower values reduce machine pressure but make Git-backed workspace state and
Reload the daemon with `paseo reload`. Environment-variable overrides still require a restart because
the launch environment remains authoritative. Lower values reduce machine pressure but make Git-backed workspace state and
Git RPCs wait longer. See [Git process limits](data-model.md#git-process-limits) for defaults,
semantics, and environment-variable overrides.
@@ -304,7 +304,7 @@ Worktrees inherit committed Git state only; uncommitted source-checkout changes
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
Lifecycle commands run in the worktree through a stable script shell: `bash`
For runtime-selected workspaces, lifecycle commands run inside the selected runtime through a stable script shell. Legacy worktrees use `bash`
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
+1 -1
View File
@@ -10,7 +10,7 @@ The image source lives in [`docker/`](../docker/).
The official image:
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
- builds `@getpaseo/server`, `@getpaseo/cli`, and their public workspace packages from source-built workspace tarballs
- runs the daemon as the non-root `paseo` user
- listens on `0.0.0.0:6767` inside the container
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
+5 -3
View File
@@ -5,8 +5,10 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label.
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it.
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent).
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Runtime**, which chooses where commands and files live.
- **Runtime** — The selected environment where a workspace's files, terminals, Git operations, scripts, provider discovery, and agents run. UI: "Runtime" on New Workspace. Configured runtimes use their configured label. Code: `WorkspaceRuntimeService`; the remembered choice is `FormPreferences.runtimeId`.
- **Local** — Built-in runtime that adopts the selected host directory. UI: "Local". It does not mean “the current client device”; it means the daemon host.
- **Docker** — Built-in runtime that materializes committed Git content into a runtime-owned Docker volume. UI: "Docker". Host bind mounts may provide configuration outside `/workspace`, but never replace project materialization.
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
@@ -17,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Worktree** — Built-in runtime that creates a Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
+173
View File
@@ -0,0 +1,173 @@
# Local plugins
Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one
`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client
contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it.
## Install a directory source
Create a typecheckable plugin project, install its development dependencies, then install it into
the daemon. `init` only writes the project files; it does not run the package manager.
```bash
paseo plugin init /absolute/path/to/my-plugin
cd /absolute/path/to/my-plugin
npm install
npm run typecheck
paseo plugin install /absolute/path/to/my-plugin
paseo plugin install /absolute/path/to/my-plugin --id another-runtime-id
paseo plugin ls
```
The daemon stores directory sources under the root `plugins` object:
```json
{
"pluginsEnabled": true,
"plugins": {
"my-plugin": {
"source": "directory",
"path": "/absolute/path/to/my-plugin",
"enabled": true
}
}
}
```
The plugin system is disabled unless `pluginsEnabled` is `true`.
The directory contains an identity-only manifest, one entry point, and local typechecking support:
```text
my-plugin/
paseo-plugin.json
index.tsx
paseo-plugin.d.ts
package.json
tsconfig.json
```
Paseo compiles TSX when loading the plugin, so these packages are development dependencies only.
The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a
public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes.
```json
{
"id": "my-plugin"
}
```
The config key is the runtime plugin ID. The manifest ID is the default selected during install;
`--id` overrides it. Existing configuration is not renamed when the manifest changes, and the
runtime does not compare the two IDs. The same directory can be installed under several config
keys.
Source changes are explicit. Run `paseo plugin reload <id>` to stop and fully tear down the old
plugin before compiling and starting from disk. A failed reload stays failed; Paseo does not restore
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Remove deletes only its
configuration, never its source directory. The global `pluginsEnabled` switch remains available.
## Contribute behavior and UI
Default export one contribution function. Paseo calls it with a plugin-scoped context. The compiler
removes UI registrations from the server bundle and RPC registrations from the client bundle before
resolving dependencies.
```tsx
import { Text } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin";
const greetRpc = defineRpc({
name: "greet",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
});
function Greeting() {
const greet = useRpc(greetRpc);
const query = useQuery({
queryKey: ["greeting", "Paseo"],
queryFn: () => greet({ name: "Paseo" }),
});
return <Text>{query.data?.message}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.handle(greetRpc, async ({ name }) => ({ message: `Hello, ${name}` }));
plugin.addSurface("main", Greeting);
plugin.addSidebarItem({
id: "main",
title: "Greeting",
icon: "MessageCircle",
surface: "main",
});
return () => undefined;
}
```
The contribution function must return cleanup. Paseo invokes it once when the plugin is reloaded,
disabled, removed, disconnected, or shut down. Cleanup is for resources created by plugin code.
Paseo removes registered contributions, unmounts surfaces, clears query state, rejects pending RPCs,
and stops the subprocess. Cleanup errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
RPC contracts validate inputs and outputs in both the app and plugin subprocess. `useRpc` returns a
typed async function. Use the host-provided `@tanstack/react-query` for request state and caching;
Paseo gives each plugin installation its own query client.
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Plugin code cannot address another host.
## Contribute composer attachments
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots;
credentials and vendor API calls stay in the daemon handler.
```tsx
const searchIssues = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: z.object({
items: z.array(
z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
subtitle: z.string().optional(),
url: z.string().url(),
text: z.string(),
resourceType: z.string(),
}),
),
}),
});
const issues = defineAttachmentSource({
id: "issues",
title: "Acme issue",
icon: "CircleDot",
pickerTitle: "Attach Acme issue",
searchPlaceholder: "Search by identifier or title",
search: searchIssues,
});
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
plugin.addAttachmentSource(issues);
return () => undefined;
}
```
Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources
on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral
external-resource presentation, so it remains readable if the plugin is removed or an older peer
drops the optional presentation fields.
See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete
attachment-source example.
+5 -3
View File
@@ -103,7 +103,9 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
## Provider Snapshot Refresh Contract
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
The daemon keeps provider snapshots per bound workspace, with separate legacy cwd and semantic global scopes. New Workspace first ensures one invisible provider-probe workspace for the selected `(projectId, runtimeId)`, then reads the snapshot through that workspace binding. The probe is created through `WorkspaceRuntimeService`, so availability, models, and modes come from the selected runtime. Probe creation never runs repository setup. Old clients and bare-cwd callers keep the cwd snapshot path; do not replace or remove it.
Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` after the workspace runtime has bound its private placement. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
`ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the
availability check and covers that check plus the complete catalog probe. Providers that make
@@ -114,13 +116,13 @@ that were still active when the deadline expired.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and workspace identity, or per cwd for the legacy path, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
Boundary tests should assert observable behavior: New Workspace never falls back to a host cwd after selecting a runtime; cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one workspace; settings refresh wipes all scopes but immediately refreshes only global.
---
+2
View File
@@ -21,6 +21,8 @@ Evidence is something someone else can look at:
- A video of the interaction
- Logs, requests, responses
Workspace-runtime changes include the selected Local/Worktree parity cases, the shared common-Git watcher assertion, the fixture Desktop journey, and the blocking Docker matrix when Docker behavior changes. Provider-probe changes also include lifecycle/restart evidence and proof that probe records, processes, watchers, containers, and volumes do not leak.
Redact what you need to, keep the technical details. If an agent did the work, submit its raw output. A summary drops the details someone else needs to check it.
## Does it work well
+15 -9
View File
@@ -85,16 +85,22 @@ recomposition while the runtime still owns the same directory snapshot and timel
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record
contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch,
older-history availability, authority status, or sync generation because those facts would describe
the complete source dataset rather than the truncated display dataset.
The durable replica cache persists synchronization authority only when it can store the complete
current canonical window losslessly. The stored range describes those exact items: `startSeq` drives
older pagination and `endSeq` drives forward catch-up. Restore paints the items immediately, requests
`after endSeq`, and requests `before startSeq` when the user loads older history.
Restoring that cache produces a painted timeline: the items may render immediately, but the first
daemon timeline request is still `tail`. A successful tail response atomically establishes canonical
items, range, and older-history availability. Live rows received between cache paint and that tail
response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are
reconciled with the authoritative tail and subsequent catch-up.
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
instead of replaying every missed page. Live gap recovery still pages forward until current.
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail. Restore then uses the ordinary bounded `tail` bootstrap. Never
slice items while retaining the pre-slice range; that falsely certifies discarded source rows.
Live rows received between cache paint and catch-up stay in the separate live head and reconcile with
the authoritative range through the existing forward-page path. The cache does not persist sync
generation or unreconciled local submissions.
Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap
replacement keeps only positioned rows newer than the page it installs, while unresolved local
+1 -1
View File
@@ -1 +1 @@
sha256-i5PbVUe2Ec+GtghV9IpCJQJ9hcUT5hFhmxneNvoD584=
sha256-YJpDgaoaBS1lCJIVhxBIfeNelyYRsJMcN/HgEO4KehM=
+604 -11
View File
@@ -12,6 +12,7 @@
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
"packages/protocol",
"packages/client",
"packages/server",
@@ -19,7 +20,10 @@
"packages/relay",
"packages/website",
"packages/desktop",
"packages/cli"
"packages/cli",
"packages/workspace-runtime-contract",
"packages/workspace-helper",
"runtimes/fixture"
],
"devDependencies": {
"@types/ws": "^8.5.14",
@@ -6255,6 +6259,10 @@
"resolved": "packages/expo-two-way-audio",
"link": true
},
"node_modules/@getpaseo/fixture-workspace-runtime": {
"resolved": "runtimes/fixture",
"link": true
},
"node_modules/@getpaseo/highlight": {
"resolved": "packages/highlight",
"link": true
@@ -6275,6 +6283,14 @@
"resolved": "packages/website",
"link": true
},
"node_modules/@getpaseo/workspace-helper": {
"resolved": "packages/workspace-helper",
"link": true
},
"node_modules/@getpaseo/workspace-runtime-contract": {
"resolved": "packages/workspace-runtime-contract",
"link": true
},
"node_modules/@gorhom/bottom-sheet": {
"version": "5.2.14",
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.14.tgz",
@@ -7762,6 +7778,16 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@mattermost/react-native-paste-input": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@mattermost/react-native-paste-input/-/react-native-paste-input-2.0.1.tgz",
"integrity": "sha512-Q9zjXvjwxVQ1M8ylxr9Y+wUpG7xb1wW4u+I34oPvF3etTyBrGtVyA4Bt7Xphgc29eW6wQFUxTIbzZc3E6W2MQg==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": ">=0.76.0"
}
},
"node_modules/@mermaid-js/parser": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
@@ -9558,6 +9584,10 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@paseo/plugin": {
"resolved": "packages/plugin",
"link": true
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -28455,6 +28485,16 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz",
"integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/node-releases": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -36108,6 +36148,8 @@
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@mattermost/react-native-paste-input": "2.0.1",
"@paseo/plugin": "*",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
@@ -38571,6 +38613,22 @@
"vitest": "^4.1.6"
}
},
"packages/plugin": {
"name": "@paseo/plugin",
"version": "0.3.0",
"devDependencies": {
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
"typescript": "^5.9.3",
"vitest": "^4.1.6",
"zod": "^4.4.3"
},
"peerDependencies": {
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.4.0",
@@ -38809,18 +38867,23 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.4.0",
"@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",
"@paseo/plugin": "*",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"lru-cache": "^11.5.1",
@@ -38984,6 +39047,468 @@
"win32"
]
},
"packages/server/node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"packages/server/node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"packages/server/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/@isaacs/ttlcache": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
@@ -39138,6 +39663,47 @@
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"packages/server/node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
@@ -39156,16 +39722,6 @@
"node": "20 || >=22"
}
},
"packages/server/node_modules/node-pty": {
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz",
"integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"packages/server/node_modules/openai": {
"version": "6.44.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz",
@@ -40962,6 +41518,43 @@
"optional": true
}
}
},
"packages/workspace-helper": {
"name": "@getpaseo/workspace-helper",
"version": "0.4.0",
"dependencies": {
"zod": "^4.4.3"
},
"bin": {
"paseo-workspace-helper": "dist/executable.mjs"
},
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.9.3",
"vitest": "^4.1.6"
}
},
"packages/workspace-runtime-contract": {
"name": "@getpaseo/workspace-runtime-contract",
"version": "0.4.0",
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"typescript": "^5.9.3"
}
},
"runtimes/fixture": {
"name": "@getpaseo/fixture-workspace-runtime",
"version": "0.4.0",
"dependencies": {
"@getpaseo/workspace-helper": "0.4.0",
"@getpaseo/workspace-runtime-contract": "0.4.0",
"node-pty": "1.2.0-beta.15"
},
"bin": {
"paseo-fixture-workspace-runtime": "src/index.mjs"
}
}
}
}
+20 -10
View File
@@ -23,6 +23,7 @@
"workspaces": [
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
"packages/protocol",
"packages/client",
"packages/server",
@@ -30,7 +31,10 @@
"packages/relay",
"packages/website",
"packages/desktop",
"packages/cli"
"packages/cli",
"packages/workspace-runtime-contract",
"packages/workspace-helper",
"runtimes/fixture"
],
"scripts": {
"dev": "npm run dev:server",
@@ -46,19 +50,24 @@
"build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
"build:plugin": "npm run build --workspace=@paseo/plugin",
"build:plugin:clean": "npm run build:clean --workspace=@paseo/plugin",
"build:relay": "npm run build --workspace=@getpaseo/relay",
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
"build:client": "npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:workspace-runtime-contract": "npm run build --workspace=@getpaseo/workspace-runtime-contract",
"build:workspace-helper": "npm run build --workspace=@getpaseo/workspace-helper",
"build:workspace-runtime-fixture": "npm run build --workspace=@getpaseo/fixture-workspace-runtime",
"build:server-deps": "npm run build:workspace-runtime-contract && npm run build:workspace-helper && concurrently --kill-others-on-fail --names highlight,plugin,relay,client --prefix-colors yellow,green,blue,cyan \"npm run build:highlight\" \"npm run build:plugin\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean": "npm run build:clean --workspace=@getpaseo/workspace-runtime-contract && npm run build:clean --workspace=@getpaseo/workspace-helper && npm run build:highlight:clean && npm run build:plugin:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps": "npm run build:highlight && npm run build:plugin && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:plugin:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
@@ -69,6 +78,7 @@
"format:check": "oxfmt --check .",
"format:check:files": "oxfmt --check",
"lint": "oxlint",
"check:workspace-runtime-boundaries": "node scripts/check-workspace-runtime-boundaries.mjs",
"lint:fix": "oxlint --fix",
"knip": "knip",
"acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs",
@@ -99,11 +109,11 @@
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/workspace-runtime-contract && npm run typecheck --workspace=@getpaseo/workspace-helper && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/workspace-runtime-contract && npm pack --dry-run --workspace=@getpaseo/workspace-helper && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --dry-run --workspace=@getpaseo/workspace-helper --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-helper --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --workspace=@getpaseo/workspace-helper --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --workspace=@getpaseo/workspace-helper --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push",
+3 -1
View File
@@ -4,6 +4,7 @@ const pkg = require("./package.json");
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const withPasteInput = require("./plugins/with-paste-input");
const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
@@ -139,7 +140,8 @@ export default {
},
plugins: [
"expo-router",
[withAndroidAsyncStorageSize, 10],
withPasteInput,
[withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins,
[
"expo-splash-screen",
@@ -809,6 +809,7 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
);
});
const gate = await installDaemonWebSocketGate(page);
gate.setAgentStreamItemSuppressed("assistant_message", true);
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
@@ -816,14 +817,20 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
const prompt = "Delay synthetic user message by 300ms.";
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
gate.holdNextServerMessage("fetch_agent_timeline_response");
await recordTurnFrames(page, prompt);
await submitMessage(page, prompt);
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
await expect(submittedRow).toBeVisible();
await gate.waitForServerMessage("fetch_agent_timeline_response");
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
gate.truncateHeldTimelineAfterLast("user_message");
gate.setServerMessageSuppressed("fetch_agent_timeline_response", true);
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
await recordPaintsFor(page, 80);
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
gate.setServerMessageSuppressed("fetch_agent_timeline_response", false);
gate.setAgentStreamItemSuppressed("assistant_message", false);
} finally {
await workspace.cleanup();
}
@@ -30,6 +30,12 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { selectModel } from "../support/helpers/app";
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
import {
expectResumeOverflowFallsBackToOneTail,
rememberTimelineRequestCounts,
} from "../support/helpers/timeline-resume";
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
import {
scrollTimelineToNewestLoadedEdge,
scrollTimelineUntilOlderHistoryIsReachable,
@@ -336,6 +342,64 @@ async function expectInterruptedTurnOrderAfterReconnect(
}
}
async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const subscriptions = observeTimelineSubscriptions(page);
const gate = await installDaemonWebSocketGate(page);
const target = await seedMockAgentWorkspace({
repoPrefix: `submission-hidden-stream-${testInfo.workerIndex}-`,
title: "Hidden streaming submission",
model: "ten-second-stream",
});
const evictionAgents = await Promise.all(
Array.from({ length: 3 }, (_unused, index) =>
seedMockAgentWorkspace({
repoPrefix: `submission-workspace-eviction-${testInfo.workerIndex}-${index}-`,
title: `Workspace eviction ${index + 1}`,
}),
),
);
const prompt = "Keep this hidden image prompt before its streaming output.";
const targetDeckEntry = workspaceDeckEntryLocator(page, getServerId(), target.workspaceId);
try {
await openAgentRoute(page, target);
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents([target.agentId]);
const userMessageCount = gate.getAgentStreamItemCount("user_message");
gate.setAgentStreamSuppressed(true);
const promptRow = await submitMessageWithImage(page, prompt);
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
for (const evictionAgent of evictionAgents) {
await openAgentRoute(page, evictionAgent);
await expectComposerVisible(page);
}
await expect(targetDeckEntry).toHaveCount(0);
await subscriptions.waitForSubscribedAgents([evictionAgents[2]!.agentId]);
gate.setAgentStreamSuppressed(false);
await target.client.waitForFinish(target.agentId, 30_000);
const requestsBeforeReturn = rememberTimelineRequestCounts(gate);
await openAgentRoute(page, target);
await expectComposerVisible(page);
await subscriptions.waitForSubscribedAgents([target.agentId]);
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(response).toBeVisible();
await expectRenderedBefore(promptRow, response);
expectResumeOverflowFallsBackToOneTail(gate, requestsBeforeReturn);
} finally {
gate.setAgentStreamSuppressed(false);
gate.restore();
await Promise.all([...evictionAgents.map((agent) => agent.cleanup()), target.cleanup()]);
}
}
async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
page: Page,
testInfo: { workerIndex: number },
@@ -1013,6 +1077,13 @@ test.describe("Agent message submission", () => {
await expectInterruptedTurnOrderAfterReconnect(page, testInfo);
});
test("keeps a streaming hidden submission before its output after workspace eviction", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
await expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction(page, testInfo);
});
test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({
page,
}, testInfo) => {
@@ -26,9 +26,41 @@ import {
expectTimelineViewportAnchoredAfterPrepend,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
waitForPersistedCanonicalTimelineRange,
} from "../support/helpers/timeline-pagination";
import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.describe("Agent timeline pagination", () => {
test("resumes a persisted canonical timeline after its exact end", async ({ page }) => {
const prompt = "timeline persisted range resumes without tail replay";
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-persisted-range-",
title: "Persisted timeline range",
initialPrompt: prompt,
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, prompt);
const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId);
const tracker = await trackAgentTimelineRequests(page, agent.agentId);
await page.reload();
await expectTimelinePromptVisible(page, prompt);
const request = await tracker.nextRequest();
expect(request).toEqual({
direction: "after",
cursor: { epoch: range.epoch, seq: range.endSeq },
});
await tracker.waitForResponse();
expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false);
} finally {
await agent.cleanup();
}
});
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
page,
}) => {
@@ -13,9 +13,10 @@ import {
import {
commitTimelineTurnsWhileDisconnected,
disconnectViewedTimeline,
expectOneTailCheckWithoutCatchUpPages,
expectOneResumeCheckWithoutTail,
expectResumeOverflowFallsBackToOneTail,
rememberTimelineRequestCounts,
restoreViewedTimelineWithHeldTail,
restoreViewedTimelineWithHeldResponse,
} from "../support/helpers/timeline-resume";
test.describe("Agent timeline resume", () => {
@@ -29,11 +30,11 @@ test.describe("Agent timeline resume", () => {
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
const background = await commitTimelineTurnsWhileDisconnected(agent, 24);
await restoreViewedTimelineWithHeldTail(page, gate);
await restoreViewedTimelineWithHeldResponse(page, gate);
await expectTimelinePromptVisible(page, background.newestPrompt);
await expectTimelinePromptNotMounted(page, background.firstPrompt);
expectOneTailCheckWithoutCatchUpPages(gate, requests);
expectResumeOverflowFallsBackToOneTail(gate, requests);
await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt);
} finally {
gate.restore();
@@ -53,10 +54,10 @@ test.describe("Agent timeline resume", () => {
const requests = rememberTimelineRequestCounts(gate);
await disconnectViewedTimeline(page, gate);
await restoreViewedTimelineWithHeldTail(page, gate);
await restoreViewedTimelineWithHeldResponse(page, gate);
await expectTimelinePresentationUnchanged(page, presentation);
expectOneTailCheckWithoutCatchUpPages(gate, requests);
expectOneResumeCheckWithoutTail(gate, requests);
} finally {
gate.restore();
await agent.cleanup();
@@ -0,0 +1,79 @@
import { test } from "../support/fixtures";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
archiveFinishedSubagents,
expectArchiveFinishedInProgress,
expectArchiveFinishedRetry,
expectManagedSubagentArchived,
expectManagedSubagentUnarchived,
expectSubagentRowGone,
expectSubagentRowVisible,
holdManagedSubagentArchiveRequest,
openSubagentsTrack,
rejectNextManagedSubagentArchiveRequest,
seedParentWithSubagent,
} from "../support/helpers/subagents";
test.describe("Archive finished subagents", () => {
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "archive-finished-subagents-" });
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test("archives a finished managed child from the track", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Archive parent",
childTitle: "Finished child",
});
await workspace.client.waitForAgentUpsert(
agents.child.id,
(snapshot) => snapshot.status === "idle",
);
const archiveGate = await holdManagedSubagentArchiveRequest(page, agents.child.id);
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
await archiveFinishedSubagents(page);
await archiveGate.waitForRequest();
await expectSubagentRowGone(page, agents.child.id);
await expectArchiveFinishedInProgress(page, 0, 1);
await expectManagedSubagentUnarchived(workspace, agents.child.id);
archiveGate.release();
await expectManagedSubagentArchived(workspace, agents.child.id);
});
test("restores a failed child and retries the archive", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Retry parent",
childTitle: "Retry child",
});
await workspace.client.waitForAgentUpsert(
agents.child.id,
(snapshot) => snapshot.status === "idle",
);
const rejection = await rejectNextManagedSubagentArchiveRequest(page, agents.child.id);
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
await archiveFinishedSubagents(page);
await rejection.waitForRejection();
await expectSubagentRowVisible(page, agents.child.id);
await expectArchiveFinishedRetry(page, 1, 1);
await archiveFinishedSubagents(page);
await expectSubagentRowGone(page, agents.child.id);
await expectManagedSubagentArchived(workspace, agents.child.id);
});
});
@@ -1,10 +1,7 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import {
openNewWorkspaceComposer,
selectWorkspaceIsolation,
} from "../support/helpers/new-workspace";
import { openNewWorkspaceComposer, selectWorkspaceRuntime } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
@@ -177,7 +174,7 @@ test.describe("New Workspace dictation submit", () => {
projectKey: seeded.projectKey,
projectDisplayName: seeded.projectDisplayName,
});
await selectWorkspaceIsolation(page, "local");
await selectWorkspaceRuntime(page, "local");
await dictateAndSend(page, harness.waitForAudio);
await harness.waitForCreateRequest();
@@ -4,6 +4,8 @@ import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceControlsEnabled,
expectNewWorkspaceProjectSelected,
expectWorkspaceRuntimeChoices,
expectWorkspaceRuntimeSelected,
expectNewWorkspaceTriggerLabelsAligned,
openGlobalNewWorkspaceComposer,
openMissingProjectNewWorkspaceComposer,
@@ -29,7 +31,7 @@ import {
// (preselects that project) — shown for git projects and for non-git projects on
// a multiplicity-capable host. These specs prove the global entry opens the
// screen, the project icon preselects the right project across the reused 'new'
// screen, and non-git projects never offer the worktree Isolation control.
// screen, and non-git projects offer only the Local runtime.
function projectRow(page: import("@playwright/test").Page, projectKey: string) {
return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`);
@@ -134,10 +136,11 @@ test.describe("New workspace entry points", () => {
sourceDirectory: "/tmp/missing-project",
});
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible({ timeout: 30_000 });
await expectNewWorkspaceControlsEnabled(page);
});
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
test("the project shortcut opens the project picker with search focused", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
try {
@@ -246,9 +249,7 @@ test.describe("New workspace entry points", () => {
}
});
test("the Isolation control is hidden for a non-git project and shown for a git project", async ({
page,
}) => {
test("the Runtime control hides Git runtimes for a non-git project", async ({ page }) => {
const gitProject: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-iso-git-" });
const nonGitProject: SeededWorkspace = await seedWorkspace({
repoPrefix: "entry-iso-nongit-",
@@ -274,11 +275,10 @@ test.describe("New workspace entry points", () => {
await nonGitOption.click();
await expectNewWorkspaceProjectSelected(page, nonGitProject.projectDisplayName);
// No git checkout means no worktree isolation choice: the Isolation row is
// absent entirely.
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
await expectWorkspaceRuntimeSelected(page, "local");
await expectWorkspaceRuntimeChoices(page, ["local"]);
// Switching to the git project on the same screen reveals the Isolation row.
// Switching to the git project on the same screen adds Worktree.
await trigger.click();
const gitOption = page.getByTestId(
`new-workspace-project-picker-option-${projectEquivalenceViewKey(gitProject.projectKey)}`,
@@ -287,9 +287,8 @@ test.describe("New workspace entry points", () => {
await gitOption.click();
await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName);
await expect(page.getByTestId("workspace-create-isolation-trigger")).toBeVisible({
timeout: 30_000,
});
await expectWorkspaceRuntimeSelected(page, "local");
await expectWorkspaceRuntimeChoices(page, ["local", "worktree"]);
} finally {
await gitProject.cleanup();
await nonGitProject.cleanup();
@@ -1,29 +1,24 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
archiveLocalWorkspaceFromDaemon,
archiveWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
expectWorkspaceIsolationSelected,
expectWorkspaceRuntimeSelected,
openNewWorkspaceComposer,
openProjectViaDaemon,
openStartingRefPicker,
selectBranchInPicker,
} from "../support/helpers/new-workspace";
import {
gotoNewWorkspaceForRuntime,
seedGitProjectForRuntime,
} from "../support/helpers/new-workspace-runtime";
import { expectNoTruncation } from "../support/helpers/no-truncation";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Regression for "the local / worktree selection in the new workspace is not
// remembered." The isolation choice persists in the create-form preferences
// (FormPreferences.isolation), so it must survive the create→reopen remount:
// creating a worktree workspace navigates away from /new and unmounts it, and
// reopening New Workspace has to still show "New worktree".
test.describe("New workspace isolation memory", () => {
// Regression for "the Local / Worktree runtime selection in New Workspace is not
// remembered." The runtime choice persists in the create-form preferences, so it
// must survive the create→reopen remount: creating a Worktree workspace
// navigates away from /new and unmounts it.
test.describe("New workspace runtime memory", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const localWorkspaceIds = new Set<string>();
const createdWorktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 240_000 });
@@ -33,45 +28,26 @@ test.describe("New workspace isolation memory", () => {
});
test.afterEach(async () => {
if (client) {
for (const workspaceDirectory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
}
for (const workspaceId of localWorkspaceIds) {
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
}
for (const workspaceDirectory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(client, workspaceDirectory).catch(() => undefined);
}
createdWorktreeDirectories.clear();
localWorkspaceIds.clear();
await client?.close().catch(() => undefined);
});
test("remembers the worktree isolation choice after creating a workspace", async ({ page }) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("isolation-memory-", { branches: ["main", "dev"] });
test("remembers the Worktree runtime after creating a workspace", async ({ page }) => {
const project = await seedGitProjectForRuntime();
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
// First visit: the screen opens on Local, switch it to New worktree and create.
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "local");
await page.getByTestId("workspace-create-isolation-trigger").click();
const isolationPopup = page.getByTestId("combobox-desktop-container").last();
await expect(isolationPopup).toBeVisible({ timeout: 30_000 });
await expectNoTruncation(isolationPopup);
await page.getByTestId("workspace-create-isolation-worktree").click();
await expectWorkspaceIsolationSelected(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
// First visit: the screen opens on Local, switch it to Worktree and create.
await gotoNewWorkspaceForRuntime(page, project);
await expectWorkspaceRuntimeSelected(page, "local");
await page.getByRole("button", { name: "Runtime", exact: true }).click();
const runtimePopup = page.getByRole("dialog").last();
await expect(runtimePopup).toBeVisible({ timeout: 30_000 });
await expectNoTruncation(runtimePopup);
await runtimePopup.getByRole("button", { name: "Worktree", exact: true }).click();
await expectWorkspaceRuntimeSelected(page, "worktree");
const createButton = page
.getByTestId("message-input-root")
@@ -80,21 +56,21 @@ test.describe("New workspace isolation memory", () => {
await createButton.click();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
serverId: getServerId(),
client,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
previousWorkspaceId: "",
projectDisplayName: project.projectDisplayName,
});
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
// Second visit (fresh mount of /new): the worktree choice must stick.
// Second visit (fresh mount of /new): the runtime choice must stick.
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
projectKey: project.projectKey,
projectDisplayName: project.projectDisplayName,
});
await expectWorkspaceIsolationSelected(page, "worktree");
await expectWorkspaceRuntimeSelected(page, "worktree");
} finally {
await tempRepo.cleanup();
await project.cleanup();
}
});
});
@@ -21,13 +21,13 @@ import {
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// `sleep` keeps the terminal alive long enough for the UI to attach and
// render output before the process exits — see new-workspace-launch-terminal.spec.ts.
// Loaded CI workers can take several seconds to materialize the runtime and attach the UI.
// Keep the fixture bounded but alive through that assertion window.
const PROMPT_PROFILE: TerminalProfile = {
id: "e2e-memory-profile",
name: "Memory Profile",
command: "/bin/sh",
args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"],
args: ["-c", 'echo remembered: "$0"; sleep 60', "{{{prompt}}}"],
};
function hasLeftNewWorkspaceRoute(url: URL): boolean {
@@ -1,16 +1,17 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
openNewWorkspaceComposer,
openStartingRefPicker,
selectBranchInPicker,
selectWorkspaceIsolation,
selectWorkspaceRuntime,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
gotoNewWorkspaceForRuntime,
seedGitProjectForRuntime,
type SeededRuntimeProject,
} from "../support/helpers/new-workspace-runtime";
import { getServerId } from "../support/helpers/server-id";
import { seedSavedSettingsHosts } from "../support/helpers/settings";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const LONG_HOST_NAME =
"development-macbook-pro.local-connected-through-a-very-long-private-hostname";
@@ -27,17 +28,14 @@ function measureControlRightEdges(controls: HTMLElement[]) {
}
test.describe("New workspace metadata row layout", () => {
let workspace: SeededWorkspace;
let project: SeededRuntimeProject;
test.beforeEach(async () => {
workspace = await seedWorkspace({
repoPrefix: "new-workspace-layout-",
repo: { branches: [LONG_BRANCH_NAME] },
});
project = await seedGitProjectForRuntime({ branches: [LONG_BRANCH_NAME] });
});
test.afterEach(async () => {
await workspace?.cleanup();
await project?.cleanup();
});
test("long host and branch names stay inside the composer's right rail", async ({ page }) => {
@@ -55,13 +53,8 @@ test.describe("New workspace metadata row layout", () => {
},
]);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await gotoNewWorkspaceForRuntime(page, project);
await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, LONG_BRANCH_NAME);
@@ -32,7 +32,7 @@ import {
selectBranchInPicker,
selectGitHubPrInPicker,
selectPickerOptionByKeyboard,
selectWorkspaceIsolation,
selectWorkspaceRuntime,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import {
@@ -723,7 +723,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
@@ -771,7 +771,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
return openedProject;
}
@@ -905,7 +905,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
@@ -930,7 +930,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await openBranchPicker(page);
await expectPickerOpen(page);
@@ -960,7 +960,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, pr.number);
@@ -996,7 +996,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page);
await selectBranchInPicker(page, "main");
@@ -1054,7 +1054,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await pasteGithubPrUrl(page, context, pr.url);
await expectStartingRefPickerTriggerPr(page, {
number: pr.number,
@@ -1102,7 +1102,7 @@ test.describe("New workspace flow", () => {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await selectWorkspaceIsolation(page, "worktree");
await selectWorkspaceRuntime(page, "worktree");
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, pr.number);
await submitNewWorkspaceWithoutPrompt(page);
@@ -0,0 +1,209 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
expectSettingsHeader,
openHostSection,
openSettingsHost,
} from "../support/helpers/settings";
function observePluginCatalog(page: Page) {
let responses = 0;
page.on("websocket", (socket) => {
socket.on("framereceived", ({ payload }) => {
if (typeof payload !== "string") return;
try {
const envelope = JSON.parse(payload) as {
type?: unknown;
message?: { type?: unknown };
};
const message = envelope.type === "session" ? envelope.message : envelope;
if (message?.type === "plugin.catalog.get.response") responses += 1;
} catch {
return;
}
});
});
return {
waitForInitialFetch: async () => {
await expect.poll(() => responses).toBeGreaterThan(0);
},
};
}
function pluginSource(title: string, renderError?: string): string {
return `import React from "react";
import { Text } from "react-native";
export default function contribute(plugin) {
function Surface() {
${renderError ? `throw new Error(${JSON.stringify(renderError)});` : ""}
const cleanups = Number(globalThis.sessionStorage?.getItem("paseo-plugin-cleanups") || "0");
return <Text>${title} cleanup {cleanups}</Text>;
}
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: ${JSON.stringify(title)}, icon: "Blocks", surface: "main" });
${
renderError
? `function HealthySurface() { return <Text>Healthy contribution</Text>; }
plugin.addSurface("healthy", HealthySurface);
plugin.addSidebarItem({ id: "healthy", title: "Healthy surface", icon: "Blocks", surface: "healthy" });`
: ""
}
return () => {
const storage = globalThis.sessionStorage;
if (storage) {
const cleanups = Number(storage.getItem("paseo-plugin-cleanups") || "0");
storage.setItem("paseo-plugin-cleanups", String(cleanups + 1));
}
};
}`;
}
async function openPluginSettings(page: Page): Promise<void> {
const serverId = getServerId();
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "plugins");
await expectSettingsHeader(page, "Plugins");
}
async function reloadPlugin(page: Page): Promise<void> {
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByText("Reloaded e2e-plugin", { exact: true })).toBeVisible();
}
async function expectContributionBody(page: Page, expectedBody: string): Promise<void> {
const body = page.getByText(expectedBody, { exact: true }).filter({ visible: true });
await expect(body).toHaveCount(1);
await expect(body).toBeVisible();
}
async function openContribution(page: Page, title: string, expectedBody: string): Promise<void> {
await page.getByRole("button", { name: title, exact: true }).click();
await expectContributionBody(page, expectedBody);
}
async function leavePluginSettings(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).not.toHaveURL(/\/settings\//);
}
async function openContributionFromSettings(
page: Page,
title: string,
expectedBody: string,
): Promise<void> {
await leavePluginSettings(page);
await openContribution(page, title, expectedBody);
}
async function reloadActiveContribution(
page: Page,
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
expectedBody: string,
): Promise<void> {
await client.reloadPlugin("e2e-plugin");
await expectContributionBody(page, expectedBody);
}
async function expectContributionRemoved(page: Page, title: string): Promise<void> {
await expect(page.getByRole("button", { name: title, exact: true })).not.toBeVisible();
}
test("installs, reloads, recovers, disables, and removes a trusted local plugin", async ({
page,
}) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1"));
try {
const catalog = observePluginCatalog(page);
await gotoAppShell(page);
await openPluginSettings(page);
await catalog.waitForInitialFetch();
await client.patchDaemonConfig({ pluginsEnabled: false });
await page.getByRole("switch", { name: "Enable plugins" }).click();
await expect(page.getByText("Plugins enabled", { exact: true })).toBeVisible();
await page.getByLabel("Plugin directory").fill(directory);
await page.getByLabel("Plugin installation ID").focus();
await expect(page.getByLabel("Plugin installation ID")).toHaveValue("e2e-plugin");
await page.getByRole("button", { name: "Install directory" }).click();
await expect(page.getByText("Installed e2e-plugin", { exact: true })).toBeVisible();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 0");
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2"));
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
await writeFile(
path.join(directory, "index.tsx"),
pluginSource("Broken surface", "render exploded"),
);
await openPluginSettings(page);
await reloadPlugin(page);
await openContributionFromSettings(page, "Broken surface", "Plugin failed: render exploded");
await openContribution(page, "Healthy surface", "Healthy contribution");
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface"));
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
await expect(
page.getByRole("button", { name: "Broken surface", exact: true }),
).not.toBeVisible();
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
await expect(page.getByLabel("e2e-plugin failed")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Recovered surface");
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3"));
await openPluginSettings(page);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 5");
await openPluginSettings(page);
await page.getByRole("button", { name: "Disable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin disabled")).toBeVisible();
await leavePluginSettings(page);
await expectContributionRemoved(page, "Plugin v3");
await openPluginSettings(page);
await page.getByRole("button", { name: "Enable", exact: true }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await openContributionFromSettings(page, "Plugin v3", "Plugin v3 cleanup 6");
await openPluginSettings(page);
page.once("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Remove", exact: true }).click();
await expect(page.getByText("No plugins configured", { exact: true })).toBeVisible();
await page.getByLabel("Plugin directory").fill(directory);
await page.getByRole("button", { name: "Install directory" }).click();
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
await gotoAppShell(page);
await openContribution(page, "Plugin v3", "Plugin v3 cleanup 7");
} finally {
await client.removePlugin("e2e-plugin").catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
@@ -10,6 +10,7 @@ import {
fillComposerDraft,
submitMessage,
} from "../support/helpers/composer";
import { readReplicaCache, writeReplicaCache } from "../support/helpers/replica-cache-storage";
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
@@ -27,39 +28,28 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
for (const host of cache.hosts ?? []) {
for (const item of host.timeline?.items ?? []) {
if (item.kind === "user_message" && item.text === messageText && item.messageId) {
return true;
}
.poll(async () => {
const cache = await readReplicaCache(page);
if (!cache) return false;
for (const host of cache.hosts ?? []) {
for (const item of host.timeline?.items ?? []) {
if (item.kind === "user_message" && item.text === prompt && item.messageId) {
return true;
}
}
return false;
}, prompt),
)
}
return false;
})
.toBe(true);
await page.evaluate((messageText) => {
const key = "@paseo:replica-cache";
const raw = localStorage.getItem(key);
if (!raw) throw new Error("Replica cache was not persisted");
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
const cachedMessage = cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.find((item) => item.kind === "user_message" && item.text === messageText);
if (!cachedMessage) throw new Error("Cached user message was not found");
delete cachedMessage.messageId;
localStorage.setItem(key, JSON.stringify(cache));
}, prompt);
const cache = await readReplicaCache(page);
if (!cache) throw new Error("Replica cache was not persisted");
const cachedMessage = cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.find((item) => item.kind === "user_message" && item.text === prompt);
if (!cachedMessage) throw new Error("Cached user message was not found");
delete cachedMessage.messageId;
await writeReplicaCache(page, cache);
}
async function waitForCurrentSubmissionExcludedFromCache(
@@ -67,46 +57,34 @@ async function waitForCurrentSubmissionExcludedFromCache(
prompt: string,
): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
return !cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
typeof item.clientMessageId === "string" &&
item.messageId === undefined,
);
}, prompt),
)
.poll(async () => {
const cache = await readReplicaCache(page);
if (!cache) return false;
return !cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === prompt &&
typeof item.clientMessageId === "string" &&
item.messageId === undefined,
);
})
.toBe(true);
}
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
return cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
item.messageId === undefined,
);
}, prompt),
)
.poll(async () => {
const cache = await readReplicaCache(page);
if (!cache) return false;
return cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" && item.text === prompt && item.messageId === undefined,
);
})
.toBe(true);
}
@@ -148,16 +126,13 @@ test.describe("Rewind sheet", () => {
title: "Rewind cache upgrade e2e",
initialPrompt: prompt,
});
let heldTimelineRequest = false;
try {
await openAgentRoute(page, session);
await expectUserMessageVisible(page, prompt);
await gate.drop();
await rewriteCachedMessageAsLegacyRow(page, prompt);
gate.holdNextClientRequest("fetch_agent_timeline_request");
await page.reload();
await gate.waitForHeldClientRequest();
heldTimelineRequest = true;
const restoredMessage = userMessage(page, prompt);
await expect(restoredMessage).toBeVisible();
@@ -165,7 +140,6 @@ test.describe("Rewind sheet", () => {
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
await waitForCachedMessageWithoutProviderId(page, prompt);
} finally {
if (heldTimelineRequest) gate.releaseHeldClientRequest();
gate.restore();
await session.cleanup();
}
@@ -18,7 +18,25 @@ async function visibleBoundingBox(row: Locator) {
return box;
}
async function quickDragFirstRowAfterSecond(rows: Locator) {
async function pressProjectRow(rows: Locator) {
await rows.page().mouse.down();
}
async function pressWorkspaceRow(rows: Locator) {
const solidScrimStop = rows
.nth(0)
.getByTestId("sidebar-workspace-trailing-scrim")
.locator("stop")
.nth(1);
const hoverScrimColor = await solidScrimStop.getAttribute("stop-color");
await rows.page().mouse.down();
await expect.poll(() => solidScrimStop.getAttribute("stop-color")).not.toBe(hoverScrimColor);
}
async function quickDragFirstRowAfterSecond(
rows: Locator,
pressRow: (rows: Locator) => Promise<void>,
) {
await expect(rows).toHaveCount(2);
const before = await rowTestIds(rows);
const sourceBox = await visibleBoundingBox(rows.nth(0));
@@ -29,15 +47,19 @@ async function quickDragFirstRowAfterSecond(rows: Locator) {
const target = { x: targetBox.x + targetBox.width / 2, y: targetBox.y + targetBox.height / 2 };
await page.mouse.move(source.x, source.y);
await page.mouse.down();
const trailingScrim = rows.nth(0).getByTestId("sidebar-workspace-trailing-scrim");
await pressRow(rows);
await page.mouse.move(source.x, source.y + 7);
await expect(trailingScrim).toHaveCount(0);
await page.mouse.move(target.x, target.y, { steps: 4 });
await page.mouse.up();
await expect.poll(() => rowTestIds(rows)).toEqual([before[1], before[0]]);
}
test("projects and workspaces reorder with an immediate mouse drag", async ({ page }) => {
test("projects, workspaces, and pinned chats reorder with an immediate mouse drag", async ({
page,
}) => {
const firstProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-first-" });
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-reorder-second-" });
@@ -61,6 +83,7 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa
const secondProjectTestId = `sidebar-project-row-${projectEquivalenceViewKey(secondProject.projectKey)}`;
await quickDragFirstRowAfterSecond(
page.locator(`[data-testid="${firstProjectTestId}"], [data-testid="${secondProjectTestId}"]`),
pressProjectRow,
);
const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`;
const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`;
@@ -68,6 +91,17 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa
page.locator(
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondWorkspaceTestId}"]`,
),
pressWorkspaceRow,
);
await firstProject.client.setWorkspacePinned(firstProject.workspaceId, true);
await secondProject.client.setWorkspacePinned(secondProject.workspaceId, true);
const secondProjectWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondProject.workspaceId}`;
await quickDragFirstRowAfterSecond(
page.locator(
`[data-testid="${firstWorkspaceTestId}"], [data-testid="${secondProjectWorkspaceTestId}"]`,
),
pressWorkspaceRow,
);
} finally {
await firstProject.cleanup();
@@ -11,7 +11,7 @@ import {
connectNewWorkspaceDaemonClient,
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
selectWorkspaceIsolation,
selectWorkspaceRuntime,
submitNewWorkspaceEmpty,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
@@ -271,7 +271,7 @@ test.describe("Workspace model regressions", () => {
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceIsolation(page, "local");
await selectWorkspaceRuntime(page, "local");
await submitNewWorkspacePrompt(page, "Fix login bug");
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -356,7 +356,7 @@ test.describe("Workspace model regressions", () => {
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceIsolation(page, "local");
await selectWorkspaceRuntime(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -472,7 +472,7 @@ test.describe("Workspace model regressions", () => {
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
await selectWorkspaceIsolation(page, "local");
await selectWorkspaceRuntime(page, "local");
await submitNewWorkspaceEmpty(page);
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
@@ -6,7 +6,7 @@ import {
connectNewWorkspaceDaemonClient,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
selectWorkspaceIsolation,
selectWorkspaceRuntime,
submitNewWorkspaceEmpty,
} from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
@@ -15,8 +15,8 @@ import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
// Model B reshape: a workspace is the unit, its isolation (local checkout or
// worktree) is a CHOICE at creation, and creation NEVER dedupes by
// Model B reshape: a workspace is the unit, its runtime (Local or Worktree) is
// a CHOICE at creation, and creation NEVER dedupes by
// directory. These specs drive the real creation UI (workspace-create-* test
// IDs) to prove a single directory can back any number of workspaces.
@@ -39,17 +39,16 @@ async function createWorkspaceViaUi(
page: Page,
input: {
project: { projectKey: string; projectDisplayName: string };
// null when the project has no git checkout: there is no Isolation control to
// touch, the isolation is implicitly local.
isolation: "local" | "worktree" | null;
// null when the project has no git checkout: Local is the only available runtime.
runtime: "local" | "worktree" | null;
previousWorkspaceId: string;
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
},
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, input.project);
if (input.isolation !== null) {
await selectWorkspaceIsolation(page, input.isolation);
if (input.runtime !== null) {
await selectWorkspaceRuntime(page, input.runtime);
}
await submitNewWorkspaceEmpty(page);
@@ -97,7 +96,7 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, {
project,
isolation: "local",
runtime: "local",
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -128,9 +127,7 @@ test.describe("Workspace multiplicity creation flow", () => {
}
});
test("New worktree isolation creates a worktree-backed workspace in a distinct directory", async ({
page,
}) => {
test("the Worktree runtime creates a workspace in a distinct directory", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({
repoPrefix: "multiplicity-worktree-",
});
@@ -149,7 +146,7 @@ test.describe("Workspace multiplicity creation flow", () => {
const worktree = await createWorkspaceViaUi(page, {
project,
isolation: "worktree",
runtime: "worktree",
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -200,8 +197,8 @@ test.describe("Workspace multiplicity creation flow", () => {
const second = await createWorkspaceViaUi(page, {
project,
// Non-git project: no Isolation control, isolation is implicitly local.
isolation: null,
// Non-git project: Local is the only available runtime.
runtime: null,
previousWorkspaceId: seeded.workspaceId,
client,
});
@@ -59,15 +59,16 @@ test.describe("Worktree restore", () => {
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agents = await Promise.all(
Array.from({ length: options.agentCount ?? 1 }, () =>
createIdleAgent(client, {
const agents = [];
for (let index = 0; index < (options.agentCount ?? 1); index += 1) {
agents.push(
await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
}),
),
);
);
}
const agent = agents[0];
if (!agent) {
throw new Error("Expected at least one archived-worktree agent");
@@ -369,10 +370,7 @@ test.describe("Worktree restore", () => {
timeout: 30_000,
});
await expect(
page.getByText(
"The archived workspace directory no longer exists and cannot be recreated.",
{ exact: true },
),
page.getByText("The archived workspace runtime is missing.", { exact: true }),
).toBeVisible();
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
} finally {
@@ -31,6 +31,82 @@ export interface PromptJumpRequestTracker {
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
}
export interface TimelineRequestTracker {
nextRequest(): Promise<{
direction: string | null;
cursor: { epoch: string; seq: number } | null;
}>;
requests(): Array<{
direction: string | null;
cursor: { epoch: string; seq: number } | null;
}>;
waitForResponse(): Promise<void>;
}
export async function trackAgentTimelineRequests(
page: Page,
agentId: string,
): Promise<TimelineRequestTracker> {
type Request = ReturnType<TimelineRequestTracker["requests"]>[number];
const seen: Request[] = [];
const waiters: Array<(request: Request) => void> = [];
let responseSeen = false;
let resolveResponse: (() => void) | null = null;
const response = new Promise<void>((resolve) => {
resolveResponse = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (
sessionMessage?.type === "fetch_agent_timeline_request" &&
sessionMessage.agentId === agentId
) {
const rawCursor = sessionMessage.cursor;
const cursor =
rawCursor &&
typeof rawCursor === "object" &&
typeof (rawCursor as { epoch?: unknown }).epoch === "string" &&
typeof (rawCursor as { seq?: unknown }).seq === "number"
? {
epoch: (rawCursor as { epoch: string }).epoch,
seq: (rawCursor as { seq: number }).seq,
}
: null;
const request = {
direction: typeof sessionMessage.direction === "string" ? sessionMessage.direction : null,
cursor,
};
seen.push(request);
waiters.shift()?.(request);
}
server.send(message);
});
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
sessionMessage?.type === "fetch_agent_timeline_response" &&
payload?.agentId === agentId
) {
responseSeen = true;
resolveResponse?.();
}
ws.send(message);
});
});
return {
nextRequest() {
const request = seen[0];
if (request) return Promise.resolve(request);
return new Promise((resolve) => waiters.push(resolve));
},
requests: () => [...seen],
waitForResponse: () => (responseSeen ? Promise.resolve() : response),
};
}
export async function trackPromptJumpRequests(
page: Page,
agentId: string,
+71 -1
View File
@@ -1,5 +1,6 @@
import { execSync } from "node:child_process";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./paseo-home-fork";
@@ -139,6 +140,74 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro
);
}
async function configureDesktopFixtureRuntime(paseoHome: string): Promise<void> {
if (process.env.E2E_DESKTOP_RUNTIME !== "1") return;
const configPath = path.join(paseoHome, "config.json");
const existing = existsSync(configPath) ? JSON.parse(await readFile(configPath, "utf8")) : {};
const stateDirectory = path.join(paseoHome, "fixture-runtime");
const materializeRoot = path.join(paseoHome, "fixture-workspaces");
const fixtureProviderSource = path.resolve(
__dirname,
"../../../../../runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs",
);
await mkdir(stateDirectory, { recursive: true });
await writeFile(
configPath,
`${JSON.stringify({
...existing,
version: 1,
agents: {
...existing.agents,
providers: {
...existing.agents?.providers,
claude: { ...existing.agents?.providers?.claude, enabled: false },
codex: { ...existing.agents?.providers?.codex, enabled: false },
copilot: { ...existing.agents?.providers?.copilot, enabled: false },
opencode: { ...existing.agents?.providers?.opencode, enabled: false },
pi: { ...existing.agents?.providers?.pi, enabled: false },
"fixture-agent": {
extends: "acp",
label: "Fixture Agent",
command: [process.execPath, "./.paseo-fixture-agent.mjs"],
models: [{ id: "fixture-model", label: "Fixture Model", isDefault: true }],
enabled: true,
},
},
},
workspaceRuntimes: {
...existing.workspaceRuntimes,
fixture: {
type: "command",
label: "Fixture",
command: [
process.execPath,
path.resolve(__dirname, "../../../../../runtimes/fixture/src/index.mjs"),
],
options: {
stateDirectory,
materializeRoot,
fixtureProviderSource,
preserveSourceDisplayCwd: true,
},
},
"fixture-failure": {
type: "command",
label: "Fixture Failure",
command: [
process.execPath,
path.resolve(__dirname, "../../../../../runtimes/fixture/src/index.mjs"),
],
options: {
stateDirectory: path.join(paseoHome, "fixture-failure-runtime"),
failCreate: true,
createError: "Fixture probe creation failed",
},
},
},
})}\n`,
);
}
export async function startE2EWorker(
workerIndex: number,
options: { forkProviders?: string[] } = {},
@@ -154,6 +223,7 @@ export async function startE2EWorker(
try {
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
await configureDesktopFixtureRuntime(paseoHome);
const daemon = await startIsolatedHostDaemon(serverId, {
paseoHome,
preserveHome,
@@ -0,0 +1,330 @@
import { access, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { waitForConnectedHost } from "./hosts";
import {
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
submitNewWorkspaceEmpty,
} from "./new-workspace";
import { connectSeedClient } from "./seed-client";
import { getServerId } from "./server-id";
import { createTempDirectory, createTempGitRepo } from "./workspace";
export interface SeededRuntimeProject {
projectId: string;
projectKey: string;
projectDisplayName: string;
sourceDirectory: string;
cleanup(): Promise<void>;
}
const SETUP_MARKER = "runtime-user-setup-ran.txt";
export async function seedGitProjectForRuntime(
options?: Parameters<typeof createTempGitRepo>[1],
): Promise<SeededRuntimeProject> {
const repo = await createTempGitRepo("runtime-selector-", {
...options,
paseoConfig: options?.paseoConfig ?? {
worktree: { setup: [`printf setup > ${SETUP_MARKER}`] },
},
});
return seedRuntimeProject(repo);
}
async function readProbeRecords(projectId: string): Promise<Array<{ workspaceId: string }>> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "provider-probes.json"), "utf8"),
) as Array<{ workspaceId: string; projectId: string }>;
return records.filter((record) => record.projectId === projectId);
}
async function markerExists(root: string): Promise<boolean> {
return access(path.join(root, SETUP_MARKER)).then(
() => true,
() => false,
);
}
export async function expectProbeSkippedProjectSetup(project: SeededRuntimeProject): Promise<void> {
expect(await markerExists(project.sourceDirectory)).toBe(false);
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const probeIds = new Set(
(await readProbeRecords(project.projectId)).map((record) => record.workspaceId),
);
const stateDirectory = path.join(paseoHome, "fixture-runtime");
const stateFiles = await readdir(stateDirectory);
const states = await Promise.all(
stateFiles.map(
async (file) =>
JSON.parse(await readFile(path.join(stateDirectory, file), "utf8")) as {
workspaceId: string;
root: string;
},
),
);
const probe = states.find((state) => probeIds.has(state.workspaceId));
expect(probe, "fixture probe runtime state").toBeDefined();
expect(await markerExists(probe!.root)).toBe(false);
}
export async function expectUserWorkspaceRanProjectSetup(
project: SeededRuntimeProject,
): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
) as Array<{ projectId: string; workspaceId: string; runtime?: { runtimeId: string } }>;
const workspace = records.find(
(record) => record.projectId === project.projectId && record.runtime?.runtimeId === "fixture",
);
expect(workspace, "fixture user workspace record").toBeDefined();
const stateFiles = await readdir(path.join(paseoHome, "fixture-runtime"));
const states = await Promise.all(
stateFiles.map(
async (file) =>
JSON.parse(await readFile(path.join(paseoHome, "fixture-runtime", file), "utf8")) as {
workspaceId: string;
root: string;
},
),
);
const state = states.find((candidate) => candidate.workspaceId === workspace!.workspaceId);
expect(state, "fixture user runtime state").toBeDefined();
await expect.poll(() => markerExists(state!.root), { timeout: 30_000 }).toBe(true);
}
export async function expectProviderAvailable(page: Page, providerLabel: string): Promise<void> {
const trigger = page.getByRole("button", { name: /Select model/ });
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
await page.getByRole("dialog").last().getByRole("button", { name: "Back", exact: true }).click();
await page.getByText(providerLabel, { exact: true }).click();
await expect(page.getByRole("button", { name: `Open ${providerLabel} settings` })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
}
export async function selectRuntimeProviderModel(
page: Page,
input: { provider: string; model: string },
): Promise<void> {
const trigger = page.getByRole("button", { name: /Select model/ });
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
const dialog = page.getByRole("dialog").last();
await dialog.getByRole("button", { name: "Back", exact: true }).click();
await dialog.getByText(input.provider, { exact: true }).click();
const model = dialog.getByText(input.model, { exact: true });
await expect(model).toBeVisible({ timeout: 30_000 });
await model.click();
await expect(trigger).toContainText(input.model);
}
export async function expectRuntimeProviderUnavailable(
page: Page,
providerLabel: string,
): Promise<void> {
const trigger = page.getByRole("button", { name: /Select model/ });
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
const dialog = page.getByRole("dialog").last();
await dialog.getByRole("button", { name: "Back", exact: true }).click();
const providerId = providerLabel.toLowerCase().replaceAll(" ", "");
const providerRow = dialog.getByTestId(`model-provider-${providerId}`);
await expect(providerRow).toBeVisible({ timeout: 30_000 });
await expect(providerRow).toHaveText(`${providerLabel}Error`);
await page.keyboard.press("Escape");
}
export async function expectFixtureProviderUnavailable(page: Page): Promise<void> {
const providerLabel = "Fixture Agent";
const trigger = page.getByRole("button", { name: /Select model/ });
await expect(trigger).toBeEnabled({ timeout: 30_000 });
await trigger.click();
await page.getByRole("dialog").last().getByRole("button", { name: "Back", exact: true }).click();
await page.getByText(providerLabel, { exact: true }).click();
await expect(page.getByRole("button", { name: `Open ${providerLabel} settings` })).toBeVisible();
await expect(page.getByText(/^Fixture Model/u)).not.toBeVisible();
await page.keyboard.press("Escape");
}
export async function expectProbeFailureWithRetry(page: Page, message: string): Promise<void> {
await expect(page.getByRole("alert")).toContainText(message, { timeout: 30_000 });
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /Select model/ })).toBeDisabled();
await expect(page.getByText("Fixture Agent", { exact: true })).not.toBeVisible();
}
export async function retryFailedProbe(page: Page): Promise<void> {
await page.getByRole("button", { name: "Retry", exact: true }).click();
}
export async function expectNoProbeInWorkspaceProjection(
page: Page,
project: SeededRuntimeProject,
): Promise<void> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const probeIds = new Set(
(await readProbeRecords(project.projectId)).map((record) => record.workspaceId),
);
const workspaces = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(workspaces.entries.some((workspace) => probeIds.has(workspace.id))).toBe(false);
for (const probeId of probeIds) {
await expect(page.getByTestId(`sidebar-workspace-${probeId}`)).toHaveCount(0);
}
} finally {
await client.close();
}
}
export async function seedNonGitProjectForRuntime(): Promise<SeededRuntimeProject> {
const directory = await createTempDirectory("runtime-selector-non-git-");
return seedRuntimeProject(directory);
}
async function seedRuntimeProject(resource: {
path: string;
cleanup(): Promise<void>;
}): Promise<SeededRuntimeProject> {
const client = await connectSeedClient();
const added = await client.addProject(resource.path);
if (added.error || !added.project) {
await client.close();
await resource.cleanup();
throw new Error(added.error ?? "Runtime project was not added");
}
const listed = await client.listProjects();
const project = listed.projects.find(
(candidate) => candidate.projectId === added.project?.projectId,
);
if (!project?.projectKey) {
await client.close();
await resource.cleanup();
throw new Error("Runtime project has no project key");
}
return {
projectId: added.project.projectId,
projectKey: project.projectKey,
projectDisplayName: added.project.projectDisplayName,
sourceDirectory: resource.path,
cleanup: async () => {
await client.removeProject(added.project!.projectId);
await client.close();
await resource.cleanup();
},
};
}
export async function gotoNewWorkspaceForRuntime(
page: Page,
project: SeededRuntimeProject,
): Promise<void> {
await gotoAppShell(page);
await waitForConnectedHost(page, {
serverId: getServerId(),
endpoint: `localhost:${getE2EDaemonPort()}`,
});
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, project);
await expect(page.getByRole("button", { name: "Runtime", exact: true })).toContainText("Local");
}
export async function expectRuntimeChoices(page: Page, labels: readonly string[]): Promise<void> {
await page.getByRole("button", { name: "Runtime", exact: true }).click();
const dialog = page.getByRole("dialog").last();
for (const label of labels) {
await expect(dialog.getByRole("button", { name: label, exact: true })).toBeVisible();
}
await expect(dialog.getByRole("button")).toHaveCount(labels.length);
await page.keyboard.press("Escape");
}
export async function expectRuntimeSelected(page: Page, label: string): Promise<void> {
await expect(page.getByRole("button", { name: "Runtime", exact: true })).toContainText(label);
}
export async function selectRuntime(page: Page, label: string): Promise<void> {
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
await trigger.click();
await page.getByRole("dialog").last().getByRole("button", { name: label, exact: true }).click();
await expect(trigger).toContainText(label);
}
export async function createWorkspaceInSelectedRuntime(page: Page): Promise<void> {
await submitNewWorkspaceEmpty(page);
}
export async function expectWorkspaceOpenInRuntime(
page: Page,
project: SeededRuntimeProject,
runtimeId: string,
): Promise<string> {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
try {
const workspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
client,
previousWorkspaceId: "",
projectDisplayName: project.projectDisplayName,
timeoutMs: 120_000,
});
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>;
const record = records.find((candidate) => candidate.workspaceId === workspace.workspaceId);
expect(record?.runtime).toEqual({ runtimeId });
return workspace.workspaceId;
} finally {
await client.close();
}
}
export async function expectSelectedHostRuntimePlacement(
project: SeededRuntimeProject,
runtimeId: "local" | "worktree",
setupRan: boolean,
): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
) as Array<{
projectId: string;
cwd: string;
hostVisiblePath?: string | null;
runtime?: { runtimeId: string };
}>;
const workspace = records.find(
(record) => record.projectId === project.projectId && record.runtime?.runtimeId === runtimeId,
);
expect(workspace, `${runtimeId} selected workspace record`).toBeDefined();
expect(workspace?.hostVisiblePath).toBe(workspace?.cwd);
if (setupRan) {
await expect.poll(() => markerExists(workspace!.cwd), { timeout: 30_000 }).toBe(true);
} else {
expect(await markerExists(workspace!.cwd)).toBe(false);
}
}
export async function expectHostWorkspaceAffordances(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Open workspace in VS Code" })).toBeVisible();
await page.getByRole("button", { name: "Choose editor" }).click();
await expect(page.getByText("VS Code", { exact: true })).toBeVisible();
await expect(page.getByText("Finder", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
}
@@ -24,6 +24,8 @@ type NewWorkspaceDaemonClient = Pick<
| "on"
| "patchDaemonConfig"
| "removeProject"
| "removePlugin"
| "reloadPlugin"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
@@ -219,7 +221,7 @@ export async function expectNewWorkspaceControlsEnabled(page: Page): Promise<voi
}
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
await page.keyboard.press("Control+P");
await page.keyboard.press(process.platform === "darwin" ? "Meta+P" : "Control+P");
const searchInput = page.getByPlaceholder("Search projects");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
@@ -317,35 +319,61 @@ export async function selectNewWorkspaceProject(
await expectNewWorkspaceProjectSelected(page, input.projectDisplayName);
}
// The isolation trigger renders the active isolation's label ("Local" / "New
// worktree"), so asserting its text proves what the screen currently remembers.
const ISOLATION_TRIGGER_LABEL: Record<"local" | "worktree", string> = {
type WorkspaceRuntimeChoice = "local" | "worktree";
const WORKSPACE_RUNTIME_LABEL: Record<WorkspaceRuntimeChoice, string> = {
local: "Local",
worktree: "New worktree",
worktree: "Worktree",
};
export async function expectWorkspaceIsolationSelected(
export async function expectWorkspaceRuntimeSelected(
page: Page,
isolation: "local" | "worktree",
runtime: WorkspaceRuntimeChoice,
): Promise<void> {
const trigger = page.getByRole("button", { name: "Workspace isolation" });
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await expect(trigger).toContainText(ISOLATION_TRIGGER_LABEL[isolation]);
await expect(trigger.getByText(WORKSPACE_RUNTIME_LABEL[runtime], { exact: true })).toBeVisible();
}
export async function selectWorkspaceIsolation(
export async function selectWorkspaceRuntime(
page: Page,
isolation: "local" | "worktree",
runtime: WorkspaceRuntimeChoice,
): Promise<void> {
const trigger = page.getByTestId("workspace-create-isolation-trigger");
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
// Isolation options are derived from project capability. Wait for the option
// so this helper also covers route-to-project reconciliation.
const option = page.getByTestId(`workspace-create-isolation-${isolation}`);
const runtimePicker = page.getByRole("dialog").last();
await expect(runtimePicker).toBeVisible({ timeout: 30_000 });
const option = runtimePicker.getByRole("button", {
name: WORKSPACE_RUNTIME_LABEL[runtime],
exact: true,
});
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
await expectWorkspaceRuntimeSelected(page, runtime);
}
export async function expectWorkspaceRuntimeChoices(
page: Page,
runtimes: readonly WorkspaceRuntimeChoice[],
): Promise<void> {
const trigger = page.getByRole("button", { name: "Runtime", exact: true });
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
const runtimePicker = page.getByRole("dialog").last();
await expect(runtimePicker).toBeVisible({ timeout: 30_000 });
for (const runtime of runtimes) {
await expect(
runtimePicker.getByRole("button", {
name: WORKSPACE_RUNTIME_LABEL[runtime],
exact: true,
}),
).toBeVisible();
}
await expect(runtimePicker.getByRole("button")).toHaveCount(runtimes.length);
await page.keyboard.press("Escape");
}
export async function submitNewWorkspaceEmpty(page: Page): Promise<void> {
@@ -462,7 +490,7 @@ export async function pasteGithubPrUrl(
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.evaluate((value) => navigator.clipboard.writeText(value), url);
await composer.focus();
await page.keyboard.press("Control+V");
await page.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V");
}
export async function assertNewWorkspaceSidebarAndHeader(
@@ -474,6 +502,7 @@ export async function assertNewWorkspaceSidebarAndHeader(
projectDisplayName: string;
assertSidebarRow?: boolean;
assertHeader?: boolean;
timeoutMs?: number;
},
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
// URL is the source of truth so concurrent sidebar rows cannot satisfy this.
@@ -483,7 +512,7 @@ export async function assertNewWorkspaceSidebarAndHeader(
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
},
{ timeout: 60_000 },
{ timeout: input.timeoutMs ?? 60_000 },
)
.not.toBeNull();
@@ -1,6 +1,9 @@
import type { Page } from "@playwright/test";
const REPLICA_CACHE_STORAGE_KEY = "@paseo:replica-cache";
import {
observeReplicaCacheStorageWrites,
readReplicaCacheStorageWriteObserver,
resetReplicaCacheStorageWriteObserver,
} from "./replica-cache-storage";
export interface ReplicaCachePerfReport {
elapsedMs: number;
@@ -11,12 +14,7 @@ export interface ReplicaCachePerfReport {
}
interface ReplicaCachePerfState {
lastValue: string | null;
resetAt: number;
redundantWrites: number;
serializedChars: number;
storageDurationMs: number;
writes: number;
}
declare global {
@@ -26,55 +24,25 @@ declare global {
}
export async function observeReplicaCacheWrites(page: Page): Promise<void> {
await page.addInitScript((storageKey) => {
const originalSetItem = Storage.prototype.setItem;
window.__replicaCachePerf = {
lastValue: localStorage.getItem(storageKey),
resetAt: performance.now(),
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
Storage.prototype.setItem = function measuredSetItem(key: string, value: string): void {
const startedAt = performance.now();
originalSetItem.call(this, key, value);
if (key !== storageKey) return;
const state = window.__replicaCachePerf;
if (!state) return;
if (state.lastValue === value) state.redundantWrites += 1;
state.lastValue = value;
state.writes += 1;
state.serializedChars += value.length;
state.storageDurationMs += performance.now() - startedAt;
};
}, REPLICA_CACHE_STORAGE_KEY);
await observeReplicaCacheStorageWrites(page);
await page.addInitScript(() => {
window.__replicaCachePerf = { resetAt: performance.now() };
});
}
export async function beginReplicaCacheMeasurement(page: Page): Promise<void> {
await resetReplicaCacheStorageWriteObserver(page);
await page.evaluate(() => {
const lastValue = window.__replicaCachePerf?.lastValue ?? null;
window.__replicaCachePerf = {
lastValue,
resetAt: performance.now(),
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
window.__replicaCachePerf = { resetAt: performance.now() };
});
}
export async function readReplicaCacheMeasurement(page: Page): Promise<ReplicaCachePerfReport> {
return page.evaluate(() => {
const storage = await readReplicaCacheStorageWriteObserver(page);
const elapsedMs = await page.evaluate(() => {
const state = window.__replicaCachePerf;
if (!state) throw new Error("Replica cache performance observer is not installed");
return {
elapsedMs: performance.now() - state.resetAt,
redundantWrites: state.redundantWrites,
serializedChars: state.serializedChars,
storageDurationMs: state.storageDurationMs,
writes: state.writes,
};
return performance.now() - state.resetAt;
});
return { elapsedMs, ...storage };
}
@@ -0,0 +1,156 @@
import type { Page } from "@playwright/test";
const DATABASE_NAME = "paseo-replica-cache";
const STORE_NAME = "key-value";
const STORAGE_KEY = "@paseo:replica-cache";
export interface ReplicaCacheRecord {
version?: number;
hosts?: Array<{
timeline?: {
agentId?: string;
items?: Array<Record<string, unknown>>;
range?: {
endSeq?: number;
epoch?: string;
startSeq?: number;
} | null;
} | null;
}>;
}
interface ReplicaCacheWriteObserverState {
lastValue: string | null;
redundantWrites: number;
serializedChars: number;
storageDurationMs: number;
writes: number;
}
declare global {
interface Window {
__replicaCacheWriteObserver?: ReplicaCacheWriteObserverState;
}
}
async function readStoredValue(input: {
databaseName: string;
storeName: string;
storageKey: string;
}): Promise<string | null> {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(input.databaseName, 1);
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
return new Promise((resolve, reject) => {
const request = database
.transaction(input.storeName, "readonly")
.objectStore(input.storeName)
.get(input.storageKey);
request.addEventListener("success", () =>
resolve(typeof request.result === "string" ? request.result : null),
);
request.addEventListener("error", () => reject(request.error));
});
}
export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord | null> {
const raw = await page.evaluate(readStoredValue, {
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
});
return raw ? (JSON.parse(raw) as ReplicaCacheRecord) : null;
}
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
await page.evaluate(
async ({ databaseName, storeName, storageKey, raw }) => {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(databaseName, 1);
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
await new Promise<void>((resolve, reject) => {
const transaction = database.transaction(storeName, "readwrite");
transaction.objectStore(storeName).put(raw, storageKey);
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () => reject(transaction.error));
transaction.addEventListener("abort", () => reject(transaction.error));
});
},
{
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
raw: JSON.stringify(value),
},
);
}
export async function observeReplicaCacheStorageWrites(page: Page): Promise<void> {
await page.addInitScript(
({ databaseName, storeName, storageKey }) => {
window.__replicaCacheWriteObserver = {
lastValue: null,
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
const originalPut = IDBObjectStore.prototype.put;
IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) {
const request =
key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key);
if (
this.transaction.db.name !== databaseName ||
this.name !== storeName ||
key !== storageKey ||
typeof value !== "string"
) {
return request;
}
const startedAt = performance.now();
request.addEventListener("success", () => {
const state = window.__replicaCacheWriteObserver;
if (!state) return;
if (state.lastValue === value) state.redundantWrites += 1;
state.lastValue = value;
state.writes += 1;
state.serializedChars += value.length;
state.storageDurationMs += performance.now() - startedAt;
});
return request;
};
},
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, storageKey: STORAGE_KEY },
);
}
export async function resetReplicaCacheStorageWriteObserver(page: Page): Promise<void> {
const lastValue = await page.evaluate(readStoredValue, {
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
});
await page.evaluate((value) => {
window.__replicaCacheWriteObserver = {
lastValue: value,
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
}, lastValue);
}
export async function readReplicaCacheStorageWriteObserver(
page: Page,
): Promise<ReplicaCacheWriteObserverState> {
return page.evaluate(() => {
const state = window.__replicaCacheWriteObserver;
if (!state) throw new Error("Replica cache write observer is not installed");
return state;
});
}
@@ -42,6 +42,7 @@ export interface SeedDaemonClient {
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
entries: SeedWorkspaceDescriptor[];
}>;
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
createWorkspace(input: {
source:
@@ -142,6 +143,7 @@ export interface SeedDaemonClient {
timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
cancelAgent(agentId: string): Promise<void>;
refreshAgent(agentId: string): Promise<unknown>;
fetchAgent(options: {
agentId: string;
@@ -172,6 +174,10 @@ export interface SeedDaemonClient {
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
listWorkspaceScripts(workspaceId: string): Promise<{
scripts: Array<{ lifecycle: "running" | "stopped"; scriptName: string }>;
}>;
stopWorkspaceScript(workspaceId: string, scriptName: string): Promise<{ error?: string | null }>;
}
export async function connectSeedClient(options?: {
@@ -41,6 +41,7 @@ type HostSection =
| "providers"
| "usage"
| "terminals"
| "plugins"
| "host";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
@@ -1,7 +1,19 @@
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import type { SeededWorkspace } from "./seed-client";
type WebSocketMessage = string | Buffer;
export interface ManagedSubagentArchiveGate {
release(): void;
waitForRequest(): Promise<void>;
}
export interface ManagedSubagentArchiveRejection {
waitForRejection(): Promise<void>;
}
export interface SeededSubagentPair {
parent: {
id: string;
@@ -132,6 +144,147 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
});
}
export async function expectManagedSubagentUnarchived(
workspace: Pick<SeededWorkspace, "client">,
childId: string,
): Promise<void> {
await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({
agent: { id: childId, archivedAt: null },
});
}
export async function expectManagedSubagentArchived(
workspace: Pick<SeededWorkspace, "client">,
childId: string,
): Promise<void> {
await expect
.poll(() => workspace.client.fetchAgent({ agentId: childId }), { timeout: 30_000 })
.toMatchObject({ agent: { id: childId, archivedAt: expect.any(String) } });
}
export async function archiveFinishedSubagents(page: Page): Promise<void> {
await page.getByRole("button", { name: "Archive finished subagents" }).click();
}
export async function expectArchiveFinishedInProgress(
page: Page,
completed: number,
total: number,
): Promise<void> {
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
}
export async function expectArchiveFinishedRetry(
page: Page,
failed: number,
total: number,
): Promise<void> {
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
}
export async function holdManagedSubagentArchiveRequest(
page: Page,
subagentId: string,
): Promise<ManagedSubagentArchiveGate> {
let released = false;
let resolveRequest: (() => void) | undefined;
const delayedForwards: Array<() => void> = [];
const request = new Promise<void>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
if (isArchiveRequestForSubagent(message, subagentId) && !released) {
delayedForwards.push(() => server.send(message));
resolveRequest?.();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return {
release() {
released = true;
for (const forward of delayedForwards.splice(0)) forward();
},
waitForRequest: () => request,
};
}
export async function rejectNextManagedSubagentArchiveRequest(
page: Page,
subagentId: string,
): Promise<ManagedSubagentArchiveRejection> {
let resolveRejection: (() => void) | undefined;
const rejection = new Promise<void>((resolve) => {
resolveRejection = resolve;
});
let rejected = false;
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const requestId = archiveRequestId(message, subagentId);
if (requestId && !rejected) {
rejected = true;
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
// correlated error travels back through the real browser client and mutation rollback.
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "archive_agent_request",
error: "Archive rejected for test",
code: "archive_rejected",
},
},
}),
);
resolveRejection?.();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return { waitForRejection: () => rejection };
}
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
return archiveRequestId(message, subagentId) !== null;
}
function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as {
type?: unknown;
message?: { type?: unknown; agentId?: unknown; requestId?: unknown };
};
if (
envelope.type === "session" &&
envelope.message?.type === "archive_agent_request" &&
envelope.message.agentId === subagentId &&
typeof envelope.message.requestId === "string"
) {
return envelope.message.requestId;
}
return null;
} catch {
return null;
}
}
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
const row = page.getByTestId(`subagents-track-row-${childId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
@@ -1,5 +1,6 @@
import { expect, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { readReplicaCache } from "./replica-cache-storage";
import {
delayAgentBootstrapTailResponse,
delayAgentOlderTimelineResponse,
@@ -39,6 +40,12 @@ interface TimelineViewportSnapshot {
scrollTop: number;
}
interface PersistedCanonicalTimelineRange {
epoch: string;
startSeq: number;
endSeq: number;
}
export interface TimelinePresentationSnapshot {
marker: string;
position: TimelinePromptPositionSnapshot;
@@ -284,28 +291,45 @@ export async function reloadAgentTimelineFromPersistedReplica(
agent: LongTimelineAgent,
): Promise<void> {
await expect
.poll(() =>
page.evaluate((agentId) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{
timeline?: {
agentId?: string;
items?: unknown[];
} | null;
}>;
};
const timeline = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline;
return timeline?.items?.length === 50;
}, agent.agentId),
)
.poll(async () => {
const cache = await readReplicaCache(page);
const timeline = cache?.hosts?.find(
(host) => host.timeline?.agentId === agent.agentId,
)?.timeline;
return timeline?.items?.length === 50;
})
.toBe(true);
await page.reload();
await expectTimelinePromptVisible(page, agent.newestPrompt);
}
export async function waitForPersistedCanonicalTimelineRange(
page: Page,
agentId: string,
): Promise<PersistedCanonicalTimelineRange> {
const readRange = async () => {
const cache = await readReplicaCache(page);
if (cache?.version !== 6) return null;
const range = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline?.range;
if (
typeof range?.epoch !== "string" ||
typeof range.startSeq !== "number" ||
typeof range.endSeq !== "number"
) {
return null;
}
return { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq };
};
await expect.poll(readRange).not.toBeNull();
const range = await readRange();
if (!range) {
throw new Error(`Persisted canonical timeline range is missing for ${agentId}`);
}
return range;
}
export async function holdNextOlderTimelinePage(
page: Page,
agent: LongTimelineAgent,
@@ -40,12 +40,20 @@ export function rememberTimelineRequestCounts(gate: DaemonWebSocketGate): Timeli
};
}
export function expectOneTailCheckWithoutCatchUpPages(
export function expectOneResumeCheckWithoutTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
expect(gate.getTimelineRequestCount("after") - before.after).toBe(1);
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(0);
}
export function expectResumeOverflowFallsBackToOneTail(
gate: DaemonWebSocketGate,
before: TimelineRequestCounts,
): void {
expect(gate.getTimelineRequestCount("after") - before.after).toBe(1);
expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1);
expect(gate.getTimelineRequestCount("after") - before.after).toBe(0);
}
export async function disconnectViewedTimeline(
@@ -56,7 +64,7 @@ export async function disconnectViewedTimeline(
await expectReconnectingToastVisible(page);
}
export async function restoreViewedTimelineWithHeldTail(
export async function restoreViewedTimelineWithHeldResponse(
page: Page,
gate: DaemonWebSocketGate,
): Promise<void> {
+2
View File
@@ -48,6 +48,8 @@
"@getpaseo/protocol": "*",
"@gorhom/bottom-sheet": "^5.2.14",
"@gorhom/portal": "^1.0.14",
"@mattermost/react-native-paste-input": "2.0.1",
"@paseo/plugin": "*",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "^0.3.2",
"@react-native/normalize-colors": "^0.81.5",
+82
View File
@@ -0,0 +1,82 @@
const fs = require("node:fs");
const path = require("node:path");
const {
IOSConfig,
withAppDelegate,
withDangerousMod,
withPodfile,
} = require("expo/config-plugins");
const MODULE_SETUP = " PasteInputModule.setup(factory.rootViewFactory)";
const REACT_NATIVE_START_END = " launchOptions: launchOptions)\n#endif";
const BRIDGING_HEADER_IMPORT = "#import <react-native-paste-input/PasteInputModule.h>";
const PODFILE_NEW_ARCH_DISABLED =
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
const PODFILE_NEW_ARCH_ENABLED =
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'";
function configurePasteInputAppDelegate(contents) {
const configured = contents.replace(`\n${MODULE_SETUP}`, "");
if (!configured.includes(REACT_NATIVE_START_END)) {
throw new Error("Could not register paste input after React Native starts");
}
return configured.replace(
REACT_NATIVE_START_END,
` launchOptions: launchOptions)\n${MODULE_SETUP}\n#endif`,
);
}
function configurePasteInputBridgingHeader(contents) {
if (contents.includes(BRIDGING_HEADER_IMPORT)) {
return contents;
}
return `${contents.trimEnd()}\n${BRIDGING_HEADER_IMPORT}\n`;
}
function configurePasteInputPodfile(contents) {
if (contents.includes(PODFILE_NEW_ARCH_ENABLED)) {
return contents;
}
if (!contents.includes(PODFILE_NEW_ARCH_DISABLED)) {
throw new Error("Could not configure the new architecture for paste input");
}
return contents.replace(
PODFILE_NEW_ARCH_DISABLED,
`${PODFILE_NEW_ARCH_DISABLED}\n${PODFILE_NEW_ARCH_ENABLED}`,
);
}
function withPasteInput(config) {
const withDelegate = withAppDelegate(config, (modConfig) => {
if (modConfig.modResults.language !== "swift") {
throw new Error("Paste input setup requires a Swift AppDelegate");
}
modConfig.modResults.contents = configurePasteInputAppDelegate(modConfig.modResults.contents);
return modConfig;
});
const withPods = withPodfile(withDelegate, (modConfig) => {
modConfig.modResults.contents = configurePasteInputPodfile(modConfig.modResults.contents);
return modConfig;
});
return withPasteInputBridgingHeader(withPods);
}
function withPasteInputBridgingHeader(config) {
return withDangerousMod(config, [
"ios",
async (modConfig) => {
const projectRoot = modConfig.modRequest.projectRoot;
const sourceRoot = IOSConfig.Paths.getSourceRoot(projectRoot);
const projectName = IOSConfig.XcodeUtils.getProjectName(projectRoot);
const headerPath = path.join(sourceRoot, `${projectName}-Bridging-Header.h`);
const contents = fs.readFileSync(headerPath, "utf8");
fs.writeFileSync(headerPath, configurePasteInputBridgingHeader(contents));
return modConfig;
},
]);
}
module.exports = withPasteInput;
module.exports.configurePasteInputAppDelegate = configurePasteInputAppDelegate;
module.exports.configurePasteInputBridgingHeader = configurePasteInputBridgingHeader;
module.exports.configurePasteInputPodfile = configurePasteInputPodfile;
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
const {
configurePasteInputAppDelegate,
configurePasteInputBridgingHeader,
configurePasteInputPodfile,
} = require("./with-paste-input");
describe("withPasteInput", () => {
it("registers the module after React Native starts", () => {
const source = [
"import ReactAppDependencyProvider",
"",
" bindReactNativeFactory(factory)",
" factory.startReactNative(",
' withModuleName: "main",',
" in: window,",
" launchOptions: launchOptions)",
"#endif",
].join("\n");
const configured = configurePasteInputAppDelegate(source);
expect(configured).toContain(
" launchOptions: launchOptions)\n PasteInputModule.setup(factory.rootViewFactory)\n#endif",
);
expect(configurePasteInputAppDelegate(configured)).toBe(configured);
});
it("exposes the Objective-C module to Swift through the bridging header", () => {
const source = "// Swift bridging header\n";
const configured = configurePasteInputBridgingHeader(source);
expect(configured).toContain("#import <react-native-paste-input/PasteInputModule.h>");
expect(configurePasteInputBridgingHeader(configured)).toBe(configured);
});
it("sets the new architecture environment before CocoaPods evaluates dependencies", () => {
const source =
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false'";
const configured = configurePasteInputPodfile(source);
expect(configured).toContain(
"ENV['RCT_NEW_ARCH_ENABLED'] ||= '1' if podfile_properties['newArchEnabled'] == 'true'",
);
expect(configurePasteInputPodfile(configured)).toBe(configured);
});
});
+10 -1
View File
@@ -650,6 +650,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
message={item.text}
timestamp={item.timestamp.getTime()}
workspaceRoot={workspaceRoot}
workspaceId={context.workspaceId}
serverId={resolvedServerId}
client={client}
spacing={layoutItem.assistantSpacing}
@@ -658,7 +659,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
</AssistantFileLinkResolverProvider>
);
},
[agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
[
agentId,
client,
context.workspaceId,
handleInlinePathPress,
resolvedServerId,
toast,
workspaceRoot,
],
);
const renderThoughtItem = useCallback(
@@ -3,7 +3,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
describe("Android AsyncStorage configuration", () => {
it("writes a 10 MiB database limit into Gradle properties", () => {
it("writes a 64 MiB database limit into Gradle properties", () => {
const appDirectory = path.resolve(import.meta.dirname, "..");
const output = execFileSync(
process.execPath,
@@ -11,7 +11,7 @@ describe("Android AsyncStorage configuration", () => {
"-e",
`
const plugin = require("./plugins/with-android-async-storage-size");
const config = plugin({ name: "Paseo", slug: "paseo" }, 10);
const config = plugin({ name: "Paseo", slug: "paseo" }, 64);
const mod = config.mods.android.gradleProperties;
mod({ ...config, modResults: [], modRequest: {}, modRawConfig: config })
.then((result) => process.stdout.write(JSON.stringify(result.modResults)));
@@ -21,7 +21,7 @@ describe("Android AsyncStorage configuration", () => {
);
expect(JSON.parse(output)).toEqual([
{ type: "property", key: "AsyncStorage_db_size_in_MB", value: "10" },
{ type: "property", key: "AsyncStorage_db_size_in_MB", value: "64" },
]);
});
});
+2
View File
@@ -127,6 +127,7 @@ import {
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { PluginCatalogSync } from "@/plugins";
import {
ensureOsNotificationPermission,
WEB_NOTIFICATION_CLICK_EVENT,
@@ -270,6 +271,7 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
return (
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
<LegacyFavoriteProfileMigrationBootstrap serverId={daemon.serverId} client={client} />
<PluginCatalogSync serverId={daemon.serverId} client={client} />
</SessionProvider>
);
}
@@ -41,6 +41,7 @@ function KnownHostRoute() {
<Stack.Screen name="sessions" />
<Stack.Screen name="open-project" />
<Stack.Screen name="settings" />
<Stack.Screen name="plugin/[pluginId]/[surfaceId]" />
</ThemedStack>
);
@@ -0,0 +1,5 @@
import { PluginSurfaceScreen } from "@/plugins";
export default function PluginSurfaceRoute() {
return <PluginSurfaceScreen />;
}
@@ -94,6 +94,25 @@ describe("assistant image acquisition cache", () => {
expect(laterMessage).not.toBe(first);
});
it("scopes same-cwd file acquisitions to the selected workspace", () => {
const first = createAssistantImageFileAcquisitionKey({
serverId: "server",
workspaceId: "workspace-1",
occurrenceKey: "message:image",
cwd: "/workspace",
path: "screenshot.png",
});
const second = createAssistantImageFileAcquisitionKey({
serverId: "server",
workspaceId: "workspace-2",
occurrenceKey: "message:image",
cwd: "/workspace",
path: "screenshot.png",
});
expect(second).not.toBe(first);
});
it("scopes persisted file previews to the rendered message occurrence", () => {
const first = createAssistantImageFilePreviewAttachmentId({
serverId: "server-1",
@@ -19,6 +19,7 @@ export function createAssistantImageOccurrenceKey(input: {
export function createAssistantImageFilePreviewAttachmentId(input: {
serverId?: string;
workspaceId?: string;
occurrenceKey: string;
mimeType: string;
path: string;
@@ -32,17 +33,18 @@ export function createAssistantImageFilePreviewAttachmentId(input: {
size: input.size,
modifiedAt: input.modifiedAt,
contentLength: input.contentLength,
contentKey: `${input.serverId ?? "unknown-server"}:${input.occurrenceKey}`,
contentKey: `${input.serverId ?? "unknown-server"}:${input.workspaceId ?? "legacy-workspace"}:${input.occurrenceKey}`,
});
}
export function createAssistantImageFileAcquisitionKey(input: {
serverId?: string;
workspaceId?: string;
occurrenceKey: string;
cwd: string;
path: string;
}): string {
return `file:${input.serverId ?? "unknown-server"}:${input.occurrenceKey}:${input.cwd}:${input.path}`;
return `file:${input.serverId ?? "unknown-server"}:${input.workspaceId ?? "legacy-workspace"}:${input.occurrenceKey}:${input.cwd}:${input.path}`;
}
export function createAssistantImageAcquisitionCache<T>(input: {
@@ -6,10 +6,10 @@ import {
} from "./file-acquisition";
class MemoryFileAcquisitionPort implements AssistantImageFileAcquisitionPort {
readonly reads: Array<{ cwd: string; path: string }> = [];
readonly reads: Array<{ cwd: string; path: string; workspaceId?: string }> = [];
async readFile(cwd: string, path: string) {
this.reads.push({ cwd, path });
async readFile(cwd: string, path: string, workspaceId?: string) {
this.reads.push({ cwd, path, ...(workspaceId ? { workspaceId } : {}) });
return {
kind: "image" as const,
path,
@@ -38,6 +38,7 @@ describe("assistant image file acquisition", () => {
const common = {
resolution: { kind: "file_rpc" as const, cwd: "/workspace", path: "reconnect.png" },
serverId: "server",
workspaceId: "workspace-selected",
occurrenceKey: "agent:message:reconnect-image",
unavailableMessage: "Image unavailable",
};
@@ -48,6 +49,23 @@ describe("assistant image file acquisition", () => {
expect(disconnected?.key).toBe(connected?.key);
await expect(disconnected?.locate()).rejects.toThrow("Image unavailable");
await expect(connected?.locate()).resolves.toMatchObject({ mimeType: "image/png" });
expect(connectedPort.reads).toEqual([{ cwd: "/workspace", path: "reconnect.png" }]);
expect(connectedPort.reads).toEqual([
{ cwd: "/workspace", path: "reconnect.png", workspaceId: "workspace-selected" },
]);
});
it("keeps legacy file acquisition cwd-compatible when workspaceId is absent", async () => {
const port = new MemoryFileAcquisitionPort();
const acquisition = createAssistantImageFileAcquisition({
port,
resolution: { kind: "file_rpc", cwd: "/legacy", path: "preview.png" },
serverId: "server",
occurrenceKey: "agent:message:legacy-image",
unavailableMessage: "Image unavailable",
});
await acquisition?.locate();
expect(port.reads).toEqual([{ cwd: "/legacy", path: "preview.png" }]);
});
});
@@ -8,7 +8,7 @@ import {
} from "./acquisition-cache";
export interface AssistantImageFileAcquisitionPort {
readFile(cwd: string, path: string): Promise<FileReadResult>;
readFile(cwd: string, path: string, workspaceId?: string): Promise<FileReadResult>;
persist(input: {
id: string;
bytes: Uint8Array;
@@ -26,6 +26,7 @@ export function createAssistantImageFileAcquisition(input: {
port: AssistantImageFileAcquisitionPort | null;
resolution: AssistantImageSourceResolution | null;
serverId?: string;
workspaceId?: string;
occurrenceKey: string;
unavailableMessage: string;
}): AssistantImageAcquisition | null {
@@ -36,6 +37,7 @@ export function createAssistantImageFileAcquisition(input: {
return {
key: createAssistantImageFileAcquisitionKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
occurrenceKey: input.occurrenceKey,
cwd: resolution.cwd,
path: resolution.path,
@@ -44,13 +46,14 @@ export function createAssistantImageFileAcquisition(input: {
if (!port) {
throw new Error(input.unavailableMessage);
}
const file = await port.readFile(resolution.cwd, resolution.path);
const file = await port.readFile(resolution.cwd, resolution.path, input.workspaceId);
if (file.kind !== "image") {
throw new Error(input.unavailableMessage);
}
return await port.persist({
id: createAssistantImageFilePreviewAttachmentId({
serverId: input.serverId,
workspaceId: input.workspaceId,
occurrenceKey: input.occurrenceKey,
mimeType: file.mime,
path: file.path || resolution.path,
@@ -82,6 +82,7 @@ interface UseAssistantImageInput {
client?: DaemonClient | null;
workspaceRoot?: string;
serverId?: string;
workspaceId?: string;
}
type PreviewUrlState =
@@ -377,6 +378,7 @@ export function useAssistantImage({
client,
workspaceRoot,
serverId,
workspaceId,
}: UseAssistantImageInput): AssistantImageResult {
const { t } = useTranslation();
const resolution = useMemo(
@@ -387,7 +389,8 @@ export function useAssistantImage({
const fileAcquisition = useMemo(() => {
const port: AssistantImageFileAcquisitionPort | null = client
? {
readFile: async (cwd, path) => await client.readFile(cwd, path),
readFile: async (cwd, path, selectedWorkspaceId) =>
await client.readFile(cwd, path, undefined, selectedWorkspaceId),
persist: persistAttachmentFromBytes,
}
: null;
@@ -395,10 +398,11 @@ export function useAssistantImage({
port,
resolution,
serverId,
workspaceId,
occurrenceKey,
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
});
}, [client, occurrenceKey, resolution, serverId, t]);
}, [client, occurrenceKey, resolution, serverId, t, workspaceId]);
const dataImageAcquisition = useMemo(
() => createDataImageAcquisition({ source, dataImage }),
[dataImage, source],
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import { getAgentAttachmentPillContent } from "./attachment-pill-content";
describe("agent attachment pill content", () => {
it("presents external resources with their provider identity", () => {
const content = getAgentAttachmentPillContent(
{
type: "text",
mimeType: "text/plain",
title: "ENG-123 Plugin attachments",
text: "Linear issue ENG-123: Plugin attachments",
externalResource: {
provider: "linear",
providerLabel: "Linear issue",
resourceType: "issue",
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
},
},
i18n.t,
);
expect(content.title).toBe("Plugin attachments");
expect(content.subtitle).toBe("Linear issue ENG-123");
});
});
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import type { TFunction } from "i18next";
import React from "react";
import {
CircleDot,
FileText,
@@ -86,6 +87,13 @@ export function getAgentAttachmentPillContent(
subtitle: `Issue #${attachment.number}`,
};
case "text":
if (attachment.externalResource) {
return {
icon: attachmentGithubIssueIcon,
title: attachment.externalResource.title,
subtitle: `${attachment.externalResource.providerLabel} ${attachment.externalResource.identifier}`,
};
}
return {
icon: attachmentFileIcon,
title: attachment.title ?? t("message.attachments.textAttachment"),
+2
View File
@@ -3,6 +3,7 @@ import type {
ForgeSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
import type { PluginResourceComposerAttachment } from "@/plugins/attachments";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
@@ -106,6 +107,7 @@ export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| WorkspaceFileComposerAttachment
| PluginResourceComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
@@ -5,6 +5,7 @@ import { getIsElectron } from "@/constants/platform";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
import { useBoundWorkspaceGit, WorkspaceGitBoundary } from "@/git/workspace-git";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import {
resolveShortcutKeysForAction,
@@ -13,6 +14,7 @@ import {
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getCommandCenterIcon } from "./icon";
@@ -107,6 +109,26 @@ export function useWorkspaceCommandCenterActions(): void {
}
export function CommandCenterWorkspaceActions() {
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? "";
const workspaceId = selection?.workspaceId ?? "";
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const client = useHostRuntimeClient(serverId);
const address = useMemo(
() => (workspaceId && cwd ? ({ kind: "selected", workspaceId, cwd } as const) : null),
[cwd, workspaceId],
);
const workspaceGit = useBoundWorkspaceGit(client, address);
if (!workspaceGit) return null;
return (
<WorkspaceGitBoundary workspaceGit={workspaceGit}>
<CommandCenterWorkspaceActionRegistration />
</WorkspaceGitBoundary>
);
}
function CommandCenterWorkspaceActionRegistration() {
useWorkspaceCommandCenterActions();
return null;
}
@@ -6,15 +6,13 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/combobox";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
interface BranchSwitcherProps {
currentBranchName: string | null;
serverId: string;
workspaceId: string;
workspaceDirectory: string | null;
isGitCheckout: boolean;
testID?: string;
}
@@ -29,23 +27,17 @@ const ThemedChevronDown = withUnistyles(ChevronDown);
export function BranchSwitcher({
currentBranchName,
serverId,
workspaceId,
workspaceDirectory,
isGitCheckout,
testID = "workspace-header-branch-switcher",
}: BranchSwitcherProps) {
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const toast = useToast();
const queryClient = useQueryClient();
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
client,
normalizedServerId: serverId,
normalizedWorkspaceId: workspaceId,
workspaceDirectory,
currentBranchName,
isGitCheckout,
isConnected,
@@ -11,6 +11,11 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { isWeb } from "@/constants/platform";
import {
bindSelectedWorkspaceGit,
WorkspaceGitBoundary,
type WorkspaceGitClient,
} from "@/git/workspace-git";
import {
resolveCompactExplorerSidebarHostModel,
type CompactExplorerSidebarHostModel,
@@ -24,6 +29,62 @@ interface CompactExplorerOpenGestureSurfaceProps {
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
function useCompactWorkspaceGit(
client: ReturnType<typeof useHostRuntimeClient>,
workspaceId: string | null | undefined,
cwd: string | null | undefined,
): WorkspaceGitClient | null {
return useMemo(() => {
if (!client || !workspaceId || !cwd) {
return null;
}
return bindSelectedWorkspaceGit(client, { kind: "selected", workspaceId, cwd });
}, [client, cwd, workspaceId]);
}
function useCompactExplorerModel({
enabled,
isExplorerOpen,
selection,
workspace,
isGit,
showMobileAgent,
}: {
enabled: boolean;
isExplorerOpen: boolean;
selection: Parameters<typeof resolveCompactExplorerSidebarHostModel>[0]["selection"];
workspace: Parameters<typeof resolveCompactExplorerSidebarHostModel>[0]["workspace"];
isGit: boolean;
showMobileAgent: () => void;
}): CompactExplorerSidebarHostModel | null {
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null);
const resolvedModel = useMemo(
() =>
resolveCompactExplorerSidebarHostModel({
previous: isExplorerOpen ? retainedModelRef.current : null,
selection,
workspace,
isGit,
}),
[isExplorerOpen, isGit, selection, workspace],
);
useEffect(() => {
if (!selection) {
retainedModelRef.current = null;
if (enabled && isExplorerOpen) showMobileAgent();
return;
}
if (!isExplorerOpen) {
retainedModelRef.current = null;
return;
}
if (resolvedModel) retainedModelRef.current = resolvedModel;
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
if (!selection) return null;
return resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null);
}
function CompactExplorerOpenGestureSurface({
children,
enabled,
@@ -41,9 +102,10 @@ function CompactExplorerOpenGestureSurface({
);
}
function useActiveCompactExplorerSidebarModel(
enabled: boolean,
): CompactExplorerSidebarHostModel | null {
function useActiveCompactExplorerSidebarModel(enabled: boolean): {
model: CompactExplorerSidebarHostModel | null;
workspaceGit: WorkspaceGitClient | null;
} {
const selection = useActiveWorkspaceSelection();
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
const isExplorerOpen = usePanelStore((state) =>
@@ -52,44 +114,32 @@ function useActiveCompactExplorerSidebarModel(
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const client = useHostRuntimeClient(selection?.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null);
const { checkoutQuery } = useWorkspaceCheckoutStatus({
const workspaceGit = useCompactWorkspaceGit(
client,
selection?.workspaceId,
workspace?.workspaceDirectory,
);
const { checkoutQuery } = useWorkspaceCheckoutStatus({
workspaceGit,
isConnected,
isRouteFocused: enabled && selection !== null,
normalizedServerId: selection?.serverId ?? "",
normalizedWorkspaceId: selection?.workspaceId ?? "",
workspaceDirectory: workspace?.workspaceDirectory || null,
});
const resolvedModel = useMemo(
() =>
resolveCompactExplorerSidebarHostModel({
previous: isExplorerOpen ? retainedModelRef.current : null,
selection,
workspace,
isGit: checkoutQuery.data?.isGit ?? false,
}),
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
);
const model = useCompactExplorerModel({
enabled,
isExplorerOpen,
selection,
workspace,
isGit: checkoutQuery.data?.isGit ?? false,
showMobileAgent,
});
useEffect(() => {
if (!selection) {
retainedModelRef.current = null;
if (enabled && isExplorerOpen) {
showMobileAgent();
}
return;
}
if (!isExplorerOpen) {
retainedModelRef.current = null;
return;
}
if (resolvedModel) {
retainedModelRef.current = resolvedModel;
}
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
return {
model,
workspaceGit,
};
}
interface CompactExplorerSidebarHostProps {
@@ -98,7 +148,7 @@ interface CompactExplorerSidebarHostProps {
}
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
const model = useActiveCompactExplorerSidebarModel(enabled);
const { model, workspaceGit } = useActiveCompactExplorerSidebarModel(enabled);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
@@ -137,20 +187,22 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
return (
<>
<CompactExplorerOpenGestureSurface
enabled={enabled && Boolean(model?.workspaceRoot)}
enabled={enabled && Boolean(model?.workspaceRoot) && Boolean(workspaceGit)}
onOpenExplorer={handleOpenExplorer}
>
{children}
</CompactExplorerOpenGestureSurface>
{enabled && model ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
<WorkspaceGitBoundary workspaceGit={workspaceGit}>
{enabled && model && workspaceGit ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
workspaceRoot={model.workspaceRoot}
isGit={model.isGit}
onOpenFile={handleOpenFile}
/>
) : null}
</WorkspaceGitBoundary>
</>
);
}
@@ -0,0 +1,90 @@
/**
* @vitest-environment jsdom
*/
import React, { memo } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list.web";
interface Item {
id: string;
}
const items: Item[] = [{ id: "one" }];
function keyExtractor(item: Item): string {
return item.id;
}
function onDragEnd() {}
let root: Root | null = null;
let container: HTMLElement | null = null;
let rowRenderCount = 0;
const Row = memo(function Row({
dragHandleProps: _dragHandleProps,
}: {
dragHandleProps?: DraggableListDragHandleProps;
}) {
rowRenderCount += 1;
return <div>row</div>;
});
function renderRow(info: DraggableRenderItemInfo<Item>) {
return <Row dragHandleProps={info.dragHandleProps} />;
}
function renderReplacementRow(info: DraggableRenderItemInfo<Item>) {
return <Row dragHandleProps={info.dragHandleProps} />;
}
beforeEach(() => {
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
rowRenderCount = 0;
});
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
vi.unstubAllGlobals();
});
describe("DraggableList web row stability", () => {
it("keeps drag handle props stable when the render callback changes", () => {
act(() => {
root?.render(
<DraggableList
data={items}
keyExtractor={keyExtractor}
renderItem={renderRow}
onDragEnd={onDragEnd}
useDragHandle
/>,
);
});
expect(rowRenderCount).toBe(1);
act(() => {
root?.render(
<DraggableList
data={items}
keyExtractor={keyExtractor}
renderItem={renderReplacementRow}
onDragEnd={onDragEnd}
useDragHandle
/>,
);
});
expect(rowRenderCount).toBe(1);
});
});
@@ -34,6 +34,61 @@ const DRAG_ACTIVATION_CONFIG = {
touchHoldTolerance: 8,
};
function areRecordsEqual(
left: Record<string, unknown> | undefined,
right: Record<string, unknown> | undefined,
): boolean {
if (left === right) return true;
if (!left || !right) return false;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every((key) => Object.is(left[key], right[key]));
}
function useShallowStableRecord(
value: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const stableRef = useRef(value);
if (!areRecordsEqual(stableRef.current, value)) {
stableRef.current = value;
}
return stableRef.current;
}
function useStableListenerRecord(
listeners: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
const latestListenersRef = useRef(listeners);
latestListenersRef.current = listeners;
const listenerKeys = Object.keys(listeners ?? {}).sort();
const listenerKeySignature = listenerKeys.join("\u0000");
const stableListenersRef = useRef<{
keySignature: string;
listeners: Record<string, unknown> | undefined;
}>(undefined);
if (stableListenersRef.current?.keySignature !== listenerKeySignature) {
stableListenersRef.current = {
keySignature: listenerKeySignature,
listeners:
listenerKeys.length === 0
? undefined
: Object.fromEntries(
listenerKeys.map((key) => [
key,
(...args: unknown[]) => {
const listener = latestListenersRef.current?.[key];
if (typeof listener === "function") {
return Reflect.apply(listener, undefined, args);
}
},
]),
),
};
}
return stableListenersRef.current.listeners;
}
interface SortableItemProps<T> {
id: string;
item: T;
@@ -92,19 +147,28 @@ function SortableItemInner<T>({
}),
[combinedTransform, transition, isDragging],
);
const stableAttributes = useShallowStableRecord(attributes as unknown as Record<string, unknown>);
const stableListeners = useStableListenerRecord(
listeners as unknown as Record<string, unknown> | undefined,
);
const dragHandleProps = useMemo(
() =>
useDragHandle
? {
attributes: stableAttributes,
listeners: stableListeners,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
}
: undefined,
[setActivatorNodeRef, stableAttributes, stableListeners, useDragHandle],
);
const info: DraggableRenderItemInfo<T> = {
item,
index,
drag,
isActive: activeId === id,
dragHandleProps: useDragHandle
? {
attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void,
}
: undefined,
dragHandleProps,
};
const wrapperProps = useDragHandle
@@ -22,6 +22,7 @@ import {
usePrPaneData,
} from "@/git/pull-request-panel";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useRequiredWorkspaceGit } from "@/git/workspace-git";
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store";
import { useToast } from "@/contexts/toast-context";
@@ -320,10 +321,10 @@ function ExplorerSidebarContent({
const { t } = useTranslation();
const toast = useToast();
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
const workspaceGit = useRequiredWorkspaceGit();
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
const prPane = usePrPaneData({
serverId,
cwd: workspaceRoot,
enabled: canQueryPullRequest && isOpen,
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
});
@@ -335,10 +336,13 @@ function ExplorerSidebarContent({
const prTabLabel = formatPrTabLabel(prPane.prNumber);
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
const handlePrRetry = useCallback(() => {
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => {
refreshGitActions({
serverId,
target: workspaceGit,
}).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
}, [refreshGitActions, serverId, t, toast, workspaceGit]);
const workspaceAttachmentScopeKey = useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
[serverId, workspaceId, workspaceRoot],
@@ -419,7 +423,7 @@ function ExplorerSidebarContent({
{resolvedTab === "changes" && (
<ChangedFilesPane
serverId={serverId}
workspaceId={workspaceId}
workspaceId={workspaceGit.workspaceId}
workspaceRoot={workspaceRoot}
isOpen={isOpen}
onOpenFile={onOpenFile}
@@ -428,7 +432,7 @@ function ExplorerSidebarContent({
{resolvedTab === "files" && (
<FilesPane
serverId={serverId}
workspaceId={workspaceId}
workspaceId={workspaceGit.workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
/>
@@ -436,6 +440,7 @@ function ExplorerSidebarContent({
{resolvedTab === "pr" && (
<PrTabContent
serverId={serverId}
workspaceId={workspaceGit.workspaceId}
cwd={workspaceRoot}
prPane={prPane}
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
@@ -526,6 +531,7 @@ function FilesPane({
interface PrTabContentProps {
serverId: string;
workspaceId: string;
cwd: string;
prPane: UsePrPaneDataResult;
workspaceAttachmentScopeKey: string;
@@ -534,6 +540,7 @@ interface PrTabContentProps {
function PrTabContent({
serverId,
workspaceId,
cwd,
prPane,
workspaceAttachmentScopeKey,
@@ -543,6 +550,7 @@ function PrTabContent({
return (
<PullRequestPane
serverId={serverId}
workspaceId={workspaceId}
cwd={cwd}
data={prPane.data}
activityLoading={prPane.activityLoading}
@@ -68,10 +68,18 @@ function buildSessionsQueriesConfig(args: {
visible: boolean;
client: RecentProviderSessionsClient | null;
cwd: string | null | undefined;
workspaceId?: string | null;
hostDisconnectedMessage?: string;
}): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } =
args;
const {
providersToFetch,
sessionsQueryRoot,
visible,
client,
cwd,
workspaceId,
hostDisconnectedMessage,
} = args;
if (providersToFetch === null) return [];
const enabled = visible && Boolean(client);
return providersToFetch.map((provider) => ({
@@ -82,6 +90,7 @@ function buildSessionsQueriesConfig(args: {
throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
}
return await client.fetchRecentProviderSessions({
...(workspaceId ? { workspaceId } : {}),
...(cwd ? { cwd } : {}),
providers: [provider],
limit: PER_PROVIDER_LIMIT,
@@ -274,6 +283,7 @@ export function ImportSessionSheet({
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
cwd,
workspaceId,
enabled: visible,
});
const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget");
@@ -294,8 +304,8 @@ export function ImportSessionSheet({
);
const sessionsQueryRoot = useMemo(
() => ["recent-provider-sessions", cwd ?? null] as const,
[cwd],
() => ["recent-provider-sessions", workspaceId ?? null, cwd ?? null] as const,
[cwd, workspaceId],
);
const queriesConfig = useMemo(
@@ -306,9 +316,10 @@ export function ImportSessionSheet({
visible,
client,
cwd,
workspaceId,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[providersToFetch, sessionsQueryRoot, visible, client, cwd, t],
[providersToFetch, sessionsQueryRoot, visible, client, cwd, workspaceId, t],
);
const queries = useQueries({ queries: queriesConfig });
@@ -75,6 +75,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { PluginSidebarItems } from "@/plugins";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
@@ -681,6 +682,7 @@ function MobileSidebar({
testID="sidebar-schedules"
variant="compact"
/>
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
</View>
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
<Pressable
@@ -899,6 +901,7 @@ function DesktopSidebar({
testID="sidebar-schedules"
variant="compact"
/>
<PluginSidebarItems />
</View>
</View>
+16 -1
View File
@@ -736,6 +736,7 @@ interface AssistantMessageProps {
message: string;
timestamp: number;
workspaceRoot?: string;
workspaceId?: string;
serverId?: string;
client?: DaemonClient | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
@@ -799,6 +800,7 @@ function AssistantMarkdownImage({
hasLeadingContent,
client,
workspaceRoot,
workspaceId,
serverId,
}: {
source: string;
@@ -807,6 +809,7 @@ function AssistantMarkdownImage({
hasLeadingContent: boolean;
client?: DaemonClient | null;
workspaceRoot?: string;
workspaceId?: string;
serverId?: string;
}) {
const containerStyle = useMemo<StyleProp<ViewStyle>>(
@@ -821,6 +824,7 @@ function AssistantMarkdownImage({
occurrenceKey,
client,
workspaceRoot,
workspaceId,
serverId,
});
const binding = image.status === "failed" ? null : image.binding;
@@ -1448,6 +1452,7 @@ export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp: _timestamp,
workspaceRoot,
workspaceId,
serverId,
client,
spacing = "default",
@@ -1885,12 +1890,22 @@ export const AssistantMessage = memo(function AssistantMessage({
hasLeadingContent={hasLeadingContent}
client={client}
workspaceRoot={workspaceRoot}
workspaceId={workspaceId}
serverId={serverId}
/>
);
},
};
}, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]);
}, [
client,
fileLinkActions,
markdownParser,
occurrenceKey,
phase,
serverId,
workspaceId,
workspaceRoot,
]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo(
@@ -40,7 +40,6 @@ export interface ProjectEditSheetProps {
export function ProjectEditSheet({
visible,
onClose,
serverId,
projectId,
projectViewKey,
client,
@@ -60,7 +59,6 @@ export function ProjectEditSheet({
submitProjectEdit({ client, projectId, submission }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["projectIcon", serverId] });
toast.show(t("settings.project.edit.savedToast"), { variant: "success" });
onClose();
},
@@ -174,7 +174,7 @@ function updateSelectionPreferences(input: {
...(model && thinkingOptionId ? { thinkingByModel: { [model]: thinkingOptionId } } : {}),
},
}),
isolation: input.isolation,
runtimeId: input.isolation,
};
}
@@ -33,6 +33,7 @@ import {
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
@@ -616,6 +617,7 @@ function ProjectMenuItems({
function WorkspaceRowRightGroup({
workspace,
backdrop,
isHovered,
isTouchPlatform,
isCreating,
@@ -634,6 +636,7 @@ function WorkspaceRowRightGroup({
onTogglePin,
}: {
workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
isHovered: boolean;
isTouchPlatform: boolean;
isCreating: boolean;
@@ -651,7 +654,7 @@ function WorkspaceRowRightGroup({
isPinned?: boolean;
onTogglePin?: () => void;
}) {
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
const workspacePath = workspace.hostVisiblePath;
const { t } = useTranslation();
const trailing = useSidebarWorkspaceTrailing();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
@@ -681,7 +684,10 @@ function WorkspaceRowRightGroup({
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
@@ -1124,6 +1130,7 @@ function WorkspaceRowInner({
selected,
isHovered,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...dragAttributes}
@@ -1149,7 +1156,7 @@ function WorkspaceRowInner({
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory}
openInFileManagerPath={workspace.hostVisiblePath}
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
@@ -1168,7 +1175,7 @@ function WorkspaceRowInner({
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -1178,6 +1185,7 @@ function WorkspaceRowInner({
>
<WorkspaceRowRightGroup
workspace={workspace}
backdrop={backdrop}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
isCreating={isCreating}
@@ -1941,6 +1949,30 @@ export function SidebarWorkspaceList({
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning");
const onToggleWorkspacePin = useSidebarWorkspacePinController();
const getPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.getPinnedWorkspaceOrder);
const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder);
const handlePinnedWorkspaceReorder = useCallback(
(reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
const currentOrder = getPinnedWorkspaceOrder();
if (
!hasVisibleOrderChanged({
currentOrder,
reorderedVisibleKeys: reorderedWorkspaceKeys,
})
) {
return;
}
setPinnedWorkspaceOrder(
mergeWithRemainder({
currentOrder,
reorderedVisibleKeys: reorderedWorkspaceKeys,
}),
);
},
[getPinnedWorkspaceOrder, setPinnedWorkspaceOrder],
);
// Status mode drops the project grouping, so its rows carry their own project
// icon. Project mode fetches the same icons inside ProjectModeList for its
// project headers, so only the active mode requests them.
@@ -1964,7 +1996,10 @@ export function SidebarWorkspaceList({
hostBadgeByServerId={hostBadgeByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
listHeaderComponent={listHeaderComponent}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
/>
) : (
<ProjectModeList
@@ -1985,6 +2020,7 @@ export function SidebarWorkspaceList({
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
/>
);
@@ -2001,7 +2037,10 @@ function SidebarStatusModeWrapper({
hostBadgeByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
onPinnedWorkspaceReorder,
listHeaderComponent,
parentGestureRef,
dragGestureHostPresented,
}: {
statusGroups: StatusGroup[];
pinnedGroups: PinnedSidebarGroups;
@@ -2012,17 +2051,25 @@ function SidebarStatusModeWrapper({
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
listHeaderComponent?: ReactElement | null;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean;
}) {
const showShortcutBadges = useShowShortcutBadges();
const pinnedWorkspaces = useMemo(
() =>
pinnedGroups.pinnedChats.flatMap((workspace) => {
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
return entry ? [entry] : [];
}),
[pinnedGroups.pinnedChats, workspaceEntriesByKey],
);
return (
<SidebarStatusWorkspaceList
groups={statusGroups}
pinnedWorkspaces={pinnedGroups.pinnedChats.flatMap((workspace) => {
const entry = workspaceEntriesByKey.get(workspace.workspaceKey);
return entry ? [entry] : [];
})}
pinnedWorkspaces={pinnedWorkspaces}
projectIconByProjectViewKey={projectIconByProjectViewKey}
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
showShortcutBadges={showShortcutBadges}
@@ -2030,7 +2077,10 @@ function SidebarStatusModeWrapper({
hostBadgeByServerId={hostBadgeByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
onPinnedWorkspaceReorder={onPinnedWorkspaceReorder}
listHeaderComponent={listHeaderComponent}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
/>
);
}
@@ -2053,12 +2103,14 @@ function ProjectModeList({
supportsMultiplicityByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
onPinnedWorkspaceReorder,
}: Omit<SidebarWorkspaceListProps, "statusGroups" | "groupMode" | "isRefreshing" | "onRefresh"> & {
pathname: string;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
}) {
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const { t } = useTranslation();
@@ -2296,10 +2348,14 @@ function ProjectModeList({
);
const renderPinnedChat = useCallback(
(workspace: SidebarWorkspacePlacement) => {
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return (
<MemoWorkspaceRowItem
key={workspace.workspaceKey}
workspace={workspace}
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
@@ -2316,6 +2372,9 @@ function ProjectModeList({
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
/>
);
},
@@ -2341,7 +2400,20 @@ function ProjectModeList({
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
{visiblePinnedChats.map(renderPinnedChat)}
<DraggableList
testID="sidebar-pinned-list"
data={visiblePinnedChats}
keyExtractor={workspaceKeyExtractor}
renderItem={renderPinnedChat}
onDragEnd={onPinnedWorkspaceReorder}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented}
containerStyle={styles.workspaceListContainer}
/>
{canTogglePinnedChats ? (
<SidebarGroupToggleRow
expanded={pinnedChatsExpanded}
@@ -9,6 +9,7 @@ import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
import { usePinnedSidebarKeys, type PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import type { SidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { buildSidebarProjection } from "./sidebar-projection";
@@ -41,6 +42,7 @@ export function SidebarModelProvider({
(state) => state.collapsedStatusGroupKeys,
);
const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned);
const pinnedWorkspaceOrder = useSidebarOrderStore((state) => state.pinnedWorkspaceOrder);
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.toggleProjectCollapsed,
);
@@ -58,6 +60,7 @@ export function SidebarModelProvider({
buildSidebarProjection({
projects: list.projects,
pinnedKeys,
pinnedWorkspaceOrder,
workspaceEntriesByKey: projectionWorkspaceEntriesByKey,
projectNamesByViewKey: list.projectNamesByViewKey,
groupMode,
@@ -73,6 +76,7 @@ export function SidebarModelProvider({
list.projects,
pinnedCollapsed,
pinnedKeys,
pinnedWorkspaceOrder,
projectionWorkspaceEntriesByKey,
],
);
@@ -66,6 +66,7 @@ function projectionInput(options?: {
pinnedWorkspaceKeys: [pinned.placement.workspaceKey],
pinnedAtByKey: { [pinned.placement.workspaceKey]: "2026-07-12T12:00:00.000Z" },
},
pinnedWorkspaceOrder: [],
workspaceEntriesByKey: new Map([
[pinned.entry.workspaceKey, pinned.entry],
[unpinned.entry.workspaceKey, unpinned.entry],
@@ -24,6 +24,7 @@ export interface SidebarProjection {
export function buildSidebarProjection(input: {
projects: SidebarProjectEntry[];
pinnedKeys: PinnedSidebarKeys;
pinnedWorkspaceOrder: string[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByViewKey: Map<string, string>;
groupMode: SidebarGroupMode;
@@ -34,6 +35,7 @@ export function buildSidebarProjection(input: {
const pinnedGroups = splitPinnedSidebarGroups({
projects: input.projects,
keys: input.pinnedKeys,
pinnedWorkspaceOrder: input.pinnedWorkspaceOrder,
});
const pinnedWorkspaceKeys = new Set(input.pinnedKeys.pinnedWorkspaceKeys);
const statusGroups =
@@ -1,7 +1,23 @@
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
import {
memo,
useCallback,
useMemo,
useState,
type MutableRefObject,
type ReactNode,
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import {
View,
Text,
Pressable,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import type { GestureType } from "react-native-gesture-handler";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
@@ -10,6 +26,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { StyleSheet } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { withUnistyles } from "react-native-unistyles";
import {
ChevronDown,
@@ -59,6 +76,9 @@ import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
// Themed icon wrappers
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -85,6 +105,12 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const EMPTY_SHORTCUT_INDEX = new Map<string, number>();
function statusWorkspaceKeyExtractor(workspace: SidebarWorkspaceEntry): string {
return workspace.workspaceKey;
}
interface StatusWorkspaceListProps {
groups: StatusGroup[];
pinnedWorkspaces: SidebarWorkspaceEntry[];
@@ -95,7 +121,10 @@ interface StatusWorkspaceListProps {
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspaceEntry[]) => void;
listHeaderComponent?: ReactNode;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean;
}
export function SidebarStatusWorkspaceList({
@@ -108,7 +137,10 @@ export function SidebarStatusWorkspaceList({
hostBadgeByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
onPinnedWorkspaceReorder,
listHeaderComponent,
parentGestureRef,
dragGestureHostPresented,
}: StatusWorkspaceListProps) {
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys,
@@ -124,7 +156,44 @@ export function SidebarStatusWorkspaceList({
toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup(pinnedWorkspaces);
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
const statusShortcutIndex = showShortcutBadges
? shortcutIndexByWorkspaceKey
: EMPTY_SHORTCUT_INDEX;
const renderPinnedWorkspace = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => (
<StatusWorkspaceRow
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
inStatusGroup={false}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
/>
),
[
hostBadgeByServerId,
onToggleWorkspacePin,
onWorkspacePress,
projectIconByProjectViewKey,
showShortcutBadges,
statusShortcutIndex,
supportsPinningByServerId,
],
);
const content = (
<>
{pinnedWorkspaces.length > 0 ? (
@@ -132,23 +201,18 @@ export function SidebarStatusWorkspaceList({
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
{visiblePinnedWorkspaces.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
inStatusGroup={false}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
))}
<DraggableList
testID="sidebar-pinned-list"
data={visiblePinnedWorkspaces}
keyExtractor={statusWorkspaceKeyExtractor}
renderItem={renderPinnedWorkspace}
onDragEnd={onPinnedWorkspaceReorder}
scrollEnabled={false}
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented}
/>
{canTogglePinnedWorkspaces ? (
<SidebarGroupToggleRow
expanded={pinnedWorkspacesExpanded}
@@ -423,6 +487,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onWorkspacePress,
drag,
isDragging = false,
dragHandleProps,
}: {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
@@ -439,6 +506,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
*/
inStatusGroup?: boolean;
onWorkspacePress?: () => void;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
@@ -465,6 +535,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup}
onPress={handlePress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
);
});
@@ -482,6 +555,9 @@ function StatusWorkspaceRowWithMenu({
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onPress,
drag,
isDragging = false,
dragHandleProps,
}: {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
@@ -499,6 +575,9 @@ function StatusWorkspaceRowWithMenu({
*/
inStatusGroup?: boolean;
onPress: () => void;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
const { t } = useTranslation();
const toast = useToast();
@@ -620,6 +699,9 @@ function StatusWorkspaceRowWithMenu({
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
@@ -635,7 +717,53 @@ function StatusWorkspaceRowWithMenu({
);
}
function StatusWorkspaceRowInner({
interface StatusWorkspaceRowInnerProps {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
projectName: string;
projectIconDataUri: string | null;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
isPinned?: boolean;
onTogglePin?: () => void;
reserveIdleStatusIndicatorSpace?: boolean;
/** Pinned rows are flat under their own header; status-group rows indent from theirs. */
inStatusGroup?: boolean;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
function StatusWorkspaceRowInner(props: StatusWorkspaceRowInnerProps) {
if (props.drag) {
return <DraggableStatusWorkspaceRowInner {...props} drag={props.drag} />;
}
return <StatusWorkspaceRowInnerContent {...props} />;
}
function DraggableStatusWorkspaceRowInner(
props: StatusWorkspaceRowInnerProps & { drag: () => void },
) {
const dragInteraction = useLongPressDragInteraction({
drag: props.drag,
menuController: null,
});
return <StatusWorkspaceRowInnerContent {...props} dragInteraction={dragInteraction} />;
}
function StatusWorkspaceRowInnerContent({
workspace,
hostBadge,
projectName,
@@ -658,47 +786,51 @@ function StatusWorkspaceRowInner({
onTogglePin,
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
}: {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
projectName: string;
projectIconDataUri: string | null;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
isPinned?: boolean;
onTogglePin?: () => void;
reserveIdleStatusIndicatorSpace?: boolean;
/**
* Whether the row sits under a status header, which is what it indents from. Pinned rows
* are a flat list under their own header and sit flush.
*/
inStatusGroup?: boolean;
isDragging = false,
dragHandleProps,
dragInteraction,
}: StatusWorkspaceRowInnerProps & {
dragInteraction?: ReturnType<typeof useLongPressDragInteraction>;
}) {
const isTouchPlatform = platformIsNative;
const [isPressed, setIsPressed] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragAttributes
} = dragHandleProps?.attributes ?? {};
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const handlePressIn = useCallback(() => setIsPressed(true), []);
const handlePressOut = useCallback(() => setIsPressed(false), []);
const didLongPressRef = dragInteraction?.didLongPressRef;
const startDragPress = dragInteraction?.handlePressIn;
const moveDragPress = dragInteraction?.handleTouchMove;
const endDragPress = dragInteraction?.handlePressOut;
const handlePress = useCallback(() => {
if (didLongPressRef?.current) {
didLongPressRef.current = false;
return;
}
onPress();
}, [didLongPressRef, onPress]);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
startDragPress?.(event);
},
[startDragPress],
);
const handlePressOut = useCallback(() => {
setIsPressed(false);
endDragPress?.();
}, [endDragPress]);
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
@@ -720,9 +852,17 @@ function StatusWorkspaceRowInner({
selected,
isHovered,
inStatusGroup,
isDragging,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange}
@@ -742,15 +882,16 @@ function StatusWorkspaceRowInner({
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory}
openInFileManagerPath={workspace.hostVisiblePath}
disabled={isArchiving}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handlePressIn}
onTouchMove={moveDragPress}
onPressOut={handlePressOut}
onPress={onPress}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
@@ -759,7 +900,7 @@ function StatusWorkspaceRowInner({
leadingProjectName={projectName}
leadingProjectIconDataUri={projectIconDataUri}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isPressed, selected, isHovered })}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
@@ -769,6 +910,7 @@ function StatusWorkspaceRowInner({
{renderSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
backdrop={backdrop}
trailing={trailing}
showBase={showTrailing}
showKebab={showKebabInSlot}
@@ -798,6 +940,7 @@ function StatusWorkspaceRowInner({
function StatusWorkspaceActionSlot({
workspace,
backdrop,
trailing,
showBase,
showKebab,
@@ -816,6 +959,7 @@ function StatusWorkspaceActionSlot({
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
trailing: SidebarWorkspaceTrailing;
showBase: boolean;
showKebab: boolean;
@@ -839,7 +983,10 @@ function StatusWorkspaceActionSlot({
<SidebarWorkspaceTrailingActionBase visible={showBase}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{kebab.showKebab && onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
@@ -867,17 +1014,20 @@ function getStatusWorkspaceRowStyle({
selected,
isHovered,
inStatusGroup,
isDragging,
}: {
isPressed: boolean;
selected: boolean;
isHovered: boolean;
inStatusGroup: boolean;
isDragging: boolean;
}) {
return [
styles.workspaceRow,
inStatusGroup && sidebarWorkspaceRowStyles.rowIndented,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -972,6 +1122,14 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
workspaceRowDragging: {
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
...theme.shadow.md,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
@@ -70,7 +70,11 @@ function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; col
const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
const scrimColorMapping = (theme: Theme) => ({ color: theme.colors.surfaceSidebarHover });
const scrimColorMappings: Record<SidebarSurfaceBackdrop, (theme: Theme) => { color: string }> = {
surfaceSidebar: (theme) => ({ color: theme.colors.surfaceSidebar }),
surfaceSidebarHover: (theme) => ({ color: theme.colors.surfaceSidebarHover }),
surface2: (theme) => ({ color: theme.colors.surface2 }),
};
export function SidebarWorkspaceRowFrame({
workspace,
@@ -109,7 +113,7 @@ export function SidebarWorkspaceRowFrame({
disabled={contextMenuOpen}
>
{children({
isHovered: isHovered && !contextMenuOpen,
isHovered: isHovered && !contextMenuOpen && !isDragging,
contextMenuOpen,
onContextMenuOpenChange: handleContextMenuOpenChange,
hoverHandlers,
@@ -458,18 +462,18 @@ export function SidebarWorkspaceTrailingActionBase({
export function SidebarWorkspaceTrailingActionOverlay({
visible,
scrim = false,
scrimBackdrop,
children,
}: {
visible: boolean;
/** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */
scrim?: boolean;
scrimBackdrop?: SidebarSurfaceBackdrop;
children: ReactNode;
}) {
if (!visible || !children) return null;
return (
<>
{scrim ? <TrailingActionScrim /> : null}
{scrimBackdrop ? <TrailingActionScrim backdrop={scrimBackdrop} /> : null}
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
</>
);
@@ -484,14 +488,21 @@ export function SidebarWorkspaceTrailingActionOverlay({
* Anchored to the trailing slot, which is position:relative. Wider than the slot on purpose:
* the fade has to start before the diff stat does or the diff's left edge cuts off hard.
*/
function TrailingActionScrim() {
function TrailingActionScrim({ backdrop }: { backdrop: SidebarSurfaceBackdrop }) {
// useId's output contains characters that are not legal inside url(#...) — React 19 wraps
// ids in guillemets, React 18 in colons — and an unresolvable fill paints nothing at all.
// Keep the per-instance uniqueness, drop everything a fragment reference can't carry.
const gradientId = `sidebar-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<View style={sidebarWorkspaceRowStyles.trailingActionScrim} pointerEvents="none">
<ThemedTrailingActionScrimSvg gradientId={gradientId} uniProps={scrimColorMapping} />
<View
style={sidebarWorkspaceRowStyles.trailingActionScrim}
pointerEvents="none"
testID="sidebar-workspace-trailing-scrim"
>
<ThemedTrailingActionScrimSvg
gradientId={gradientId}
uniProps={scrimColorMappings[backdrop]}
/>
</View>
);
}
@@ -6,6 +6,7 @@ import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { AdaptiveRenameModal } from "@/components/rename-modal";
@@ -310,6 +311,7 @@ function WorkspaceRowBody({
selected,
isHovered,
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...(draggable ? dragAttributes : {})}
@@ -336,7 +338,7 @@ function WorkspaceRowBody({
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
openInFileManagerPath={workspace.workspaceDirectory}
openInFileManagerPath={workspace.hostVisiblePath}
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
@@ -353,7 +355,7 @@ function WorkspaceRowBody({
workspace={workspace}
hostBadge={hostBadge}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -362,6 +364,7 @@ function WorkspaceRowBody({
>
<WorkspaceRowTrailingActions
workspace={workspace}
backdrop={backdrop}
trailing={trailing}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
@@ -389,6 +392,7 @@ function WorkspaceRowBody({
function WorkspaceRowTrailingActions({
workspace,
backdrop,
trailing,
isHovered,
isTouchPlatform,
@@ -406,6 +410,7 @@ function WorkspaceRowTrailingActions({
onRename,
}: {
workspace: SidebarWorkspaceEntry;
backdrop: SidebarSurfaceBackdrop;
trailing: SidebarWorkspaceTrailing;
isHovered: boolean;
isTouchPlatform: boolean;
@@ -450,7 +455,10 @@ function WorkspaceRowTrailingActions({
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={kebab.showKebab} scrim={showScrim}>
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
{...kebab.menuProps}
@@ -9,6 +9,7 @@ export function useLongPressDragInteraction(input: {
drag: () => void;
menuController: ReturnType<typeof useContextMenu> | null;
}) {
const { drag, menuController } = input;
const didLongPressRef = useRef(false);
const dragArmedRef = useRef(false);
const dragActivatedRef = useRef(false);
@@ -32,20 +33,20 @@ export function useLongPressDragInteraction(input: {
}, []);
const openContextMenuAtStartPoint = useCallback(() => {
if (!input.menuController || !touchStartRef.current) {
if (!menuController || !touchStartRef.current) {
return;
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
input.menuController.setAnchorRect({
menuController.setAnchorRect({
x: touchStartRef.current.x,
y: touchStartRef.current.y + statusBarHeight,
width: 0,
height: 0,
});
input.menuController.setOpen(true);
menuController.setOpen(true);
menuOpenedRef.current = true;
didLongPressRef.current = true;
}, [input.menuController]);
}, [menuController]);
const handleLongPress = useCallback(() => {
// Manual timers own long-press behavior on mobile.
@@ -87,10 +88,10 @@ export function useLongPressDragInteraction(input: {
dragActivatedRef.current = true;
didLongPressRef.current = true;
void Haptics.selectionAsync().catch(() => {});
input.drag();
drag();
}, DRAG_ARM_DELAY_MS);
if (!input.menuController) {
if (!menuController) {
return;
}
@@ -112,7 +113,7 @@ export function useLongPressDragInteraction(input: {
void Haptics.selectionAsync().catch(() => {});
openContextMenuAtStartPoint();
}, CONTEXT_MENU_DELAY_MS);
}, [clearTimers, input, openContextMenuAtStartPoint]);
}, [clearTimers, drag, menuController, openContextMenuAtStartPoint]);
const handleDragIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
@@ -31,6 +31,7 @@ import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import type { MessagePayload } from "@/composer/types";
import { PreWorkspaceComposerGitOwner } from "@/git/workspace-git";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -429,26 +430,29 @@ export function WorkspaceSetupDialog() {
testID="workspace-setup-dialog"
desktopMaxWidth={640}
>
<FileDropZone style={styles.section}>
<Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
serverId={serverId}
isPaneFocused={true}
onSubmitMessage={handleCreateChatAgent}
isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={sourceDirectory}
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
/>
</FileDropZone>
<PreWorkspaceComposerGitOwner client={client} cwd={sourceDirectory}>
<FileDropZone style={styles.section}>
<Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
serverId={serverId}
isPaneFocused={true}
onSubmitMessage={handleCreateChatAgent}
isSubmitLoading={pendingAction === "chat"}
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.editText}
textReplacementKey={chatDraft.textReplacementKey}
attachments={chatDraft.attachments}
onChangeAttachments={chatDraft.setAttachments}
cwd={sourceDirectory}
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
/>
</FileDropZone>
</PreWorkspaceComposerGitOwner>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</AdaptiveModalSheet>
+29
View File
@@ -863,6 +863,35 @@ describe("openComposerAttachment", () => {
});
expect(externalUrlCalls).toEqual([issueItem.url]);
});
it("opens plugin resource URLs through the external url opener", () => {
const externalUrlCalls: string[] = [];
openComposerAttachment({
attachment: {
kind: "plugin_resource",
pluginId: "linear",
sourceId: "issues",
sourceTitle: "Linear issue",
sourceIcon: "CircleDot",
item: {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments",
resourceType: "issue",
},
},
setLightboxMetadata: () => {
throw new Error("unexpected lightbox call");
},
openWorkspaceAttachment: () => false,
openExternalUrl: (url) => {
externalUrlCalls.push(url);
},
});
expect(externalUrlCalls).toEqual(["https://linear.app/acme/issue/ENG-123/plugin-attachments"]);
});
});
describe("toggleGithubAttachment", () => {
@@ -349,6 +349,7 @@ function pickDesktopModel({
type AgentControlsSlice = {
provider: string;
cwd: string | null;
workspaceId: string | undefined;
runtimeModelId: string | null;
model: string | null | undefined;
features: AgentFeature[] | undefined;
@@ -368,6 +369,7 @@ function selectAgentControlsSlice(
return {
provider: currentAgent.provider,
cwd: currentAgent.cwd,
workspaceId: currentAgent.workspaceId,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model,
features: currentAgent.features,
@@ -376,6 +378,16 @@ function selectAgentControlsSlice(
};
}
function providerSnapshotTarget(agent: AgentControlsSlice): {
cwd: string | null | undefined;
workspaceId: string | undefined;
} {
return {
cwd: agent?.cwd,
workspaceId: agent?.workspaceId,
};
}
function resolveSnapshotSelectedEntry(
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"],
agentProvider: string | undefined,
@@ -564,7 +576,7 @@ function ControlledAgentControls({
}
}, [updateDensityForWidth]);
const modelDisabled = disabled;
const modelDisabled = [disabled, isModelLoading].some(Boolean);
const comboboxProviderOptions = useMemo<ComboboxOption[]>(
() => toComboboxOptions(providerOptions),
@@ -1481,7 +1493,7 @@ export const AgentControls = memo(function AgentControls({
isRefreshing: snapshotIsRefreshing,
refresh: refreshSnapshot,
refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
} = useProvidersSnapshot(serverId, providerSnapshotTarget(agent));
const snapshotSelectedEntry = useMemo(
() => resolveSnapshotSelectedEntry(snapshotEntries, agent?.provider),
@@ -253,6 +253,7 @@ export function useLiveAgentModeControl(
return {
provider: agent.provider,
cwd: agent.cwd,
workspaceId: agent.workspaceId,
currentModeId: agent.currentModeId,
};
}),
@@ -265,7 +266,10 @@ export function useLiveAgentModeControl(
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const { updatePreferences } = useFormPreferences();
const toast = useToast();
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd });
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, {
cwd: slice?.cwd,
workspaceId: slice?.workspaceId,
});
const providerDefinitions = useMemo<AgentProviderDefinition[]>(() => {
if (!slice?.provider) return [];
@@ -10,6 +10,7 @@ import {
buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments";
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
import { pluginResourceAttachmentToAgentAttachment } from "@/plugins/attachments";
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
@@ -55,6 +56,11 @@ export function splitComposerAttachmentsForSubmit(
continue;
}
if (attachment.kind === "plugin_resource") {
agentAttachments.push(pluginResourceAttachmentToAgentAttachment(attachment));
continue;
}
if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
images.push(attachment.attachment.screenshot);
@@ -224,11 +224,25 @@ describe("useAgentInputDraft live contract", () => {
thinkingOptionId: "high",
});
const hydratedReplacementKey = getLatest().textReplacementKey;
await act(async () => {
getLatest().setText("hello world");
getLatest().editText("hello world");
getLatest().setAttachments([{ kind: "image", metadata: image }]);
});
expect(getLatest().textReplacementKey).toBe(hydratedReplacementKey);
await act(async () => {
getLatest().replaceText("replacement text");
});
expect(getLatest().textReplacementKey).not.toBe(hydratedReplacementKey);
await act(async () => {
getLatest().editText("hello world");
});
await act(async () => {
root!.unmount();
});
@@ -418,7 +432,7 @@ describe("useAgentInputDraft live contract", () => {
});
await act(async () => {
getLatest().setText("with attachment");
getLatest().editText("with attachment");
getLatest().setAttachments([{ kind: "image", metadata: image }]);
});
@@ -500,7 +514,7 @@ describe("useAgentInputDraft live contract", () => {
});
await act(async () => {
getLatest().setText("queued message");
getLatest().editText("queued message");
getLatest().setAttachments([{ kind: "image", metadata: image }]);
});
@@ -554,7 +568,7 @@ describe("useAgentInputDraft live contract", () => {
});
await act(async () => {
getLatest().setText("queued message");
getLatest().editText("queued message");
getLatest().setAttachments([{ kind: "image", metadata: sentImage }]);
});
@@ -570,7 +584,7 @@ describe("useAgentInputDraft live contract", () => {
});
await act(async () => {
getLatest().setText("draft again");
getLatest().editText("draft again");
});
await act(async () => {
+34 -7
View File
@@ -5,6 +5,7 @@ import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import {
useAgentFormState,
type CreateAgentInitialValues,
type UseAgentFormStateOptions,
type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
@@ -28,6 +29,9 @@ type AttachmentUpdater =
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
interface AgentInputDraftComposerOptions {
workspaceId?: string | null;
providerSnapshotCwd?: string | null;
isTargetDaemonReady?: boolean;
initialServerId: string | null;
initialValues?: CreateAgentInitialValues;
initialFeatureValues?: Record<string, unknown>;
@@ -52,7 +56,9 @@ type DraftComposerState = UseAgentFormStateResult & {
export interface AgentInputDraft {
text: string;
setText: (text: string) => void;
editText: (text: string) => void;
replaceText: (text: string) => void;
textReplacementKey: string;
attachments: UserComposerAttachment[];
setAttachments: (updater: AttachmentUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
@@ -61,15 +67,24 @@ export interface AgentInputDraft {
composerState: DraftComposerState | null;
}
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
const composerOptions = input.composer ?? null;
const formState = useAgentFormState({
function buildAgentFormStateOptions(
composerOptions: AgentInputDraftComposerOptions | null,
): UseAgentFormStateOptions {
return {
initialServerId: composerOptions?.initialServerId ?? null,
initialValues: composerOptions?.initialValues,
isVisible: composerOptions?.isVisible ?? false,
isCreateFlow: true,
onlineServerIds: composerOptions?.onlineServerIds ?? [],
});
workspaceId: composerOptions?.workspaceId,
providerSnapshotCwd: composerOptions?.providerSnapshotCwd,
isTargetDaemonReady: composerOptions?.isTargetDaemonReady,
};
}
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
const composerOptions = input.composer ?? null;
const formState = useAgentFormState(buildAgentFormStateOptions(composerOptions));
const draftKey = useMemo(
() =>
resolveDraftKey({
@@ -84,6 +99,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
);
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
const [textReplacementRevision, setTextReplacementRevision] = useState(0);
const text = draft?.text ?? "";
const attachments = draft?.attachments ?? [];
const isHydrated = hydratedDraftKey === draftKey;
@@ -107,13 +123,21 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[draftKey],
);
const setText = useCallback(
const editText = useCallback(
(nextText: string) => {
saveDraft((current) => ({ ...current, text: nextText }));
},
[saveDraft],
);
const replaceText = useCallback(
(nextText: string) => {
saveDraft((current) => ({ ...current, text: nextText }));
setTextReplacementRevision((revision) => revision + 1);
},
[saveDraft],
);
const setAttachments = useCallback(
(updater: AttachmentUpdater) => {
saveDraft((current) => ({
@@ -136,6 +160,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
void (async () => {
await useDraftStore.getState().hydrateDraftInput({ draftKey });
if (!cancelled) {
setTextReplacementRevision((revision) => revision + 1);
setHydratedDraftKey(draftKey);
}
})();
@@ -264,7 +289,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
return {
text,
setText,
editText,
replaceText,
textReplacementKey: `${draftKey}:${textReplacementRevision}`,
attachments,
setAttachments,
clear,
@@ -365,6 +365,7 @@ export function WorkspaceDraftAgentTab({
const draftInput = useAgentInputDraft({
draftKey: draftStoreKey,
composer: {
workspaceId,
initialServerId: serverId,
initialValues: draftInitialValues,
initialFeatureValues: draftSetup?.featureValues,
@@ -390,7 +391,7 @@ export function WorkspaceDraftAgentTab({
const draftOnSetFeature = composerState.agentControls.onSetFeature;
const clearDraftInput = draftInput.clear;
const setDraftText = draftInput.setText;
const replaceDraftText = draftInput.replaceText;
const setDraftAttachments = draftInput.setAttachments;
const pendingAutoSubmit = useWorkspaceDraftSubmissionStore((state) => {
const pending = state.pendingByDraftId[draftId] ?? null;
@@ -587,7 +588,7 @@ export function WorkspaceDraftAgentTab({
return;
}
autoSubmitKeyRef.current = submitKey;
setDraftText("");
replaceDraftText("");
setDraftAttachments([]);
const preparedAttempt =
initialCreateAttempt?.clientMessageId === submission.clientMessageId
@@ -604,7 +605,7 @@ export function WorkspaceDraftAgentTab({
cwd: submission.cwd,
});
void createPromise.catch(() => {
setDraftText(submission.text);
replaceDraftText(submission.text);
setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments));
autoSubmitKeyRef.current = null;
});
@@ -617,7 +618,7 @@ export function WorkspaceDraftAgentTab({
isReadyForPendingAutoSubmit,
serverId,
setDraftAttachments,
setDraftText,
replaceDraftText,
workspaceId,
]);
@@ -699,7 +700,8 @@ export function WorkspaceDraftAgentTab({
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={draftInput.text}
onChangeText={draftInput.setText}
onChangeText={draftInput.editText}
textReplacementKey={draftInput.textReplacementKey}
attachments={draftInput.attachments}
attachmentScopeKeys={attachmentScopeKeys}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
@@ -4,7 +4,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook } from "@testing-library/react";
import React, { type ReactNode } from "react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { describe, expect, it, vi } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { ForgeSearchClient } from "@/git/use-forge-search-query";
@@ -49,11 +49,12 @@ const issue202: ForgeSearchItem = {
};
interface SearchCall {
cwd: string;
query: string;
limit?: number;
}
type TestSearchClient = Pick<ForgeSearchClient, "searchForge" | "searchGitHub">;
interface HarnessInput {
initialAttachments?: UserComposerAttachment[];
initialCwd?: string;
@@ -72,7 +73,7 @@ function githubPayload(items: ForgeSearchItem[], requestId: string): ForgeSearch
};
}
function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { calls: SearchCall[] } {
function createSearchClient(items: ForgeSearchItem[]): TestSearchClient & { calls: SearchCall[] } {
const calls: SearchCall[] = [];
return {
calls,
@@ -104,21 +105,28 @@ function createWrapper() {
};
}
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
function useHarness(client: TestSearchClient, input: HarnessInput = {}) {
const [text, setText] = useState(input.initialText ?? "");
const [searchClient, setSearchClient] = useState(client);
const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd);
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
input.initialAttachments ?? [],
);
const boundSearchClient = useMemo(
() => ({
...searchClient,
queryIdentity: ["selected", "workspace-1"] as const,
cwd: workingDirectory,
}),
[searchClient, workingDirectory],
);
const autoAttach = useComposerGithubAutoAttach({
text,
remoteUrl: input.remote ?? remoteUrl,
attachments,
client: searchClient,
client: boundSearchClient,
isConnected: true,
serverId: "server-1",
cwd: workingDirectory,
setAttachments,
onPullRequestDetected: input.onPullRequestDetected,
onPullRequestAdded: input.onPullRequestAdded,
@@ -161,7 +169,7 @@ describe("useComposerGithubAutoAttach", () => {
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
expect(result.current.isResolving).toBe(false);
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
expect(client.calls).toEqual([{ query: "101", limit: 20 }]);
vi.useRealTimers();
});
@@ -235,8 +243,8 @@ describe("useComposerGithubAutoAttach", () => {
{ kind: "forge_issue", item: issue202 },
]);
expect(client.calls).toEqual([
{ cwd, query: "101", limit: 20 },
{ cwd, query: "202", limit: 20 },
{ query: "101", limit: 20 },
{ query: "202", limit: 20 },
]);
vi.useRealTimers();
});
@@ -245,7 +253,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
const client: TestSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
@@ -288,7 +296,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
const client: TestSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
@@ -357,8 +365,8 @@ describe("useComposerGithubAutoAttach", () => {
expect(onPullRequestAdded.mock.calls).toEqual([[pr202], [pr101]]);
expect(client.calls).toEqual([
{ cwd, query: "202", limit: 20 },
{ cwd, query: "101", limit: 20 },
{ query: "202", limit: 20 },
{ query: "101", limit: 20 },
]);
vi.useRealTimers();
});
@@ -367,7 +375,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
const client: TestSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
@@ -419,7 +427,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
const client: TestSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
@@ -459,7 +467,7 @@ describe("useComposerGithubAutoAttach", () => {
it("keeps resolving when a pending pull request URL is removed and re-added", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
const client: TestSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const onPullRequestAdded = vi.fn();
@@ -500,7 +508,7 @@ describe("useComposerGithubAutoAttach", () => {
vi.useFakeTimers();
const firstLookup = deferred<ForgeSearchPayload>();
const secondLookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
const client: TestSearchClient = {
searchForge: vi
.fn()
.mockReturnValueOnce(firstLookup.promise)
@@ -545,7 +553,7 @@ describe("useComposerGithubAutoAttach", () => {
it("stays resolving when an unrelated attachment is added during lookup", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
const client: TestSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
@@ -578,7 +586,7 @@ describe("useComposerGithubAutoAttach", () => {
it("stops resolving when an in-flight PR URL is removed", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
const client: TestSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
@@ -598,7 +606,7 @@ describe("useComposerGithubAutoAttach", () => {
it("ignores a lookup that finishes after the target changes", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const client: ForgeSearchClient = {
const client: TestSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
@@ -625,7 +633,7 @@ describe("useComposerGithubAutoAttach", () => {
it("accepts a lookup after the transport client is replaced for the same target", async () => {
vi.useFakeTimers();
const lookup = deferred<ForgeSearchPayload>();
const firstClient: ForgeSearchClient = {
const firstClient: TestSearchClient = {
searchForge: vi.fn().mockReturnValue(lookup.promise),
};
const replacementClient = createSearchClient([pr101]);
+32 -13
View File
@@ -10,7 +10,12 @@ import {
type SetStateAction,
} from "react";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query";
import {
buildForgeSearchQueryOptions,
forgeSearchClientIdentity,
type ForgeSearchClient,
} from "@/git/use-forge-search-query";
import { workspaceGitQueryIdentitiesEqual } from "@/git/workspace-git";
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions";
@@ -24,7 +29,6 @@ interface ComposerGithubAutoAttachInput {
client: ForgeSearchClient | null;
isConnected: boolean;
serverId: string;
cwd: string;
supportsForgeSearch?: boolean;
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
onPullRequestDetected?: () => void;
@@ -50,7 +54,7 @@ export function useComposerGithubAutoAttach(
const removedRefKeysRef = useRef(new Set<string>());
const presentPullRequestKeysRef = useRef(new Set<string>());
const activeLookupsRef = useRef(new Set<ActiveGithubLookup>());
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd });
const previousTargetRef = useRef({ serverId: params.serverId, client: params.client });
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
() => new Map(),
);
@@ -79,7 +83,7 @@ export function useComposerGithubAutoAttach(
hasClient,
params.isConnected,
params.serverId,
params.cwd,
params.client,
]);
useEffect(() => {
@@ -143,7 +147,7 @@ export function useComposerGithubAutoAttach(
hasClient,
params.isConnected,
params.serverId,
params.cwd,
params.client,
queryClient,
]);
@@ -236,15 +240,25 @@ function suppressRefsCarriedAcrossTargets({
removedRefKeys,
}: {
params: ComposerGithubAutoAttachInput;
previousTargetRef: RefObject<{ serverId: string; cwd: string }>;
previousTargetRef: RefObject<{
serverId: string;
client: ForgeSearchClient | null;
}>;
removedRefKeys: Set<string>;
}): void {
const previous = previousTargetRef.current;
const targetChanged =
previous.cwd.trim().length > 0 &&
params.cwd.trim().length > 0 &&
(previous.serverId !== params.serverId || previous.cwd !== params.cwd);
previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd };
previous.client !== null &&
params.client !== null &&
previous.client.cwd.trim().length > 0 &&
params.client.cwd.trim().length > 0 &&
(previous.serverId !== params.serverId ||
!workspaceGitQueryIdentitiesEqual(
forgeSearchClientIdentity(previous.client),
forgeSearchClientIdentity(params.client),
) ||
previous.client.cwd !== params.client.cwd);
previousTargetRef.current = { serverId: params.serverId, client: params.client };
if (!targetChanged) return;
for (const ref of extractGithubRefs(params.text, params.remoteUrl)) {
@@ -444,7 +458,7 @@ function refsReadyForLookup({
removedRefKeys: Set<string>;
activeLookups: ReadonlySet<ActiveGithubLookup>;
}): GithubRef[] {
if (!params.client || !params.isConnected || params.cwd.trim().length === 0) {
if (!params.client || !params.isConnected || params.client.cwd.trim().length === 0) {
return [];
}
@@ -476,7 +490,6 @@ async function fetchGithubRefSearch({
buildForgeSearchQueryOptions({
client: snapshot.client,
serverId: snapshot.serverId,
cwd: snapshot.cwd,
query: String(ref.number),
supportsForgeSearch: snapshot.supportsForgeSearch,
enabled: true,
@@ -499,7 +512,13 @@ function isSameLookupTarget(
): boolean {
return (
initial.serverId === current.serverId &&
initial.cwd === current.cwd &&
(initial.client === null || current.client === null
? initial.client === current.client
: workspaceGitQueryIdentitiesEqual(
forgeSearchClientIdentity(initial.client),
forgeSearchClientIdentity(current.client),
)) &&
initial.client?.cwd === current.client?.cwd &&
initial.remoteUrl === current.remoteUrl
);
}
+98 -20
View File
@@ -121,10 +121,13 @@ 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";
import { readClipboardImage } from "./clipboard-image";
import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image";
import { PluginResourceAttachmentPill, usePluginAttachmentPicker } from "@/plugins";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import {
appendWorkspaceFileAttachment,
@@ -440,6 +443,20 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
onRemove,
});
}
if (attachment.kind === "plugin_resource") {
return (
<PluginResourceAttachmentPill
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
attachment={attachment}
index={index}
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openGithub}
removeLabel={labels.removeGithub}
/>
);
}
return (
<GithubAttachmentPill
key={`${attachment.item.kind}:${attachment.item.number}`}
@@ -839,6 +856,8 @@ interface ComposerProps {
submitIcon?: "arrow" | "return";
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
/** Disables submission without locking draft editing or attachments. */
isSubmitDisabled?: boolean;
/** When true, waits for pasted GitHub links to resolve before enabling submit. */
waitForGithubAutoAttachOnSubmit?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
@@ -846,6 +865,7 @@ interface ComposerProps {
blurOnSubmit?: boolean;
value: string;
onChangeText: (text: string) => void;
textReplacementKey: string;
attachments: UserComposerAttachment[];
attachmentScopeKeys?: readonly string[];
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
@@ -1049,11 +1069,13 @@ export function Composer({
submitButtonTestID,
submitIcon = "arrow",
isSubmitLoading = false,
isSubmitDisabled = false,
waitForGithubAutoAttachOnSubmit = false,
submitBehavior = "clear",
blurOnSubmit = false,
value,
onChangeText,
textReplacementKey,
attachments,
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
onOpenWorkspaceAttachment,
@@ -1131,7 +1153,8 @@ export function Composer({
onOpenWorkspaceAttachment,
});
const setSelectedAttachments = onChangeAttachments;
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId });
const workspaceGitClient = useWorkspaceGit();
const supportsForgeSearch = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
);
@@ -1139,10 +1162,9 @@ export function Composer({
text: userInput,
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
attachments,
client,
client: workspaceGitClient,
isConnected,
serverId,
cwd,
supportsForgeSearch,
setAttachments: setSelectedAttachments,
onPullRequestDetected: onGithubPrDetected,
@@ -1151,6 +1173,7 @@ export function Composer({
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false);
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
@@ -1158,11 +1181,30 @@ export function Composer({
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(null);
const pluginAttachments = usePluginAttachmentPicker({
serverId,
client,
connected: isConnected,
attachments,
onChangeAttachments: setSelectedAttachments,
anchorRef: attachButtonRef,
});
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
);
const replaceUserInput = useCallback(
(text: string, selection?: { start: number; end: number }) => {
if (messageInputRef.current) {
messageInputRef.current.replaceText(text, selection);
return;
}
onChangeText(text);
},
[onChangeText],
);
const runClientSlashCommand = useCallback(
(command: ClientSlashCommand): boolean => {
if (command.execution !== "immediate" || !onClientSlashCommand) {
@@ -1173,7 +1215,7 @@ export function Composer({
messageInputRef.current?.blur();
}
clearDraft("sent");
setUserInput("");
replaceUserInput("");
setSelectedAttachments([]);
resetSuppression();
setSendError(null);
@@ -1194,14 +1236,14 @@ export function Composer({
onClientSlashCommand,
resetSuppression,
setSelectedAttachments,
setUserInput,
replaceUserInput,
],
);
const autocomplete = useAgentAutocomplete({
userInput,
cursorIndex,
setUserInput,
setUserInput: replaceUserInput,
serverId,
agentId,
draftConfig: commandDraftConfig,
@@ -1356,7 +1398,7 @@ export function Composer({
});
if (!result.queued) return;
setUserInput("");
replaceUserInput("");
setSelectedAttachments([]);
resetSuppression();
clearSentAttachments(queuedAttachments);
@@ -1367,7 +1409,7 @@ export function Composer({
queueWriter,
resetSuppression,
setSelectedAttachments,
setUserInput,
replaceUserInput,
],
);
@@ -1398,7 +1440,7 @@ export function Composer({
await submitMessage(submitText, submitAttachments);
},
clearDraft,
setUserInput,
setUserInput: replaceUserInput,
setAttachments: (nextAttachments) => {
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments));
},
@@ -1423,7 +1465,7 @@ export function Composer({
isAgentRunning,
queueMessage,
setSelectedAttachments,
setUserInput,
replaceUserInput,
submitBehavior,
submitMessage,
t,
@@ -1484,6 +1526,30 @@ export function Composer({
}
}, [addImages, t]);
const handleNativePasteImages = useCallback(
(files: readonly NativePastedFile[]) => {
setPendingNativeImagePastes((pending) => pending + 1);
void pickAndPersistImages({
pickImages: async () => normalizeNativePastedImages(files),
persister: composerImageAttachmentPersister,
})
.then((newImages) => {
if (newImages.length > 0) {
addImages(newImages);
}
return undefined;
})
.catch((error) => {
console.error("[Composer] Failed to persist pasted image:", error);
toastErrorRef.current(t("composer.errors.pasteImageFailed"));
})
.finally(() => {
setPendingNativeImagePastes((pending) => Math.max(0, pending - 1));
});
},
[addImages, t],
);
const uploadPickedFiles = useCallback(
async (files: PickedFile[]) => {
if (files.length === 0) return;
@@ -1686,10 +1752,10 @@ export function Composer({
queue: queueWriter,
});
if (!result) return;
setUserInput(result.text);
replaceUserInput(result.text);
setSelectedAttachments(result.attachments);
},
[agentId, queueWriter, setSelectedAttachments, setUserInput],
[agentId, queueWriter, replaceUserInput, setSelectedAttachments],
);
const handleSendQueuedNow = useCallback(
@@ -1856,16 +1922,14 @@ export function Composer({
// until the forge-specific picker or attachment presentation is visible.
const { forge } = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment),
});
const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useForgeSearchQuery({
client,
client: workspaceGitClient,
serverId,
cwd,
query: githubSearchQueryTrimmed,
supportsForgeSearch,
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
@@ -1922,6 +1986,7 @@ export function Composer({
setIsGithubPickerOpen(true);
},
},
...pluginAttachments.menuItems,
{
id: "file",
label: t("composer.attachments.addFile"),
@@ -1932,7 +1997,14 @@ export function Composer({
},
);
return items;
}, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]);
}, [
forgePresentation,
handlePasteImage,
handlePickFile,
handlePickImage,
pluginAttachments.menuItems,
t,
]);
const handleToggleGithubItem = useCallback(
(item: ForgeSearchItem) => {
@@ -2073,9 +2145,12 @@ export function Composer({
const messageInputContainerRef = useRef<View>(null);
const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile;
const isSubmitDisabled =
isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
const isSubmitLoadingVisible =
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
const isSubmitActionDisabled =
isSubmitDisabled ||
isSubmitLoadingVisible ||
(waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
@@ -2134,7 +2209,7 @@ export function Composer({
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitButtonTestID={submitButtonTestID}
submitIcon={submitIcon}
isSubmitDisabled={isSubmitDisabled}
isSubmitDisabled={isSubmitActionDisabled}
isSubmitLoading={isSubmitLoadingVisible}
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
attachments={selectedAttachments}
@@ -2142,6 +2217,7 @@ export function Composer({
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={handleAttachButtonRef}
onAddImages={addImages}
onPasteImages={handleNativePasteImages}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
@@ -2167,6 +2243,7 @@ export function Composer({
attachmentSlot={attachmentTray}
inputMode={inputMode}
readOnly={readOnly}
textReplacementKey={textReplacementKey}
submitLabel={submitLabel}
/>
<Combobox
@@ -2189,6 +2266,7 @@ export function Composer({
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
{pluginAttachments.picker}
</View>
</View>
</View>
+63 -34
View File
@@ -56,6 +56,9 @@ import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerHeightMirror } from "./height-mirror";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import { ComposerTextInput } from "./text-input";
import type { ComposerTextInputHandle } from "./text-input-types";
import {
resolveSendTooltipLabel,
resolveSubmitAccessibilityLabel,
@@ -105,6 +108,7 @@ export interface MessageInputProps {
attachmentMenuItems: AttachmentMenuItem[];
onAttachButtonRef?: (node: View | null) => void;
onAddImages?: (images: ImageAttachment[]) => void;
onPasteImages?: (files: readonly NativePastedFile[]) => void;
client: DaemonClient | null;
/** Dictation start gate from host runtime (socket connected + directory ready). */
isReadyForDictation?: boolean;
@@ -147,6 +151,8 @@ export interface MessageInputProps {
inputMode?: ComposerInputMode;
/** Renders `value` as static text on the same surface, for content there is nothing to type into. */
readOnly?: boolean;
/** Changes only when application state must replace native-owned text. */
textReplacementKey: string;
/** Replaces the submit icon with this label, still inside the composer's own toolbar row. */
submitLabel?: string;
}
@@ -154,6 +160,8 @@ export interface MessageInputProps {
export interface MessageInputRef {
focus: () => void;
blur: () => void;
getText: () => string;
replaceText: (text: string, selection?: { start: number; end: number }) => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
@@ -397,12 +405,9 @@ function handleDesktopKeyPressImpl(
ctx.handleDefaultSendAction();
}
function getTextInputNativeElement(
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
): HTMLElement | null {
function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTMLElement | null {
if (!current) return null;
const handle = current as TextInput & { getNativeRef?: () => unknown };
const native = typeof handle.getNativeRef === "function" ? handle.getNativeRef() : current;
const native = typeof current.getNativeRef === "function" ? current.getNativeRef() : current;
return native instanceof HTMLElement ? native : null;
}
@@ -478,9 +483,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
}
function useAutoFocusOnWebEffect(
textInputRef: React.MutableRefObject<
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
>,
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>,
autoFocus: boolean,
autoFocusKey: string | undefined,
): void {
@@ -588,8 +591,8 @@ function FocusHint({
interface ComposerTextSurfaceProps {
readOnly: boolean;
value: string;
textInputRef: React.Ref<TextInput>;
textInputStyle: React.ComponentProps<typeof ThemedTextInput>["style"];
textInputRef: React.Ref<ComposerTextInputHandle>;
textInputStyle: React.ComponentProps<typeof TextInput>["style"];
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
placeholder: string;
accessibilityLabel: string;
@@ -602,6 +605,8 @@ interface ComposerTextSurfaceProps {
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined;
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
onPasteError: (message: string) => void;
focusHintVisible: boolean;
focusInputKeys: ShortcutChord | null | undefined;
focusHintLabel: string;
@@ -624,13 +629,12 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
}
return (
<View style={styles.textInputScrollWrapper}>
<ThemedTextInput
<ComposerTextInput
ref={props.textInputRef}
dataSet={COMPOSER_INPUT_DATASET}
value={props.value}
text={props.value}
onChangeText={props.onChangeText}
placeholder={props.placeholder}
uniProps={textInputPlaceholderColorMapping}
accessibilityLabel={props.accessibilityLabel}
onFocus={props.onFocus}
onBlur={props.onBlur}
@@ -641,6 +645,8 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
editable={props.editable}
onKeyPress={props.onKeyPress}
onSelectionChange={props.onSelectionChange}
onPasteImages={props.onPasteImages}
onPasteError={props.onPasteError}
autoFocus={props.autoFocus}
/>
<FocusHint
@@ -904,7 +910,7 @@ interface QueueMessageContext {
attachments: ComposerAttachment[];
cwd: string;
onQueue: ((payload: MessagePayload) => void) | undefined;
onChangeText: (text: string) => void;
replaceText: (text: string) => void;
onMinimizeHeight: () => void;
}
@@ -913,7 +919,7 @@ function queueMessageImpl(ctx: QueueMessageContext): void {
const trimmed = ctx.value.trim();
if (!trimmed && ctx.attachments.length === 0) return;
ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd });
ctx.onChangeText("");
ctx.replaceText("");
ctx.onMinimizeHeight();
}
@@ -965,9 +971,7 @@ function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v;
}
function getWebTextAreaImpl(
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
): TextAreaHandle | null {
function getWebTextAreaImpl(current: ComposerTextInputHandle | null): TextAreaHandle | null {
if (!current) return null;
const candidate = current as { getNativeRef?: () => unknown };
if (typeof candidate.getNativeRef === "function") {
@@ -1019,6 +1023,7 @@ interface ResolvedMessageInputProps {
attachmentMenuItems: AttachmentMenuItem[];
onAttachButtonRef: ((node: View | null) => void) | undefined;
onAddImages: ((images: ImageAttachment[]) => void) | undefined;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
client: DaemonClient | null;
isReadyForDictation: boolean | undefined;
placeholder: string | undefined;
@@ -1044,6 +1049,7 @@ interface ResolvedMessageInputProps {
attachmentSlot: React.ReactNode;
inputMode: ComposerInputMode;
readOnly: boolean;
textReplacementKey: string;
submitLabel: string | undefined;
}
@@ -1065,6 +1071,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
attachmentMenuItems: props.attachmentMenuItems,
onAttachButtonRef: props.onAttachButtonRef,
onAddImages: props.onAddImages,
onPasteImages: props.onPasteImages,
client: props.client,
isReadyForDictation: props.isReadyForDictation,
placeholder: props.placeholder,
@@ -1090,6 +1097,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
attachmentSlot: props.attachmentSlot,
inputMode: props.inputMode ?? "chat",
readOnly: props.readOnly ?? false,
textReplacementKey: props.textReplacementKey,
submitLabel: props.submitLabel,
};
}
@@ -1119,6 +1127,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
attachmentMenuItems,
onAttachButtonRef,
onAddImages,
onPasteImages,
client,
isReadyForDictation,
placeholder,
@@ -1144,6 +1153,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
attachmentSlot,
inputMode,
readOnly,
textReplacementKey,
submitLabel,
} = resolveMessageInputProps(props);
const mode = resolveComposerInputMode(inputMode);
@@ -1161,18 +1171,29 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const [isInputFocused, setIsInputFocused] = useState(false);
const rootRef = useRef<View | null>(null);
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
null,
);
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
const isInputFocusedRef = useRef(false);
const valueRef = useRef(value);
const appliedTextReplacementKeyRef = useRef(textReplacementKey);
const replaceText = useCallback(
(nextText: string, selection?: { start: number; end: number }) => {
valueRef.current = nextText;
textInputRef.current?.replaceText(nextText, selection);
onChangeText(nextText);
},
[onChangeText],
);
useImperativeHandle(ref, () => ({
focus: () => {
textInputRef.current?.focus();
},
blur: () => {
textInputRef.current?.blur?.();
textInputRef.current?.blur();
},
getText: () => textInputRef.current?.getText() ?? valueRef.current,
replaceText,
runKeyboardAction: (action) =>
runMessageInputKeyboardAction(action, {
focusInput: () => textInputRef.current?.focus(),
@@ -1191,7 +1212,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const sendAfterTranscriptRef = useRef(false);
const valueRef = useRef(value);
const serverInfo = useSessionStore(
useCallback(
(state) => {
@@ -1205,8 +1225,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
);
useEffect(() => {
if (appliedTextReplacementKeyRef.current === textReplacementKey) return;
appliedTextReplacementKeyRef.current = textReplacementKey;
valueRef.current = value;
}, [value]);
textInputRef.current?.replaceText(value);
}, [textReplacementKey, value]);
useEffect(() => {
return () => {
@@ -1226,13 +1249,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isAgentRunning,
onQueue,
onSubmit,
onChangeText,
replaceText,
attachments,
cwd,
autoSend,
});
},
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
[replaceText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
);
const handleDictationError = useCallback(
@@ -1413,7 +1436,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleSendMessage = useCallback(
() =>
sendMessageImpl({
value: valueRef.current,
value: textInputRef.current?.getText() ?? valueRef.current,
attachments,
hasExternalContent,
allowEmptySubmit,
@@ -1438,14 +1461,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleQueueMessage = useCallback(
() =>
queueMessageImpl({
value: valueRef.current,
value: textInputRef.current?.getText() ?? valueRef.current,
attachments,
cwd,
onQueue,
onChangeText,
replaceText,
onMinimizeHeight: minimizeInputHeight,
}),
[attachments, cwd, onQueue, onChangeText, minimizeInputHeight],
[attachments, cwd, onQueue, replaceText, minimizeInputHeight],
);
const handleDefaultSendAction = useCallback(() => {
@@ -1618,6 +1641,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onFocusChange?.(false);
}, [onFocusChange]);
const handlePasteError = useCallback(
(message: string) => {
console.error("[MessageInput] Native paste failed:", message);
toast.error(t("composer.errors.pasteImageFailed"));
},
[t, toast],
);
const attachButtonStyle = useCallback(
({ hovered }: { hovered?: boolean }) => [
styles.attachButton,
@@ -1732,6 +1763,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onContentSizeChange={handleContentSizeChange}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
onPasteError={handlePasteError}
focusHintVisible={isWeb && isPaneFocused && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", {
@@ -1982,11 +2015,7 @@ const ThemedMicOff = withUnistyles(MicOff);
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedCornerDownLeft = withUnistyles(CornerDownLeft);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedTextInput = withUnistyles(TextInput);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
const textInputPlaceholderColorMapping = (theme: Theme) => ({
placeholderTextColor: theme.colors.surface4,
});
@@ -173,7 +173,7 @@ describe("dictation transcript behavior", () => {
defaultSendBehavior: "interrupt",
isAgentRunning: false,
onQueue: undefined,
onChangeText: (text) => actions.push(`change:${text}`),
replaceText: (text) => actions.push(`replace:${text}`),
onSubmit: (payload) => actions.push(`submit:${payload.text}`),
attachments: [],
cwd: "/repo",
@@ -181,7 +181,7 @@ describe("dictation transcript behavior", () => {
});
expect(actions).toEqual([
"change:typed context spoken prompt",
"replace:typed context spoken prompt",
"submit:typed context spoken prompt",
]);
});
+4 -4
View File
@@ -52,7 +52,7 @@ interface DictationTranscriptContext {
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
onSubmit: (payload: MessagePayload) => void;
onChangeText: (text: string) => void;
replaceText: (text: string) => void;
attachments: MessagePayload["attachments"];
cwd: string;
autoSend: boolean;
@@ -64,15 +64,15 @@ export function applyDictationTranscript(text: string, ctx: DictationTranscriptC
const nextValue = `${ctx.value}${shouldPad ? " " : ""}${text}`;
if (!ctx.autoSend) {
ctx.onChangeText(nextValue);
ctx.replaceText(nextValue);
return;
}
ctx.onChangeText(nextValue);
ctx.replaceText(nextValue);
if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) {
ctx.onQueue({ text: nextValue, attachments: ctx.attachments, cwd: ctx.cwd });
ctx.onChangeText("");
ctx.replaceText("");
return;
}
@@ -0,0 +1,20 @@
import type { TextInputProps } from "react-native";
import type { NativePastedFile } from "@/composer/native-pasted-image";
export interface ComposerTextInputHandle {
focus: () => void;
blur: () => void;
getText: () => string;
replaceText: (text: string, selection?: { start: number; end: number }) => void;
getNativeRef?: () => unknown;
}
export interface ComposerTextInputProps extends Omit<
TextInputProps,
"defaultValue" | "onChangeText" | "value"
> {
text: string;
onChangeText: (text: string) => void;
onPasteImages?: (files: readonly NativePastedFile[]) => void;
onPasteError?: (message: string) => void;
}

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