From 42f1d0b2894c644b9c71aea8e334bc6ef36411ea Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 16:13:51 +0200 Subject: [PATCH 01/73] docs(hub): align public docs with v0.3 (#3152) * docs(hub): align public docs with v0.3 Bring the public workflow, API, authority, activity, and self-hosting guidance in line with the released Hub contracts. Clarify the public HTTPS boundary required by provider events while keeping Hub and PostgreSQL private. * docs(hub): clarify daemon logout scope * docs(hub): restore Compose path and simplify TLS guidance * docs(hub): link the single-repo-team-bot example --- public-docs/hub/activity.md | 28 +++++-------- public-docs/hub/api.md | 42 +++++++++---------- public-docs/hub/configuration/hub-yml.md | 38 ++++++++++++----- public-docs/hub/configuration/index.md | 2 + public-docs/hub/github.md | 15 ++++--- public-docs/hub/hosted.md | 6 +-- public-docs/hub/quickstart.md | 31 +++++++++----- public-docs/hub/self-hosting/github-app.md | 2 + public-docs/hub/self-hosting/index.md | 48 +++++++++++++++++++--- public-docs/hub/self-hosting/slack-app.md | 2 +- public-docs/hub/triggers/discord.md | 2 +- public-docs/hub/triggers/index.md | 4 +- public-docs/hub/triggers/slack.md | 2 +- public-docs/hub/workflows.md | 8 +++- 14 files changed, 149 insertions(+), 81 deletions(-) diff --git a/public-docs/hub/activity.md b/public-docs/hub/activity.md index b00f3e1f34..5abe91b2cf 100644 --- a/public-docs/hub/activity.md +++ b/public-docs/hub/activity.md @@ -12,34 +12,28 @@ Every event Hub accepts is recorded, whether or not it ran anything. That record ## Where to look -**Project → Activity** lists the events routed to that project: the event, its source, the result, and when it arrived. +**Project → Activity** lists routed trigger runs and their execution state. -The result column says what happened: +**Connections → Known unrouted events** is the organization-level view of accepted provider events that did not start a configured trigger. Hub records one of four bounded reasons: -| Result | Meaning | -| --------------------- | ----------------------------------------------------------------- | -| A trigger name | The event matched that trigger and dispatched | -| A lifecycle state | The execution is in progress or finished | -| `no_matching_trigger` | The event reached the project, but no trigger's filters matched | -| `provider_unrouted` | No project route existed for the resource that produced the event | - -**Project → Executions** lists the runs: which trigger, which configuration revision, which daemon, duration, and outcome. - -**Connections → Known unrouted events** is the organization-level view. It holds events whose credential belongs to your organization but which no project's configuration claimed. A repository you connected but never named in a trigger lands here. - -Those two are different problems. `no_matching_trigger` means your filters are wrong. An unrouted event means no configuration mentions that repository, workspace, or guild at all. +| Reason | Meaning | +| --------------------------- | ------------------------------------------------------------ | +| `no_project_route` | No project route is configured for the event | +| `no_trigger_for_source` | No configured trigger handles that event source | +| `trigger_filters_rejected` | A trigger handles the source, but its filters rejected it | +| `configuration_unavailable` | A relevant configuration or connection could not be resolved | ## Nothing happened when I mentioned the bot Work down this list. -1. **Is the event in the project's Activity?** If not, check **Connections → Known unrouted events**. If it is there, no trigger names that resource. Add `filters.repo`, `filters.workspace`, or `filters.guild` and push. +1. **Is the event in the project's Activity?** If not, check **Connections → Known unrouted events** and use its reason to distinguish routing, source, filter, and configuration failures. 2. **Is the event anywhere at all?** If not, the event never reached Hub. Check the provider's own delivery log: GitHub's App → Advanced → Recent Deliveries, or Slack's Event Subscriptions page. Then check that the app is subscribed to that event type. 3. **Is your user in `from_users`?** This is the most common cause. GitHub uses your login; Slack and Discord use the user ID, not the display name. -4. **Did the mention match?** On GitHub, `contains` must appear in the comment body. On Slack and Discord the bot must actually be mentioned, and `pattern` matches only at the start of the text after the mention. +4. **Did the invocation match?** On GitHub, `contains` must appear in the comment body. On Slack and Discord the bot must be mentioned, and `pattern` or its legacy `contains` alias must prefix the text after the mention. 5. **Is the configuration you think is active actually active?** The Configuration tab shows the active revision and the last sync attempt. A failed push leaves the old revision serving. 6. **Is the daemon connected?** An offline daemon fails dispatch with `daemon_not_connected`. -7. **Did it run and stop early?** Check the execution. An agent that never reports back is ended by `idle_timeout` after 5 minutes by default, or `timeout` after an hour. +7. **Did it run and stop early?** Compare the execution with the step's authored `idle_timeout` and `max_runtime`, and the workflow's `max_runtime`. All three limits are explicit in the workflow. 8. **Did it run, but deliver nothing?** Check the step's prompt. An agent that is not told to call `hub.reply` and `hub.finish_execution` can answer in its own transcript and never report back. See [Tell the agent which tool to call](/docs/hub/workflows#tell-the-agent-which-tool-to-call). ## Sync failures diff --git a/public-docs/hub/api.md b/public-docs/hub/api.md index 14d52b4b97..cfc0bbc7eb 100644 --- a/public-docs/hub/api.md +++ b/public-docs/hub/api.md @@ -54,10 +54,12 @@ API failures use RFC 9457 problem details. Missing, invalid, or revoked credenti ```json { - "type": "https://hub.example.com/problems/unauthorized", - "title": "Unauthorized", + "type": "https://paseo.sh/problems/unauthorized", + "title": "Authentication required", "status": 401, - "detail": "Provide a valid organization API key." + "detail": "Provide an active Paseo organization credential in the Authorization: Bearer header.", + "code": "unauthorized", + "requestId": "5e967c44-fc22-4f6d-8fc5-1bbff33121af" } ``` @@ -130,7 +132,8 @@ Limits: - Bundle: at most 100 files. - File path: at most 512 characters. -- File content: at most 1,000,000 characters; partial byte limits are enforced during compilation. +- File content: at most 1,000,000 characters. +- Prompt partials: at most 1,000,000 bytes each and 5,000,000 bytes in total. On success, Hub returns `201`: @@ -163,7 +166,7 @@ The trigger must exist in the active configuration, and `actor` must be listed in that trigger's `filters.from_users` allowlist. ```http -POST /api/manual-runs +POST /api/v1/manual-runs ``` Request body: @@ -181,32 +184,27 @@ Request body: - `expectedVersionId` is optional. When supplied, Hub rejects the dispatch if that revision is no longer active. - `input` is the same string a provider message uses: leading `key=value` tokens are parsed as declared inputs, and the remainder becomes `${{ paseo.prompt }}`. -- `deliveryKey` should be unique and stable per dispatch. Reusing one resolves to the existing trigger instead of starting a duplicate run. +- `deliveryKey` should be unique and stable per dispatch. Hub uses it for durable deduplication, but does not promise exactly-once dispatch or replay of an earlier response. On success, Hub returns `200`: ```json { "deliveryKey": "deploy-2026-08-04-001", - "triggerId": "00000000-0000-4000-8000-000000000000", - "executionId": "00000000-0000-4000-8000-000000000000", - "status": "spawning", - "daemonId": "00000000-0000-4000-8000-000000000000", - "agentId": "agent-id" + "providerEventReceiptId": "00000000-0000-4000-8000-000000000000", + "triggerRunId": "00000000-0000-4000-8000-000000000000", + "configuredTriggerName": "deploy", + "workflowStatus": "running" } ``` -Common responses are `400 {"error":"invalid_request"}`, `404` for an -unknown project, missing configuration, or missing trigger, `403 -{"error":"actor_forbidden"}` when the actor is not allowed by the trigger, -and `409` when the daemon is offline, the expected configuration is no longer -current, or the run could not be dispatched. +Common responses are `400` for an invalid request, `403` when the actor is not allowed by the trigger, `404` for an unknown project, configuration, or trigger, and `409` when the daemon is offline, the expected configuration is no longer current, or dispatch conflicts. Each uses the problem-details shape above. Example: ```bash -curl --fail-with-body -sS -X POST "$HUB_URL/api/manual-runs" \ - -H "Authorization: Bearer $PASEO_API_KEY" \ +curl --fail-with-body -sS -X POST "$PASEO_HUB_URL/api/v1/manual-runs" \ + -H "Authorization: Bearer $PASEO_HUB_API_KEY" \ -H "Content-Type: application/json" \ --data '{ "projectSlug": "my-project", @@ -230,7 +228,7 @@ be used as one. POST /api/v1/daemons/enrollment-tokens ``` -Send an empty JSON object as the request body. On success, Hub returns `201`: +No request body is required. On success, Hub returns `201`: ```json { @@ -245,10 +243,8 @@ The token expires after 10 minutes and is consumed when the daemon enrolls. ```bash curl --fail-with-body -sS -X POST \ - "$HUB_URL/api/v1/daemons/enrollment-tokens" \ - -H "Authorization: Bearer $PASEO_API_KEY" \ - -H "Content-Type: application/json" \ - --data '{}' + "$PASEO_HUB_URL/api/v1/daemons/enrollment-tokens" \ + -H "Authorization: Bearer $PASEO_HUB_API_KEY" ``` Direct API consumers can pass the returned token to the daemon enrollment protocol. The Paseo CLI intentionally does not accept raw enrollment tokens; `connect` owns the authenticated single-flow exchange. diff --git a/public-docs/hub/configuration/hub-yml.md b/public-docs/hub/configuration/hub-yml.md index 2ab9d28895..9d8a44247a 100644 --- a/public-docs/hub/configuration/hub-yml.md +++ b/public-docs/hub/configuration/hub-yml.md @@ -64,6 +64,18 @@ The only top-level keys are `environments` and `agents`. A `triggers` key is rej For `worktree`, use `newBranch` and optional `base` with `branch-off`, `branch` with `checkout-branch`, or positive `prNumber` with `checkout-pr`. +```yaml +environments: + review: + kind: daemon + daemon: build-server + cwd: /workspace/project + worktree: + mode: branch-off + newBranch: paseo/review + base: origin/main +``` + An environment is a complete named object. A step selects its name; objects are not inherited, merged, or partially overridden. ### Named agents @@ -80,6 +92,8 @@ Each agent is one complete provider configuration: A named selection preserves the complete object, including structured options. Named agents have no parent, patch, or per-step override. +Hub passes `model`, `mode`, `thinkingOptionId`, and `options` to the Paseo daemon without renaming or flattening provider fields. The selected daemon validates them against its current provider schema; Hub does not translate provider-native options. + ## Workflow files `.paseo/workflows/review.yml`: @@ -88,6 +102,8 @@ A named selection preserves the complete object, including structured options. N name: review on: manual.run max_runtime: 2h +filters: + from_users: [automation] inputs: repo: type: string @@ -103,15 +119,15 @@ steps: - text: ${{ paseo.prompt }} ``` -| Field | Required | Notes | -| ------------- | ------------------ | --------------------------------------------------------- | -| `name` | yes | Workflow name, unique across the bundle. | -| `on` | yes | Provider event such as `manual.run` or `discord.mention`. | -| `max_runtime` | yes | Hard limit for the complete run, up to 24h. | -| `filters` | provider-dependent | Provider resource and sender allowlists. | -| `inputs` | no | Typed invocation headers. | -| `values` | no | Named expressions. | -| `steps` | yes | One or more ordered inline steps. | +| Field | Required | Notes | +| ------------- | -------- | --------------------------------------------------------- | +| `name` | yes | Workflow name, unique across the bundle. | +| `on` | yes | Provider event such as `manual.run` or `discord.mention`. | +| `max_runtime` | yes | Hard limit for the complete run, up to 24h. | +| `filters` | yes | Provider resource filters and the sender allowlist. | +| `inputs` | no | Typed invocation headers. | +| `values` | no | Named expressions. | +| `steps` | yes | One or more ordered inline steps. | ### Inputs and values @@ -176,7 +192,7 @@ prompt: Includes resolve relative to `.paseo/workflows/`, so shared partials use `partials/.md`. Missing files, absolute or traversing paths, symlinks, content mismatches, and files outside the partial tree are rejected. -### Output authority +### Output capabilities Authority stays on the step that uses it: @@ -189,6 +205,8 @@ allow_outputs: Slack workflows use `slack.reply`; Discord workflows use `discord.reply`. The declaration grants `hub.reply`, and the prompt must tell the agent to call it. GitHub has no reply output; use an explicit [`github` block](/docs/hub/github). +Every step receives `hub.finish_execution`. The prompt must tell the agent when to call it; Hub does not append completion or reply instructions. If `output.schema` is present, `hub.finish_execution` requires an `output` value that matches the schema. If an `allow_outputs` entry is `required: true`, the agent must emit that output before finishing. `max` defaults to `1`. + ## Migrating a monolithic file Keep `environments` in `hub.yml`, convert the environment list to a named map, and move each former trigger into its own `.paseo/workflows/.yml` file. Move shared prompt files to `.paseo/workflows/partials/`. Define complete named agent configurations under `agents` and replace dynamic provider fields with finite named-agent selection. diff --git a/public-docs/hub/configuration/index.md b/public-docs/hub/configuration/index.md index f87e0314fe..f579995275 100644 --- a/public-docs/hub/configuration/index.md +++ b/public-docs/hub/configuration/index.md @@ -21,6 +21,8 @@ A project configuration is one versioned bundle: `hub.yml` owns named environments and agents. Each direct-child workflow file owns one trigger and its ordered inline steps. Prompt partials referenced by those workflows live below `workflows/partials/`. Workflow discovery is fixed by convention; there is no manifest or include list. +[single-repo-team-bot](https://github.com/getpaseo/hub/tree/main/examples/single-repo-team-bot) is a complete bundle in this shape: Discord, Slack, and GitHub workflows running a classifier and a worker on shared partials. Copy `.paseo/` into your repository and replace the placeholders its README lists. + ## Sources A configuration comes from one source: diff --git a/public-docs/hub/github.md b/public-docs/hub/github.md index 822082863f..51b9a0e28b 100644 --- a/public-docs/hub/github.md +++ b/public-docs/hub/github.md @@ -46,18 +46,21 @@ The agent can use `git` and `gh` within the declared repositories and permission | `connection` | Project GitHub connection slug. | | `repositories` | Repositories the token can reach. On a GitHub-triggered run, this defaults to the triggering repository. Required for other providers. | | `permissions` | Installation-token permissions such as `contents`, `pull_requests`, and `issues`. Defaults to `contents: read`. | -| `duration` | Token lifetime. Defaults to `1h`, GitHub's maximum. | +| `duration` | Positive token lifetime up to `1h`. Defaults to `1h`, GitHub's maximum. | Requested authority cannot exceed the GitHub App installation. Activation and dispatch fail clearly when the connection, repository, or permissions cannot be resolved. ## Agent environment -Hub supplies `GH_TOKEN` and git configuration through environment variables: +Hub supplies `GH_TOKEN` and process-scoped git configuration through environment variables: -- Commits use the App bot identity. -- `git@github.com` remotes are rewritten to HTTPS. -- Global and system git configuration are ignored. -- The daemon host's git credentials are not used or changed. +- Commits use the App bot login as `user.name` and `+@users.noreply.github.com` as `user.email`. +- `git@github.com:` and `ssh://git@github.com/` remotes are rewritten to HTTPS. +- `gh auth git-credential` handles GitHub credentials for the step. +- User-global and system git configuration are ignored, and terminal credential prompts are disabled. +- The daemon host's git identity and credentials are not read or changed. + +`GH_TOKEN` and Hub's git configuration variables are reserved when a step has a `github` block; workflow `env` cannot replace them. ## Keep authority on the worker diff --git a/public-docs/hub/hosted.md b/public-docs/hub/hosted.md index f1c306b420..dd513ef46b 100644 --- a/public-docs/hub/hosted.md +++ b/public-docs/hub/hosted.md @@ -1,6 +1,6 @@ --- title: Hosted Hub -description: The managed Paseo Hub, not available yet. +description: Sign in to the managed Paseo Hub or self-host the same service. nav: Hosted order: 73 category: Hub @@ -8,6 +8,6 @@ category: Hub # Hosted Hub -Not available yet. For now, see [self-hosting](/docs/hub/self-hosting). +[Paseo Hub](https://hub.paseo.sh) is the managed service. Existing accounts can sign in; new account registration is currently closed. -When it ships, Paseo owns the GitHub App, Slack app, and Discord application, so connecting a provider is one click rather than a console walkthrough. Projects, configuration, triggers, daemons, and activity work the same way on both. +Paseo owns the hosted GitHub App, Slack app, and Discord application. Projects, configuration, triggers, daemons, and activity work the same way when you [self-host](/docs/hub/self-hosting). diff --git a/public-docs/hub/quickstart.md b/public-docs/hub/quickstart.md index b2a6ce24b3..51fbfc5958 100644 --- a/public-docs/hub/quickstart.md +++ b/public-docs/hub/quickstart.md @@ -8,20 +8,21 @@ category: Hub # Hub quickstart -## 1. Connect GitHub and a daemon +## 1. Create a project -Open **Connections** in Hub and connect the GitHub account or organization whose repositories you use. On the machine that will run agents: +Open **Connections** in Hub and connect the GitHub account or organization whose repositories you use. Then open **Projects → New project** and create a project. + +## 2. Log in and connect a daemon + +On the machine that will run agents: ```sh paseo hub login https://your-hub.example.com +paseo hub projects paseo hub connect ``` -Approve the browser login. See [Daemons](/docs/hub/daemons) for the separation between your CLI login and the daemon relationship. - -## 2. Create a project - -Open **Projects → New project**. In **Configuration**, choose the repository that will hold the configuration and select **Use for configuration**. +Approve the browser login. `projects` shows the slug needed by `deploy`. See [Daemons](/docs/hub/daemons) for the separation between your CLI login and the daemon relationship. ## 3. Add the bundle @@ -79,9 +80,7 @@ Workflow files are discovered as direct `.yml` children of `.paseo/workflows/`. ## 4. Validate and deploy -Push the files to the configuration repository's default branch. Hub fetches the complete bundle at that commit, validates it, and activates a revision. A failed sync leaves the previous revision active. - -You can validate or deploy the same local bundle from its project root: +From the project root: ```sh paseo hub deploy -p your-project --dry-run @@ -101,3 +100,15 @@ Comment from the account in `from_users`: Open the project's **Activity** tab to see routing and execution. If nothing runs, use the [Activity checklist](/docs/hub/activity). Before widening the allowlist or granting write authority, read [Hub security](/docs/hub/security). + +When you no longer need the local CLI login: + +```sh +paseo hub logout +``` + +Logging out does not disconnect the daemon. When the daemon is connected to the same Hub as the active CLI login, use `paseo hub logout --disconnect-daemon` to remove both relationships. + +## Next + +[single-repo-team-bot](https://github.com/getpaseo/hub/tree/main/examples/single-repo-team-bot) is a complete bundle covering all three providers, with a classifier, a worker, and shared prompt partials. diff --git a/public-docs/hub/self-hosting/github-app.md b/public-docs/hub/self-hosting/github-app.md index 7bb8e9acb9..24820ac893 100644 --- a/public-docs/hub/self-hosting/github-app.md +++ b/public-docs/hub/self-hosting/github-app.md @@ -16,6 +16,8 @@ Go to **Settings → Developer settings → GitHub Apps → New GitHub App** on Replace `hub.example.com` with your `PASEO_HUB_APP_URL`. +The webhook URL must be reachable from GitHub. Keep GitHub's default SSL verification enabled; see [Provider URLs](/docs/hub/self-hosting#provider-urls). + | Setting | Value | | ------------------ | ---------------------------------------------------------- | | Homepage URL | `https://hub.example.com` | diff --git a/public-docs/hub/self-hosting/index.md b/public-docs/hub/self-hosting/index.md index 295b142d17..431cc42e1f 100644 --- a/public-docs/hub/self-hosting/index.md +++ b/public-docs/hub/self-hosting/index.md @@ -20,11 +20,11 @@ Migrations run automatically at startup. Hub does not start listening when a mig Hub has one public URL and one persistent application secret: -| Variable | Purpose | -| ----------------------- | ------------------------------------------------------------------ | -| `PASEO_HUB_APP_URL` | Public origin used by the dashboard, authentication, and callbacks | -| `PASEO_HUB_AUTH_SECRET` | Protects browser sessions and derives execution credentials | -| `DATABASE_URL` | PostgreSQL connection string | +| Variable | Purpose | +| ----------------------- | ---------------------------------------------------------------------------- | +| `PASEO_HUB_APP_URL` | Public origin used by the dashboard, authentication, callbacks, and webhooks | +| `PASEO_HUB_AUTH_SECRET` | Protects browser sessions and derives execution credentials | +| `DATABASE_URL` | PostgreSQL connection string | Generate `PASEO_HUB_AUTH_SECRET` once and keep it across restarts: @@ -89,7 +89,26 @@ docker compose up -d The stack publishes Hub on port `3000` and stores PostgreSQL data in a named volume. The Hub image is `ghcr.io/getpaseo/hub:latest`. -When a reverse proxy terminates HTTPS, set `PASEO_HUB_TRUSTED_CLIENT_IP_HEADER` to the header carrying the original client IP. +### HTTPS with Caddy + +Compose serves plain HTTP on port `3000`. Run Caddy on the same host to terminate TLS: + +```caddyfile +hub.example.com { + reverse_proxy 127.0.0.1:3000 +} +``` + +Point `hub.example.com` at the host and open ports 80 and 443. Caddy [obtains and renews the certificate](https://caddyserver.com/docs/automatic-https). + +Then set in `.env`: + +```dotenv +PASEO_HUB_APP_URL=https://hub.example.com +PASEO_HUB_TRUSTED_CLIENT_IP_HEADER=x-forwarded-for +``` + +To keep port `3000` off the public interface, change the `hub` port in `compose.yml` to `"127.0.0.1:3000:3000"`. ## Fly @@ -122,6 +141,23 @@ fly deploy -a your-hub \ Keep one machine running. Hub holds the Discord gateway connection and dispatches events to daemons, so a stopped machine misses events. +## Provider URLs + +Slack and GitHub call Hub at `PASEO_HUB_APP_URL`: + +| Provider setting | URL | +| ---------------------------- | ------------------------------------------------------ | +| GitHub webhook | `/webhook` | +| GitHub OAuth callback | `/api/integrations/github/callback` | +| Slack Events API request URL | `/api/integrations/slack/events` | +| Slack OAuth callback | `/api/integrations/slack/callback` | + +Slack requires the Events API request URL to be publicly reachable over HTTPS with a valid certificate, and its [OAuth redirect URL](https://docs.slack.dev/authentication/installing-with-oauth/) must use HTTPS. See Slack's [HTTP request URL](https://docs.slack.dev/apis/events-api/using-http-request-urls/) requirements. + +GitHub must reach the webhook URL and [verifies SSL certificates](https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks#use-https-and-ssl-verification) by default. + +A Hub on `localhost` or a LAN address still runs manual and daemon workflows, but Slack and GitHub cannot reach it. To test provider setup locally, point `PASEO_HUB_APP_URL` at an HTTPS tunnel before starting Hub. A changed tunnel origin requires updated provider settings and a Hub restart. + ## Upgrades Pull the new image or source and deploy it. Migrations are forward-only. Back up PostgreSQL first; it contains configuration revisions, connections, and execution history. diff --git a/public-docs/hub/self-hosting/slack-app.md b/public-docs/hub/self-hosting/slack-app.md index b9264e4ed7..0661c3d29f 100644 --- a/public-docs/hub/self-hosting/slack-app.md +++ b/public-docs/hub/self-hosting/slack-app.md @@ -8,7 +8,7 @@ category: Hub # Slack for Hub -Hub receives Slack mentions over the Events API. Socket Mode is not used, so your Hub must be reachable from Slack. +Hub receives Slack mentions over the Events API. Socket Mode is not used, so your Hub needs a publicly reachable HTTPS origin with a valid certificate. See [Provider URLs](/docs/hub/self-hosting#provider-urls). ## Create the app diff --git a/public-docs/hub/triggers/discord.md b/public-docs/hub/triggers/discord.md index 6435680627..a5b46b4972 100644 --- a/public-docs/hub/triggers/discord.md +++ b/public-docs/hub/triggers/discord.md @@ -34,7 +34,7 @@ steps: - { type: discord.reply, max: 1, required: true } ``` -Turn on Discord Developer Mode to copy IDs. `from_users` matches the author; `guild` and `channels` constrain where the mention arrived. All filters must pass. +Turn on Discord Developer Mode to copy IDs. `from_users` matches the author; `guild` and `channels` constrain where the mention arrived. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass. The reply posts in the triggering thread or channel. `discord.reply` grants `hub.reply`, but does not rewrite the prompt. A Discord trigger grants no GitHub credential; add a [`github` block](/docs/hub/github) to the step that needs one. diff --git a/public-docs/hub/triggers/index.md b/public-docs/hub/triggers/index.md index 819c16a7c5..565f29aabf 100644 --- a/public-docs/hub/triggers/index.md +++ b/public-docs/hub/triggers/index.md @@ -66,8 +66,8 @@ An allowlist is one layer of defense. It does not make a permitted account trust | `workspace` | Slack | Team id, `T01234567` | | `guild` | Discord | Guild id | | `channels` | Slack, Discord | Channel ids | -| `contains` | all | Substring of the message text | -| `pattern` | all | Prefix of the message text | +| `contains` | all | GitHub substring; Slack and Discord invocation prefix | +| `pattern` | all | Invocation prefix | | `connection` | all | A connection slug, when the organization has several | All conditions must pass. There is no `any` mode. diff --git a/public-docs/hub/triggers/slack.md b/public-docs/hub/triggers/slack.md index 11bb7301eb..b6e24c6e7c 100644 --- a/public-docs/hub/triggers/slack.md +++ b/public-docs/hub/triggers/slack.md @@ -34,7 +34,7 @@ steps: - { type: slack.reply, max: 1, required: true } ``` -Slack filters use IDs, not display names. `from_users` matches the author, `workspace` the team, and `channels` the channel. `pattern` checks the beginning of text after the mention; `contains` checks a substring. All filters must pass. +Slack filters use IDs, not display names. `from_users` matches the author, `workspace` the team, and `channels` the channel. `pattern` is a required prefix after the mention; `contains` is its legacy alias and has the same prefix behavior. All filters must pass. The reply posts in the triggering thread. A root message gets a thread; a threaded message stays there. `slack.reply` grants `hub.reply`, but does not add reply instructions to the prompt. diff --git a/public-docs/hub/workflows.md b/public-docs/hub/workflows.md index 5e85ea89ba..74787fa557 100644 --- a/public-docs/hub/workflows.md +++ b/public-docs/hub/workflows.md @@ -2,7 +2,7 @@ title: Hub workflows description: Build ordered Hub workflows with prompts, routing, outputs, and provider authority. nav: Workflows -order: 65 +order: 64 category: Hub --- @@ -55,6 +55,8 @@ A finite input can select among complete named environments: name: route-repository on: manual.run max_runtime: 1h +filters: + from_users: [automation] inputs: repo: type: string @@ -94,6 +96,8 @@ For dynamic routing, select complete named agents from a finite expression: name: route-agent on: manual.run max_runtime: 1h +filters: + from_users: [automation] inputs: agent: type: string @@ -192,6 +196,8 @@ Steps run in file order. `if` may read inputs, values, and prior step output: name: conditional-review on: manual.run max_runtime: 1h +filters: + from_users: [automation] steps: - id: inspect environment: paseo From a98d154b6bea2a177b193ca731cbac87560d0ecb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 17:02:35 +0200 Subject: [PATCH 02/73] Search workspace files from the command center (#3059) * feat(command-center): add workspace file search Use the existing workspace-aware fuzzy search so file lookup follows the file explorer's ignore and hidden-directory rules. Cmd/Ctrl+P opens a removable Files scope while the normal command center also includes file matches. * fix(command-center): stabilize file search results * fix(shortcuts): preserve project picker command * fix(command-center): keep file results while searching * feat(command-center): integrate the files scope into the search row The scope was a 24px pill with 12px text sitting inside the 16px input line, so it read as a badge dropped into the query rather than the scope of the search. Make it a leading cell of the search row instead: folder icon, input-sized label, and a hairline divider before the caret. It stretches to the row's cross size and bleeds through the header padding with negative margins, so its height is driven entirely by the input and the panel's own radius clips its corner. Toggling the scope cannot resize the header, which the new spec pins. * fix(command-center): restore the files scope as a chip The previous commit turned the scope into a flush leading cell of the header. Put the chip back: rounded, bordered, folder icon and 14px label, no divider. It stretches to the row instead of carrying a height, so it is exactly as tall as the input's line box and cannot resize the header. * refactor(shortcuts): regroup and reorder the shortcut help sections Panels was a junk drawer: the command center, file search, the shortcuts sheet itself, new terminal, the sidebars, settings, focus mode and theme cycling. The two shortcuts people reach for most, Cmd+K and Cmd+P, sat inside it below three other sections. Navigation held both workspace and tab movement. Sections are now General, Projects & Workspaces, Tabs & Panes, Layout, and Agent Input. General leads with the command center and file search. Tab movement joins Tabs & Panes, new terminal joins the tabs it creates, and Layout keeps only the sidebar and focus toggles. Row order is now data rather than a side effect of binding-definition order, so the row someone opens the sheet to find sits at the top of its section. A test fails when a new shortcut has no place in that order instead of letting it sink to the bottom unnoticed. * Keep file search failures in view --- .../command-center-file-search.spec.ts | 139 +++++++++++ .../helpers/command-center-file-search.ts | 211 +++++++++++++++++ .../app/src/command-center/command-center.tsx | 224 ++++++++++++++++-- packages/app/src/command-center/results.ts | 12 + .../workspace-file-search-model.ts | 15 ++ .../workspace-file-search.test.ts | 28 +++ .../command-center/workspace-file-search.ts | 148 ++++++++++++ .../app/src/hooks/use-keyboard-shortcuts.ts | 4 +- packages/app/src/i18n/resources.test.ts | 6 +- packages/app/src/i18n/resources/ar.ts | 12 +- packages/app/src/i18n/resources/en.ts | 12 +- packages/app/src/i18n/resources/es.ts | 12 +- packages/app/src/i18n/resources/fr.ts | 12 +- packages/app/src/i18n/resources/ja.ts | 12 +- packages/app/src/i18n/resources/ko.ts | 12 +- packages/app/src/i18n/resources/pt-BR.ts | 12 +- packages/app/src/i18n/resources/ru.ts | 12 +- packages/app/src/i18n/resources/zh-CN.ts | 12 +- packages/app/src/keyboard/actions.ts | 1 + .../src/keyboard/keyboard-shortcuts.test.ts | 60 ++++- .../app/src/keyboard/keyboard-shortcuts.ts | 196 +++++++++------ .../app/src/keyboard/route-shortcut.test.ts | 18 ++ packages/app/src/keyboard/route-shortcut.ts | 7 +- .../src/keyboard/shortcut-help-search.test.ts | 8 +- .../stores/keyboard-shortcuts-store.test.ts | 15 ++ .../src/stores/keyboard-shortcuts-store.ts | 11 +- 26 files changed, 1070 insertions(+), 141 deletions(-) create mode 100644 packages/app/e2e/browser/command-center-file-search.spec.ts create mode 100644 packages/app/e2e/support/helpers/command-center-file-search.ts create mode 100644 packages/app/src/command-center/workspace-file-search-model.ts create mode 100644 packages/app/src/command-center/workspace-file-search.test.ts create mode 100644 packages/app/src/command-center/workspace-file-search.ts diff --git a/packages/app/e2e/browser/command-center-file-search.spec.ts b/packages/app/e2e/browser/command-center-file-search.spec.ts new file mode 100644 index 0000000000..5964dcc978 --- /dev/null +++ b/packages/app/e2e/browser/command-center-file-search.spec.ts @@ -0,0 +1,139 @@ +import { expect, test } from "../support/fixtures"; +import { openCommandCenter } from "../support/helpers/command-center"; +import { + delayDirectorySuggestionResponses, + expectOneLineTruncatedFileResult, + expectStableCommandCenterLayout, + failDirectorySuggestionRequests, + startCommandCenterLayoutObservation, +} from "../support/helpers/command-center-file-search"; +import { expectFileTabOpen } from "../support/helpers/file-explorer"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; + +const FILE_PATH = + "packages/app/src/command-center/features/workspace-file-search/layout-stability/needle-layout-stability-command-center.tsx"; +const FILE_NAME = "needle-layout-stability-command-center.tsx"; +const FILE_DIRECTORY = + "packages/app/src/command-center/features/workspace-file-search/layout-stability"; + +function paseoSizedFiles(): Array<{ path: string; content: string }> { + const files = Array.from({ length: 120 }, (_, index) => ({ + path: `packages/app/src/features/feature-${String(index).padStart(3, "0")}/index.ts`, + content: `export const feature${index} = ${index};\n`, + })); + files.push({ path: FILE_PATH, content: "export const layoutNeedle = true;\n" }); + return files; +} + +async function seedMatchingWorkspaces(count: number): Promise { + return Promise.all( + Array.from({ length: count }, (_, index) => + seedWorkspace({ + repoPrefix: `command-center-file-search-error-${String(index).padStart(2, "0")}-`, + title: `Home failure ${String(index).padStart(2, "0")}`, + }), + ), + ); +} + +test.use({ + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36", +}); + +test("workspace file search stays geometrically stable through delayed loading and results", async ({ + page, +}) => { + test.setTimeout(120_000); + const seeded = await seedWorkspace({ + repoPrefix: "command-center-file-search-", + title: "Paseo-shaped file search", + repo: { files: paseoSizedFiles() }, + }); + + try { + await delayDirectorySuggestionResponses(page, 800); + await gotoWorkspace(page, seeded.workspaceId); + await page.keyboard.press("Meta+P"); + + const panel = page.getByTestId("command-center-panel"); + await expect(panel).toBeVisible({ timeout: 30_000 }); + await expect(panel.getByRole("button", { name: "Files" })).toBeVisible(); + await startCommandCenterLayoutObservation(page); + + await panel.getByTestId("command-center-input").fill(FILE_NAME); + const row = panel.getByRole("button", { + name: `${FILE_NAME} ${FILE_DIRECTORY}`, + }); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expectOneLineTruncatedFileResult(row, { + filename: FILE_NAME, + path: FILE_DIRECTORY, + }); + + await panel.getByTestId("command-center-input").fill("needle-layout"); + await page.waitForTimeout(150); + expect(await row.isVisible()).toBe(true); + await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible(); + await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0); + await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden(); + await expectStableCommandCenterLayout(page); + + await row.click(); + await expectFileTabOpen(page, FILE_PATH); + } finally { + await seeded.cleanup(); + } +}); + +test("dropping the files scope leaves the search row the same height", async ({ page }) => { + const seeded = await seedWorkspace({ + repoPrefix: "command-center-file-search-scope-", + title: "Scope chip height", + }); + + try { + await gotoWorkspace(page, seeded.workspaceId); + await page.keyboard.press("Meta+P"); + + const panel = page.getByTestId("command-center-panel"); + await expect(panel).toBeVisible({ timeout: 30_000 }); + const header = panel.getByTestId("command-center-header"); + const scoped = await header.boundingBox(); + + await page.getByTestId("command-center-files-scope").click(); + await expect(page.getByTestId("command-center-files-scope")).toHaveCount(0); + const unscoped = await header.boundingBox(); + + expect(scoped?.height).toBe(unscoped?.height); + } finally { + await seeded.cleanup(); + } +}); + +test("unscoped command search keeps commands visible and reports file-search failures", async ({ + page, +}) => { + test.setTimeout(120_000); + const seeded = await seedMatchingWorkspaces(9); + + try { + await failDirectorySuggestionRequests(page); + await gotoWorkspace(page, seeded[0].workspaceId); + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill("home"); + + await expect(panel.getByRole("button", { name: "Home", exact: true })).toBeVisible(); + await expect(panel.getByRole("button", { name: /Home failure 08/ })).toBeVisible(); + await expect(panel.getByTestId("command-center-file-search-error")).toHaveText( + "Error: Test file search transport failure.", + ); + await expect(panel.getByTestId("command-center-file-search-error")).toBeInViewport({ + ratio: 1, + }); + await expect(panel.getByText("No matches", { exact: true })).toHaveCount(0); + } finally { + await Promise.allSettled(seeded.map((workspace) => workspace.cleanup())); + } +}); diff --git a/packages/app/e2e/support/helpers/command-center-file-search.ts b/packages/app/e2e/support/helpers/command-center-file-search.ts new file mode 100644 index 0000000000..781470b582 --- /dev/null +++ b/packages/app/e2e/support/helpers/command-center-file-search.ts @@ -0,0 +1,211 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; + +type WebSocketMessage = string | Buffer; + +interface SurfaceRect { + height: number; + width: number; + x: number; + y: number; +} + +interface LayoutObservation { + active: boolean; + layoutShift: number; + samples: Record; +} + +type LayoutOracleWindow = Window & { + __commandCenterLayoutObservation?: LayoutObservation; +}; + +const SURFACE_SELECTORS = { + panel: '[data-testid="command-center-panel"]', + header: '[data-testid="command-center-header"]', + input: '[data-testid="command-center-input"]', + fileSearchStatus: '[data-testid="command-center-file-search-status"]', + results: '[data-testid="command-center-results"]', + scope: '[data-testid="command-center-files-scope"]', + fileRow: '[data-testid^="command-center-file-row-"]', + fileIcon: '[data-testid="command-center-file-icon"]', + fileLine: '[data-testid="command-center-file-line"]', +} as const; + +function parseSessionMessage(message: WebSocketMessage): Record | null { + const raw = typeof message === "string" ? message : message.toString("utf8"); + try { + const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown }; + return envelope.type === "session" && envelope.message && typeof envelope.message === "object" + ? (envelope.message as Record) + : null; + } catch { + return null; + } +} + +export async function delayDirectorySuggestionResponses(page: Page, delayMs = 400): Promise { + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + const sessionMessage = parseSessionMessage(message); + if (sessionMessage?.type === "directory_suggestions_response") { + setTimeout(() => ws.send(message), delayMs); + return; + } + ws.send(message); + }); + }); +} + +export async function failDirectorySuggestionRequests(page: Page): Promise { + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => { + const sessionMessage = parseSessionMessage(message); + if (sessionMessage?.type === "directory_suggestions_request") { + const requestId = sessionMessage.requestId; + if (typeof requestId === "string") { + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "rpc_error", + payload: { + requestId, + requestType: "directory_suggestions_request", + error: "Test file search transport failure.", + code: "transport", + }, + }, + }), + ); + } + return; + } + server.send(message); + }); + server.onMessage((message) => ws.send(message)); + }); +} + +export async function startCommandCenterLayoutObservation(page: Page): Promise { + await page.evaluate((selectors) => { + const oracleWindow = window as LayoutOracleWindow; + const observation: LayoutObservation = { active: true, layoutShift: 0, samples: {} }; + oracleWindow.__commandCenterLayoutObservation = observation; + + const observedElements = new WeakSet(); + const resizeObserver = new ResizeObserver(record); + const mutationObserver = new MutationObserver(discover); + + function record(): void { + if (!observation.active) return; + for (const [name, selector] of Object.entries(selectors)) { + const element = document.querySelector(selector); + if (!element) continue; + const rect = element.getBoundingClientRect(); + (observation.samples[name] ??= []).push({ + height: rect.height, + width: rect.width, + x: rect.x, + y: rect.y, + }); + } + } + + function discover(): void { + for (const selector of Object.values(selectors)) { + const element = document.querySelector(selector); + if (!element || observedElements.has(element)) continue; + observedElements.add(element); + resizeObserver.observe(element); + } + record(); + } + + mutationObserver.observe(document.body, { attributes: true, childList: true, subtree: true }); + discover(); + + const sampleFrame = () => { + if (!observation.active) { + mutationObserver.disconnect(); + resizeObserver.disconnect(); + return; + } + record(); + requestAnimationFrame(sampleFrame); + }; + requestAnimationFrame(sampleFrame); + + if (typeof PerformanceObserver !== "undefined") { + try { + const performanceObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0; + } + }); + performanceObserver.observe({ type: "layout-shift", buffered: false }); + } catch { + // LayoutShift is diagnostic-only on engines that do not expose it. + } + } + }, SURFACE_SELECTORS); +} + +export async function expectStableCommandCenterLayout(page: Page): Promise { + await page.waitForTimeout(500); + const observation = await page.evaluate(() => { + const state = (window as LayoutOracleWindow).__commandCenterLayoutObservation; + if (!state) throw new Error("Command center layout observer was not installed"); + state.active = false; + return state; + }); + + for (const surface of Object.keys(SURFACE_SELECTORS)) { + const samples = observation.samples[surface] ?? []; + expect(samples.length, `${surface}: ${JSON.stringify(observation)}`).toBeGreaterThan(0); + for (const dimension of ["height", "width", "x", "y"] as const) { + const values = samples.map((sample) => sample[dimension]); + expect( + Math.max(...values) - Math.min(...values), + `${surface}.${dimension}: ${JSON.stringify(samples)}`, + ).toBeLessThanOrEqual(1); + } + } + expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001); +} + +export async function expectOneLineTruncatedFileResult( + row: Locator, + input: { filename: string; path: string }, +): Promise { + await expect(row).toContainText(`${input.filename} ${input.path}`); + await expect(row.getByTestId("command-center-file-icon")).toBeVisible(); + const line = row.getByTestId("command-center-file-line"); + const filename = row.getByTestId("command-center-file-name"); + const filePath = row.getByTestId("command-center-file-path"); + const geometry = await Promise.all([ + row.boundingBox(), + line.boundingBox(), + filename.boundingBox(), + filePath.boundingBox(), + line.evaluate((element) => ({ + clientWidth: element.clientWidth, + overflow: getComputedStyle(element).overflow, + scrollWidth: element.scrollWidth, + whiteSpace: getComputedStyle(element).whiteSpace, + })), + ]); + const [rowBox, lineBox, filenameBox, pathBox, overflow] = geometry; + expect(rowBox).not.toBeNull(); + expect(lineBox).not.toBeNull(); + expect(filenameBox).not.toBeNull(); + expect(pathBox).not.toBeNull(); + expect(rowBox?.height).toBe(36); + expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1); + expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth); + expect(overflow.overflow).toBe("hidden"); + expect(overflow.whiteSpace).toBe("nowrap"); +} diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 051655bc50..0daac4cb2e 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -13,7 +13,7 @@ import { } from "react-native"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Check, ChevronRight, Folder } from "lucide-react-native"; +import { Check, ChevronRight, Folder, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { BottomSheetBackdrop, @@ -22,6 +22,8 @@ import { type BottomSheetFlatListMethods, } from "@gorhom/bottom-sheet"; import { AgentStatusDot } from "@/components/agent-status-dot"; +import { MaterialFileIcon } from "@/components/material-file-icon"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Shortcut } from "@/components/ui/shortcut"; import { IsolatedBottomSheetModal, @@ -37,7 +39,10 @@ import { useWebOverlayRegistration, } from "@/lib/overlay-root"; import { useHosts } from "@/runtime/host-runtime"; -import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { + useKeyboardShortcutsStore, + type CommandCenterScope, +} from "@/stores/keyboard-shortcuts-store"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { @@ -56,11 +61,13 @@ import { preserveActiveResultId, projectCommandCenterRows, type CommandCenterAgentResult, + type CommandCenterFileResult, type CommandCenterListRow, type CommandCenterResult, type CommandCenterResultSection, type CommandCenterWorkspaceResult, } from "./results"; +import { useWorkspaceFileSearch } from "./workspace-file-search"; const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({ placeholderTextColor: theme.colors.foregroundMuted, @@ -73,6 +80,10 @@ const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreg const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({ color: theme.colors.foregroundMuted, })); +const ThemedX = withUnistyles(X, (theme) => ({ color: theme.colors.foregroundMuted })); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({ + color: theme.colors.foregroundMuted, +})); const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"]; const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const; const DEFAULT_CATEGORY_RESULT_LIMIT = 5; @@ -191,6 +202,8 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe interface CommandCenterState { open: boolean; + scope: CommandCenterScope; + clearScope(): void; query: string; setQuery(query: string): void; activeId: string | null; @@ -199,27 +212,58 @@ interface CommandCenterState { rowIndexByResultId: ReadonlyMap; offsets: readonly number[]; inputRef: React.RefObject; + fileSearchLoading: boolean; + fileSearchError: string | null; close(): void; select(result: CommandCenterResult): void; key(key: string): boolean; } function useCommandCenterState(): CommandCenterState { + const { t } = useTranslation(); const open = useKeyboardShortcutsStore((state) => state.commandCenterOpen); + const scope = useKeyboardShortcutsStore((state) => state.commandCenterScope); const setOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen); + const setScope = useKeyboardShortcutsStore((state) => state.setCommandCenterScope); const snapshot = useCommandCenterContributions(); const inputRef = useRef(null); const previousOpenRef = useRef(open); const [query, setQuery] = useState(""); const [activeId, setActiveId] = useState(null); const builtInSections = useBuiltInSections(open, query); + const { + entries: fileSearchEntries, + loading: fileSearchLoading, + error: fileSearchError, + openFile, + } = useWorkspaceFileSearch({ + enabled: open && (scope === "files" || Boolean(query.trim())), + query, + }); + const fileSections = useMemo(() => { + const results = fileSearchEntries.map((entry) => ({ + kind: "file", + id: `file:${entry.path}`, + filePath: entry.path, + title: entry.name, + subtitle: entry.directory, + searchText: entry.path.toLowerCase(), + run: () => openFile(entry.path), + })); + return [{ id: "files", rank: 4, title: t("shell.commandCenter.files"), results }]; + }, [fileSearchEntries, openFile, t]); const contributionSections = useMemo( () => buildContributionSections(snapshot.contributions, query), [query, snapshot.contributions], ); const projection = useMemo( - () => projectCommandCenterRows([...contributionSections, ...builtInSections]), - [builtInSections, contributionSections], + () => + projectCommandCenterRows( + scope === "files" + ? fileSections + : [...contributionSections, ...fileSections, ...builtInSections], + ), + [builtInSections, contributionSections, fileSections, scope], ); const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults); @@ -239,6 +283,10 @@ function useCommandCenterState(): CommandCenterState { close(); return true; } + if (pressed === "Backspace" && !query.trim() && scope) { + setScope(null); + return true; + } if (pressed === "Enter") { const selected = results.find((result) => result.id === resolvedActiveId); if (!selected) return false; @@ -251,7 +299,7 @@ function useCommandCenterState(): CommandCenterState { setActiveId(moveActiveResultId(resolvedActiveId, results, direction)); return true; }, - [close, open, projection.selectableResults, resolvedActiveId, select], + [close, open, projection.selectableResults, query, resolvedActiveId, scope, select, setScope], ); useEffect(() => { @@ -277,6 +325,8 @@ function useCommandCenterState(): CommandCenterState { return { open, + scope, + clearScope: () => setScope(null), query, setQuery, activeId: resolvedActiveId, @@ -285,6 +335,8 @@ function useCommandCenterState(): CommandCenterState { rowIndexByResultId: projection.rowIndexByResultId, offsets: projection.offsets, inputRef, + fileSearchLoading, + fileSearchError, close, select, key, @@ -303,7 +355,10 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo result.kind === "contribution" && result.contribution.presentation.kind === "choice" ? result.contribution.presentation : null; - const accessibilityLabel = choice?.path.join(" › "); + const accessibilityLabel = + result.kind === "file" + ? [result.title, result.subtitle].filter(Boolean).join(" ") + : choice?.path.join(" › "); const accessibilityState = useMemo( () => (isNative && choice ? { selected: choice.selected } : undefined), [choice], @@ -329,7 +384,9 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo accessibilityLabel={accessibilityLabel} accessibilityState={accessibilityState} aria-pressed={isWeb ? choice?.selected : undefined} - testID={choice?.testId} + testID={ + result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId + } > @@ -337,6 +394,28 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo }); function ResultContent({ result }: { result: CommandCenterResult }) { + if (result.kind === "file") { + return ( + + + + + + + + {result.title} + + {result.subtitle ? ( + + {" "} + {result.subtitle} + + ) : null} + + + + ); + } if (result.kind === "agent") { const agent = result.agent; return ( @@ -545,8 +624,24 @@ export function CommandCenter() { ); const keyExtractor = useCallback((row: CommandCenterListRow) => row.key, []); const empty = useMemo( - () => {t("shell.commandCenter.noMatches")}, - [t], + () => + state.fileSearchError || state.fileSearchLoading ? null : ( + {t("shell.commandCenter.noMatches")} + ), + [state.fileSearchError, state.fileSearchLoading, t], + ); + const fileSearchError = useMemo( + () => + state.fileSearchError ? ( + + {t("common.errors.error")}: {state.fileSearchError} + + ) : null, + [state.fileSearchError, t], ); const commonListProps = { data: state.rows, @@ -554,6 +649,8 @@ export function CommandCenter() { keyExtractor, getItemLayout, ListEmptyComponent: empty, + style: styles.results, + testID: "command-center-results", keyboardShouldPersistTaps: KEYBOARD_SHOULD_PERSIST_TAPS, showsVerticalScrollIndicator: false, initialNumToRender: 12, @@ -606,7 +703,10 @@ export function CommandCenter() { keyboardBlurBehavior="restore" accessible={false} > - + + {state.scope === "files" ? ( + + ) : null} + + {fileSearchError} ); @@ -632,20 +741,32 @@ export function CommandCenter() { - + + {state.scope === "files" ? ( + + ) : null} + - + {fileSearchError} + @@ -653,6 +774,41 @@ export function CommandCenter() { ); } +function FileSearchLoadingIndicator({ loading, label }: { loading: boolean; label: string }) { + return ( + + {loading ? ( + + + + ) : null} + + ); +} + +// The chip sits at the input's own height and type size so it reads as the scope of the field +// rather than a badge dropped into the query. Its 28px matches the input's line box, so showing or +// dropping the scope cannot resize the header. +function ScopeChip({ label, onRemove }: { label: string; onRemove(): void }) { + return ( + + + {label} + + + ); +} + const styles = StyleSheet.create((theme) => ({ overlay: { flex: 1, @@ -663,6 +819,7 @@ const styles = StyleSheet.create((theme) => ({ backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.5)" }, panel: { width: 640, + height: 560, maxWidth: "92%", maxHeight: "80%", borderWidth: 1, @@ -690,7 +847,29 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, outlineWidth: 0, }, - results: { flexGrow: 0 }, + growingInput: { flex: 1, minWidth: 0 }, + searchRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + fileSearchStatus: { width: 14, height: 14, alignItems: "center", justifyContent: "center" }, + scopeChip: { + // Stretching to the row rather than setting a height keeps the chip exactly as tall as the + // input's line box, so showing or dropping the scope can never resize the header. + alignSelf: "stretch", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + marginRight: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.borderAccent, + backgroundColor: theme.colors.surface2, + }, + scopeChipLabel: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: "500", + }, + results: { flex: 1 }, sectionLabel: { paddingHorizontal: theme.spacing[4], paddingBottom: theme.spacing[2], @@ -726,6 +905,9 @@ const styles = StyleSheet.create((theme) => ({ lineHeight: 18, flexShrink: 1, }, + fileLine: { flex: 1, minWidth: 0, fontSize: theme.fontSize.sm, lineHeight: 20 }, + fileName: { color: theme.colors.foreground }, + filePath: { color: theme.colors.foregroundMuted }, subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 16 }, iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" }, rowShortcut: { flexShrink: 0 }, @@ -755,6 +937,14 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + errorText: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + color: theme.colors.statusDanger, + fontSize: theme.fontSize.sm, + }, sheetBackground: { backgroundColor: theme.colors.surface0 }, sheetHandle: { backgroundColor: theme.colors.palette.zinc[600] }, titledSection: { height: 32, justifyContent: "flex-end" }, diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts index da3ea5c250..e0c6107958 100644 --- a/packages/app/src/command-center/results.ts +++ b/packages/app/src/command-center/results.ts @@ -20,6 +20,16 @@ export interface CommandCenterAgentResult { run(): void; } +export interface CommandCenterFileResult { + kind: "file"; + id: string; + filePath: string; + title: string; + subtitle: string; + searchText: string; + run(): void; +} + export interface CommandCenterContributionResult { kind: "contribution"; id: string; @@ -31,6 +41,7 @@ export interface CommandCenterContributionResult { export type CommandCenterResult = | CommandCenterWorkspaceResult | CommandCenterAgentResult + | CommandCenterFileResult | CommandCenterContributionResult; export interface CommandCenterResultSection { @@ -78,6 +89,7 @@ function contributionSearchText(contribution: CommandCenterContribution): string function resultHeight(result: CommandCenterResult): number { if (result.kind === "workspace" || result.kind === "agent") return 56; + if (result.kind === "file") return 36; if (result.contribution.presentation.kind === "action") { return result.contribution.presentation.subtitle ? 56 : 36; } diff --git a/packages/app/src/command-center/workspace-file-search-model.ts b/packages/app/src/command-center/workspace-file-search-model.ts new file mode 100644 index 0000000000..4f43feaf78 --- /dev/null +++ b/packages/app/src/command-center/workspace-file-search-model.ts @@ -0,0 +1,15 @@ +export interface WorkspaceFileSearchEntry { + path: string; + name: string; + directory: string; +} + +export function describeWorkspaceFilePath(path: string): WorkspaceFileSearchEntry { + const normalized = path.replace(/\\/g, "/"); + const separator = normalized.lastIndexOf("/"); + return { + path: normalized, + name: separator >= 0 ? normalized.slice(separator + 1) : normalized, + directory: separator >= 0 ? normalized.slice(0, separator) : "", + }; +} diff --git a/packages/app/src/command-center/workspace-file-search.test.ts b/packages/app/src/command-center/workspace-file-search.test.ts new file mode 100644 index 0000000000..6070ce1576 --- /dev/null +++ b/packages/app/src/command-center/workspace-file-search.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { describeWorkspaceFilePath } from "./workspace-file-search-model"; + +describe("describeWorkspaceFilePath", () => { + it("separates a workspace-relative file into its row labels", () => { + expect(describeWorkspaceFilePath("src/components/message.tsx")).toEqual({ + path: "src/components/message.tsx", + name: "message.tsx", + directory: "src/components", + }); + }); + + it("normalizes Windows separators before opening or presenting a file", () => { + expect(describeWorkspaceFilePath("src\\components\\message.tsx")).toEqual({ + path: "src/components/message.tsx", + name: "message.tsx", + directory: "src/components", + }); + }); + + it("keeps root files free of a redundant directory label", () => { + expect(describeWorkspaceFilePath("package.json")).toEqual({ + path: "package.json", + name: "package.json", + directory: "", + }); + }); +}); diff --git a/packages/app/src/command-center/workspace-file-search.ts b/packages/app/src/command-center/workspace-file-search.ts new file mode 100644 index 0000000000..7a536159b6 --- /dev/null +++ b/packages/app/src/command-center/workspace-file-search.ts @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; +import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { usePanelStore } from "@/stores/panel-store"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { + describeWorkspaceFilePath, + type WorkspaceFileSearchEntry, +} from "./workspace-file-search-model"; + +interface DirectorySuggestionEntry { + path: string; +} + +const FILE_SEARCH_DEBOUNCE_MS = 100; +const FILE_SEARCH_LIMIT = 100; + +interface WorkspaceFileSearchState { + sourceKey: string | null; + requestKey: string | null; + entries: readonly WorkspaceFileSearchEntry[]; + loading: boolean; + error: string | null; +} + +const EMPTY_STATE: WorkspaceFileSearchState = { + sourceKey: null, + requestKey: null, + entries: [], + loading: false, + error: null, +}; + +function errorMessage(error: unknown): string { + if (!(error instanceof Error)) return String(error); + if (error.name !== "DaemonRpcError") return error.message; + return error.message.replace(/ requestType=\S+(?: code=\S+)?$/, ""); +} + +function describeFileEntries( + entries: readonly DirectorySuggestionEntry[], +): WorkspaceFileSearchEntry[] { + return entries.map(({ path }) => describeWorkspaceFilePath(path)); +} + +export function useWorkspaceFileSearch(input: { enabled: boolean; query: string }): { + entries: readonly WorkspaceFileSearchEntry[]; + loading: boolean; + error: string | null; + openFile(path: string): void; +} { + const selection = useActiveWorkspaceSelection(); + const serverId = selection?.serverId ?? null; + const workspaceId = selection?.workspaceId ?? null; + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const client = useSessionStore((state) => + serverId ? (state.sessions[serverId]?.client ?? null) : null, + ); + const [state, setState] = useState(EMPTY_STATE); + const sourceKey = useMemo( + () => (serverId && workspaceId && cwd && client ? `${serverId}\0${workspaceId}\0${cwd}` : null), + [client, cwd, serverId, workspaceId], + ); + const requestKey = useMemo( + () => (input.enabled && sourceKey ? `${sourceKey}\0${input.query}` : null), + [input.enabled, input.query, sourceKey], + ); + + useEffect(() => { + if (!requestKey || !client || !cwd) { + setState(EMPTY_STATE); + return; + } + const activeClient = client; + const activeCwd = cwd; + + let cancelled = false; + setState((previous) => ({ + sourceKey, + requestKey, + entries: previous.sourceKey === sourceKey ? previous.entries : [], + loading: true, + error: null, + })); + async function search(): Promise { + try { + const payload = await activeClient.getDirectorySuggestions({ + cwd: activeCwd, + query: input.query, + includeFiles: true, + includeDirectories: false, + limit: FILE_SEARCH_LIMIT, + }); + if (cancelled) return; + setState({ + sourceKey, + requestKey, + entries: payload.error ? [] : describeFileEntries(payload.entries), + loading: false, + error: payload.error ?? null, + }); + } catch (error) { + if (!cancelled) { + setState({ + sourceKey, + requestKey, + entries: [], + loading: false, + error: errorMessage(error), + }); + } + } + } + + const timer = setTimeout(() => void search(), input.query.trim() ? FILE_SEARCH_DEBOUNCE_MS : 0); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [client, cwd, input.query, requestKey, sourceKey]); + + const openFile = useCallback( + (path: string) => { + if (!serverId || !workspaceId) return; + clearCommandCenterFocusRestoreElement(); + openWorkspaceFileFromExplorer({ + filePath: path, + persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), + showMobileAgent: usePanelStore.getState().showMobileAgent, + openWorkspaceTabFocused: useWorkspaceLayoutStore.getState().openTabFocused, + focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab, + }); + }, + [serverId, workspaceId], + ); + + return { + entries: requestKey && state.sourceKey === sourceKey ? state.entries : [], + loading: Boolean(requestKey) && (state.requestKey !== requestKey || state.loading), + error: state.requestKey === requestKey ? state.error : null, + openFile, + }; +} diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 6c5785b483..d7abeb1db2 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -201,7 +201,9 @@ export function useKeyboardShortcuts({ setCommandCenterFocusRestoreElement(browserFocusRestoreElement); } } - useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen); + useKeyboardShortcutsStore + .getState() + .setCommandCenterOpen(action.nextOpen, action.scope ?? null); return true; } case "shortcuts-dialog-toggle": diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 1c0ff7b8ca..ca2c42f736 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -190,7 +190,11 @@ describe("translation resources", () => { expect(en.shell.menu.toggleSidebar).toBe("Toggle sidebar"); expect(en.shell.menu.open).toBe("Open menu"); expect(en.shell.menu.close).toBe("Close menu"); - expect(en.shell.commandCenter.placeholder).toBe("Search commands, workspaces, and agents..."); + expect(en.shell.commandCenter.placeholder).toBe( + "Search commands, files, workspaces, and agents...", + ); + expect(en.shell.commandCenter.filePlaceholder).toBe("Search files..."); + expect(en.shell.commandCenter.files).toBe("Files"); expect(en.shell.commandCenter.noMatches).toBe("No matches"); expect(en.shell.commandCenter.actions).toBe("Actions"); expect(en.shell.commandCenter.workspaces).toBe("Workspaces"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 2329fea347..bc3c09323a 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -54,9 +54,12 @@ export const ar: TranslationResources = { close: "إغلاق القائمة", }, commandCenter: { - placeholder: "ابحث في الأوامر ومساحات العمل والوكلاء...", + placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...", + filePlaceholder: "البحث في الملفات...", + searchingFiles: "جارٍ البحث في الملفات...", noMatches: "لا توجد مباريات", actions: "الإجراءات", + files: "الملفات", workspaces: "مساحات العمل", agents: "الوكلاء", newAgent: "وكيل جديد", @@ -1912,10 +1915,10 @@ export const ar: TranslationResources = { resetAll: "إعادة ضبط الكل", }, sections: { - navigation: "ملاحة", + general: "عام", + workspaces: "المشاريع ومساحات العمل", tabsPanes: "علامات التبويب والأجزاء", - projects: "المشاريع", - panels: "لوحات", + layout: "التخطيط", agentInput: "إدخال Agent", }, help: { @@ -1943,6 +1946,7 @@ export const ar: TranslationResources = { moveTabDown: "حرك علامة التبويب لأسفل", closePane: "إغلاق الجزء", newTerminal: "محطة جديدة", + searchFiles: "البحث في الملفات", toggleCommandCenter: "تبديل مركز القيادة", showKeyboardShortcuts: "إظهار اختصارات لوحة المفاتيح", toggleLeftSidebar: "تبديل الشريط الجانبي الأيسر", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 357ccef585..a8694a5512 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -52,9 +52,12 @@ export const en = { close: "Close menu", }, commandCenter: { - placeholder: "Search commands, workspaces, and agents...", + placeholder: "Search commands, files, workspaces, and agents...", + filePlaceholder: "Search files...", + searchingFiles: "Searching files...", noMatches: "No matches", actions: "Actions", + files: "Files", workspaces: "Workspaces", agents: "Agents", newAgent: "New agent", @@ -1922,10 +1925,10 @@ export const en = { resetAll: "Reset all", }, sections: { - navigation: "Navigation", + general: "General", + workspaces: "Projects & Workspaces", tabsPanes: "Tabs & Panes", - projects: "Projects", - panels: "Panels", + layout: "Layout", agentInput: "Agent Input", }, help: { @@ -1953,6 +1956,7 @@ export const en = { moveTabDown: "Move tab down", closePane: "Close pane", newTerminal: "New terminal", + searchFiles: "Search files", toggleCommandCenter: "Toggle command center", showKeyboardShortcuts: "Show keyboard shortcuts", toggleLeftSidebar: "Toggle left sidebar", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index af64e4e766..244dec9d1e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -54,9 +54,12 @@ export const es: TranslationResources = { close: "Cerrar menú", }, commandCenter: { - placeholder: "Buscar comandos, espacios de trabajo y agentes...", + placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...", + filePlaceholder: "Buscar archivos...", + searchingFiles: "Buscando archivos...", noMatches: "No hay coincidencias", actions: "Comportamiento", + files: "Archivos", workspaces: "Espacios de trabajo", agents: "Agentes", newAgent: "Nuevo agente", @@ -1960,10 +1963,10 @@ export const es: TranslationResources = { resetAll: "Restablecer todo", }, sections: { - navigation: "Navegación", + general: "General", + workspaces: "Proyectos y espacios de trabajo", tabsPanes: "Pestañas y paneles", - projects: "Proyectos", - panels: "Paneles", + layout: "Diseño", agentInput: "EntradaAgent", }, help: { @@ -1991,6 +1994,7 @@ export const es: TranslationResources = { moveTabDown: "Mover pestaña hacia abajo", closePane: "Cerrar panel", newTerminal: "Nueva terminal", + searchFiles: "Buscar archivos", toggleCommandCenter: "Alternar centro de comando", showKeyboardShortcuts: "Mostrar atajos de teclado", toggleLeftSidebar: "Alternar barra lateral izquierda", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 3cd4fb551f..cde09ccb20 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -55,9 +55,12 @@ export const fr: TranslationResources = { close: "Fermer le menu", }, commandCenter: { - placeholder: "Rechercher des commandes, espaces de travail et agents...", + placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...", + filePlaceholder: "Rechercher des fichiers...", + searchingFiles: "Recherche de fichiers...", noMatches: "Aucune correspondance", actions: "Actes", + files: "Fichiers", workspaces: "Espaces de travail", agents: "Agents", newAgent: "Nouvel agent", @@ -1963,10 +1966,10 @@ export const fr: TranslationResources = { resetAll: "Tout réinitialiser", }, sections: { - navigation: "Navigation", + general: "Général", + workspaces: "Projets et espaces de travail", tabsPanes: "Onglets et volets", - projects: "Projets", - panels: "Panneaux", + layout: "Disposition", agentInput: "EntréeAgent", }, help: { @@ -1994,6 +1997,7 @@ export const fr: TranslationResources = { moveTabDown: "Déplacer l'onglet vers le bas", closePane: "Fermer le volet", newTerminal: "Nouvelle borne", + searchFiles: "Rechercher des fichiers", toggleCommandCenter: "Basculer le centre de commande", showKeyboardShortcuts: "Afficher les raccourcis clavier", toggleLeftSidebar: "Basculer la barre latérale gauche", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index a4a5b139fd..6aff6de07a 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -54,9 +54,12 @@ export const ja: TranslationResources = { close: "メニューを閉じる", }, commandCenter: { - placeholder: "コマンド、ワークスペース、エージェントを検索...", + placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...", + filePlaceholder: "ファイルを検索...", + searchingFiles: "ファイルを検索中...", noMatches: "一致なし", actions: "アクション", + files: "ファイル", workspaces: "ワークスペース", agents: "エージェント", newAgent: "新しいエージェント", @@ -1928,10 +1931,10 @@ export const ja: TranslationResources = { resetAll: "すべてリセット", }, sections: { - navigation: "ナビゲーション", + general: "一般", + workspaces: "プロジェクト&ワークスペース", tabsPanes: "タブ&ペイン", - projects: "プロジェクト", - panels: "パネル", + layout: "レイアウト", agentInput: "エージェント入力", }, help: { @@ -1959,6 +1962,7 @@ export const ja: TranslationResources = { moveTabDown: "タブを下に移動", closePane: "ペインを閉じる", newTerminal: "新しいターミナル", + searchFiles: "ファイルを検索", toggleCommandCenter: "コマンドセンターを切り替え", showKeyboardShortcuts: "キーボードショートカットを表示", toggleLeftSidebar: "左サイドバーを切り替え", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 9d43e6ebc7..aa1b9a9c80 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -54,9 +54,12 @@ export const ko: TranslationResources = { close: "메뉴 닫기", }, commandCenter: { - placeholder: "명령, 워크스페이스 및 에이전트 검색...", + placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...", + filePlaceholder: "파일 검색...", + searchingFiles: "파일 검색 중...", noMatches: "일치 항목 없음", actions: "작업", + files: "파일", workspaces: "워크스페이스", agents: "에이전트", newAgent: "새 에이전트", @@ -1923,10 +1926,10 @@ export const ko: TranslationResources = { resetAll: "모두 재설정", }, sections: { - navigation: "탐색", + general: "일반", + workspaces: "프로젝트 및 워크스페이스", tabsPanes: "탭 및 창", - projects: "프로젝트", - panels: "패널", + layout: "레이아웃", agentInput: "에이전트 입력", }, help: { @@ -1954,6 +1957,7 @@ export const ko: TranslationResources = { moveTabDown: "탭을 아래로 이동", closePane: "창 닫기", newTerminal: "새 터미널", + searchFiles: "파일 검색", toggleCommandCenter: "명령 센터 토글", showKeyboardShortcuts: "키보드 단축키 표시", toggleLeftSidebar: "왼쪽 사이드바 토글", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 3659ae9bc8..1eaa5e994d 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -54,9 +54,12 @@ export const ptBR: TranslationResources = { close: "Fechar menu", }, commandCenter: { - placeholder: "Buscar comandos, espaços de trabalho e agentes...", + placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...", + filePlaceholder: "Pesquisar arquivos...", + searchingFiles: "Pesquisando arquivos...", noMatches: "Nenhuma correspondência", actions: "Ações", + files: "Arquivos", workspaces: "Espaços de trabalho", agents: "Agentes", newAgent: "Novo agente", @@ -1943,10 +1946,10 @@ export const ptBR: TranslationResources = { resetAll: "Redefinir tudo", }, sections: { - navigation: "Navegação", + general: "Geral", + workspaces: "Projetos e workspaces", tabsPanes: "Abas e painéis", - projects: "Projetos", - panels: "Painéis", + layout: "Layout", agentInput: "Entrada do agente", }, help: { @@ -1974,6 +1977,7 @@ export const ptBR: TranslationResources = { moveTabDown: "Mover aba para baixo", closePane: "Fechar painel", newTerminal: "Novo terminal", + searchFiles: "Pesquisar arquivos", toggleCommandCenter: "Alternar central de comandos", showKeyboardShortcuts: "Mostrar atalhos de teclado", toggleLeftSidebar: "Alternar barra lateral esquerda", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index c4053e568c..93427bd6aa 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -54,9 +54,12 @@ export const ru: TranslationResources = { close: "Закрыть меню", }, commandCenter: { - placeholder: "Поиск команд, рабочих пространств и агентов...", + placeholder: "Поиск команд, файлов, рабочих пространств и агентов...", + filePlaceholder: "Поиск файлов...", + searchingFiles: "Поиск файлов...", noMatches: "Нет совпадений", actions: "Действия", + files: "Файлы", workspaces: "Рабочие пространства", agents: "Агенты", newAgent: "Новый агент", @@ -1950,10 +1953,10 @@ export const ru: TranslationResources = { resetAll: "Сбросить все", }, sections: { - navigation: "Навигация", + general: "Общие", + workspaces: "Проекты и рабочие пространства", tabsPanes: "Вкладки и панели", - projects: "Проекты", - panels: "Панели", + layout: "Макет", agentInput: "Вход Agent", }, help: { @@ -1981,6 +1984,7 @@ export const ru: TranslationResources = { moveTabDown: "Переместить вкладку вниз", closePane: "Закрыть панель", newTerminal: "Новый терминал", + searchFiles: "Поиск файлов", toggleCommandCenter: "Переключить командный центр", showKeyboardShortcuts: "Показать сочетания клавиш", toggleLeftSidebar: "Переключить левую боковую панель", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e4c1fbddbf..561e643763 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -54,9 +54,12 @@ export const zhCN: TranslationResources = { close: "关闭菜单", }, commandCenter: { - placeholder: "搜索命令、工作区和 Agent...", + placeholder: "搜索命令、文件、工作区和 Agent...", + filePlaceholder: "搜索文件...", + searchingFiles: "正在搜索文件...", noMatches: "没有匹配项", actions: "操作", + files: "文件", workspaces: "工作区", agents: "Agents", newAgent: "新建 Agent", @@ -1890,10 +1893,10 @@ export const zhCN: TranslationResources = { resetAll: "全部重置", }, sections: { - navigation: "导航", + general: "通用", + workspaces: "项目和工作区", tabsPanes: "标签和窗格", - projects: "项目", - panels: "面板", + layout: "布局", agentInput: "Agent 输入", }, help: { @@ -1921,6 +1924,7 @@ export const zhCN: TranslationResources = { moveTabDown: "向下移动标签", closePane: "关闭窗格", newTerminal: "新建终端", + searchFiles: "搜索文件", toggleCommandCenter: "切换命令中心", showKeyboardShortcuts: "显示键盘快捷键", toggleLeftSidebar: "切换左侧边栏", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 8130852578..5a0d86f842 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -41,6 +41,7 @@ export type KeyboardActionId = | "sidebar.toggle.both" | "settings.toggle" | "command-center.toggle" + | "command-center.files" | "shortcuts.dialog.toggle" | "workspace.terminal.new" | "workspace.new" diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index cf12f2c5d9..69fd45b8b2 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -9,6 +9,7 @@ import { parseBindingChord, resolveKeyboardShortcut, resolveShortcutKeysForAction, + SHORTCUT_HELP_ROW_ORDER, UNASSIGNED_COMBO, type ChordState, type KeyboardShortcutContext, @@ -149,16 +150,16 @@ describe("keyboard-shortcuts", () => { action: "workspace.new", }, { - name: "matches Cmd+P to switch project on mac", + name: "matches Cmd+P to search workspace files on mac", event: { key: "p", code: "KeyP", metaKey: true }, context: { isMac: true, commandCenterOpen: false }, - action: "workspace.project.pick", + action: "command-center.files", }, { - name: "matches Ctrl+P to switch project on non-mac", + name: "matches Ctrl+P to search workspace files on non-mac", event: { key: "p", code: "KeyP", ctrlKey: true }, context: { isMac: false, commandCenterOpen: false, focusScope: "other" }, - action: "workspace.project.pick", + action: "command-center.files", }, { name: "matches question-mark shortcut to toggle the shortcuts dialog", @@ -781,20 +782,63 @@ describe("keyboard-shortcut help sections", () => { it("returns stable i18n keys for section titles and help rows", () => { const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }); - const projects = sections.find((section) => section.id === "projects"); - const panels = sections.find((section) => section.id === "panels"); + const workspaces = sections.find((section) => section.id === "workspaces"); + const layout = sections.find((section) => section.id === "layout"); const openProject = findRow(sections, "new-agent"); const cycleAgentMode = findRow(sections, "cycle-agent-mode"); const showShortcuts = findRow(sections, "show-shortcuts"); - expect(projects?.titleKey).toBe("settings.shortcuts.sections.projects"); - expect(panels?.titleKey).toBe("settings.shortcuts.sections.panels"); + expect(workspaces?.titleKey).toBe("settings.shortcuts.sections.workspaces"); + expect(layout?.titleKey).toBe("settings.shortcuts.sections.layout"); expect(openProject?.labelKey).toBe("settings.shortcuts.help.openProject"); expect(openProject?.label).toBe("Open project"); expect(cycleAgentMode?.labelKey).toBe("settings.shortcuts.help.cycleAgentMode"); expect(showShortcuts?.noteKey).toBe("settings.shortcuts.helpNotes.showKeyboardShortcuts"); }); + it("gives every help row an explicit place in its section's order", () => { + const platforms = [ + { isMac: true, isDesktop: true }, + { isMac: false, isDesktop: true }, + { isMac: true, isDesktop: false }, + { isMac: false, isDesktop: false }, + ]; + const unplaced: string[] = []; + for (const platform of platforms) { + for (const section of buildKeyboardShortcutHelpSections(platform)) { + for (const row of section.rows) { + if (SHORTCUT_HELP_ROW_ORDER[section.id].includes(row.id)) continue; + unplaced.push(`${section.id}:${row.id}`); + } + } + } + + expect(unplaced).toEqual([]); + }); + + it("leads the general section with the command center and file search", () => { + const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }); + + expect(sections[0]?.id).toBe("general"); + expect(sections[0]?.rows.slice(0, 2).map((row) => row.id)).toEqual([ + "toggle-command-center", + "search-files", + ]); + }); + + it("reuses the project-picker binding ids for rebindable file search", () => { + expect(getBindingIdForAction("search-files", { isMac: true, isDesktop: true })).toBe( + "workspace-project-pick-cmd-p-mac", + ); + expect(getBindingIdForAction("search-files", { isMac: false, isDesktop: true })).toBe( + "workspace-project-pick-ctrl-p-non-mac", + ); + expect( + findRow(buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }), "search-files") + ?.chord, + ).not.toBeNull(); + }); + it("does not expose Enter send behavior as rebindable shortcut rows", () => { const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 6ca660c378..bd0afaf44c 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -49,7 +49,7 @@ export interface KeyboardShortcutHelpRow { noteKey?: string; } -export type ShortcutSectionId = "navigation" | "tabs-panes" | "projects" | "panels" | "agent-input"; +export type ShortcutSectionId = "general" | "workspaces" | "tabs-panes" | "layout" | "agent-input"; export interface KeyboardShortcutHelpSection { id: ShortcutSectionId; @@ -124,22 +124,81 @@ export interface ChordState { // --- Constants --- +// Sections are listed in this order, most-reached-for first. +const SHORTCUT_HELP_SECTION_ORDER: readonly ShortcutSectionId[] = [ + "general", + "workspaces", + "tabs-panes", + "layout", + "agent-input", +]; + const SHORTCUT_HELP_SECTION_TITLES: Record = { - navigation: "Navigation", + general: "General", + workspaces: "Projects & Workspaces", "tabs-panes": "Tabs & Panes", - projects: "Projects", - panels: "Panels", + layout: "Layout", "agent-input": "Agent Input", }; const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record = { - navigation: "settings.shortcuts.sections.navigation", + general: "settings.shortcuts.sections.general", + workspaces: "settings.shortcuts.sections.workspaces", "tabs-panes": "settings.shortcuts.sections.tabsPanes", - projects: "settings.shortcuts.sections.projects", - panels: "settings.shortcuts.sections.panels", + layout: "settings.shortcuts.sections.layout", "agent-input": "settings.shortcuts.sections.agentInput", }; +// Rows render in this order rather than in binding-definition order, so the shortcut someone opens +// this sheet to find sits at the top of its section. Every help id must appear here; the unit test +// fails when a new shortcut is added without a place, instead of silently sinking to the bottom. +export const SHORTCUT_HELP_ROW_ORDER: Record = { + general: [ + "toggle-command-center", + "search-files", + "show-shortcuts", + "toggle-settings", + "cycle-theme", + ], + workspaces: [ + "new-agent", + "new-workspace", + "workspace-jump-index", + "workspace-prev", + "workspace-next", + "pin-workspace", + "archive-workspace", + ], + "tabs-panes": [ + "workspace-tab-new", + "workspace-terminal-new", + "workspace-tab-close-current", + "workspace-tab-jump-index", + "workspace-tab-prev", + "workspace-tab-next", + "workspace-pane-split-right", + "workspace-pane-split-down", + "workspace-pane-focus-left", + "workspace-pane-focus-right", + "workspace-pane-focus-up", + "workspace-pane-focus-down", + "workspace-pane-move-tab-left", + "workspace-pane-move-tab-right", + "workspace-pane-move-tab-up", + "workspace-pane-move-tab-down", + "workspace-pane-close", + ], + layout: ["toggle-left-sidebar", "toggle-right-sidebar", "toggle-both-sidebars", "toggle-focus"], + "agent-input": [ + "focus-message-input", + "cycle-agent-mode", + "voice-toggle", + "dictation-toggle", + "agent-interrupt", + "voice-mute-toggle", + ], +}; + const SHORTCUT_HELP_LABEL_KEYS: Record = { "new-agent": "settings.shortcuts.help.openProject", "new-workspace": "settings.shortcuts.help.newWorkspace", @@ -165,6 +224,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record = { "workspace-pane-move-tab-down": "settings.shortcuts.help.moveTabDown", "workspace-pane-close": "settings.shortcuts.help.closePane", "workspace-terminal-new": "settings.shortcuts.help.newTerminal", + "search-files": "settings.shortcuts.help.searchFiles", "toggle-command-center": "settings.shortcuts.help.toggleCommandCenter", "show-shortcuts": "settings.shortcuts.help.showKeyboardShortcuts", "toggle-left-sidebar": "settings.shortcuts.help.toggleLeftSidebar", @@ -200,7 +260,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true }, help: { id: "new-agent", - section: "projects", + section: "workspaces", label: "Open project", }, }, @@ -211,7 +271,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, terminal: false }, help: { id: "new-agent", - section: "projects", + section: "workspaces", label: "Open project", }, }, @@ -224,7 +284,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "new-workspace", - section: "projects", + section: "workspaces", label: "New workspace", }, }, @@ -235,32 +295,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "new-workspace", - section: "projects", + section: "workspaces", label: "New workspace", }, }, - // --- Switch project (New Workspace screen) --- + // --- Search files (switch project on the New Workspace screen) --- { id: "workspace-project-pick-cmd-p-mac", - action: "workspace.project.pick", + action: "command-center.files", combo: "Cmd+P", when: { mac: true, commandCenter: false }, help: { - id: "switch-project", - section: "projects", - label: "Switch project", + id: "search-files", + section: "general", + label: "Search files", }, }, { id: "workspace-project-pick-ctrl-p-non-mac", - action: "workspace.project.pick", + action: "command-center.files", combo: "Ctrl+P", when: { mac: false, commandCenter: false, terminal: false }, help: { - id: "switch-project", - section: "projects", - label: "Switch project", + id: "search-files", + section: "general", + label: "Search files", }, }, @@ -274,7 +334,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "archive-workspace", - section: "projects", + section: "workspaces", label: "Archive workspace", }, }, @@ -287,7 +347,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "archive-workspace", - section: "projects", + section: "workspaces", label: "Archive workspace", }, }, @@ -300,7 +360,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "pin-workspace", - section: "projects", + section: "workspaces", label: "Pin chat", }, }, @@ -311,7 +371,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "pin-workspace", - section: "projects", + section: "workspaces", label: "Pin chat", }, }, @@ -382,7 +442,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "index" }, help: { id: "workspace-jump-index", - section: "navigation", + section: "workspaces", label: "Jump to workspace", defaultDisplayKeys: ["mod", "1-9"], }, @@ -395,7 +455,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "index" }, help: { id: "workspace-jump-index", - section: "navigation", + section: "workspaces", label: "Jump to workspace", defaultDisplayKeys: ["mod", "1-9"], }, @@ -408,7 +468,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "index" }, help: { id: "workspace-jump-index", - section: "navigation", + section: "workspaces", label: "Jump to workspace", defaultDisplayKeys: ["alt", "1-9"], }, @@ -423,7 +483,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "index" }, help: { id: "workspace-tab-jump-index", - section: "navigation", + section: "tabs-panes", label: "Jump to tab", defaultDisplayKeys: ["mod", "alt", "1-9"], }, @@ -436,7 +496,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "index" }, help: { id: "workspace-tab-jump-index", - section: "navigation", + section: "tabs-panes", label: "Jump to tab", defaultDisplayKeys: ["alt", "1-9"], }, @@ -449,7 +509,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "index" }, help: { id: "workspace-tab-jump-index", - section: "navigation", + section: "tabs-panes", label: "Jump to tab", defaultDisplayKeys: ["alt", "shift", "1-9"], }, @@ -464,7 +524,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: -1 }, help: { id: "workspace-prev", - section: "navigation", + section: "workspaces", label: "Previous workspace", }, }, @@ -476,7 +536,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: -1 }, help: { id: "workspace-prev", - section: "navigation", + section: "workspaces", label: "Previous workspace", }, }, @@ -488,7 +548,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: 1 }, help: { id: "workspace-next", - section: "navigation", + section: "workspaces", label: "Next workspace", }, }, @@ -500,7 +560,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: 1 }, help: { id: "workspace-next", - section: "navigation", + section: "workspaces", label: "Next workspace", }, }, @@ -512,7 +572,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: -1 }, help: { id: "workspace-prev", - section: "navigation", + section: "workspaces", label: "Previous workspace", }, }, @@ -524,7 +584,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: 1 }, help: { id: "workspace-next", - section: "navigation", + section: "workspaces", label: "Next workspace", }, }, @@ -538,7 +598,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: -1 }, help: { id: "workspace-tab-prev", - section: "navigation", + section: "tabs-panes", label: "Previous tab", }, }, @@ -550,7 +610,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ payload: { type: "delta", delta: 1 }, help: { id: "workspace-tab-next", - section: "navigation", + section: "tabs-panes", label: "Next tab", }, }, @@ -686,7 +746,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "workspace-terminal-new", - section: "panels", + section: "tabs-panes", label: "New terminal", }, }, @@ -697,7 +757,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "workspace-terminal-new", - section: "panels", + section: "tabs-panes", label: "New terminal", }, }, @@ -710,7 +770,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true }, help: { id: "toggle-command-center", - section: "panels", + section: "general", label: "Toggle command center", }, }, @@ -721,7 +781,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, terminal: false }, help: { id: "toggle-command-center", - section: "panels", + section: "general", label: "Toggle command center", }, }, @@ -735,7 +795,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { focusScope: "other" }, help: { id: "show-shortcuts", - section: "panels", + section: "general", label: "Show keyboard shortcuts", defaultDisplayKeys: ["?"], note: "Available when focus is not in a text field or terminal.", @@ -750,7 +810,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true }, help: { id: "toggle-left-sidebar", - section: "panels", + section: "layout", label: "Toggle left sidebar", }, }, @@ -761,7 +821,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-left-sidebar", - section: "panels", + section: "layout", label: "Toggle left sidebar", }, }, @@ -772,7 +832,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "toggle-right-sidebar", - section: "panels", + section: "layout", label: "Toggle right sidebar", }, }, @@ -783,7 +843,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-right-sidebar", - section: "panels", + section: "layout", label: "Toggle right sidebar", }, }, @@ -802,7 +862,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "toggle-both-sidebars", - section: "panels", + section: "layout", label: "Toggle both sidebars", }, }, @@ -813,7 +873,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-both-sidebars", - section: "panels", + section: "layout", label: "Toggle both sidebars", }, }, @@ -826,7 +886,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "toggle-settings", - section: "panels", + section: "general", label: "Toggle settings", }, }, @@ -837,7 +897,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-settings", - section: "panels", + section: "general", label: "Toggle settings", }, }, @@ -850,7 +910,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "toggle-focus", - section: "panels", + section: "layout", label: "Toggle focus mode", }, }, @@ -861,7 +921,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-focus", - section: "panels", + section: "layout", label: "Toggle focus mode", }, }, @@ -874,7 +934,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: true, commandCenter: false }, help: { id: "cycle-theme", - section: "panels", + section: "general", label: "Cycle theme", }, }, @@ -885,7 +945,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ when: { mac: false, commandCenter: false, terminal: false }, help: { id: "cycle-theme", - section: "panels", + section: "general", label: "Cycle theme", }, }, @@ -1504,13 +1564,9 @@ export function buildKeyboardShortcutHelpSections( bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS, ): KeyboardShortcutHelpSection[] { const seenRows = new Set(); - const rowsBySection = new Map([ - ["navigation", []], - ["tabs-panes", []], - ["projects", []], - ["panels", []], - ["agent-input", []], - ]); + const rowsBySection = new Map( + SHORTCUT_HELP_SECTION_ORDER.map((sectionId) => [sectionId, []]), + ); for (const binding of bindings) { const help = binding.help; @@ -1540,19 +1596,17 @@ export function buildKeyboardShortcutHelpSections( }); } - const sectionOrder: ShortcutSectionId[] = [ - "navigation", - "tabs-panes", - "projects", - "panels", - "agent-input", - ]; - - return sectionOrder.flatMap((sectionId) => { + return SHORTCUT_HELP_SECTION_ORDER.flatMap((sectionId) => { const rows = rowsBySection.get(sectionId) ?? []; if (rows.length === 0) { return []; } + const order = SHORTCUT_HELP_ROW_ORDER[sectionId]; + const rank = (row: KeyboardShortcutHelpRow) => { + const index = order.indexOf(row.id); + return index === -1 ? order.length : index; + }; + rows.sort((left, right) => rank(left) - rank(right)); return [ { id: sectionId, diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts index 5ace809b91..494c2a1883 100644 --- a/packages/app/src/keyboard/route-shortcut.test.ts +++ b/packages/app/src/keyboard/route-shortcut.test.ts @@ -351,6 +351,24 @@ describe("routeKeyboardShortcut — callbacks and pickers", () => { }); describe("routeKeyboardShortcut — toggle dialogs", () => { + it("opens the command center scoped to files from a workspace", () => { + expect( + routeKeyboardShortcut({ action: "command-center.files", payload: null }, makeCtx()), + ).toEqual({ kind: "command-center-toggle", nextOpen: true, scope: "files" }); + }); + + it("leaves the file-search shortcut to the project-picker host outside a workspace", () => { + expect( + routeKeyboardShortcut( + { action: "command-center.files", payload: null }, + makeCtx({ pathname: "/settings" }), + ), + ).toEqual({ + kind: "dispatch", + action: { id: "workspace.project.pick", scope: "workspace" }, + }); + }); + it("opens the command center when closed", () => { expect( routeKeyboardShortcut( diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index 71b4395cca..0fd622d30e 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -32,7 +32,7 @@ export type ShortcutAction = | { kind: "router-push"; route: string } | { kind: "open-project-picker" } | { kind: "callback"; name: ShortcutCallbackName } - | { kind: "command-center-toggle"; nextOpen: boolean } + | { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" } | { kind: "shortcuts-dialog-toggle"; nextOpen: boolean }; const NONE: ShortcutAction = { kind: "none" }; @@ -198,6 +198,11 @@ export function routeKeyboardShortcut( return routeSettingsToggle(ctx); case "command-center.toggle": return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen }; + case "command-center.files": + if (parseHostWorkspaceRouteFromPathname(ctx.pathname)) { + return { kind: "command-center-toggle", nextOpen: true, scope: "files" }; + } + return dispatch({ id: "workspace.project.pick", scope: "workspace" }); case "shortcuts.dialog.toggle": return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen }; default: diff --git a/packages/app/src/keyboard/shortcut-help-search.test.ts b/packages/app/src/keyboard/shortcut-help-search.test.ts index c365a09b03..201d4865f0 100644 --- a/packages/app/src/keyboard/shortcut-help-search.test.ts +++ b/packages/app/src/keyboard/shortcut-help-search.test.ts @@ -99,12 +99,12 @@ describe("filterShortcutHelpSections", () => { }); it("keeps every row of a section whose own title matches", () => { - const projects = filter("projects").find((section) => section.id === "projects"); - const allProjects = buildKeyboardShortcutHelpSections(MAC_DESKTOP).find( - (section) => section.id === "projects", + const matched = filter("layout").find((section) => section.id === "layout"); + const all = buildKeyboardShortcutHelpSections(MAC_DESKTOP).find( + (section) => section.id === "layout", ); - expect(projects?.rows).toHaveLength(allProjects?.rows.length ?? 0); + expect(matched?.rows).toHaveLength(all?.rows.length ?? 0); }); it("drops a section whose rows all miss", () => { diff --git a/packages/app/src/stores/keyboard-shortcuts-store.test.ts b/packages/app/src/stores/keyboard-shortcuts-store.test.ts index 17a67d767f..f35b6ea18a 100644 --- a/packages/app/src/stores/keyboard-shortcuts-store.test.ts +++ b/packages/app/src/stores/keyboard-shortcuts-store.test.ts @@ -4,6 +4,7 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store"; beforeEach(() => { useKeyboardShortcutsStore.setState({ commandCenterOpen: false, + commandCenterScope: null, shortcutsDialogOpen: false, capturingShortcut: false, altDown: false, @@ -19,6 +20,20 @@ describe("keyboard-shortcuts-store", () => { expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true); }); + it("opens the command center with a scope and clears it when closed", () => { + useKeyboardShortcutsStore.getState().setCommandCenterOpen(true, "files"); + expect(useKeyboardShortcutsStore.getState()).toMatchObject({ + commandCenterOpen: true, + commandCenterScope: "files", + }); + + useKeyboardShortcutsStore.getState().setCommandCenterOpen(false); + expect(useKeyboardShortcutsStore.getState()).toMatchObject({ + commandCenterOpen: false, + commandCenterScope: null, + }); + }); + it("toggles shortcut capture state", () => { expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(false); useKeyboardShortcutsStore.getState().setCapturingShortcut(true); diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts index f0ce7f960f..0b0e2a516c 100644 --- a/packages/app/src/stores/keyboard-shortcuts-store.ts +++ b/packages/app/src/stores/keyboard-shortcuts-store.ts @@ -3,8 +3,11 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts"; const SHORTCUT_BADGE_DELAY_MS = 150; +export type CommandCenterScope = "files" | null; + interface KeyboardShortcutsState { commandCenterOpen: boolean; + commandCenterScope: CommandCenterScope; shortcutsDialogOpen: boolean; capturingShortcut: boolean; altDown: boolean; @@ -13,7 +16,8 @@ interface KeyboardShortcutsState { /** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */ sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[]; - setCommandCenterOpen: (open: boolean) => void; + setCommandCenterOpen: (open: boolean, scope?: CommandCenterScope) => void; + setCommandCenterScope: (scope: CommandCenterScope) => void; setShortcutsDialogOpen: (open: boolean) => void; setCapturingShortcut: (capturing: boolean) => void; setAltDown: (down: boolean) => void; @@ -47,6 +51,7 @@ function updateBadgeTimer( export const useKeyboardShortcutsStore = create((set, get) => ({ commandCenterOpen: false, + commandCenterScope: null, shortcutsDialogOpen: false, capturingShortcut: false, altDown: false, @@ -54,7 +59,9 @@ export const useKeyboardShortcutsStore = create((set, ge showShortcutBadges: false, sidebarShortcutWorkspaceTargets: [], - setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), + setCommandCenterOpen: (open, scope = null) => + set({ commandCenterOpen: open, commandCenterScope: open ? scope : null }), + setCommandCenterScope: (scope) => set({ commandCenterScope: scope }), setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }), setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }), setAltDown: (down) => { From ca2b63328675b9ebf3a9576ecfc5320b30c0cee9 Mon Sep 17 00:00:00 2001 From: nikuscs Date: Mon, 10 Aug 2026 17:18:23 +0100 Subject: [PATCH 03/73] feat: add file manager context actions (#3027) * feat(app): add file manager context actions * fix: reshape file context actions --------- Co-authored-by: Mohamed Boudra --- packages/app/e2e/browser/changes-pane.spec.ts | 278 +++++- .../file-explorer-context-actions.spec.ts | 417 +++++++++ .../app/src/components/file-actions-menu.tsx | 242 ++++- .../app/src/components/file-explorer-pane.tsx | 852 ++++++++++++++++-- .../app/src/components/tree-primitives.tsx | 30 +- .../app/src/components/ui/context-menu.tsx | 5 +- .../src/components/ui/menu/menu-overlay.tsx | 4 + packages/app/src/git/actions-store.test.ts | 25 + packages/app/src/git/actions-store.ts | 21 +- packages/app/src/git/diff-folder-row.tsx | 173 +++- packages/app/src/git/diff-pane.tsx | 376 +++++++- .../src/hooks/use-file-explorer-actions.ts | 79 ++ packages/app/src/i18n/resources/ar.ts | 32 + packages/app/src/i18n/resources/en.ts | 32 + packages/app/src/i18n/resources/es.ts | 32 + packages/app/src/i18n/resources/fr.ts | 32 + packages/app/src/i18n/resources/ja.ts | 32 + packages/app/src/i18n/resources/ko.ts | 32 + packages/app/src/i18n/resources/pt-BR.ts | 32 + packages/app/src/i18n/resources/ru.ts | 32 + packages/app/src/i18n/resources/zh-CN.ts | 32 + packages/app/src/utils/explorer-paths.ts | 5 + packages/client/src/daemon-client.test.ts | 175 ++++ packages/client/src/daemon-client.ts | 48 + .../src/messages.file-context-actions.test.ts | 168 ++++ packages/protocol/src/messages.ts | 123 +++ .../src/server/file-explorer/service.test.ts | 215 ++++- .../src/server/file-explorer/service.ts | 210 +++++ packages/server/src/server/session.ts | 10 + .../session/checkout/checkout-session.test.ts | 86 ++ .../session/checkout/checkout-session.ts | 21 + .../files/workspace-files-session.test.ts | 230 ++++- .../session/files/workspace-files-session.ts | 82 ++ .../server/src/server/websocket-server.ts | 6 + .../server/src/utils/checkout-git.test.ts | 147 +++ packages/server/src/utils/checkout-git.ts | 70 ++ 36 files changed, 4159 insertions(+), 227 deletions(-) create mode 100644 packages/app/e2e/browser/file-explorer-context-actions.spec.ts create mode 100644 packages/protocol/src/messages.file-context-actions.test.ts diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 174cea357e..98f69808b6 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -1,8 +1,10 @@ -import { unlink, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { readFile, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { type Page } from "@playwright/test"; import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes"; import { test, expect } from "../support/fixtures"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { getServerId } from "../support/helpers/server-id"; import { connectSeedClient } from "../support/helpers/seed-client"; import { createTempGitRepo } from "../support/helpers/workspace"; @@ -15,6 +17,9 @@ interface DirtyWorkspace { interface WorkspaceFixtureOptions { includeDeletedFile?: boolean; + includeNestedFolders?: boolean; + includeRenamedFile?: boolean; + includeUntrackedFile?: boolean; } interface CleanupTask { @@ -23,6 +28,39 @@ interface CleanupTask { const cleanupTasks: CleanupTask[] = []; const APP_SETTINGS_KEY = "@paseo:app-settings"; + +async function failNextDiscardRequest(page: Page): Promise { + await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { + const serverSocket = browserSocket.connectToServer(); + browserSocket.onMessage((message) => { + if (typeof message === "string") { + const envelope = JSON.parse(message) as { + message?: { type?: string; cwd?: string; requestId?: string }; + }; + if (envelope.message?.type === "checkout.discard_changes.request") { + browserSocket.send( + JSON.stringify({ + type: "session", + message: { + type: "checkout.discard_changes.response", + payload: { + cwd: envelope.message.cwd, + success: false, + error: { code: "UNKNOWN", message: "Injected revert failure" }, + requestId: envelope.message.requestId, + }, + }, + }), + ); + return; + } + } + serverSocket.send(message); + }); + serverSocket.onMessage((message) => browserSocket.send(message)); + }); +} + const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences"; const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -209,9 +247,15 @@ test("changes file actions open below the right-click without a reserved kebab", await openWorkspaceChanges(page, workspace); await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts"); + const deletedFileName = page.getByText("zz-deleted.ts", { exact: true }); + await expect(deletedFileName).toHaveCSS("user-select", "none"); + await deletedFileName.dblclick(); + expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe(""); await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0); await page.getByTestId("diff-file-1-toggle").click({ button: "right" }); await expect(page.getByText("Copy path")).toBeVisible(); + await page.getByText("Copy path", { exact: true }).click({ button: "right" }); + await expect(page.getByText("Copy path")).toBeVisible(); await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0); await page.keyboard.press("Escape"); @@ -230,6 +274,183 @@ test("changes file actions open below the right-click without a reserved kebab", await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible(); }); +test("changes context menus duplicate files and folders", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff(); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + await page.getByTestId("diff-file-0-duplicate").click(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"), "utf8")) + .toBe(AFTER); + + await page.getByTestId("changes-toggle-view-mode").click(); + await page.getByTestId("diff-folder-src-toggle").click({ button: "right" }); + await page.getByTestId("diff-folder-src-duplicate").click(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"), "utf8")) + .toBe(AFTER); +}); + +test("changes context menu recursively collapses descendant folders", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true }); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + await page.getByTestId("changes-toggle-view-mode").click(); + await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); + await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); + + await page.getByTestId("diff-folder-src-toggle").click({ button: "right" }); + await page.getByTestId("diff-folder-src-collapse-folder").click(); + await expect(page.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0); + + await page.getByTestId("diff-folder-src-toggle").click(); + await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); + await expect(page.getByText("root.ts", { exact: true })).toHaveCount(0); + + await page.getByTestId("diff-folder-src/zz-folder-toggle").click(); + await expect(page.getByText("root.ts", { exact: true })).toBeVisible(); + await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); + await expect(page.getByText("changed.ts", { exact: true })).toHaveCount(0); + + await page.getByTestId("diff-folder-src/zz-folder/nested-toggle").click(); + await expect(page.getByText("changed.ts", { exact: true })).toBeVisible(); +}); + +test("changes context menus expose folder revert and restore a file after confirmation", async ({ + page, +}) => { + const workspace = await createWorkspaceWithMountedTabDiff(); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + await page.getByTestId("changes-toggle-view-mode").click(); + await page.getByTestId("diff-folder-src-toggle").click({ button: "right" }); + const folderRevert = page.getByTestId("diff-folder-src-revert"); + await expect(folderRevert).toBeVisible(); + const revertLabelColor = await folderRevert + .getByText("Discard changes", { exact: true }) + .evaluate((element) => getComputedStyle(element).color); + await expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor); + await page.keyboard.press("Escape"); + + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + const cancelledConfirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + const message = dialog.message(); + await dialog.dismiss(); + resolve(message); + }); + }); + await page.getByTestId("diff-file-0-revert").click(); + expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts"); + await expect(page.getByTestId("diff-file-0")).toBeVisible(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) + .toBe(AFTER); + + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + const confirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + resolve(message); + }); + }); + await page.getByTestId("diff-file-0-revert").click(); + expect(await confirmation).toContain("src/use-mounted-tab-set.ts"); + + await expect(page.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 }); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) + .toBe(BEFORE); +}); + +test("discarding a staged rename restores its source path", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true }); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + const renamedToggle = page + .getByTestId(/^diff-file-\d+-toggle$/) + .filter({ hasText: "zz-renamed.ts" }); + const toggleTestId = await renamedToggle.getAttribute("data-testid"); + expect(toggleTestId).not.toBeNull(); + const rowTestId = toggleTestId!.slice(0, -"-toggle".length); + await renamedToggle.click({ button: "right" }); + const confirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + await dialog.accept(); + resolve(); + }); + }); + await page.getByTestId(`${rowTestId}-revert`).click(); + await confirmation; + + await expect(page.getByText("zz-renamed.ts", { exact: true })).toHaveCount(0, { + timeout: 30_000, + }); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src/rename-source.ts"), "utf8")) + .toBe("export const renamed = true;\n"); +}); + +test("discarding an untracked file removes it from the working tree", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true }); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + const untrackedToggle = page + .getByTestId(/^diff-file-\d+-toggle$/) + .filter({ hasText: "zz-untracked.txt" }); + const toggleTestId = await untrackedToggle.getAttribute("data-testid"); + expect(toggleTestId).not.toBeNull(); + const rowTestId = toggleTestId!.slice(0, -"-toggle".length); + await untrackedToggle.click({ button: "right" }); + const confirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + await dialog.accept(); + resolve(); + }); + }); + await page.getByTestId(`${rowTestId}-revert`).click(); + await confirmation; + + await expect(page.getByText("zz-untracked.txt", { exact: true })).toHaveCount(0, { + timeout: 30_000, + }); + await expect( + readFile(path.join(workspace.repoPath, "zz-untracked.txt"), "utf8"), + ).rejects.toThrow(); +}); + +test("shows a revert error returned by the daemon", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff(); + await failNextDiscardRequest(page); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + const confirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + await dialog.accept(); + resolve(); + }); + }); + await page.getByTestId("diff-file-0-revert").click(); + await confirmation; + + await expect(page.getByText("Injected revert failure", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("diff-file-0")).toBeVisible(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) + .toBe(AFTER); +}); + test("Changes switches between inline and full-tab navigation", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); await useUnwrappedDiffLines(page); @@ -327,8 +548,37 @@ test("changes diff switches between flat and tree file lists", async ({ page }) await scrollToLowerUnwrappedDiffRows(page); await page.getByTestId("changes-toggle-view-mode").click(); await expect(page.getByTestId("diff-folder-src")).toBeVisible(); + await expect(page.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS( + "user-select", + "none", + ); await expect(page.getByTestId("diff-file-0")).toBeVisible(); - await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(2); + await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1); + const folderLabelBounds = await page + .getByTestId("diff-folder-src") + .getByText("src", { exact: true }) + .boundingBox(); + const fileLabelBounds = await page + .getByTestId("diff-file-0") + .getByText("use-mounted-tab-set.ts", { exact: true }) + .boundingBox(); + expect(folderLabelBounds).not.toBeNull(); + expect(fileLabelBounds).not.toBeNull(); + expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(14, 0); + + const folderToggle = page.getByTestId("diff-folder-src-toggle"); + await folderToggle.click(); + await expect(folderToggle).toHaveAttribute("aria-selected", "true"); + await expect(page.getByTestId("diff-file-0")).toHaveCount(0); + await folderToggle.click(); + await expect(folderToggle).toHaveAttribute("aria-selected", "true"); + await expect(page.getByTestId("diff-file-0")).toBeVisible(); + + const fileToggle = page.getByTestId("diff-file-0-toggle"); + await fileToggle.click({ button: "right" }); + await expect(fileToggle).toHaveAttribute("aria-selected", "true"); + await expect(folderToggle).toHaveAttribute("aria-selected", "false"); await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible(); await page.keyboard.press("Escape"); @@ -488,6 +738,15 @@ async function createWorkspaceWithMountedTabDiff( if (options.includeDeletedFile) { files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" }); } + if (options.includeRenamedFile) { + files.push({ path: "src/rename-source.ts", content: "export const renamed = true;\n" }); + } + if (options.includeNestedFolders) { + files.push( + { path: "src/zz-folder/root.ts", content: "export const root = 1;\n" }, + { path: "src/zz-folder/nested/changed.ts", content: "export const nested = 1;\n" }, + ); + } const repo = await createTempGitRepo("changes-pane-", { files }); const client = await connectSeedClient(); cleanupTasks.push({ @@ -498,9 +757,24 @@ async function createWorkspaceWithMountedTabDiff( }); await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER); + if (options.includeUntrackedFile) { + await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n"); + } if (options.includeDeletedFile) { await unlink(path.join(repo.path, "src/zz-deleted.ts")); } + if (options.includeRenamedFile) { + execFileSync("git", ["mv", "src/rename-source.ts", "src/zz-renamed.ts"], { + cwd: repo.path, + }); + } + if (options.includeNestedFolders) { + await writeFile(path.join(repo.path, "src/zz-folder/root.ts"), "export const root = 2;\n"); + await writeFile( + path.join(repo.path, "src/zz-folder/nested/changed.ts"), + "export const nested = 2;\n", + ); + } const createdWorkspace = await client.createWorkspace({ source: { kind: "directory", path: repo.path }, }); diff --git a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts new file mode 100644 index 0000000000..e4643b87e9 --- /dev/null +++ b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts @@ -0,0 +1,417 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, type Page } from "@playwright/test"; +import { test } from "../support/fixtures"; +import { openFileExplorer } from "../support/helpers/file-explorer"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; + +let workspace: SeededWorkspace; + +async function failNextDeleteRequest(page: Page): Promise { + await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { + const serverSocket = browserSocket.connectToServer(); + browserSocket.onMessage((message) => { + if (typeof message === "string") { + const envelope = JSON.parse(message) as { + message?: { type?: string; cwd?: string; path?: string; requestId?: string }; + }; + if (envelope.message?.type === "fs.entry.delete.request") { + browserSocket.send( + JSON.stringify({ + type: "session", + message: { + type: "fs.entry.delete.response", + payload: { + cwd: envelope.message.cwd, + path: envelope.message.path, + success: false, + error: "Injected delete failure", + requestId: envelope.message.requestId, + }, + }, + }), + ); + return; + } + } + serverSocket.send(message); + }); + serverSocket.onMessage((message) => browserSocket.send(message)); + }); +} + +async function hideContextActionCapabilities(page: Page): Promise { + await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { + const serverSocket = browserSocket.connectToServer(); + browserSocket.onMessage((message) => serverSocket.send(message)); + serverSocket.onMessage((message) => { + if (typeof message !== "string") { + browserSocket.send(message); + return; + } + const envelope = JSON.parse(message) as { + message?: { + type?: string; + payload?: { status?: string; features?: Record }; + }; + }; + if ( + envelope.message?.type === "status" && + envelope.message.payload?.status === "server_info" + ) { + envelope.message.payload.features = { + ...envelope.message.payload.features, + fsEntryOps: false, + fsEntryDuplicate: false, + checkoutDiscardChanges: false, + }; + } + browserSocket.send(JSON.stringify(envelope)); + }); + }); +} + +async function disconnectNextContextAction(page: Page): Promise<{ wait: () => Promise }> { + let resolveRequest: (() => void) | undefined; + const requestSeen = new Promise((resolve) => { + resolveRequest = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { + const serverSocket = browserSocket.connectToServer(); + browserSocket.onMessage((message) => { + if (typeof message === "string") { + const envelope = JSON.parse(message) as { message?: { type?: string } }; + const type = envelope.message?.type; + if (type?.startsWith("fs.entry.") || type === "checkout.discard_changes.request") { + resolveRequest?.(); + void browserSocket.close({ + code: 1008, + reason: "Context action connection failed", + }); + return; + } + } + serverSocket.send(message); + }); + serverSocket.onMessage((message) => browserSocket.send(message)); + }); + + return { wait: () => requestSeen }; +} + +test.beforeEach(async () => { + workspace = await seedWorkspace({ repoPrefix: "file-explorer-context-actions-" }); +}); + +test.afterEach(async () => { + await workspace?.cleanup(); +}); + +test("creates, renames, copies, and deletes entries through the file explorer", async ({ + context, + page, +}) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + const tree = page.getByTestId("file-explorer-tree-scroll"); + const entry = (name: string) => tree.getByText(name, { exact: true }).first(); + const nameInput = page.getByTestId("file-explorer-name-input"); + + await page.getByTestId("files-new-folder").click(); + await expect(nameInput).toBeVisible(); + await nameInput.press("Tab"); + await expect(nameInput).toBeHidden(); + + await page.getByTestId("files-new-file").click(); + await nameInput.fill("README.md"); + await nameInput.press("Enter"); + await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible(); + + const emptyArea = page.getByTestId("files-empty-area"); + await expect(emptyArea).toBeVisible(); + const emptyAreaBounds = await emptyArea.boundingBox(); + expect(emptyAreaBounds).not.toBeNull(); + await emptyArea.click({ + button: "right", + position: { x: emptyAreaBounds!.width / 2, y: emptyAreaBounds!.height - 24 }, + }); + await expect(page.getByTestId("files-empty-area-new-file")).toBeVisible(); + await page.getByTestId("files-empty-area-new-folder").click(); + await nameInput.fill("folder"); + await nameInput.press("Enter"); + await expect(entry("folder")).toBeVisible(); + + await entry("folder").click({ button: "right" }); + await page.getByText("New file", { exact: true }).click(); + await nameInput.fill("child.txt"); + await nameInput.press("Enter"); + await expect(entry("child.txt")).toBeVisible(); + + await entry("folder").click({ button: "right" }); + await page.getByText("New folder", { exact: true }).click(); + await nameInput.fill("nested"); + await nameInput.press("Enter"); + await entry("nested").click({ button: "right" }); + await page.getByText("New folder", { exact: true }).click(); + await nameInput.fill("deep"); + await nameInput.press("Enter"); + await expect(entry("deep")).toBeVisible(); + + await entry("folder").click({ button: "right" }); + await page.getByText("Collapse folder", { exact: true }).click(); + await expect(entry("child.txt")).toBeHidden(); + await expect(entry("nested")).toBeHidden(); + await expect(entry("deep")).toBeHidden(); + + await entry("folder").click(); + await expect(entry("child.txt")).toBeVisible(); + await expect(entry("nested")).toBeVisible(); + await expect(entry("deep")).toBeHidden(); + await entry("nested").click(); + await expect(entry("deep")).toBeVisible(); + + await page.getByTestId("files-new-file").click(); + await nameInput.fill("created.txt"); + await nameInput.press("Enter"); + await expect(entry("created.txt")).toBeVisible(); + await expect(entry("created.txt")).toHaveCSS("user-select", "none"); + await entry("created.txt").dblclick(); + expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe(""); + + const folderRow = entry("folder").locator( + "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", + ); + const fileRow = entry("created.txt").locator( + "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", + ); + await expect(folderRow.locator("svg")).toHaveCount(2); + await expect(fileRow.locator("svg")).toHaveCount(1); + const gitRow = entry(".git").locator( + "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", + ); + const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox(); + const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox(); + expect(collapsedChevronBounds).not.toBeNull(); + expect(expandedChevronBounds).not.toBeNull(); + const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2; + const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2; + expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0); + const folderLabelBounds = await entry("folder").boundingBox(); + const fileLabelBounds = await entry("created.txt").boundingBox(); + expect(folderLabelBounds).not.toBeNull(); + expect(fileLabelBounds).not.toBeNull(); + expect(fileLabelBounds!.x).toBeCloseTo(folderLabelBounds!.x, 0); + + await folderRow.click(); + await expect(folderRow).toHaveAttribute("aria-selected", "true"); + await expect(entry("child.txt")).toBeHidden(); + await folderRow.click(); + await expect(folderRow).toHaveAttribute("aria-selected", "true"); + await expect(entry("child.txt")).toBeVisible(); + + await entry("folder").click({ button: "right" }); + await page.getByText("Rename", { exact: true }).click(); + await nameInput.fill("renamed-folder"); + await nameInput.press("Tab"); + await expect(entry("renamed-folder")).toBeVisible(); + await expect(entry("folder")).toBeHidden(); + await expect(entry("child.txt")).toBeVisible(); + const renamedFolderRow = entry("renamed-folder").locator( + "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", + ); + await expect(renamedFolderRow).toHaveAttribute("aria-selected", "true"); + + await entry("created.txt").click({ button: "right" }); + await expect(fileRow).toHaveAttribute("aria-selected", "true"); + await expect(renamedFolderRow).toHaveAttribute("aria-selected", "false"); + const menu = page.getByTestId(/file-explorer-row-\d+-context-menu$/); + await expect(menu).toBeVisible(); + + await page.getByText("Copy path", { exact: true }).click({ button: "right" }); + await expect(menu).toBeVisible(); + + const backdrop = page.getByTestId(/file-explorer-row-\d+-context-menu-backdrop$/); + await backdrop.click({ button: "right" }); + await expect(menu).toBeHidden(); + + await entry("created.txt").click({ button: "right" }); + await page.getByText("Rename", { exact: true }).click(); + await expect(nameInput).toHaveValue("created.txt"); + await nameInput.press("Escape"); + await expect(entry("created.txt")).toBeVisible(); + + await entry("created.txt").click({ button: "right" }); + await page.getByText("Rename", { exact: true }).click(); + await nameInput.fill("README.md"); + await nameInput.press("Enter"); + await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible(); + await expect(entry("created.txt")).toBeVisible(); + + await entry("created.txt").click({ button: "right" }); + await page.getByText("Rename", { exact: true }).click(); + await nameInput.fill("renamed.txt"); + await nameInput.press("Tab"); + await expect(entry("renamed.txt")).toBeVisible(); + await expect(entry("created.txt")).toBeHidden(); + + await entry("renamed.txt").click({ button: "right" }); + await page.getByText("Copy relative path", { exact: true }).click(); + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("renamed.txt"); + + await entry("renamed.txt").click({ button: "right" }); + const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/); + const deleteLabelColor = await deleteAction + .getByText("Delete", { exact: true }) + .evaluate((element) => getComputedStyle(element).color); + await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor); + const cancelledConfirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + const message = dialog.message(); + await dialog.dismiss(); + resolve(message); + }); + }); + await page.getByText("Delete", { exact: true }).click(); + expect(await cancelledConfirmation).toContain("renamed.txt"); + await expect(entry("renamed.txt")).toBeVisible(); + + await entry("renamed.txt").click({ button: "right" }); + const confirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + resolve(message); + }); + }); + await page.getByText("Delete", { exact: true }).click(); + expect(await confirmation).toContain("renamed.txt"); + await expect(entry("renamed.txt")).toBeHidden(); + + await entry("renamed-folder").click({ button: "right" }); + const folderConfirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + resolve(message); + }); + }); + await page.getByText("Delete", { exact: true }).click(); + expect(await folderConfirmation).toContain("renamed-folder"); + await expect(entry("renamed-folder")).toBeHidden(); + await expect(entry("child.txt")).toBeHidden(); +}); + +test("duplicates files and folders with collision-free names", async ({ page }) => { + const sourceFilePath = path.join(workspace.repoPath, "duplicate-source.txt"); + const sourceFolderPath = path.join(workspace.repoPath, "duplicate-folder"); + await writeFile(sourceFilePath, "duplicate contents", "utf8"); + await mkdir(sourceFolderPath); + await writeFile(path.join(sourceFolderPath, "nested.txt"), "nested contents", "utf8"); + + try { + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + const tree = page.getByTestId("file-explorer-tree-scroll"); + const entry = (name: string) => tree.getByText(name, { exact: true }).first(); + + await entry("duplicate-source.txt").click({ button: "right" }); + await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click(); + await expect(entry("duplicate-source copy.txt")).toBeVisible(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "duplicate-source copy.txt"), "utf8")) + .toBe("duplicate contents"); + + await entry("duplicate-source.txt").click({ button: "right" }); + await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click(); + await expect(entry("duplicate-source copy 2.txt")).toBeVisible(); + + await entry("duplicate-folder").click({ button: "right" }); + await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click(); + await expect(entry("duplicate-folder copy")).toBeVisible(); + await expect + .poll(() => + readFile(path.join(workspace.repoPath, "duplicate-folder copy", "nested.txt"), "utf8"), + ) + .toBe("nested contents"); + } finally { + await Promise.all([ + rm(sourceFilePath, { force: true }), + rm(path.join(workspace.repoPath, "duplicate-source copy.txt"), { force: true }), + rm(path.join(workspace.repoPath, "duplicate-source copy 2.txt"), { force: true }), + rm(sourceFolderPath, { recursive: true, force: true }), + rm(path.join(workspace.repoPath, "duplicate-folder copy"), { recursive: true, force: true }), + ]); + } +}); + +test("shows an actionable error when the connection drops during creation", async ({ page }) => { + const disconnect = await disconnectNextContextAction(page); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + await page.getByTestId("files-new-file").click(); + const nameInput = page.getByTestId("file-explorer-name-input"); + await nameInput.fill("disconnected.txt"); + await nameInput.press("Enter"); + await disconnect.wait(); + + await expect(page.getByText(/Context action connection failed/i)).toBeVisible({ + timeout: 30_000, + }); + await expect( + page.getByTestId("file-explorer-tree-scroll").getByText("disconnected.txt", { exact: true }), + ).toHaveCount(0); +}); + +test("keeps an entry visible when deletion fails", async ({ page }) => { + await failNextDeleteRequest(page); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + const readme = page + .getByTestId("file-explorer-tree-scroll") + .getByText("README.md", { exact: true }); + await readme.click({ button: "right" }); + const confirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + await dialog.accept(); + resolve(); + }); + }); + await page.getByText("Delete", { exact: true }).click(); + await confirmation; + + await expect(page.getByText("Injected delete failure", { exact: true })).toBeVisible(); + await expect(readme).toBeVisible(); +}); + +test("hides unsupported file operations and revert actions", async ({ page }) => { + await hideContextActionCapabilities(page); + await writeFile(path.join(workspace.repoPath, "README.md"), "# Changed while unsupported\n"); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + await expect(page.getByTestId("files-new-file")).toHaveCount(0); + await expect(page.getByTestId("files-new-folder")).toHaveCount(0); + await page + .getByTestId("file-explorer-tree-scroll") + .getByText("README.md", { exact: true }) + .click({ button: "right" }); + const filesMenu = page.getByTestId(/file-explorer-row-\d+-context-menu$/); + await expect(filesMenu.getByText("Rename", { exact: true })).toHaveCount(0); + await expect(filesMenu.getByText("Duplicate", { exact: true })).toHaveCount(0); + await expect(filesMenu.getByText("Delete", { exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + + await page.getByTestId("explorer-tab-changes").click(); + await expect(page.getByTestId("diff-file-0")).toBeVisible({ timeout: 30_000 }); + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0); + await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0); +}); diff --git a/packages/app/src/components/file-actions-menu.tsx b/packages/app/src/components/file-actions-menu.tsx index 185a04fde3..63de27475e 100644 --- a/packages/app/src/components/file-actions-menu.tsx +++ b/packages/app/src/components/file-actions-menu.tsx @@ -1,6 +1,20 @@ -import { useMemo, type ReactElement, type ReactNode } from "react"; +import { Fragment, useMemo, type ReactElement, type ReactNode } from "react"; import { withUnistyles } from "react-native-unistyles"; -import { Copy, Download, FileText, MessageSquarePlus, type LucideIcon } from "lucide-react-native"; +import { + Copy, + CopyPlus, + Download, + FilePlus, + FileText, + FolderMinus, + FolderOpen, + FolderPlus, + MessageSquarePlus, + Pencil, + Trash2, + Undo2, + type LucideIcon, +} from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { @@ -10,11 +24,14 @@ import { } from "@/components/ui/context-menu"; const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); interface FileAction { key: string; label: string; icon: LucideIcon; onSelect: () => void; + destructive?: boolean; + separatorBefore?: boolean; testID?: string; } @@ -23,8 +40,18 @@ interface FileActionsContextMenuContentProps { fileExists?: boolean; onOpenFile?: () => void; onCopyPath?: () => void; + onCopyRelativePath?: () => void; + onReveal?: () => void; + revealTargetName?: string; onDownload?: () => void; onAddToChat?: () => void; + onNewFile?: () => void; + onNewFolder?: () => void; + onCollapseFolder?: () => void; + onRename?: () => void; + onDuplicate?: () => void; + onRevert?: () => void; + onDelete?: () => void; /** Optional metadata block rendered above the actions (e.g. size/modified). */ header?: ReactNode; testIDPrefix?: string; @@ -39,51 +66,161 @@ export function FileActionsContextMenuContent({ fileExists = true, onOpenFile, onCopyPath, + onCopyRelativePath, + onReveal, + revealTargetName, onDownload, onAddToChat, + onNewFile, + onNewFolder, + onCollapseFolder, + onRename, + onDuplicate, + onRevert, + onDelete, header, testIDPrefix, }: FileActionsContextMenuContentProps): ReactElement | null { const { t } = useTranslation(); const actions = useMemo(() => { const availableFile = fileKind === "file" && fileExists; - const next: FileAction[] = []; - if (availableFile && onOpenFile) { - next.push({ - key: "open-file", - label: t("workspace.fileActions.openFile"), - icon: FileText, - onSelect: onOpenFile, - testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined, - }); - } - if (onCopyPath) { - next.push({ - key: "copy-path", - label: t("workspace.fileActions.copyPath"), - icon: Copy, - onSelect: onCopyPath, - }); - } - if (availableFile && onDownload) { - next.push({ - key: "download", - label: t("workspace.fileActions.download"), - icon: Download, - onSelect: onDownload, - }); - } - if (availableFile && onAddToChat) { - next.push({ - key: "add-to-chat", - label: t("workspace.fileActions.addToChat"), - icon: MessageSquarePlus, - onSelect: onAddToChat, - testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined, - }); - } - return next; - }, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]); + const specs: Array = [ + onNewFile + ? { + key: "new-file", + label: t("workspace.fileActions.newFile"), + icon: FilePlus, + onSelect: onNewFile, + } + : null, + onNewFolder + ? { + key: "new-folder", + label: t("workspace.fileActions.newFolder"), + icon: FolderPlus, + onSelect: onNewFolder, + } + : null, + onCollapseFolder + ? { + key: "collapse-folder", + label: t("workspace.fileActions.collapseFolder"), + icon: FolderMinus, + onSelect: onCollapseFolder, + } + : null, + availableFile && onOpenFile + ? { + key: "open-file", + label: t("workspace.fileActions.openFile"), + icon: FileText, + onSelect: onOpenFile, + } + : null, + onRename + ? { + key: "rename", + label: t("workspace.fileActions.rename"), + icon: Pencil, + onSelect: onRename, + } + : null, + onDuplicate + ? { + key: "duplicate", + label: t("workspace.fileActions.duplicate"), + icon: CopyPlus, + onSelect: onDuplicate, + } + : null, + onCopyPath + ? { + key: "copy-path", + label: t("workspace.fileActions.copyPath"), + icon: Copy, + onSelect: onCopyPath, + } + : null, + onCopyRelativePath + ? { + key: "copy-relative-path", + label: t("workspace.fileActions.copyRelativePath"), + icon: Copy, + onSelect: onCopyRelativePath, + } + : null, + onReveal && revealTargetName + ? { + key: "reveal", + label: t("workspace.fileActions.revealIn", { target: revealTargetName }), + icon: FolderOpen, + onSelect: onReveal, + } + : null, + availableFile && onDownload + ? { + key: "download", + label: t("workspace.fileActions.download"), + icon: Download, + onSelect: onDownload, + } + : null, + availableFile && onAddToChat + ? { + key: "add-to-chat", + label: t("workspace.fileActions.addToChat"), + icon: MessageSquarePlus, + onSelect: onAddToChat, + } + : null, + onRevert + ? { + key: "revert", + label: t("workspace.fileActions.revert"), + icon: Undo2, + onSelect: onRevert, + destructive: true, + } + : null, + onDelete + ? { + key: "delete", + label: t("workspace.fileActions.delete"), + icon: Trash2, + onSelect: onDelete, + destructive: true, + } + : null, + ]; + const availableActions = specs.filter((action): action is FileAction => action !== null); + return availableActions.map((action, index) => + Object.assign(action, { + separatorBefore: Boolean( + action.destructive && index > 0 && !availableActions[index - 1].destructive, + ), + testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined, + }), + ); + }, [ + fileExists, + fileKind, + onAddToChat, + onCollapseFolder, + onCopyPath, + onCopyRelativePath, + onDelete, + onDownload, + onDuplicate, + onNewFile, + onNewFolder, + onOpenFile, + onRename, + onReveal, + onRevert, + revealTargetName, + t, + testIDPrefix, + ]); if (actions.length === 0) { return null; @@ -101,21 +238,32 @@ export function FileActionsContextMenuContent({ ) : null} {actions.map((action) => ( - + + {action.separatorBefore ? : null} + + ))} ); } function FileActionMenuItem({ action }: { action: FileAction }): ReactElement { - const Icon = action.icon; - const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]); - const leading = useMemo( - () => , - [ThemedIcon], - ); + const leading = useMemo(() => { + const ThemedIcon = withUnistyles(action.icon); + return ( + + ); + }, [action.destructive, action.icon]); return ( - + {action.label} ); diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 798ad36b9c..969a1b5960 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -1,4 +1,13 @@ -import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObject } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type ReactNode, + type RefObject, +} from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { @@ -6,15 +15,27 @@ import { ListRenderItemInfo, Pressable, Text, + TextInput, View, + type NativeSyntheticEvent, type PressableStateCallbackType, type StyleProp, + type TextInputKeyPressEventData, type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; import * as Clipboard from "expo-clipboard"; -import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native"; +import { + ChevronDown, + Eye, + EyeOff, + FilePlus, + Folder, + FolderPlus, + RotateCw, +} from "lucide-react-native"; import { MaterialFileIcon } from "@/components/material-file-icon"; import { TreeChevron, @@ -39,8 +60,9 @@ import type { } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; -import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { useFileDownload } from "@/hooks/use-file-download"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store"; @@ -56,6 +78,9 @@ import { type ExplorerTreeRow, } from "@/file-explorer/tree"; import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { useToast } from "@/contexts/toast-context"; +import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; const SORT_OPTIONS: { value: SortOption }[] = [ { value: "name" }, @@ -64,10 +89,23 @@ const SORT_OPTIONS: { value: SortOption }[] = [ ]; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const ThemedFolder = withUnistyles(Folder); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); +function DirectoryChevronIcon({ loading, expanded }: { loading: boolean; expanded: boolean }) { + if (loading) { + return ( + + ); + } + return ; +} + function formatFileSize({ size }: { size: number }): string { if (size < 1024) { return `${size} B`; @@ -87,9 +125,18 @@ interface TreeRowItemProps { isSelected: boolean; loading: boolean; onEntryPress: (entry: ExplorerEntry) => void; + onSelectEntry: (entry: ExplorerEntry) => void; onCopyPath: (path: string) => void; + onCopyRelativePath: (path: string) => void; + onRevealEntry?: (entry: ExplorerEntry) => void; + revealTargetName?: string; onDownloadEntry: (entry: ExplorerEntry) => void; onAddToChat?: (path: string) => void; + onNewEntry?: (parentPath: string, kind: "file" | "directory") => void; + onCollapseDirectory?: (path: string) => void; + onRenameEntry?: (entry: ExplorerEntry) => void; + onDuplicateEntry?: (entry: ExplorerEntry) => void; + onDeleteEntry?: (entry: ExplorerEntry) => void; testID?: string; } @@ -104,8 +151,99 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered]; } -function treeRowKeyExtractor(row: ExplorerTreeRow) { - return row.entry.path; +type ExplorerListRow = + | { type: "entry"; row: ExplorerTreeRow } + | { type: "draft"; parentPath: string; kind: "file" | "directory"; depth: number } + | { type: "rename"; entry: ExplorerEntry; depth: number }; + +type ExplorerPendingEdit = + | { type: "create"; parentPath: string; kind: "file" | "directory" } + | { type: "rename"; entry: ExplorerEntry }; + +function listRowKeyExtractor(row: ExplorerListRow) { + if (row.type === "entry") { + return row.row.entry.path; + } + return row.type === "rename" ? `rename:${row.entry.path}` : `draft:${row.parentPath}:${row.kind}`; +} + +interface EntryNameInputRowProps { + depth: number; + kind: "file" | "directory"; + initialName?: string; + onCommit: (name: string) => void; + onCancel: () => void; +} + +function EntryNameInputRow({ + depth, + kind, + initialName = "", + onCommit, + onCancel, +}: EntryNameInputRowProps) { + const { t } = useTranslation(); + const [name, setName] = useState(initialName); + const settledRef = useRef(false); + + const commit = useCallback(() => { + if (settledRef.current) { + return; + } + settledRef.current = true; + const trimmed = name.trim(); + if (!trimmed) { + onCancel(); + return; + } + onCommit(trimmed); + }, [name, onCancel, onCommit]); + + const handleKeyPress = useCallback( + (event: NativeSyntheticEvent) => { + if (event.nativeEvent.key === "Escape") { + settledRef.current = true; + onCancel(); + } + }, + [onCancel], + ); + + return ( + + + + + {kind === "directory" ? : null} + + + {kind === "directory" ? ( + + ) : ( + + )} + + + + + ); } function TreeRowItem({ @@ -117,9 +255,18 @@ function TreeRowItem({ isSelected, loading, onEntryPress, + onSelectEntry, onCopyPath, + onCopyRelativePath, + onRevealEntry, + revealTargetName, onDownloadEntry, onAddToChat, + onNewEntry, + onCollapseDirectory, + onRenameEntry, + onDuplicateEntry, + onDeleteEntry, testID, }: TreeRowItemProps) { const { t } = useTranslation(); @@ -132,9 +279,18 @@ function TreeRowItem({ }); const handlePress = useCallback(() => { + const selection = isWeb ? window.getSelection() : null; + if (selection && !selection.isCollapsed && selection.toString().length > 0) { + return; + } onEntryPress(entry); }, [onEntryPress, entry]); + const handleSelect = useCallback(() => { + onSelectEntry(entry); + }, [entry, onSelectEntry]); + const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); + const pressableStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.entryRow, @@ -148,6 +304,14 @@ function TreeRowItem({ onCopyPath(entry.path); }, [onCopyPath, entry.path]); + const handleCopyRelativePath = useCallback(() => { + onCopyRelativePath(entry.path); + }, [onCopyRelativePath, entry.path]); + + const handleReveal = useCallback(() => { + onRevealEntry?.(entry); + }, [onRevealEntry, entry]); + const handleDownload = useCallback(() => { onDownloadEntry(entry); }, [onDownloadEntry, entry]); @@ -156,6 +320,30 @@ function TreeRowItem({ onAddToChat?.(entry.path); }, [onAddToChat, entry.path]); + const handleNewFile = useCallback(() => { + onNewEntry?.(entry.path, "file"); + }, [onNewEntry, entry.path]); + + const handleNewFolder = useCallback(() => { + onNewEntry?.(entry.path, "directory"); + }, [onNewEntry, entry.path]); + + const handleCollapseDirectory = useCallback(() => { + onCollapseDirectory?.(entry.path); + }, [entry.path, onCollapseDirectory]); + + const handleRename = useCallback(() => { + onRenameEntry?.(entry); + }, [onRenameEntry, entry]); + + const handleDuplicate = useCallback(() => { + onDuplicateEntry?.(entry); + }, [entry, onDuplicateEntry]); + + const handleDelete = useCallback(() => { + onDeleteEntry?.(entry); + }, [onDeleteEntry, entry]); + const metaHeader = useMemo( () => ( @@ -182,24 +370,29 @@ function TreeRowItem({ return ( - + + + {isDirectory ? : null} + - {(() => { - if (!isDirectory) { - return ; - } - if (loading) { - return ( - - ); - } - return ; - })()} + {isDirectory ? ( + + ) : ( + + )} {entry.name} @@ -209,8 +402,17 @@ function TreeRowItem({ @@ -252,11 +454,33 @@ export function FileExplorerPane({ : undefined, ); - const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({ + const { + requestDirectoryListing, + createEntry, + renameEntry, + duplicateEntry, + deleteEntry, + selectExplorerEntry, + } = useFileExplorerActions({ serverId, workspaceId, workspaceRoot: normalizedWorkspaceRoot, }); + const toast = useToast(); + const isLocalDaemon = useIsLocalDaemon(serverId); + const { targets: desktopOpenTargets } = useDesktopOpenTargets({ + isLocalExecution: isLocalDaemon, + }); + const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager"); + // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. + const fsEntryOpsEnabled = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryOps === true, + ); + // COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09. + const fsEntryDuplicateEnabled = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true, + ); + const [pendingEdit, setPendingEdit] = useState(null); const downloadFile = useFileDownload({ serverId, workspaceId, @@ -286,7 +510,7 @@ export function FileExplorerPane({ [isExplorerLoading, pendingRequest], ); - const treeListRef = useRef>(null); + const treeListRef = useRef>(null); const scrollbar = useOverlayFlatListScrollbar(treeListRef, { enabled: !isCompact }); const hasInitializedRef = useRef(false); @@ -333,26 +557,49 @@ export function FileExplorerPane({ ], ); + // Selection is intentionally separate from opening/expansion so future keyboard actions + // (for example, pressing R to rename) have one stable file-or-folder target. + const handleSelectEntry = useCallback( + (entry: ExplorerEntry) => { + if (hasWorkspaceScope) { + selectExplorerEntry(entry.path); + } + }, + [hasWorkspaceScope, selectExplorerEntry], + ); + const handleOpenFile = useCallback( (entry: ExplorerEntry) => { if (!hasWorkspaceScope) { return; } - selectExplorerEntry(entry.path); onOpenFile?.(entry.path); }, - [hasWorkspaceScope, onOpenFile, selectExplorerEntry], + [hasWorkspaceScope, onOpenFile], ); const handleEntryPress = useCallback( (entry: ExplorerEntry) => { + handleSelectEntry(entry); if (entry.kind === "directory") { handleToggleDirectory(entry); return; } handleOpenFile(entry); }, - [handleOpenFile, handleToggleDirectory], + [handleOpenFile, handleSelectEntry, handleToggleDirectory], + ); + + const handleCollapseDirectory = useCallback( + (path: string) => { + if (!workspaceStateKey) { + return; + } + setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => + currentPaths.filter((expandedPath) => !isExplorerPathWithin(expandedPath, path)), + ); + }, + [setExpandedPathsForWorkspace, workspaceStateKey], ); const handleCopyPath = useCallback( @@ -367,6 +614,33 @@ export function FileExplorerPane({ [normalizedWorkspaceRoot], ); + const handleCopyRelativePath = useCallback(async (path: string) => { + await Clipboard.setStringAsync(path); + }, []); + + const handleRevealEntry = useCallback( + async (entry: ExplorerEntry) => { + if (!fileManagerTarget) { + return; + } + try { + await openDesktopTarget({ + editorId: fileManagerTarget.id, + workspacePath: normalizedWorkspaceRoot, + filePath: buildAbsoluteExplorerPath({ + workspaceRoot: normalizedWorkspaceRoot, + entryPath: entry.path, + }), + }); + } catch (cause) { + toast.error( + cause instanceof Error ? cause.message : t("workspace.fileExplorer.errors.revealFailed"), + ); + } + }, + [fileManagerTarget, normalizedWorkspaceRoot, t, toast], + ); + const handleDownloadEntry = useCallback( (entry: ExplorerEntry) => { if (entry.kind !== "file") { @@ -377,6 +651,197 @@ export function FileExplorerPane({ [downloadFile], ); + const handleNewEntry = useCallback( + (parentPath: string, kind: "file" | "directory") => { + if (!workspaceStateKey) { + return; + } + if (parentPath !== ".") { + setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => + setExpandedDirectoryPath({ + currentExpandedPaths: currentPaths, + directoryPath: parentPath, + expanded: true, + }), + ); + if (!directories.has(parentPath)) { + void requestDirectoryListing(parentPath, { + recordHistory: false, + setCurrentPath: false, + }); + } + } + setPendingEdit({ type: "create", parentPath, kind }); + }, + [directories, requestDirectoryListing, setExpandedPathsForWorkspace, workspaceStateKey], + ); + + const handleEditCancel = useCallback(() => { + setPendingEdit(null); + }, []); + + const handleRenameEntry = useCallback((entry: ExplorerEntry) => { + setPendingEdit({ type: "rename", entry }); + }, []); + + const handleDraftCommit = useCallback( + async (name: string) => { + const edit = pendingEdit; + setPendingEdit(null); + if (edit?.type !== "create") { + return; + } + try { + const payload = await createEntry({ + parentPath: edit.parentPath, + name, + kind: edit.kind, + }); + if (!payload) { + return; + } + if (!payload.success) { + toast.error(payload.error ?? t("workspace.fileExplorer.errors.createFailed")); + return; + } + if (edit.kind === "file" && payload.path) { + selectExplorerEntry(payload.path); + onOpenFile?.(payload.path); + } + } catch (cause) { + toast.error(cause instanceof Error ? cause.message : String(cause)); + } + }, + [createEntry, onOpenFile, pendingEdit, selectExplorerEntry, t, toast], + ); + + const handleRenameCommit = useCallback( + async (name: string) => { + const edit = pendingEdit; + setPendingEdit(null); + if (edit?.type !== "rename" || name === edit.entry.name) { + return; + } + const { entry } = edit; + try { + const payload = await renameEntry({ + path: entry.path, + name, + }); + if (!payload) { + return; + } + if (!payload.success || !payload.renamedPath) { + toast.error(payload.error ?? t("workspace.fileExplorer.errors.renameFailed")); + return; + } + + const renamedPath = payload.renamedPath; + if (workspaceStateKey && entry.kind === "directory") { + const expandedRenamedPaths = Array.from(expandedPaths) + .filter((expandedPath) => isExplorerPathWithin(expandedPath, entry.path)) + .map((expandedPath) => + replaceExplorerPathPrefix(expandedPath, entry.path, renamedPath), + ); + setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => + currentPaths.map((currentPath) => + isExplorerPathWithin(currentPath, entry.path) + ? replaceExplorerPathPrefix(currentPath, entry.path, renamedPath) + : currentPath, + ), + ); + await Promise.all( + expandedRenamedPaths.map((expandedPath) => + requestDirectoryListing(expandedPath, { + recordHistory: false, + setCurrentPath: false, + }), + ), + ); + } + if (selectedEntryPath && isExplorerPathWithin(selectedEntryPath, entry.path)) { + const renamedSelection = replaceExplorerPathPrefix( + selectedEntryPath, + entry.path, + renamedPath, + ); + selectExplorerEntry(renamedSelection); + if (entry.kind === "file") { + onOpenFile?.(renamedSelection); + } + } + } catch (cause) { + toast.error(cause instanceof Error ? cause.message : String(cause)); + } + }, + [ + expandedPaths, + onOpenFile, + pendingEdit, + requestDirectoryListing, + renameEntry, + selectExplorerEntry, + selectedEntryPath, + setExpandedPathsForWorkspace, + t, + toast, + workspaceStateKey, + ], + ); + + const handleDuplicateEntry = useCallback( + async (entry: ExplorerEntry) => { + try { + const payload = await duplicateEntry(entry.path); + if (!payload) { + return; + } + if (!payload.success || !payload.duplicatedPath) { + toast.error(payload.error ?? t("workspace.fileExplorer.errors.duplicateFailed")); + return; + } + selectExplorerEntry(payload.duplicatedPath); + } catch (cause) { + toast.error(cause instanceof Error ? cause.message : String(cause)); + } + }, + [duplicateEntry, selectExplorerEntry, t, toast], + ); + + const handleDeleteEntry = useCallback( + async (entry: ExplorerEntry) => { + const confirmed = await confirmDialog({ + title: + entry.kind === "directory" + ? t("workspace.fileActions.confirmDelete.folderTitle") + : t("workspace.fileActions.confirmDelete.fileTitle"), + message: t("workspace.fileActions.confirmDelete.message", { name: entry.name }), + confirmLabel: t("workspace.fileActions.confirmDelete.confirm"), + cancelLabel: t("workspace.fileActions.confirmDelete.cancel"), + destructive: true, + }); + if (!confirmed) { + return; + } + try { + const payload = await deleteEntry(entry.path); + if (!payload) { + return; + } + if (!payload.success) { + toast.error(payload.error ?? t("workspace.fileExplorer.errors.deleteFailed")); + return; + } + if (selectedEntryPath === entry.path) { + selectExplorerEntry(null); + } + } catch (cause) { + toast.error(cause instanceof Error ? cause.message : String(cause)); + } + }, + [deleteEntry, selectExplorerEntry, selectedEntryPath, t, toast], + ); + const handleSortCycle = useCallback(() => { const currentIndex = SORT_OPTIONS.findIndex((opt) => opt.value === sortOption); const nextIndex = (currentIndex + 1) % SORT_OPTIONS.length; @@ -456,6 +921,33 @@ export function FileExplorerPane({ [directories, expandedPaths, showHiddenFiles, sortOption], ); + const listRows = useMemo(() => { + const rows: ExplorerListRow[] = treeRows.map((row) => + pendingEdit?.type === "rename" && pendingEdit.entry.path === row.entry.path + ? { type: "rename", entry: row.entry, depth: row.depth } + : { type: "entry", row }, + ); + if (pendingEdit?.type !== "create") { + return rows; + } + let insertionIndex = 0; + let depth = 0; + if (pendingEdit.parentPath !== ".") { + const parentIndex = treeRows.findIndex((row) => row.entry.path === pendingEdit.parentPath); + if (parentIndex >= 0) { + insertionIndex = parentIndex + 1; + depth = treeRows[parentIndex].depth + 1; + } + } + rows.splice(insertionIndex, 0, { + type: "draft", + parentPath: pendingEdit.parentPath, + kind: pendingEdit.kind, + depth, + }); + return rows; + }, [pendingEdit, treeRows]); + const showInitialLoading = resolveShowInitialLoading({ directories, isExplorerLoading, @@ -465,26 +957,73 @@ export function FileExplorerPane({ const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]); const renderTreeRow = useCallback( - (info: ListRenderItemInfo) => ( - - ), + (info: ListRenderItemInfo) => { + if (info.item.type === "draft") { + return ( + + ); + } + if (info.item.type === "rename") { + return ( + + ); + } + return ( + + ); + }, [ expandedPaths, - handleEntryPress, + fsEntryDuplicateEnabled, + fsEntryOpsEnabled, + handleCollapseDirectory, handleCopyPath, + handleCopyRelativePath, + handleDeleteEntry, handleDownloadEntry, + handleDraftCommit, + handleDuplicateEntry, + handleEditCancel, + handleEntryPress, + handleNewEntry, + handleRenameCommit, + handleRenameEntry, + handleRevealEntry, + handleSelectEntry, isDirectoryLoading, + fileManagerTarget, selectedEntryPath, onAddToChat, serverId, @@ -519,12 +1058,18 @@ export function FileExplorerPane({ } return ( - + void }) => event.preventDefault?.(), + }} + style={styles.container} + > { + if (!enabled || isFileExplorerRowTarget(event.target)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + contextMenu.setAnchorRect({ + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + width: 0, + height: 0, + }); + contextMenu.setOpen(true); + }, + [contextMenu, enabled], + ); + + return ( + + {children} + + ); +} + interface FileExplorerPaneContentProps { error: string | null; showInitialLoading: boolean; showBackFromError: boolean; - treeRows: ExplorerTreeRow[]; + listRows: ExplorerListRow[]; + onNewEntryAtRoot?: (parentPath: string, kind: "file" | "directory") => void; currentSortLabel: string; isRefreshFetching: boolean; - treeListRef: RefObject | null>; + treeListRef: RefObject | null>; scrollbar: OverlayFlatListScrollbar; - renderTreeRow: (info: ListRenderItemInfo) => ReactElement; + renderTreeRow: (info: ListRenderItemInfo) => ReactElement; handleSortCycle: () => void; handleToggleHiddenFiles: () => void; handleRefresh: () => void; @@ -568,7 +1165,8 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { error, showInitialLoading, showBackFromError, - treeRows, + listRows, + onNewEntryAtRoot, currentSortLabel, isRefreshFetching, treeListRef, @@ -585,6 +1183,13 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles); + const handleNewFileAtRoot = useCallback(() => { + onNewEntryAtRoot?.(".", "file"); + }, [onNewEntryAtRoot]); + const handleNewFolderAtRoot = useCallback(() => { + onNewEntryAtRoot?.(".", "directory"); + }, [onNewEntryAtRoot]); + const hiddenFilesToggleAccessibilityLabel = showHiddenFiles ? t("workspace.fileExplorer.actions.hideHiddenFiles") : t("workspace.fileExplorer.actions.showHiddenFiles"); @@ -644,6 +1249,30 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { + {onNewEntryAtRoot ? ( + <> + + + + + + + + ) : null} - {treeRows.length === 0 ? ( - - {emptyLabel} - - ) : ( - - )} - {treeRows.length > 0 ? scrollbar.overlay : null} + + + {listRows.length === 0 ? ( + + {emptyLabel} + + ) : ( + + )} + {listRows.length > 0 ? scrollbar.overlay : null} + + {onNewEntryAtRoot ? ( + + ) : null} + ); } @@ -799,28 +1440,48 @@ function toggleDirectory({ function TreeRowDispatcher({ serverId, workspaceId, - info, + row, + index, expandedPaths, selectedEntryPath, isDirectoryLoading, onEntryPress, + onSelectEntry, onCopyPath, + onCopyRelativePath, + onRevealEntry, + revealTargetName, onDownloadEntry, onAddToChat, + onNewEntry, + onCollapseDirectory, + onRenameEntry, + onDuplicateEntry, + onDeleteEntry, }: { serverId: string; workspaceId?: string | null; - info: ListRenderItemInfo; + row: ExplorerTreeRow; + index: number; expandedPaths: Set; selectedEntryPath: string | null; isDirectoryLoading: (path: string) => boolean; onEntryPress: (entry: ExplorerEntry) => void; + onSelectEntry: (entry: ExplorerEntry) => void; onCopyPath: (path: string) => void | Promise; + onCopyRelativePath: (path: string) => void | Promise; + onRevealEntry?: (entry: ExplorerEntry) => void; + revealTargetName?: string; onDownloadEntry: (entry: ExplorerEntry) => void; onAddToChat?: (path: string) => void; + onNewEntry?: (parentPath: string, kind: "file" | "directory") => void; + onCollapseDirectory?: (path: string) => void; + onRenameEntry?: (entry: ExplorerEntry) => void; + onDuplicateEntry?: (entry: ExplorerEntry) => void; + onDeleteEntry?: (entry: ExplorerEntry) => void; }) { - const entry = info.item.entry; - const depth = info.item.depth; + const entry = row.entry; + const depth = row.depth; const isDirectory = entry.kind === "directory"; const isExpanded = isDirectory && expandedPaths.has(entry.path); const isSelected = selectedEntryPath === entry.path; @@ -836,14 +1497,35 @@ function TreeRowDispatcher({ isSelected={isSelected} loading={loading} onEntryPress={onEntryPress} + onSelectEntry={onSelectEntry} onCopyPath={onCopyPath} + onCopyRelativePath={onCopyRelativePath} + onRevealEntry={onRevealEntry} + revealTargetName={revealTargetName} onDownloadEntry={onDownloadEntry} onAddToChat={onAddToChat} - testID={`file-explorer-row-${info.index}`} + onNewEntry={onNewEntry} + onCollapseDirectory={onCollapseDirectory} + onRenameEntry={onRenameEntry} + onDuplicateEntry={onDuplicateEntry} + onDeleteEntry={onDeleteEntry} + testID={`file-explorer-row-${index}`} /> ); } +function isExplorerPathWithin(candidatePath: string, parentPath: string): boolean { + return candidatePath === parentPath || candidatePath.startsWith(`${parentPath}/`); +} + +function replaceExplorerPathPrefix( + candidatePath: string, + previousPrefix: string, + nextPrefix: string, +): string { + return `${nextPrefix}${candidatePath.slice(previousPrefix.length)}`; +} + async function initializeExplorer({ hasWorkspaceScope, hasInitializedRef, @@ -1023,9 +1705,14 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 0, }, entriesContent: { + flexGrow: 1, paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, + rootContextTarget: { + flex: 1, + minHeight: 0, + }, centerState: { flex: 1, alignItems: "center", @@ -1085,6 +1772,13 @@ const styles = StyleSheet.create((theme) => ({ gap: WORKSPACE_TREE_ICON_LABEL_GAP, minWidth: 0, }, + entryChevron: { + width: WORKSPACE_TREE_ICON_SIZE, + height: WORKSPACE_TREE_ICON_SIZE, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, entryIcon: { width: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE, @@ -1096,6 +1790,14 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, + userSelect: "none", + }, + draftInput: { + flex: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + paddingVertical: 0, + paddingHorizontal: 0, }, contextMetaBlock: { paddingVertical: theme.spacing[1], diff --git a/packages/app/src/components/tree-primitives.tsx b/packages/app/src/components/tree-primitives.tsx index 01c26d5fa6..4a0e18a4f1 100644 --- a/packages/app/src/components/tree-primitives.tsx +++ b/packages/app/src/components/tree-primitives.tsx @@ -10,22 +10,22 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; // paths) render different data, but their ROWS should look identical — same // indentation, guide lines, and chevron. Keep those here so the two trees can't // drift apart. -export const TREE_INDENT_PER_LEVEL = 16; -export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5]; -export const WORKSPACE_TREE_ICON_SIZE = 16; -export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14; -export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2]; +export const TREE_INDENT_PER_LEVEL = 14; +export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1]; +export const WORKSPACE_TREE_ICON_SIZE = 14; +export const WORKSPACE_TREE_LOADING_ICON_SIZE = 12; +export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[1]; /** * Trailing glyph rail shared with the explorer X and Changes options chevron. * The extra 2px is optical: text ink ends inside its layout box, while the * header icons' strokes extend to theirs. */ -export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2; +export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[3] + 2; /** Left padding for a tree row at `depth`. Shared by folder rows and file headers * in the Changes tree so their indentation can't drift apart. */ export function treeRowPaddingLeft(depth: number): number { - return SPACING[3] + depth * TREE_INDENT_PER_LEVEL; + return SPACING[2] + depth * TREE_INDENT_PER_LEVEL; } const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({ @@ -46,7 +46,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) { key: index, style: [ styles.indentGuide, - inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }), + inlineUnistylesStyle({ left: SPACING[2] + index * TREE_INDENT_PER_LEVEL + 3 }), ], })), [depth], @@ -63,13 +63,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) { /** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */ export function TreeChevron({ expanded }: { expanded: boolean }) { return ( - + ({ justifyContent: "center", flexShrink: 0, }, - // Lucide leaves space around the chevron path. Align its painted right edge inside the fixed - // slot; the rotated glyph is wider, so it needs half the optical offset. - chevronCollapsed: { - left: 4, - }, chevronExpanded: { - left: 2, transform: [{ rotate: "90deg" }], }, })); diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 17a2aba754..8b117c8012 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -105,6 +105,7 @@ export function ContextMenuTrigger({ enabledOnMobile = true, enabledOnWeb = true, longPressDelayMs, + onContextMenu, triggerRef, ...props }: PropsWithChildren< @@ -115,6 +116,7 @@ export function ContextMenuTrigger({ enabledOnMobile?: boolean; enabledOnWeb?: boolean; longPressDelayMs?: number; + onContextMenu?: (event: unknown) => void; triggerRef?: Ref; } >): ReactElement { @@ -170,9 +172,10 @@ export function ContextMenuTrigger({ if (isCallable(preventDefault)) preventDefault.call(event); if (isCallable(stopPropagation)) stopPropagation.call(event); } + onContextMenu?.(event); openAtEvent(event); }, - [openAtEvent], + [onContextMenu, openAtEvent], ); const pressableStyle = useCallback( diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index ba5f3cf019..fb75fa6bfe 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -333,6 +333,7 @@ export function AnchoredSurface({ <> {backdrop ? ( void }) => event.preventDefault?.(), + }} ref={setWebOverlayScope} collapsable={false} style={[ diff --git a/packages/app/src/git/actions-store.test.ts b/packages/app/src/git/actions-store.test.ts index 75a3977758..9ab27ba40f 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -183,6 +183,31 @@ describe("checkout-git-actions-store", () => { ).toBe("idle"); }); + it("discards selected paths through the shared checkout action workflow", async () => { + const checkoutDiscardChanges = vi.fn(async () => ({ success: true, error: null })); + const client = { checkoutDiscardChanges }; + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); + + await useCheckoutGitActionsStore + .getState() + .discardChanges({ serverId, cwd, paths: ["renamed.ts", "original.ts"] }); + + expect(checkoutDiscardChanges).toHaveBeenCalledWith(cwd, { + paths: ["renamed.ts", "original.ts"], + }); + expect( + useCheckoutGitActionsStore + .getState() + .getStatus({ serverId, cwd, actionId: "discard-changes" }), + ).toBe("success"); + }); + for (const rpc of [ { label: "forge", diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index 34e7b54430..853cd2ff83 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -24,7 +24,8 @@ export type CheckoutGitAsyncActionId = | "enable-pr-auto-merge-rebase" | "disable-pr-auto-merge" | "merge-branch" - | "merge-from-base"; + | "merge-from-base" + | "discard-changes"; type CheckoutKey = string; type StatusMap = Partial>; @@ -119,6 +120,7 @@ interface CheckoutGitActionsStoreState { disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise; mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; + discardChanges: (params: { serverId: string; cwd: string; paths: string[] }) => Promise; } async function runCheckoutAction({ @@ -366,6 +368,23 @@ export const useCheckoutGitActionsStore = create() }, }); }, + + discardChanges: async ({ serverId, cwd, paths }) => { + await runCheckoutAction({ + serverId, + cwd, + actionId: "discard-changes", + run: async () => { + const client = resolveClient(serverId); + const payload = await client.checkoutDiscardChanges(cwd, { paths }); + if (!payload.success) { + throw new Error( + payload.error?.message ?? i18n.t("workspace.fileActions.confirmRevert.failed"), + ); + } + }, + }); + }, })); export function __resetCheckoutGitActionsStoreForTests() { diff --git a/packages/app/src/git/diff-folder-row.tsx b/packages/app/src/git/diff-folder-row.tsx index 992165a07a..f32bc6859f 100644 --- a/packages/app/src/git/diff-folder-row.tsx +++ b/packages/app/src/git/diff-folder-row.tsx @@ -1,12 +1,7 @@ import { useCallback, useMemo } from "react"; -import { - View, - Text, - Pressable, - type LayoutChangeEvent, - type PressableStateCallbackType, -} from "react-native"; -import { StyleSheet } from "react-native-unistyles"; +import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Folder } from "lucide-react-native"; import { DiffStat } from "@/components/diff-stat"; import { TreeChevron, @@ -15,9 +10,16 @@ import { WORKSPACE_FILE_ROW_TRAILING_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING, WORKSPACE_TREE_ICON_LABEL_GAP, + WORKSPACE_TREE_ICON_SIZE, } from "@/components/tree-primitives"; import { type Theme } from "@/styles/theme"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; +import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; +import { isWeb } from "@/constants/platform"; + +const ThemedFolder = withUnistyles(Folder); +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); interface DiffFolderRowProps { /** full uncompressed directory path — the collapse identity */ @@ -25,20 +27,27 @@ interface DiffFolderRowProps { displayName: string; depth: number; collapsed: boolean; + isSelected: boolean; additions: number; deletions: number; onToggle: (dirPath: string) => void; + onCollapse: (dirPath: string) => void; + onSelect: (dirPath: string) => void; onHeightChange?: (height: number) => void; + onCopyPath?: (path: string) => void; + onCopyRelativePath?: (path: string) => void; + onReveal?: (path: string) => void; + revealTargetName?: string; + onDuplicate?: (path: string) => void; + onRevert?: (path: string) => void; testID?: string; } -function folderRowPressableStyle({ - hovered, - pressed, -}: PressableStateCallbackType & { hovered?: boolean }) { - // Subtle background highlight on hover/press, matching the Files explorer rows - // (entryRowActive) — no opacity darken. - return [styles.folderRow, (Boolean(hovered) || pressed) && styles.folderRowActive]; +function folderRowPressableStyle( + { hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }, + isSelected: boolean, +) { + return [styles.folderRow, (Boolean(hovered) || pressed || isSelected) && styles.folderRowActive]; } export function DiffFolderRow({ @@ -46,15 +55,39 @@ export function DiffFolderRow({ displayName, depth, collapsed, + isSelected, additions, deletions, onToggle, + onCollapse, + onSelect, onHeightChange, + onCopyPath, + onCopyRelativePath, + onReveal, + revealTargetName, + onDuplicate, + onRevert, testID, }: DiffFolderRowProps) { + const handleSelect = useCallback(() => { + onSelect(dirPath); + }, [dirPath, onSelect]); + const handlePress = useCallback(() => { + const selection = isWeb ? window.getSelection() : null; + if (selection && !selection.isCollapsed && selection.toString().length > 0) { + return; + } + handleSelect(); onToggle(dirPath); - }, [dirPath, onToggle]); + }, [dirPath, handleSelect, onToggle]); + + const pressableStyle = useCallback( + (state: PressableStateCallbackType & { hovered?: boolean }) => + folderRowPressableStyle(state, isSelected), + [isSelected], + ); const handleLayout = useCallback( (event: LayoutChangeEvent) => { @@ -63,39 +96,88 @@ export function DiffFolderRow({ [onHeightChange], ); + const handleCollapse = useCallback(() => { + onCollapse(dirPath); + }, [dirPath, onCollapse]); + + const handleCopyPath = useCallback(() => { + onCopyPath?.(dirPath); + }, [dirPath, onCopyPath]); + + const handleCopyRelativePath = useCallback(() => { + onCopyRelativePath?.(dirPath); + }, [dirPath, onCopyRelativePath]); + + const handleReveal = useCallback(() => { + onReveal?.(dirPath); + }, [dirPath, onReveal]); + + const handleDuplicate = useCallback(() => { + onDuplicate?.(dirPath); + }, [dirPath, onDuplicate]); + + const handleRevert = useCallback(() => { + onRevert?.(dirPath); + }, [dirPath, onRevert]); + const leftStyle = useMemo( () => [styles.left, inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) })], [depth], ); - const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); + const accessibilityState = useMemo( + () => ({ expanded: !collapsed, selected: isSelected }), + [collapsed, isSelected], + ); return ( - - - - + + + + + + + + + + + {displayName} + - - {displayName} - - - - - - + + + + + + ); } @@ -123,8 +205,16 @@ const styles = StyleSheet.create((theme: Theme) => ({ minWidth: 0, }, chevronOpticalOffset: { - // The Changes directory chevron reads high beside the folder label. - transform: [{ translateY: 2 }], + width: WORKSPACE_TREE_ICON_SIZE, + height: WORKSPACE_TREE_ICON_SIZE, + alignItems: "center", + justifyContent: "center", + }, + folderIcon: { + width: WORKSPACE_TREE_ICON_SIZE, + height: WORKSPACE_TREE_ICON_SIZE, + alignItems: "center", + justifyContent: "center", }, right: { flexDirection: "row", @@ -138,5 +228,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ color: theme.colors.foreground, flexShrink: 1, minWidth: 0, + userSelect: "none", }, })); diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 53bc9894b4..f0756b34dd 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -81,6 +81,7 @@ import * as Clipboard from "expo-clipboard"; import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { useFileDownload } from "@/hooks/use-file-download"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { lineNumberGutterWidth } from "@/components/code-insets"; @@ -94,6 +95,7 @@ import type { ForgeAuthState } from "@getpaseo/protocol/messages"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { useToast } from "@/contexts/toast-context"; import { useSessionStore } from "@/stores/session-store"; +import { confirmDialog } from "@/utils/confirm-dialog"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useOverlayFlatListScrollbar } from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; @@ -119,6 +121,7 @@ import { } from "@/review"; import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; import { DiffTooLargeState } from "@/git/diff-too-large-state"; +import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -129,11 +132,14 @@ export function resolveDiffLayout( return canUseSplitLayout ? layout : "unified"; } -function fileHeaderPressableStyle({ - hovered, - pressed, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [styles.fileHeader, (Boolean(hovered) || pressed) && styles.fileHeaderActive]; +function fileHeaderPressableStyle( + { hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }, + isSelected: boolean, +) { + return [ + styles.fileHeader, + (Boolean(hovered) || pressed || isSelected) && styles.fileHeaderActive, + ]; } interface HighlightedTextProps { @@ -208,16 +214,23 @@ interface DiffFileSectionProps { file: ParsedDiffFile; workspaceFileDragScope?: { serverId: string; workspaceId: string }; isExpanded: boolean; + isSelected?: boolean; /** Tree indentation level (0 on the flat/mobile path). */ depth?: number; /** Show the muted directory suffix (flat list); false inside the folder tree. */ showDir?: boolean; interactive?: boolean; onToggle?: (path: string) => void; + onSelect?: (path: string) => void; onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; onCopyPath?: (path: string) => void; + onCopyRelativePath?: (path: string) => void; + onReveal?: (path: string) => void; + revealTargetName?: string; onDownload?: (path: string) => void; + onDuplicate?: (path: string) => void; + onRevert?: (path: string, oldPath?: string) => void; onHeaderHeightChange?: (path: string, height: number) => void; testID?: string; } @@ -226,6 +239,57 @@ const EMPTY_COMMENTS: readonly ReviewDraftComment[] = []; function noopStartComment(): void {} +function useDiscardChangesAction({ + serverId, + cwd, + diffMode, +}: { + serverId: string; + cwd: string; + diffMode: "uncommitted" | "base"; +}): ((path: string, oldPath?: string) => void) | undefined { + const { t } = useTranslation(); + const toast = useToast(); + const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges); + // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. + const discardSupported = useSessionStore( + (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutDiscardChanges === true, + ); + const discardPath = useCallback( + async (path: string, oldPath?: string) => { + const confirmed = await confirmDialog({ + title: t("workspace.fileActions.confirmRevert.title"), + message: t("workspace.fileActions.confirmRevert.message", { name: path }), + confirmLabel: t("workspace.fileActions.confirmRevert.confirm"), + cancelLabel: t("workspace.fileActions.confirmRevert.cancel"), + destructive: true, + }); + if (!confirmed) { + return; + } + try { + await discardChanges({ + serverId, + cwd, + paths: oldPath ? [path, oldPath] : [path], + }); + } catch (cause) { + toast.error( + cause instanceof Error ? cause.message : t("workspace.fileActions.confirmRevert.failed"), + ); + } + }, + [cwd, discardChanges, serverId, t, toast], + ); + const handleDiscardPath = useCallback( + (path: string, oldPath?: string) => { + void discardPath(path, oldPath); + }, + [discardPath], + ); + return discardSupported && diffMode === "uncommitted" ? handleDiscardPath : undefined; +} + const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null; function LongPressableLine({ @@ -247,6 +311,10 @@ function LongPressableLine({ }) { const onStartComment = reviewActions?.onStartComment; const handlePress = useCallback(() => { + const selection = isWeb ? window.getSelection() : null; + if (selection && !selection.isCollapsed && selection.toString().length > 0) { + return; + } if (reviewTarget && onStartComment) { onStartComment(reviewTarget); } @@ -912,18 +980,84 @@ function SplitDiffColumn({ ); } +function DiffFileActionsContextMenuContent({ + file, + onOpenFile, + onAddToChat, + onCopyPath, + onCopyRelativePath, + onReveal, + revealTargetName, + onDownload, + onDuplicate, + onRevert, + testID, +}: Pick< + DiffFileSectionProps, + | "file" + | "onOpenFile" + | "onAddToChat" + | "onCopyPath" + | "onCopyRelativePath" + | "onReveal" + | "revealTargetName" + | "onDownload" + | "onDuplicate" + | "onRevert" + | "testID" +>) { + const handleOpenFile = useCallback(() => onOpenFile?.(file.path), [file.path, onOpenFile]); + const handleAddToChat = useCallback(() => onAddToChat?.(file.path), [file.path, onAddToChat]); + const handleCopyPath = useCallback(() => onCopyPath?.(file.path), [file.path, onCopyPath]); + const handleCopyRelativePath = useCallback( + () => onCopyRelativePath?.(file.path), + [file.path, onCopyRelativePath], + ); + const handleReveal = useCallback(() => onReveal?.(file.path), [file.path, onReveal]); + const handleDownload = useCallback(() => onDownload?.(file.path), [file.path, onDownload]); + const handleDuplicate = useCallback(() => onDuplicate?.(file.path), [file.path, onDuplicate]); + const handleRevert = useCallback( + () => onRevert?.(file.path, file.oldPath), + [file.oldPath, file.path, onRevert], + ); + + return ( + + ); +} + const DiffFileHeader = memo(function DiffFileHeader({ file, workspaceFileDragScope, isExpanded, + isSelected = false, depth = 0, showDir = true, interactive = true, onToggle, + onSelect, onOpenFile, onAddToChat, onCopyPath, + onCopyRelativePath, + onReveal, + revealTargetName, onDownload, + onDuplicate, + onRevert, onHeaderHeightChange, testID, }: DiffFileSectionProps) { @@ -938,29 +1072,24 @@ const DiffFileHeader = memo(function DiffFileHeader({ const pressHandledRef = useRef(false); const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null); + const handleSelect = useCallback(() => { + if (interactive) { + onSelect?.(file.path); + } + }, [file.path, interactive, onSelect]); + const toggleExpanded = useCallback(() => { if (!interactive) { return; } + const selection = isWeb ? window.getSelection() : null; + if (selection && !selection.isCollapsed && selection.toString().length > 0) { + return; + } pressHandledRef.current = true; + handleSelect(); onToggle?.(file.path); - }, [file.path, interactive, onToggle]); - - const handleOpenFile = useCallback(() => { - onOpenFile?.(file.path); - }, [file.path, onOpenFile]); - - const handleAddToChat = useCallback(() => { - onAddToChat?.(file.path); - }, [file.path, onAddToChat]); - - const handleCopyPath = useCallback(() => { - onCopyPath?.(file.path); - }, [file.path, onCopyPath]); - - const handleDownload = useCallback(() => { - onDownload?.(file.path); - }, [file.path, onDownload]); + }, [file.path, handleSelect, interactive, onToggle]); const handleLayout = useCallback( (event: LayoutChangeEvent) => { @@ -981,7 +1110,8 @@ const DiffFileHeader = memo(function DiffFileHeader({ const handleLongPress = useCallback(() => { pressHandledRef.current = true; - }, []); + handleSelect(); + }, [handleSelect]); const handlePressOut = useCallback( (event: { nativeEvent: { pageX: number; pageY: number } }) => { @@ -1008,16 +1138,20 @@ const DiffFileHeader = memo(function DiffFileHeader({ () => [styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded], [isExpanded], ); + const accessibilityState = useMemo( + () => ({ expanded: isExpanded, selected: isSelected }), + [isExpanded, isSelected], + ); const headerPressableStyle = useCallback( (state: PressableStateCallbackType) => depth > 0 ? [ - fileHeaderPressableStyle(state), + fileHeaderPressableStyle(state, isSelected), inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }), ] - : fileHeaderPressableStyle(state), - [depth], + : fileHeaderPressableStyle(state, isSelected), + [depth, isSelected], ); const fileName = file.path.split("/").pop() ?? file.path; @@ -1027,9 +1161,10 @@ const DiffFileHeader = memo(function DiffFileHeader({ ref={dragSourceRef} style={showDir ? styles.fileHeaderLeft : [styles.fileHeaderLeft, styles.fileHeaderLeftTree]} > + {showDir ? null : } {showDir ? null : ( - + )} @@ -1060,7 +1195,14 @@ const DiffFileHeader = memo(function DiffFileHeader({ let trigger: ReactElement; if (!interactive) { trigger = ( - {headerContent} + void }) => event.preventDefault?.(), + }} + style={headerPressableStyle({ hovered: false, pressed: false })} + > + {headerContent} + ); } else { trigger = ( @@ -1072,7 +1214,10 @@ const DiffFileHeader = memo(function DiffFileHeader({ onPressIn={handlePressIn} onPressOut={handlePressOut} onLongPress={handleLongPress} + onContextMenu={handleSelect} onPress={toggleExpanded} + accessibilityState={accessibilityState} + aria-selected={isSelected} > {headerContent} @@ -1090,14 +1235,18 @@ const DiffFileHeader = memo(function DiffFileHeader({ {interactive ? ( - ) : null} @@ -1827,7 +1976,12 @@ interface SharedDiffViewProps { onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; onCopyPath?: (path: string) => void; + onCopyRelativePath?: (path: string) => void; + onReveal?: (path: string) => void; + revealTargetName?: string; onDownload?: (path: string) => void; + onDuplicate?: (path: string) => void; + onRevert?: (path: string, oldPath?: string) => void; onExpandedPathsChange: (paths: string[]) => void; onCollapsedFoldersChange: (paths: string[]) => void; } @@ -1882,10 +2036,28 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi const workspaceFileDragScope = mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined; const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined; + const onCopyRelativePath = mode.kind === "working_tree" ? mode.onCopyRelativePath : undefined; + const onReveal = mode.kind === "working_tree" ? mode.onReveal : undefined; + const revealTargetName = mode.kind === "working_tree" ? mode.revealTargetName : undefined; const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined; + const onDuplicate = mode.kind === "working_tree" ? mode.onDuplicate : undefined; + const onRevert = mode.kind === "working_tree" ? mode.onRevert : undefined; + // Keep selection independent from expansion so future keyboard actions (such as R to rename) + // can target the current VCS file or folder without changing its open state. + const [selectedPath, setSelectedPath] = useState(null); + const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []); const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]); const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]); const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]); + useEffect(() => { + if ( + selectedPath && + !allFolderPathSet.has(selectedPath) && + !files.some((file) => file.path === selectedPath) + ) { + setSelectedPath(null); + } + }, [allFolderPathSet, files, selectedPath]); const effectiveCollapsedFolders = useMemo( () => new Set(Array.from(collapsedFolders).filter((path) => allFolderPathSet.has(path))), [allFolderPathSet, collapsedFolders], @@ -2181,6 +2353,40 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [computeItemOffset, effectiveCollapsedFolders, mode], ); + const handleCollapseFolder = useCallback( + (dirPath: string) => { + if (mode.kind !== "working_tree") { + return; + } + const targetOffset = computeItemOffset( + (item) => item.type === "folder" && item.dirPath === dirPath, + ); + const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current; + if ( + targetOffset !== null && + shouldAnchorHeaderBeforeCollapse({ + headerOffset: targetOffset, + headerHeight: folderHeight, + viewportOffset: diffListScrollOffsetRef.current, + viewportHeight: diffListViewportHeightRef.current, + }) + ) { + diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false }); + } + + const pathPrefix = `${dirPath}/`; + mode.onCollapsedFoldersChange([ + ...new Set([ + ...effectiveCollapsedFolders, + ...allFolderPaths.filter( + (folderPath) => folderPath === dirPath || folderPath.startsWith(pathPrefix), + ), + ]), + ]); + }, + [allFolderPaths, computeItemOffset, effectiveCollapsedFolders, mode], + ); + const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { if (item.type === "folder") { @@ -2190,10 +2396,19 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi displayName={item.displayName} depth={item.depth} collapsed={item.collapsed} + isSelected={selectedPath === item.dirPath} additions={item.additions} deletions={item.deletions} onToggle={handleToggleFolder} + onCollapse={handleCollapseFolder} + onSelect={handleSelectPath} onHeightChange={handleFolderRowHeightChange} + onCopyPath={onCopyPath} + onCopyRelativePath={onCopyRelativePath} + onReveal={onReveal} + revealTargetName={revealTargetName} + onDuplicate={onDuplicate} + onRevert={onRevert} testID={`diff-folder-${item.dirPath}`} /> ); @@ -2204,14 +2419,21 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi file={item.file} workspaceFileDragScope={workspaceFileDragScope} isExpanded={item.isExpanded} + isSelected={selectedPath === item.file.path} depth={item.depth} showDir={viewMode === "flat"} interactive={interactive} onToggle={interactive ? (onFilePress ?? handleToggleExpanded) : undefined} + onSelect={handleSelectPath} onOpenFile={onOpenFile} onAddToChat={onAddToChat} onCopyPath={onCopyPath} + onCopyRelativePath={onCopyRelativePath} + onReveal={onReveal} + revealTargetName={revealTargetName} onDownload={onDownload} + onDuplicate={onDuplicate} + onRevert={onRevert} onHeaderHeightChange={handleHeaderHeightChange} testID={`diff-file-${item.fileIndex}`} /> @@ -2235,6 +2457,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi handleBodyHeightChange, handleFolderRowHeightChange, handleHeaderHeightChange, + handleCollapseFolder, + handleSelectPath, handleToggleExpanded, handleToggleFolder, layout, @@ -2248,7 +2472,13 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi onOpenFile, onAddToChat, onCopyPath, + onCopyRelativePath, + onReveal, + revealTargetName, onDownload, + onDuplicate, + onRevert, + selectedPath, ], ); @@ -2659,6 +2889,11 @@ export function GitDiffPane({ const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []); const toast = useToast(); + const isLocalDaemon = useIsLocalDaemon(serverId); + const { targets: desktopOpenTargets } = useDesktopOpenTargets({ + isLocalExecution: isLocalDaemon, + }); + const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager"); const { changesTabOpen, toggleChanges: handleToggleChangesTab, @@ -2668,6 +2903,11 @@ export function GitDiffPane({ const refreshSupported = useSessionStore( (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, ); + const client = useSessionStore((state) => state.sessions[serverId]?.client); + // COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09. + const fsEntryDuplicateEnabled = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true, + ); const runRefresh = useCheckoutGitActionsStore((s) => s.refresh); const isRefreshing = useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) === @@ -2773,12 +3013,51 @@ export function GitDiffPane({ }, [cwd], ); + const handleCopyRelativePath = useCallback((path: string) => { + void Clipboard.setStringAsync(path); + }, []); + const handleRevealPath = useCallback( + async (path: string) => { + if (!fileManagerTarget) { + return; + } + try { + await openDesktopTarget({ + editorId: fileManagerTarget.id, + workspacePath: cwd, + filePath: buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }), + }); + } catch (cause) { + toast.error( + cause instanceof Error ? cause.message : t("workspace.fileExplorer.errors.revealFailed"), + ); + } + }, + [cwd, fileManagerTarget, t, toast], + ); const handleDownloadPath = useCallback( (path: string) => { downloadFile({ fileName: path.split("/").pop() ?? path, path }); }, [downloadFile], ); + const handleDuplicatePath = useCallback( + async (path: string) => { + if (!client) { + return; + } + try { + const payload = await client.duplicateFileEntry({ cwd, path }); + if (!payload.success) { + toast.error(payload.error ?? t("workspace.fileExplorer.errors.duplicateFailed")); + } + } catch (cause) { + toast.error(cause instanceof Error ? cause.message : String(cause)); + } + }, + [client, cwd, t, toast], + ); + const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode }); const workingTreeMode = useMemo( () => ({ kind: "working_tree" as const, @@ -2791,7 +3070,12 @@ export function GitDiffPane({ onOpenFile, onAddToChat, onCopyPath: handleCopyPath, + onCopyRelativePath: handleCopyRelativePath, + onReveal: fileManagerTarget ? handleRevealPath : undefined, + revealTargetName: fileManagerTarget?.label, onDownload: handleDownloadPath, + onDuplicate: fsEntryDuplicateEnabled ? handleDuplicatePath : undefined, + onRevert: onRevertPath, onExpandedPathsChange: changesTree.updateExpandedPaths, onCollapsedFoldersChange: changesTree.updateCollapsedFolders, }), @@ -2806,7 +3090,13 @@ export function GitDiffPane({ onOpenFile, onAddToChat, handleCopyPath, + handleCopyRelativePath, handleDownloadPath, + handleDuplicatePath, + handleRevealPath, + fileManagerTarget, + fsEntryDuplicateEnabled, + onRevertPath, changesTree.updateExpandedPaths, changesTree.updateCollapsedFolders, ], @@ -2861,7 +3151,12 @@ export function GitDiffPane({ ); return ( - + void }) => event.preventDefault?.(), + }} + style={styles.container} + > {isGit && (currentBranchName || isMobile) ? ( ({ gap: theme.spacing[1], flexShrink: 0, }, + fileChevronSpacer: { + width: WORKSPACE_TREE_ICON_SIZE, + height: WORKSPACE_TREE_ICON_SIZE, + flexShrink: 0, + }, fileIcon: { width: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE, @@ -3183,6 +3483,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, flexShrink: 1, minWidth: 0, + userSelect: "none", }, fileDir: { fontSize: theme.fontSize.sm, @@ -3190,6 +3491,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, flex: 1, minWidth: 0, + userSelect: "none", }, fileDirSpacer: { flex: 1, diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index d1bb4c239a..53714e37b3 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -6,6 +6,7 @@ import { type ExplorerDirectory, } from "@/stores/session-store"; import { explorerFileFromReadResult } from "@/file-explorer/read-result"; +import { parentExplorerPath } from "@/utils/explorer-paths"; function createExplorerState(): AgentFileExplorerState { return { @@ -252,6 +253,80 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor [client, normalizedWorkspaceRoot, t], ); + const createEntry = useCallback( + async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => { + if (!client || !normalizedWorkspaceRoot) { + return null; + } + const payload = await client.createFileEntry({ + cwd: normalizedWorkspaceRoot, + ...input, + }); + if (payload.success) { + await requestDirectoryListing(input.parentPath, { + recordHistory: false, + setCurrentPath: false, + }); + } + return payload; + }, + [client, normalizedWorkspaceRoot, requestDirectoryListing], + ); + + const renameEntry = useCallback( + async (input: { path: string; name: string }) => { + if (!client || !normalizedWorkspaceRoot) { + return null; + } + const payload = await client.renameFileEntry({ + cwd: normalizedWorkspaceRoot, + ...input, + }); + if (payload.success) { + await requestDirectoryListing(parentExplorerPath(input.path), { + recordHistory: false, + setCurrentPath: false, + }); + } + return payload; + }, + [client, normalizedWorkspaceRoot, requestDirectoryListing], + ); + + const duplicateEntry = useCallback( + async (path: string) => { + if (!client || !normalizedWorkspaceRoot) { + return null; + } + const payload = await client.duplicateFileEntry({ cwd: normalizedWorkspaceRoot, path }); + if (payload.success) { + await requestDirectoryListing(parentExplorerPath(path), { + recordHistory: false, + setCurrentPath: false, + }); + } + return payload; + }, + [client, normalizedWorkspaceRoot, requestDirectoryListing], + ); + + const deleteEntry = useCallback( + async (path: string) => { + if (!client || !normalizedWorkspaceRoot) { + return null; + } + const payload = await client.deleteFileEntry({ cwd: normalizedWorkspaceRoot, path }); + if (payload.success) { + await requestDirectoryListing(parentExplorerPath(path), { + recordHistory: false, + setCurrentPath: false, + }); + } + return payload; + }, + [client, normalizedWorkspaceRoot, requestDirectoryListing], + ); + const selectExplorerEntry = useCallback( (path: string | null) => { updateExplorerState((state) => ({ @@ -267,6 +342,10 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor requestDirectoryListing, requestFilePreview, requestFileDownloadToken, + createEntry, + renameEntry, + duplicateEntry, + deleteEntry, selectExplorerEntry, }; } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index bc3c09323a..c38381b728 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -393,9 +393,32 @@ export const ar: TranslationResources = { fileActions: { openFile: "افتح الملف", copyPath: "نسخ المسار", + copyRelativePath: "نسخ المسار النسبي", + revealIn: "إظهار في {{target}}", download: "تحميل", addToChat: "إضافة إلى الدردشة", moreActions: "المزيد من الإجراءات", + newFile: "ملف جديد", + newFolder: "مجلد جديد", + collapseFolder: "طي المجلد", + rename: "إعادة تسمية", + duplicate: "تكرار", + revert: "تجاهل التغييرات", + delete: "حذف", + confirmDelete: { + fileTitle: "حذف الملف؟", + folderTitle: "حذف المجلد؟", + message: 'سيتم حذف "{{name}}" نهائيًا.', + confirm: "حذف", + cancel: "إلغاء", + }, + confirmRevert: { + title: "تجاهل التغييرات؟", + message: 'سيتم تجاهل التغييرات في "{{name}}" نهائيًا.', + confirm: "تجاهل", + cancel: "إلغاء", + failed: "فشل تجاهل التغييرات", + }, }, fileExplorer: { sort: { @@ -425,6 +448,15 @@ export const ar: TranslationResources = { }, errors: { failedToListDirectory: "فشل في سرد ​​الدليل", + createFailed: "فشل إنشاء العنصر", + renameFailed: "فشل إعادة تسمية العنصر", + duplicateFailed: "فشل تكرار العنصر", + revealFailed: "فشل إظهار العنصر", + deleteFailed: "فشل حذف العنصر", + }, + draft: { + filePlaceholder: "اسم الملف", + folderPlaceholder: "اسم المجلد", }, }, setup: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index a8694a5512..c6954b9757 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -392,9 +392,32 @@ export const en = { fileActions: { openFile: "Open file", copyPath: "Copy path", + copyRelativePath: "Copy relative path", + revealIn: "Reveal in {{target}}", download: "Download", addToChat: "Add to chat", moreActions: "More actions", + newFile: "New file", + newFolder: "New folder", + collapseFolder: "Collapse folder", + rename: "Rename", + duplicate: "Duplicate", + revert: "Discard changes", + delete: "Delete", + confirmDelete: { + fileTitle: "Delete file?", + folderTitle: "Delete folder?", + message: '"{{name}}" will be permanently deleted.', + confirm: "Delete", + cancel: "Cancel", + }, + confirmRevert: { + title: "Discard changes?", + message: 'Changes to "{{name}}" will be permanently discarded.', + confirm: "Discard", + cancel: "Cancel", + failed: "Failed to discard changes", + }, }, fileExplorer: { sort: { @@ -424,6 +447,15 @@ export const en = { }, errors: { failedToListDirectory: "Failed to list directory", + createFailed: "Failed to create entry", + renameFailed: "Failed to rename entry", + duplicateFailed: "Failed to duplicate entry", + revealFailed: "Failed to reveal entry", + deleteFailed: "Failed to delete entry", + }, + draft: { + filePlaceholder: "File name", + folderPlaceholder: "Folder name", }, }, setup: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 244dec9d1e..7b0fa38752 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -397,9 +397,32 @@ export const es: TranslationResources = { fileActions: { openFile: "Abrir archivo", copyPath: "Copiar ruta", + copyRelativePath: "Copiar ruta relativa", + revealIn: "Mostrar en {{target}}", download: "Descargar", addToChat: "Añadir al chat", moreActions: "Más acciones", + newFile: "Nuevo archivo", + newFolder: "Nueva carpeta", + collapseFolder: "Contraer carpeta", + rename: "Renombrar", + duplicate: "Duplicar", + revert: "Descartar cambios", + delete: "Eliminar", + confirmDelete: { + fileTitle: "¿Eliminar archivo?", + folderTitle: "¿Eliminar carpeta?", + message: '"{{name}}" se eliminará permanentemente.', + confirm: "Eliminar", + cancel: "Cancelar", + }, + confirmRevert: { + title: "¿Descartar cambios?", + message: 'Los cambios en "{{name}}" se descartarán permanentemente.', + confirm: "Descartar", + cancel: "Cancelar", + failed: "No se pudieron descartar los cambios", + }, }, fileExplorer: { sort: { @@ -429,6 +452,15 @@ export const es: TranslationResources = { }, errors: { failedToListDirectory: "No se pudo listar el directorio", + createFailed: "No se pudo crear la entrada", + renameFailed: "No se pudo renombrar la entrada", + duplicateFailed: "No se pudo duplicar la entrada", + revealFailed: "No se pudo mostrar la entrada", + deleteFailed: "No se pudo eliminar la entrada", + }, + draft: { + filePlaceholder: "Nombre del archivo", + folderPlaceholder: "Nombre de la carpeta", }, }, setup: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index cde09ccb20..70d7e2cc1a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -397,9 +397,32 @@ export const fr: TranslationResources = { fileActions: { openFile: "Ouvrir le fichier", copyPath: "Copier le chemin", + copyRelativePath: "Copier le chemin relatif", + revealIn: "Afficher dans {{target}}", download: "Télécharger", addToChat: "Ajouter au chat", moreActions: "Plus de propositions", + newFile: "Nouveau fichier", + newFolder: "Nouveau dossier", + collapseFolder: "Replier le dossier", + rename: "Renommer", + duplicate: "Dupliquer", + revert: "Abandonner les modifications", + delete: "Supprimer", + confirmDelete: { + fileTitle: "Supprimer le fichier ?", + folderTitle: "Supprimer le dossier ?", + message: "« {{name}} » sera définitivement supprimé.", + confirm: "Supprimer", + cancel: "Annuler", + }, + confirmRevert: { + title: "Abandonner les modifications ?", + message: "Les modifications de « {{name}} » seront définitivement abandonnées.", + confirm: "Abandonner", + cancel: "Annuler", + failed: "Échec de l'abandon des modifications", + }, }, fileExplorer: { sort: { @@ -429,6 +452,15 @@ export const fr: TranslationResources = { }, errors: { failedToListDirectory: "Échec de la liste du répertoire", + createFailed: "Échec de la création de l'entrée", + renameFailed: "Échec du renommage de l'entrée", + duplicateFailed: "Échec de la duplication de l'entrée", + revealFailed: "Échec de l'affichage de l'entrée", + deleteFailed: "Échec de la suppression de l'entrée", + }, + draft: { + filePlaceholder: "Nom du fichier", + folderPlaceholder: "Nom du dossier", }, }, setup: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 6aff6de07a..85b95d85e6 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -397,9 +397,32 @@ export const ja: TranslationResources = { fileActions: { openFile: "ファイルを開く", copyPath: "パスをコピー", + copyRelativePath: "相対パスをコピー", + revealIn: "{{target}}で表示", download: "ダウンロード", addToChat: "チャットに追加", moreActions: "その他のアクション", + newFile: "新規ファイル", + newFolder: "新規フォルダ", + collapseFolder: "フォルダを折りたたむ", + rename: "名前を変更", + duplicate: "複製", + revert: "変更を破棄", + delete: "削除", + confirmDelete: { + fileTitle: "ファイルを削除しますか?", + folderTitle: "フォルダを削除しますか?", + message: "「{{name}}」は完全に削除されます。", + confirm: "削除", + cancel: "キャンセル", + }, + confirmRevert: { + title: "変更を破棄しますか?", + message: "「{{name}}」への変更は完全に破棄されます。", + confirm: "破棄", + cancel: "キャンセル", + failed: "変更の破棄に失敗しました", + }, }, fileExplorer: { sort: { @@ -429,6 +452,15 @@ export const ja: TranslationResources = { }, errors: { failedToListDirectory: "ディレクトリの一覧取得に失敗しました", + createFailed: "エントリの作成に失敗しました", + renameFailed: "エントリの名前変更に失敗しました", + duplicateFailed: "エントリの複製に失敗しました", + revealFailed: "エントリの表示に失敗しました", + deleteFailed: "エントリの削除に失敗しました", + }, + draft: { + filePlaceholder: "ファイル名", + folderPlaceholder: "フォルダ名", }, }, setup: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index aa1b9a9c80..cd89699554 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -394,9 +394,32 @@ export const ko: TranslationResources = { fileActions: { openFile: "파일 열기", copyPath: "경로 복사", + copyRelativePath: "상대 경로 복사", + revealIn: "{{target}}에서 보기", download: "다운로드", addToChat: "채팅에 추가", moreActions: "추가 작업", + newFile: "새 파일", + newFolder: "새 폴더", + collapseFolder: "폴더 접기", + rename: "이름 바꾸기", + duplicate: "복제", + revert: "변경 사항 버리기", + delete: "삭제", + confirmDelete: { + fileTitle: "파일을 삭제할까요?", + folderTitle: "폴더를 삭제할까요?", + message: '"{{name}}"이(가) 영구적으로 삭제됩니다.', + confirm: "삭제", + cancel: "취소", + }, + confirmRevert: { + title: "변경 사항을 버릴까요?", + message: '"{{name}}"의 변경 사항이 영구적으로 삭제됩니다.', + confirm: "버리기", + cancel: "취소", + failed: "변경 사항을 버리지 못했습니다", + }, }, fileExplorer: { sort: { @@ -426,6 +449,15 @@ export const ko: TranslationResources = { }, errors: { failedToListDirectory: "디렉터리 목록을 불러오지 못했습니다", + createFailed: "항목을 만들지 못했습니다", + renameFailed: "항목 이름을 바꾸지 못했습니다", + duplicateFailed: "항목을 복제하지 못했습니다", + revealFailed: "항목을 표시하지 못했습니다", + deleteFailed: "항목을 삭제하지 못했습니다", + }, + draft: { + filePlaceholder: "파일 이름", + folderPlaceholder: "폴더 이름", }, }, setup: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 1eaa5e994d..aa9fb7a9aa 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -397,9 +397,32 @@ export const ptBR: TranslationResources = { fileActions: { openFile: "Abrir arquivo", copyPath: "Copiar caminho", + copyRelativePath: "Copiar caminho relativo", + revealIn: "Mostrar no {{target}}", download: "Baixar", addToChat: "Adicionar ao chat", moreActions: "Mais ações", + newFile: "Novo arquivo", + newFolder: "Nova pasta", + collapseFolder: "Recolher pasta", + rename: "Renomear", + duplicate: "Duplicar", + revert: "Descartar alterações", + delete: "Excluir", + confirmDelete: { + fileTitle: "Excluir arquivo?", + folderTitle: "Excluir pasta?", + message: '"{{name}}" será excluído permanentemente.', + confirm: "Excluir", + cancel: "Cancelar", + }, + confirmRevert: { + title: "Descartar alterações?", + message: 'As alterações em "{{name}}" serão descartadas permanentemente.', + confirm: "Descartar", + cancel: "Cancelar", + failed: "Falha ao descartar alterações", + }, }, fileExplorer: { sort: { @@ -429,6 +452,15 @@ export const ptBR: TranslationResources = { }, errors: { failedToListDirectory: "Falha ao listar diretório", + createFailed: "Falha ao criar entrada", + renameFailed: "Falha ao renomear entrada", + duplicateFailed: "Falha ao duplicar entrada", + revealFailed: "Falha ao mostrar entrada", + deleteFailed: "Falha ao excluir entrada", + }, + draft: { + filePlaceholder: "Nome do arquivo", + folderPlaceholder: "Nome da pasta", }, }, setup: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 93427bd6aa..753ddafc27 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -396,9 +396,32 @@ export const ru: TranslationResources = { fileActions: { openFile: "Открыть файл", copyPath: "Копировать путь", + copyRelativePath: "Копировать относительный путь", + revealIn: "Показать в {{target}}", download: "Скачать", addToChat: "Добавить в чат", moreActions: "Дополнительные действия", + newFile: "Новый файл", + newFolder: "Новая папка", + collapseFolder: "Свернуть папку", + rename: "Переименовать", + duplicate: "Дублировать", + revert: "Отменить изменения", + delete: "Удалить", + confirmDelete: { + fileTitle: "Удалить файл?", + folderTitle: "Удалить папку?", + message: "«{{name}}» будет удалён безвозвратно.", + confirm: "Удалить", + cancel: "Отмена", + }, + confirmRevert: { + title: "Отменить изменения?", + message: "Изменения в «{{name}}» будут безвозвратно отменены.", + confirm: "Отменить", + cancel: "Отмена", + failed: "Не удалось отменить изменения", + }, }, fileExplorer: { sort: { @@ -428,6 +451,15 @@ export const ru: TranslationResources = { }, errors: { failedToListDirectory: "Не удалось указать каталог", + createFailed: "Не удалось создать элемент", + renameFailed: "Не удалось переименовать элемент", + duplicateFailed: "Не удалось дублировать элемент", + revealFailed: "Не удалось показать элемент", + deleteFailed: "Не удалось удалить элемент", + }, + draft: { + filePlaceholder: "Имя файла", + folderPlaceholder: "Имя папки", }, }, setup: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 561e643763..29167ff8ef 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -393,9 +393,32 @@ export const zhCN: TranslationResources = { fileActions: { openFile: "打开文件", copyPath: "复制路径", + copyRelativePath: "复制相对路径", + revealIn: "在 {{target}} 中显示", download: "下载", addToChat: "添加到聊天", moreActions: "更多操作", + newFile: "新建文件", + newFolder: "新建文件夹", + collapseFolder: "折叠文件夹", + rename: "重命名", + duplicate: "复制", + revert: "放弃更改", + delete: "删除", + confirmDelete: { + fileTitle: "删除文件?", + folderTitle: "删除文件夹?", + message: "“{{name}}”将被永久删除。", + confirm: "删除", + cancel: "取消", + }, + confirmRevert: { + title: "放弃更改?", + message: "对“{{name}}”的更改将被永久放弃。", + confirm: "放弃", + cancel: "取消", + failed: "放弃更改失败", + }, }, fileExplorer: { sort: { @@ -425,6 +448,15 @@ export const zhCN: TranslationResources = { }, errors: { failedToListDirectory: "列出目录失败", + createFailed: "创建条目失败", + renameFailed: "重命名条目失败", + duplicateFailed: "复制条目失败", + revealFailed: "显示条目失败", + deleteFailed: "删除条目失败", + }, + draft: { + filePlaceholder: "文件名", + folderPlaceholder: "文件夹名称", }, }, setup: { diff --git a/packages/app/src/utils/explorer-paths.ts b/packages/app/src/utils/explorer-paths.ts index 30f9a8f52c..5f09541a57 100644 --- a/packages/app/src/utils/explorer-paths.ts +++ b/packages/app/src/utils/explorer-paths.ts @@ -32,3 +32,8 @@ export function buildAbsoluteExplorerPath({ return `${normalizedWorkspaceRoot}${separator}${segments.join(separator)}`; } + +export function parentExplorerPath(entryPath: string): string { + const separatorIndex = entryPath.lastIndexOf("/"); + return separatorIndex > 0 ? entryPath.slice(0, separatorIndex) : "."; +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 1c9b7faf66..af3073c283 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1727,6 +1727,181 @@ test("a candidate measurement that times out under a heartbeat tick does not cou expect(session.teardownCount()).toBe(0); }); +test("file context action RPCs correlate success and error responses", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_context_actions", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const createPromise = client.createFileEntry({ + cwd: "/tmp/project", + parentPath: "src", + name: "new.ts", + kind: "file", + }); + const createRequest = parseSentFrame(mock.sent.at(-1)); + expect(createRequest).toEqual({ + type: "fs.entry.create.request", + cwd: "/tmp/project", + parentPath: "src", + name: "new.ts", + kind: "file", + requestId: expect.any(String), + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "fs.entry.create.response", + payload: { + cwd: "/tmp/project", + parentPath: "src", + path: "src/new.ts", + success: true, + error: null, + requestId: createRequest.requestId, + }, + }), + ); + await expect(createPromise).resolves.toMatchObject({ + path: "src/new.ts", + success: true, + error: null, + }); + + const renamePromise = client.renameFileEntry({ + cwd: "/tmp/project", + path: "src/new.ts", + name: "existing.ts", + }); + const renameRequest = parseSentFrame(mock.sent.at(-1)); + expect(renameRequest).toMatchObject({ + type: "fs.entry.rename.request", + cwd: "/tmp/project", + path: "src/new.ts", + name: "existing.ts", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "fs.entry.rename.response", + payload: { + cwd: "/tmp/project", + path: "src/new.ts", + renamedPath: null, + success: false, + error: '"existing.ts" already exists', + requestId: renameRequest.requestId, + }, + }), + ); + await expect(renamePromise).resolves.toMatchObject({ + renamedPath: null, + success: false, + error: '"existing.ts" already exists', + }); + + const duplicatePromise = client.duplicateFileEntry({ + cwd: "/tmp/project", + path: "src/new.ts", + }); + const duplicateRequest = parseSentFrame(mock.sent.at(-1)); + expect(duplicateRequest).toMatchObject({ + type: "fs.entry.duplicate.request", + cwd: "/tmp/project", + path: "src/new.ts", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "fs.entry.duplicate.response", + payload: { + cwd: "/tmp/project", + path: "src/new.ts", + duplicatedPath: "src/new copy.ts", + success: true, + error: null, + requestId: duplicateRequest.requestId, + }, + }), + ); + await expect(duplicatePromise).resolves.toMatchObject({ + duplicatedPath: "src/new copy.ts", + success: true, + error: null, + }); + + const deletePromise = client.deleteFileEntry({ cwd: "/tmp/project", path: "src/new.ts" }); + const deleteRequest = parseSentFrame(mock.sent.at(-1)); + expect(deleteRequest).toMatchObject({ + type: "fs.entry.delete.request", + cwd: "/tmp/project", + path: "src/new.ts", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "fs.entry.delete.response", + payload: { + cwd: "/tmp/project", + path: "src/new.ts", + success: true, + error: null, + requestId: deleteRequest.requestId, + }, + }), + ); + await expect(deletePromise).resolves.toMatchObject({ success: true, error: null }); + + const discardPromise = client.checkoutDiscardChanges("/tmp/project", { paths: ["src"] }); + const discardRequest = parseSentFrame(mock.sent.at(-1)); + expect(discardRequest).toMatchObject({ + type: "checkout.discard_changes.request", + cwd: "/tmp/project", + paths: ["src"], + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "checkout.discard_changes.response", + payload: { + cwd: "/tmp/project", + success: false, + error: { code: "NOT_GIT_REPO", message: "Not a git repository" }, + requestId: discardRequest.requestId, + }, + }), + ); + await expect(discardPromise).resolves.toMatchObject({ + success: false, + error: { code: "NOT_GIT_REPO", message: "Not a git repository" }, + }); +}); + +test("a connection loss rejects an in-flight file context action", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_context_disconnect", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const pending = client.deleteFileEntry({ cwd: "/tmp/project", path: "src/file.ts" }); + mock.triggerClose({ code: 1006, reason: "network lost" }); + + await expect(pending).rejects.toThrow(/network lost|disconnected|closed/i); +}); + test("listDirectory sends a list file explorer request and returns directory entries", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 745212900b..b8d1b26683 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -4329,6 +4329,54 @@ export class DaemonClient { return payload.result; } + async createFileEntry(input: { + cwd: string; + parentPath: string; + name: string; + kind: "file" | "directory"; + }): Promise> { + return this.sendNamespacedCorrelatedSessionRequest<"fs.entry.create.response">({ + message: { type: "fs.entry.create.request", ...input }, + }); + } + + async renameFileEntry(input: { + cwd: string; + path: string; + name: string; + }): Promise> { + return this.sendNamespacedCorrelatedSessionRequest<"fs.entry.rename.response">({ + message: { type: "fs.entry.rename.request", ...input }, + }); + } + + async duplicateFileEntry(input: { + cwd: string; + path: string; + }): Promise> { + return this.sendNamespacedCorrelatedSessionRequest<"fs.entry.duplicate.response">({ + message: { type: "fs.entry.duplicate.request", ...input }, + }); + } + + async deleteFileEntry(input: { + cwd: string; + path: string; + }): Promise> { + return this.sendNamespacedCorrelatedSessionRequest<"fs.entry.delete.response">({ + message: { type: "fs.entry.delete.request", ...input }, + }); + } + + async checkoutDiscardChanges( + cwd: string, + input: { paths: string[] }, + ): Promise> { + return this.sendNamespacedCorrelatedSessionRequest<"checkout.discard_changes.response">({ + message: { type: "checkout.discard_changes.request", cwd, paths: input.paths }, + }); + } + async uploadFile(input: FileUploadInput): Promise { const bytes = asUint8Array(input.bytes); if (!bytes) { diff --git a/packages/protocol/src/messages.file-context-actions.test.ts b/packages/protocol/src/messages.file-context-actions.test.ts new file mode 100644 index 0000000000..0032697b77 --- /dev/null +++ b/packages/protocol/src/messages.file-context-actions.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "vitest"; +import { + CheckoutDiscardChangesRequestSchema, + CheckoutDiscardChangesResponseSchema, + FileEntryCreateRequestSchema, + FileEntryCreateResponseSchema, + FileEntryDeleteRequestSchema, + FileEntryDeleteResponseSchema, + FileEntryDuplicateRequestSchema, + FileEntryDuplicateResponseSchema, + FileEntryRenameRequestSchema, + FileEntryRenameResponseSchema, + ServerInfoStatusPayloadSchema, +} from "./messages.js"; + +describe("file context action messages", () => { + test("keeps context-action capabilities optional and preserves advertised support", () => { + const legacy = ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "server-1", + features: {}, + }); + expect(legacy.features?.fsEntryOps).toBeUndefined(); + expect(legacy.features?.fsEntryDuplicate).toBeUndefined(); + expect(legacy.features?.checkoutDiscardChanges).toBeUndefined(); + + const current = ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "server-1", + features: { + fsEntryOps: true, + fsEntryDuplicate: true, + checkoutDiscardChanges: true, + }, + }); + expect(current.features).toMatchObject({ + fsEntryOps: true, + fsEntryDuplicate: true, + checkoutDiscardChanges: true, + }); + }); + + test("rejects discard requests without a path", () => { + expect(() => + CheckoutDiscardChangesRequestSchema.parse({ + type: "checkout.discard_changes.request", + cwd: "/workspace", + paths: [], + requestId: "discard-empty", + }), + ).toThrow(); + }); + + test("round-trips file entry create requests and responses", () => { + const request = { + type: "fs.entry.create.request", + cwd: "/workspace", + parentPath: "src/components", + name: "button.tsx", + kind: "file", + requestId: "create-1", + }; + expect(FileEntryCreateRequestSchema.parse(request)).toEqual(request); + + const response = { + type: "fs.entry.create.response", + payload: { + cwd: "/workspace", + parentPath: "src/components", + path: "src/components/button.tsx", + success: true, + error: null, + requestId: "create-1", + }, + }; + expect(FileEntryCreateResponseSchema.parse(response)).toEqual(response); + }); + + test("round-trips file entry rename requests and responses", () => { + const request = { + type: "fs.entry.rename.request", + cwd: "/workspace", + path: "src/old.ts", + name: "new.ts", + requestId: "rename-1", + }; + expect(FileEntryRenameRequestSchema.parse(request)).toEqual(request); + + const response = { + type: "fs.entry.rename.response", + payload: { + cwd: "/workspace", + path: "src/old.ts", + renamedPath: "src/new.ts", + success: true, + error: null, + requestId: "rename-1", + }, + }; + expect(FileEntryRenameResponseSchema.parse(response)).toEqual(response); + }); + + test("round-trips file entry duplicate requests and responses", () => { + const request = { + type: "fs.entry.duplicate.request", + cwd: "/workspace", + path: "src/button.tsx", + requestId: "duplicate-1", + }; + expect(FileEntryDuplicateRequestSchema.parse(request)).toEqual(request); + + const response = { + type: "fs.entry.duplicate.response", + payload: { + cwd: "/workspace", + path: "src/button.tsx", + duplicatedPath: "src/button copy.tsx", + success: true, + error: null, + requestId: "duplicate-1", + }, + }; + expect(FileEntryDuplicateResponseSchema.parse(response)).toEqual(response); + }); + + test("round-trips file entry delete requests and responses", () => { + const request = { + type: "fs.entry.delete.request", + cwd: "/workspace", + path: "src/old.ts", + requestId: "delete-1", + }; + expect(FileEntryDeleteRequestSchema.parse(request)).toEqual(request); + + const response = { + type: "fs.entry.delete.response", + payload: { + cwd: "/workspace", + path: "src/old.ts", + success: false, + error: "File or folder no longer exists", + requestId: "delete-1", + }, + }; + expect(FileEntryDeleteResponseSchema.parse(response)).toEqual(response); + }); + + test("round-trips checkout discard requests and responses", () => { + const request = { + type: "checkout.discard_changes.request", + cwd: "/workspace", + paths: ["src", "README.md"], + requestId: "discard-1", + }; + expect(CheckoutDiscardChangesRequestSchema.parse(request)).toEqual(request); + + const response = { + type: "checkout.discard_changes.response", + payload: { + cwd: "/workspace", + success: false, + error: { code: "UNKNOWN", message: "discard failed" }, + requestId: "discard-1", + }, + }; + expect(CheckoutDiscardChangesResponseSchema.parse(response)).toEqual(response); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 4421080c96..aa297cc941 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1777,6 +1777,13 @@ export const CheckoutRefreshRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutDiscardChangesRequestSchema = z.object({ + type: z.literal("checkout.discard_changes.request"), + cwd: z.string(), + paths: z.array(z.string()).min(1), + requestId: z.string(), +}); + export const CheckoutPrCreateRequestSchema = z.object({ type: z.literal("checkout_pr_create_request"), cwd: z.string(), @@ -2204,6 +2211,8 @@ const DiffHunkSchema = z.object({ const ParsedDiffFileSchema = z.object({ path: z.string(), + // COMPAT(diffOldPath): added in v0.3.0, remove gate after 2027-02-09. + oldPath: z.string().optional(), isNew: z.boolean(), isDeleted: z.boolean(), additions: z.number(), @@ -2291,6 +2300,37 @@ export const FileWriteRequestSchema = z.object({ requestId: z.string(), }); +export const FileEntryCreateRequestSchema = z.object({ + type: z.literal("fs.entry.create.request"), + cwd: z.string(), + parentPath: z.string(), + name: z.string(), + kind: z.enum(["file", "directory"]), + requestId: z.string(), +}); + +export const FileEntryRenameRequestSchema = z.object({ + type: z.literal("fs.entry.rename.request"), + cwd: z.string(), + path: z.string(), + name: z.string(), + requestId: z.string(), +}); + +export const FileEntryDuplicateRequestSchema = z.object({ + type: z.literal("fs.entry.duplicate.request"), + cwd: z.string(), + path: z.string(), + requestId: z.string(), +}); + +export const FileEntryDeleteRequestSchema = z.object({ + type: z.literal("fs.entry.delete.request"), + cwd: z.string(), + path: z.string(), + requestId: z.string(), +}); + export const ProjectIconRequestSchema = z.object({ type: z.literal("project_icon_request"), cwd: z.string(), @@ -2661,6 +2701,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPullRequestSchema, CheckoutPushRequestSchema, CheckoutRefreshRequestSchema, + CheckoutDiscardChangesRequestSchema, CheckoutPrCreateRequestSchema, CheckoutPrMergeRequestSchema, CheckoutForgeSetAutoMergeRequestSchema, @@ -2699,6 +2740,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ FileSubscribeRequestSchema, FileUnsubscribeRequestSchema, FileWriteRequestSchema, + FileEntryCreateRequestSchema, + FileEntryRenameRequestSchema, + FileEntryDuplicateRequestSchema, + FileEntryDeleteRequestSchema, ProjectIconRequestSchema, ProjectIconGetRequestSchema, FileDownloadTokenRequestSchema, @@ -3004,6 +3049,12 @@ export const ServerInfoStatusPayloadSchema = z workspaceScriptManagement: z.boolean().optional(), // COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20. projectCustomIcon: z.boolean().optional(), + // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. + fsEntryOps: z.boolean().optional(), + // COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09. + fsEntryDuplicate: z.boolean().optional(), + // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. + checkoutDiscardChanges: z.boolean().optional(), }) .optional(), }) @@ -4493,6 +4544,16 @@ export const CheckoutGithubSetAutoMergeResponseSchema = z.object({ }), }); +export const CheckoutDiscardChangesResponseSchema = z.object({ + type: z.literal("checkout.discard_changes.response"), + payload: z.object({ + cwd: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + export const CheckoutCommitsListResponseSchema = z.object({ type: z.literal("checkout.commits.list.response"), payload: z.object({ @@ -4957,6 +5018,53 @@ export const FileWriteResponseSchema = z.object({ }), }); +export const FileEntryCreateResponseSchema = z.object({ + type: z.literal("fs.entry.create.response"), + payload: z.object({ + cwd: z.string(), + parentPath: z.string(), + path: z.string().nullable(), + success: z.boolean(), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + +export const FileEntryRenameResponseSchema = z.object({ + type: z.literal("fs.entry.rename.response"), + payload: z.object({ + cwd: z.string(), + path: z.string(), + renamedPath: z.string().nullable(), + success: z.boolean(), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + +export const FileEntryDuplicateResponseSchema = z.object({ + type: z.literal("fs.entry.duplicate.response"), + payload: z.object({ + cwd: z.string(), + path: z.string(), + duplicatedPath: z.string().nullable(), + success: z.boolean(), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + +export const FileEntryDeleteResponseSchema = z.object({ + type: z.literal("fs.entry.delete.response"), + payload: z.object({ + cwd: z.string(), + path: z.string(), + success: z.boolean(), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + export const FileUpdateSchema = z.object({ type: z.literal("fs.file.update"), payload: z.object({ @@ -5549,6 +5657,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPullResponseSchema, CheckoutPushResponseSchema, CheckoutRefreshResponseSchema, + CheckoutDiscardChangesResponseSchema, CheckoutPrCreateResponseSchema, CheckoutPrMergeResponseSchema, CheckoutForgeSetAutoMergeResponseSchema, @@ -5576,6 +5685,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FileSubscribeResponseSchema, FileUnsubscribeResponseSchema, FileWriteResponseSchema, + FileEntryCreateResponseSchema, + FileEntryRenameResponseSchema, + FileEntryDuplicateResponseSchema, + FileEntryDeleteResponseSchema, FileUpdateSchema, ProjectIconResponseSchema, ProjectIconGetResponseSchema, @@ -5896,6 +6009,8 @@ export type CheckoutPushRequest = z.infer; export type CheckoutPushResponse = z.infer; export type CheckoutRefreshRequest = z.infer; export type CheckoutRefreshResponse = z.infer; +export type CheckoutDiscardChangesRequest = z.infer; +export type CheckoutDiscardChangesResponse = z.infer; export type CheckoutCommitFile = z.infer; export type CheckoutCommit = z.infer; export type CheckoutCommitsListRequest = z.infer; @@ -5999,6 +6114,14 @@ export type FileUnsubscribeRequest = z.infer; export type FileWriteRequest = z.infer; export type FileWriteResponse = z.infer; +export type FileEntryCreateRequest = z.infer; +export type FileEntryCreateResponse = z.infer; +export type FileEntryRenameRequest = z.infer; +export type FileEntryRenameResponse = z.infer; +export type FileEntryDuplicateRequest = z.infer; +export type FileEntryDuplicateResponse = z.infer; +export type FileEntryDeleteRequest = z.infer; +export type FileEntryDeleteResponse = z.infer; export type FileWriteResult = z.infer; export type FileUpdate = z.infer; export type ProjectIconRequest = z.infer; diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index 07f451e83c..a191a5fabf 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -1,10 +1,25 @@ -import { appendFile, chmod, mkdtemp, rm, stat, truncate, writeFile } from "node:fs/promises"; +import { + appendFile, + chmod, + mkdir, + mkdtemp, + readFile, + rm, + stat, + truncate, + writeFile, +} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { runGitCommand } from "../../utils/run-git-command.js"; import { + createExplorerEntry, + deleteExplorerEntry, + duplicateExplorerEntry, getExplorerFileVersion, readExplorerFile, + renameExplorerEntry, streamExplorerFile, writeExplorerFile, } from "./service.js"; @@ -373,4 +388,202 @@ describe("file explorer service", () => { await rm(root, { recursive: true, force: true }); } }); + + it("creates files and directories, refusing duplicates and separator names", async () => { + const root = await createTempDir("paseo-entry-create-"); + try { + const file = await createExplorerEntry({ + root, + parentPath: ".", + name: "notes.txt", + kind: "file", + }); + expect(file).toEqual({ status: "ok", path: "notes.txt" }); + expect((await stat(path.join(root, "notes.txt"))).isFile()).toBe(true); + + const dir = await createExplorerEntry({ + root, + parentPath: ".", + name: "docs", + kind: "directory", + }); + expect(dir).toEqual({ status: "ok", path: "docs" }); + const nested = await createExplorerEntry({ + root, + parentPath: "docs", + name: "guide.md", + kind: "file", + }); + expect(nested).toEqual({ status: "ok", path: "docs/guide.md" }); + + const duplicate = await createExplorerEntry({ + root, + parentPath: ".", + name: "notes.txt", + kind: "file", + }); + expect(duplicate.status).toBe("error"); + + const traversal = await createExplorerEntry({ + root, + parentPath: ".", + name: "../escape", + kind: "directory", + }); + expect(traversal.status).toBe("error"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("duplicates files and folders with collision-free sibling names", async () => { + const root = await createTempDir("paseo-entry-duplicate-"); + try { + await writeFile(path.join(root, "notes.txt"), "original", "utf8"); + const firstFileCopy = await duplicateExplorerEntry({ + root, + relativePath: "notes.txt", + }); + expect(firstFileCopy).toEqual({ status: "ok", path: "notes copy.txt" }); + expect(await readFile(path.join(root, "notes copy.txt"), "utf8")).toBe("original"); + + const secondFileCopy = await duplicateExplorerEntry({ + root, + relativePath: "notes.txt", + }); + expect(secondFileCopy).toEqual({ status: "ok", path: "notes copy 2.txt" }); + + await mkdir(path.join(root, "docs")); + await writeFile(path.join(root, "docs", "guide.md"), "guide", "utf8"); + const folderCopy = await duplicateExplorerEntry({ root, relativePath: "docs" }); + expect(folderCopy).toEqual({ status: "ok", path: "docs copy" }); + expect(await readFile(path.join(root, "docs copy", "guide.md"), "utf8")).toBe("guide"); + + await expect(duplicateExplorerEntry({ root, relativePath: "." })).resolves.toMatchObject({ + status: "error", + }); + await expect(duplicateExplorerEntry({ root, relativePath: "missing.txt" })).resolves.toEqual({ + status: "error", + error: "File or folder no longer exists", + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("renames tracked entries with Git and untracked entries on the filesystem", async () => { + const root = await createTempDir("paseo-entry-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "tracked.txt"), "tracked", "utf8"); + await mkdir(path.join(root, "tracked-folder")); + await writeFile(path.join(root, "tracked-folder", "inside.txt"), "tracked", "utf8"); + await runGitCommand(["add", "."], { cwd: root }); + await runGitCommand( + ["-c", "user.name=Paseo Test", "-c", "user.email=test@paseo.local", "commit", "-m", "base"], + { cwd: root }, + ); + + const tracked = await renameExplorerEntry({ + root, + relativePath: "tracked.txt", + name: "renamed.txt", + }); + expect(tracked).toEqual({ status: "ok", path: "renamed.txt" }); + expect((await runGitCommand(["status", "--short"], { cwd: root })).stdout.trim()).toBe( + "R tracked.txt -> renamed.txt", + ); + + const trackedFolder = await renameExplorerEntry({ + root, + relativePath: "tracked-folder", + name: "renamed-folder", + }); + expect(trackedFolder).toEqual({ status: "ok", path: "renamed-folder" }); + const gitStatus = (await runGitCommand(["status", "--short"], { cwd: root })).stdout; + expect(gitStatus).toContain("tracked-folder/inside.txt -> renamed-folder/inside.txt"); + + await writeFile(path.join(root, "untracked.txt"), "untracked", "utf8"); + const untracked = await renameExplorerEntry({ + root, + relativePath: "untracked.txt", + name: "moved.txt", + }); + expect(untracked).toEqual({ status: "ok", path: "moved.txt" }); + expect((await stat(path.join(root, "moved.txt"))).isFile()).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("supports case-only renames for tracked and untracked files", async () => { + const root = await createTempDir("paseo-entry-case-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "Tracked.txt"), "tracked", "utf8"); + await writeFile(path.join(root, "Loose.txt"), "untracked", "utf8"); + await runGitCommand(["add", "Tracked.txt"], { cwd: root }); + await runGitCommand( + ["-c", "user.name=Paseo Test", "-c", "user.email=test@paseo.local", "commit", "-m", "base"], + { cwd: root }, + ); + + await expect( + renameExplorerEntry({ root, relativePath: "Tracked.txt", name: "tracked.txt" }), + ).resolves.toEqual({ status: "ok", path: "tracked.txt" }); + expect((await stat(path.join(root, "tracked.txt"))).isFile()).toBe(true); + + await expect( + renameExplorerEntry({ root, relativePath: "Loose.txt", name: "loose.txt" }), + ).resolves.toEqual({ status: "ok", path: "loose.txt" }); + expect((await stat(path.join(root, "loose.txt"))).isFile()).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("refuses invalid renames, existing targets, and the workspace root", async () => { + const root = await createTempDir("paseo-entry-rename-errors-"); + try { + await writeFile(path.join(root, "source.txt"), "source", "utf8"); + await writeFile(path.join(root, "existing.txt"), "existing", "utf8"); + + await expect( + renameExplorerEntry({ root, relativePath: "source.txt", name: "existing.txt" }), + ).resolves.toEqual({ status: "error", error: '"existing.txt" already exists' }); + await expect( + renameExplorerEntry({ root, relativePath: "source.txt", name: "../outside.txt" }), + ).resolves.toEqual({ status: "error", error: "Name cannot contain path separators" }); + await expect( + renameExplorerEntry({ root, relativePath: ".", name: "renamed-root" }), + ).resolves.toEqual({ status: "error", error: "Cannot rename the workspace root" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("deletes files and directories but never the workspace root or outside paths", async () => { + const root = await createTempDir("paseo-entry-delete-"); + try { + await writeFile(path.join(root, "doomed.txt"), "bye", "utf8"); + const removedFile = await deleteExplorerEntry({ root, relativePath: "doomed.txt" }); + expect(removedFile).toEqual({ status: "ok", path: "doomed.txt" }); + await expect(stat(path.join(root, "doomed.txt"))).rejects.toThrow(); + + await createExplorerEntry({ root, parentPath: ".", name: "nested", kind: "directory" }); + await writeFile(path.join(root, "nested", "inner.txt"), "hi", "utf8"); + const removedDir = await deleteExplorerEntry({ root, relativePath: "nested" }); + expect(removedDir).toEqual({ status: "ok", path: "nested" }); + await expect(stat(path.join(root, "nested"))).rejects.toThrow(); + + const rootDelete = await deleteExplorerEntry({ root, relativePath: "." }); + expect(rootDelete.status).toBe("error"); + + await expect( + deleteExplorerEntry({ root, relativePath: "../outside" }), + ).resolves.toMatchObject({ status: "error" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index a33007b596..dbc319c10b 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -3,6 +3,7 @@ import type { FileHandle } from "fs/promises"; import path from "path"; import { randomUUID } from "crypto"; import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; +import { runGitCommand } from "../../utils/run-git-command.js"; export type ExplorerEntryKind = "file" | "directory"; export type ExplorerFileKind = "text" | "image" | "binary"; @@ -572,6 +573,215 @@ export async function getDownloadableFileInfo({ root, relativePath }: ReadFilePa } } +export interface ExplorerCreateEntryParams { + root: string; + parentPath: string; + name: string; + kind: ExplorerEntryKind; +} + +export interface ExplorerRenameEntryParams { + root: string; + relativePath: string; + name: string; +} + +export type ExplorerEntryMutationResult = + | { status: "ok"; path: string } + | { status: "error"; error: string }; + +export async function createExplorerEntry({ + root, + parentPath, + name, + kind, +}: ExplorerCreateEntryParams): Promise { + const trimmedName = name.trim(); + if (!trimmedName || trimmedName === "." || trimmedName === "..") { + return { status: "error", error: "Invalid name" }; + } + if (trimmedName.includes("/") || trimmedName.includes("\\")) { + return { status: "error", error: "Name cannot contain path separators" }; + } + + try { + const parent = await resolveScopedPath({ root, relativePath: parentPath }); + const parentStats = await fs.stat(parent.resolvedPath); + if (!parentStats.isDirectory()) { + return { status: "error", error: "Parent path is not a directory" }; + } + const targetPath = path.join(parent.resolvedPath, trimmedName); + if (kind === "directory") { + await fs.mkdir(targetPath); + } else { + const handle = await fs.open(targetPath, "wx", 0o644); + await handle.close(); + } + return { + status: "ok", + path: normalizeRelativePath({ + root, + targetPath: path.join(parent.requestedPath, trimmedName), + }), + }; + } catch (error) { + if (isEntryExistsError(error)) { + return { status: "error", error: `"${trimmedName}" already exists` }; + } + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function duplicateExplorerEntry({ + root, + relativePath, +}: ReadFileParams): Promise { + try { + const source = await resolveScopedPath({ root, relativePath }); + const realRoot = await fs.realpath(expandUserPath(root)); + if (source.resolvedPath === realRoot) { + return { status: "error", error: "Cannot duplicate the workspace root" }; + } + + const stats = await fs.lstat(source.requestedPath); + const sourceName = path.basename(source.requestedPath); + const extension = stats.isDirectory() ? "" : path.extname(sourceName); + const baseName = extension ? sourceName.slice(0, -extension.length) : sourceName; + let targetPath = ""; + for (let copyNumber = 1; ; copyNumber += 1) { + const suffix = copyNumber === 1 ? " copy" : ` copy ${copyNumber}`; + targetPath = path.join( + path.dirname(source.requestedPath), + `${baseName}${suffix}${extension}`, + ); + try { + await fs.lstat(targetPath); + } catch (error) { + if (isMissingEntryError(error)) { + break; + } + throw error; + } + } + + await fs.cp(source.requestedPath, targetPath, { + recursive: stats.isDirectory(), + errorOnExist: true, + force: false, + preserveTimestamps: true, + }); + return { status: "ok", path: normalizeRelativePath({ root, targetPath }) }; + } catch (error) { + if (isMissingEntryError(error)) { + return { status: "error", error: "File or folder no longer exists" }; + } + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function renameExplorerEntry({ + root, + relativePath, + name, +}: ExplorerRenameEntryParams): Promise { + const trimmedName = name.trim(); + if (!trimmedName || trimmedName === "." || trimmedName === "..") { + return { status: "error", error: "Invalid name" }; + } + if (trimmedName.includes("/") || trimmedName.includes("\\")) { + return { status: "error", error: "Name cannot contain path separators" }; + } + + try { + const source = await resolveScopedPath({ root, relativePath }); + const realRoot = await fs.realpath(expandUserPath(root)); + if (source.resolvedPath === realRoot) { + return { status: "error", error: "Cannot rename the workspace root" }; + } + + const targetPath = path.join(path.dirname(source.requestedPath), trimmedName); + const sourceStats = await fs.lstat(source.requestedPath); + const targetStats = await fs.lstat(targetPath).catch((error: unknown) => { + if (isMissingEntryError(error)) { + return null; + } + throw error; + }); + if ( + targetStats && + (targetStats.dev !== sourceStats.dev || targetStats.ino !== sourceStats.ino) + ) { + return { status: "error", error: `"${trimmedName}" already exists` }; + } + + const sourcePath = normalizeRelativePath({ root, targetPath: source.requestedPath }); + const renamedPath = normalizeRelativePath({ root, targetPath }); + if (sourcePath === renamedPath) { + return { status: "ok", path: renamedPath }; + } + const repository = await runGitCommand(["rev-parse", "--is-inside-work-tree"], { + cwd: realRoot, + acceptExitCodes: [0, 128], + }); + const tracked = + repository.exitCode === 0 + ? await runGitCommand(["ls-files", "-z", "--", sourcePath], { cwd: realRoot }) + : null; + if (tracked?.stdout) { + await runGitCommand(["mv", "--", sourcePath, renamedPath], { cwd: realRoot }); + } else { + await fs.rename(source.requestedPath, targetPath); + } + return { status: "ok", path: renamedPath }; + } catch (error) { + if (isEntryExistsError(error)) { + return { status: "error", error: `"${trimmedName}" already exists` }; + } + if (isMissingEntryError(error)) { + return { status: "error", error: "File or folder no longer exists" }; + } + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function deleteExplorerEntry({ + root, + relativePath, +}: ReadFileParams): Promise { + try { + const scoped = await resolveScopedPath({ root, relativePath }); + const realRoot = await fs.realpath(expandUserPath(root)); + if (scoped.resolvedPath === realRoot) { + return { status: "error", error: "Cannot delete the workspace root" }; + } + // Remove the requested path, not the realpath: deleting a symlink must + // remove the link, never its target. lstat keeps the same guarantee. + const stats = await fs.lstat(scoped.requestedPath); + await fs.rm(scoped.requestedPath, { + recursive: stats.isDirectory(), + force: false, + }); + return { + status: "ok", + path: normalizeRelativePath({ root, targetPath: scoped.requestedPath }), + }; + } catch (error) { + if (isMissingEntryError(error)) { + return { status: "error", error: "File or folder no longer exists" }; + } + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } +} + +function isEntryExistsError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as NodeJS.ErrnoException).code === "EEXIST" + ); +} + async function resolveScopedPath({ root, relativePath = ".", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f67e14b12a..30a90f5527 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2106,6 +2106,8 @@ export class Session { return this.checkoutSession.handleCheckoutPushRequest(msg); case "checkout.refresh.request": return this.checkoutSession.handleRefreshRequest(msg); + case "checkout.discard_changes.request": + return this.checkoutSession.handleCheckoutDiscardChangesRequest(msg); case "checkout_pr_create_request": return this.checkoutSession.handleCheckoutPrCreateRequest(msg); case "checkout_pr_merge_request": @@ -2196,6 +2198,14 @@ export class Session { return undefined; case "fs.file.write.request": return this.workspaceFilesSession.handleFileWriteRequest(msg); + case "fs.entry.create.request": + return this.workspaceFilesSession.handleFileEntryCreateRequest(msg); + case "fs.entry.rename.request": + return this.workspaceFilesSession.handleFileEntryRenameRequest(msg); + case "fs.entry.duplicate.request": + return this.workspaceFilesSession.handleFileEntryDuplicateRequest(msg); + case "fs.entry.delete.request": + return this.workspaceFilesSession.handleFileEntryDeleteRequest(msg); case "project_icon_request": return this.workspaceFilesSession.handleProjectIconRequest(msg); case "project.icon.get.request": diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index bebc5e7a99..94e80cc327 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -1,3 +1,7 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import pino from "pino"; import { @@ -439,6 +443,88 @@ describe("CheckoutSession", () => { }); }); + describe("discard changes", () => { + it("discards the paths, notifies git mutation, refreshes diffs, and confirms success", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "checkout-session-discard-")); + const cwd = realpathSync(tempDir); + try { + execFileSync("git", ["init", "-q"], { cwd }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd }); + writeFileSync(join(cwd, "file.txt"), "original\n"); + execFileSync("git", ["add", "file.txt"], { cwd }); + execFileSync("git", ["commit", "-qm", "initial"], { cwd }); + writeFileSync(join(cwd, "file.txt"), "changed\n"); + + const { subscriber, refreshedCwds } = createFakeDiffSubscriber({ + cwd: "", + files: [], + error: null, + }); + const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({ diff: subscriber }); + + await checkout.handleCheckoutDiscardChangesRequest({ + type: "checkout.discard_changes.request", + cwd, + paths: ["file.txt"], + requestId: "discard-1", + }); + + expect(readFileSync(join(cwd, "file.txt"), "utf8").replaceAll("\r\n", "\n")).toBe( + "original\n", + ); + expect(gitMutationCalls.notifyGitMutation).toEqual([ + { cwd, reason: "discard-changes", options: undefined }, + ]); + expect(refreshedCwds).toEqual([cwd]); + expect(emitted).toEqual([ + { + type: "checkout.discard_changes.response", + payload: { cwd, success: true, error: null, requestId: "discard-1" }, + }, + ]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("returns the checkout error without notifying git or refreshing diffs", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "checkout-session-discard-error-")); + const cwd = realpathSync(tempDir); + try { + const { subscriber, refreshedCwds } = createFakeDiffSubscriber({ + cwd: "", + files: [], + error: null, + }); + const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({ diff: subscriber }); + + await checkout.handleCheckoutDiscardChangesRequest({ + type: "checkout.discard_changes.request", + cwd, + paths: ["file.txt"], + requestId: "discard-error", + }); + + expect(gitMutationCalls.notifyGitMutation).toEqual([]); + expect(refreshedCwds).toEqual([]); + expect(emitted).toEqual([ + { + type: "checkout.discard_changes.response", + payload: { + cwd, + success: false, + error: { code: "NOT_GIT_REPO", message: `Not a git repository: ${cwd}` }, + requestId: "discard-error", + }, + }, + ]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + describe("diff subscriptions", () => { it("opens a subscription, streams updates tagged with the id, and tears down on unsubscribe", async () => { const { subscriber, subscriptions } = createFakeDiffSubscriber({ diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 091ee620f7..e349f8edd7 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -42,6 +42,7 @@ import type { import { commitChanges, createPullRequest, + discardChanges, forgeAuthStateFromError, isForgeAuthError, mergeFromBase, @@ -605,6 +606,26 @@ export class CheckoutSession { } } + async handleCheckoutDiscardChangesRequest( + msg: Extract, + ): Promise { + const { cwd, paths, requestId } = msg; + try { + await discardChanges(cwd, paths); + await this.gitMutation.notifyGitMutation(cwd, "discard-changes"); + this.scheduleDiffRefresh(cwd); + this.host.emit({ + type: "checkout.discard_changes.response", + payload: { cwd, success: true, error: null, requestId }, + }); + } catch (error) { + this.host.emit({ + type: "checkout.discard_changes.response", + payload: { cwd, success: false, error: toCheckoutError(error), requestId }, + }); + } + } + async handleStashSaveRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/session/files/workspace-files-session.test.ts b/packages/server/src/server/session/files/workspace-files-session.test.ts index 0b09c87743..d176e03bf8 100644 --- a/packages/server/src/server/session/files/workspace-files-session.test.ts +++ b/packages/server/src/server/session/files/workspace-files-session.test.ts @@ -1,4 +1,11 @@ -import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -74,6 +81,227 @@ function uploadFrame(args: Parameters[0]): FileT } describe("WorkspaceFilesSession", () => { + test("creates an entry and emits the complete success response", async () => { + const cwd = makeDir("workspace-files-create-"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryCreateRequest({ + type: "fs.entry.create.request", + cwd, + parentPath: ".", + name: "notes.txt", + kind: "file", + requestId: "req-create", + }); + + expect(existsSync(join(cwd, "notes.txt"))).toBe(true); + expect(emitted).toEqual([ + { + type: "fs.entry.create.response", + payload: { + cwd, + parentPath: ".", + path: "notes.txt", + success: true, + error: null, + requestId: "req-create", + }, + }, + ]); + }); + + test("passes entry creation errors through in the response", async () => { + const cwd = makeDir("workspace-files-create-error-"); + writeFileSync(join(cwd, "notes.txt"), "existing"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryCreateRequest({ + type: "fs.entry.create.request", + cwd, + parentPath: ".", + name: "notes.txt", + kind: "file", + requestId: "req-create-error", + }); + + expect(emitted).toEqual([ + { + type: "fs.entry.create.response", + payload: { + cwd, + parentPath: ".", + path: null, + success: false, + error: '"notes.txt" already exists', + requestId: "req-create-error", + }, + }, + ]); + }); + + test("renames an entry and emits the resulting path", async () => { + const cwd = makeDir("workspace-files-rename-"); + writeFileSync(join(cwd, "notes.txt"), "rename me"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryRenameRequest({ + type: "fs.entry.rename.request", + cwd, + path: "notes.txt", + name: "renamed.txt", + requestId: "req-rename", + }); + + expect(existsSync(join(cwd, "notes.txt"))).toBe(false); + expect(existsSync(join(cwd, "renamed.txt"))).toBe(true); + expect(emitted).toEqual([ + { + type: "fs.entry.rename.response", + payload: { + cwd, + path: "notes.txt", + renamedPath: "renamed.txt", + success: true, + error: null, + requestId: "req-rename", + }, + }, + ]); + }); + + test("passes entry rename errors through in the response", async () => { + const cwd = makeDir("workspace-files-rename-error-"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryRenameRequest({ + type: "fs.entry.rename.request", + cwd, + path: "missing.txt", + name: "renamed.txt", + requestId: "req-rename-error", + }); + + expect(emitted).toEqual([ + { + type: "fs.entry.rename.response", + payload: { + cwd, + path: "missing.txt", + renamedPath: null, + success: false, + error: "File or folder no longer exists", + requestId: "req-rename-error", + }, + }, + ]); + }); + + test("duplicates an entry and emits the resulting path", async () => { + const cwd = makeDir("workspace-files-duplicate-"); + writeFileSync(join(cwd, "notes.txt"), "duplicate me"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryDuplicateRequest({ + type: "fs.entry.duplicate.request", + cwd, + path: "notes.txt", + requestId: "req-duplicate", + }); + + expect(readFileSync(join(cwd, "notes copy.txt"), "utf8")).toBe("duplicate me"); + expect(emitted).toEqual([ + { + type: "fs.entry.duplicate.response", + payload: { + cwd, + path: "notes.txt", + duplicatedPath: "notes copy.txt", + success: true, + error: null, + requestId: "req-duplicate", + }, + }, + ]); + }); + + test("passes entry duplication errors through in the response", async () => { + const cwd = makeDir("workspace-files-duplicate-error-"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryDuplicateRequest({ + type: "fs.entry.duplicate.request", + cwd, + path: "missing.txt", + requestId: "req-duplicate-error", + }); + + expect(emitted).toEqual([ + { + type: "fs.entry.duplicate.response", + payload: { + cwd, + path: "missing.txt", + duplicatedPath: null, + success: false, + error: "File or folder no longer exists", + requestId: "req-duplicate-error", + }, + }, + ]); + }); + + test("deletes an entry and emits the complete success response", async () => { + const cwd = makeDir("workspace-files-delete-"); + writeFileSync(join(cwd, "notes.txt"), "delete me"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryDeleteRequest({ + type: "fs.entry.delete.request", + cwd, + path: "notes.txt", + requestId: "req-delete", + }); + + expect(existsSync(join(cwd, "notes.txt"))).toBe(false); + expect(emitted).toEqual([ + { + type: "fs.entry.delete.response", + payload: { + cwd, + path: "notes.txt", + success: true, + error: null, + requestId: "req-delete", + }, + }, + ]); + }); + + test("passes entry deletion errors through in the response", async () => { + const cwd = makeDir("workspace-files-delete-error-"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryDeleteRequest({ + type: "fs.entry.delete.request", + cwd, + path: "missing.txt", + requestId: "req-delete-error", + }); + + expect(emitted).toEqual([ + { + type: "fs.entry.delete.response", + payload: { + cwd, + path: "missing.txt", + success: false, + error: "File or folder no longer exists", + requestId: "req-delete-error", + }, + }, + ]); + }); + test("lists directory entries", async () => { const cwd = makeDir("workspace-files-list-"); writeFileSync(join(cwd, "a.txt"), "alpha"); diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index dd7d7d04d4..c74de3acae 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -7,6 +7,10 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import type { FileDownloadTokenRequest, + FileEntryCreateRequest, + FileEntryDeleteRequest, + FileEntryDuplicateRequest, + FileEntryRenameRequest, FileExplorerRequest, FileUploadRequest, FileSubscribeRequest, @@ -18,9 +22,13 @@ import type { import { FileUploadStore } from "../../file-upload/index.js"; import type { DownloadTokenStore } from "../../file-download/token-store.js"; import { + createExplorerEntry, + deleteExplorerEntry, + duplicateExplorerEntry, getDownloadableFileInfo, listDirectoryEntries, readExplorerFile, + renameExplorerEntry, streamExplorerFile, writeExplorerFile, } from "../../file-explorer/service.js"; @@ -131,6 +139,80 @@ export class WorkspaceFilesSession { }); } + async handleFileEntryCreateRequest(request: FileEntryCreateRequest): Promise { + const result = await createExplorerEntry({ + root: request.cwd, + parentPath: request.parentPath, + name: request.name, + kind: request.kind, + }); + this.host.emit({ + type: "fs.entry.create.response", + payload: { + cwd: request.cwd, + parentPath: request.parentPath, + path: result.status === "ok" ? result.path : null, + success: result.status === "ok", + error: result.status === "ok" ? null : result.error, + requestId: request.requestId, + }, + }); + } + + async handleFileEntryRenameRequest(request: FileEntryRenameRequest): Promise { + const result = await renameExplorerEntry({ + root: request.cwd, + relativePath: request.path, + name: request.name, + }); + this.host.emit({ + type: "fs.entry.rename.response", + payload: { + cwd: request.cwd, + path: request.path, + renamedPath: result.status === "ok" ? result.path : null, + success: result.status === "ok", + error: result.status === "ok" ? null : result.error, + requestId: request.requestId, + }, + }); + } + + async handleFileEntryDuplicateRequest(request: FileEntryDuplicateRequest): Promise { + const result = await duplicateExplorerEntry({ + root: request.cwd, + relativePath: request.path, + }); + this.host.emit({ + type: "fs.entry.duplicate.response", + payload: { + cwd: request.cwd, + path: request.path, + duplicatedPath: result.status === "ok" ? result.path : null, + success: result.status === "ok", + error: result.status === "ok" ? null : result.error, + requestId: request.requestId, + }, + }); + } + + async handleFileEntryDeleteRequest(request: FileEntryDeleteRequest): Promise { + const result = await deleteExplorerEntry({ + root: request.cwd, + relativePath: request.path, + }); + this.host.emit({ + type: "fs.entry.delete.response", + payload: { + cwd: request.cwd, + path: request.path, + success: result.status === "ok", + error: result.status === "ok" ? null : result.error, + requestId: request.requestId, + }, + }); + } + dispose(): void { for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe(); this.fileSubscriptions.clear(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index cb01f18dcb..ff39bedae3 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1638,6 +1638,12 @@ export class VoiceAssistantWebSocketServer { workspaceScriptManagement: true, // COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20. projectCustomIcon: true, + // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. + fsEntryOps: true, + // COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09. + fsEntryDuplicate: true, + // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. + checkoutDiscardChanges: true, }, }; } diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 0c6978407a..ba5a7fde16 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -20,6 +20,7 @@ import { __resetPullRequestStatusCacheForTests, __setPullRequestStatusCacheTtlForTests, commitAll, + discardChanges, CHECKOUT_DIFF_MAX_STRUCTURED_BYTES, createPullRequest, getCachedCheckoutShortstat, @@ -108,6 +109,10 @@ function initRepo(): { tempDir: string; repoDir: string } { return { tempDir, repoDir }; } +function readTextFile(path: string): string { + return readFileSync(path, "utf8").replaceAll("\r\n", "\n"); +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -516,6 +521,19 @@ describe("checkout git utilities", () => { expect(message).toBe("update file"); }); + it("includes both paths for a staged rename in structured diffs", async () => { + execFileSync("git", ["mv", "file.txt", "renamed.txt"], { cwd: repoDir }); + + const diff = await getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: true, + }); + + expect(diff.structured).toContainEqual( + expect.objectContaining({ path: "renamed.txt", oldPath: "file.txt" }), + ); + }); + it("reads the origin URL once when collecting facts for an origin-tracking branch", async () => { setupRemoteTrackingMain(repoDir, tempDir); @@ -3698,3 +3716,132 @@ const x = 1; }); }); }); + +describe("discardChanges", () => { + it("discards staged and unstaged modifications, deletions, and untracked files", async () => { + const { tempDir, repoDir } = initRepo(); + try { + writeFileSync(join(repoDir, "file.txt"), "changed\n"); + writeFileSync(join(repoDir, "staged.txt"), "staged\n"); + execFileSync("git", ["add", "staged.txt"], { cwd: repoDir }); + mkdirSync(join(repoDir, "junk"), { recursive: true }); + writeFileSync(join(repoDir, "junk", "scratch.txt"), "scratch\n"); + + await discardChanges(repoDir, ["file.txt", "staged.txt", "junk"]); + + expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n"); + expect(existsSync(join(repoDir, "staged.txt"))).toBe(false); + expect(existsSync(join(repoDir, "junk"))).toBe(false); + const status = execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }) + .toString() + .trim(); + expect(status).toBe(""); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("restores both sides of a staged rename", async () => { + const { tempDir, repoDir } = initRepo(); + try { + execFileSync("git", ["mv", "file.txt", "renamed.txt"], { cwd: repoDir }); + + await discardChanges(repoDir, ["file.txt", "renamed.txt"]); + + expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n"); + expect(existsSync(join(repoDir, "renamed.txt"))).toBe(false); + expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe(""); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("unstages and removes files in a repository with an unborn HEAD", async () => { + const tempDir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-git-unborn-"))); + const repoDir = join(tempDir, "repo"); + mkdirSync(repoDir, { recursive: true }); + try { + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir }); + writeFileSync(join(repoDir, "new.txt"), "new\n"); + execFileSync("git", ["add", "new.txt"], { cwd: repoDir }); + + await discardChanges(repoDir, ["new.txt"]); + + expect(existsSync(join(repoDir, "new.txt"))).toBe(false); + expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe(""); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("discards a nested folder pathspec without touching sibling changes", async () => { + const { tempDir, repoDir } = initRepo(); + try { + mkdirSync(join(repoDir, "nested")); + writeFileSync(join(repoDir, "nested", "tracked.txt"), "original\n"); + execFileSync("git", ["add", "nested/tracked.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested"], { + cwd: repoDir, + }); + writeFileSync(join(repoDir, "nested", "tracked.txt"), "changed\n"); + writeFileSync(join(repoDir, "nested", "untracked.txt"), "remove\n"); + writeFileSync(join(repoDir, "outside.txt"), "keep\n"); + + await discardChanges(repoDir, ["nested"]); + + expect(readTextFile(join(repoDir, "nested", "tracked.txt"))).toBe("original\n"); + expect(existsSync(join(repoDir, "nested", "untracked.txt"))).toBe(false); + expect(readFileSync(join(repoDir, "outside.txt"), "utf8")).toBe("keep\n"); + expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe( + "?? outside.txt\n", + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("restores a staged deletion", async () => { + const { tempDir, repoDir } = initRepo(); + try { + execFileSync("git", ["rm", "file.txt"], { cwd: repoDir }); + await discardChanges(repoDir, ["file.txt"]); + expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("leaves files outside the given pathspecs untouched", async () => { + const { tempDir, repoDir } = initRepo(); + try { + writeFileSync(join(repoDir, "file.txt"), "changed\n"); + writeFileSync(join(repoDir, "other.txt"), "keep\n"); + await discardChanges(repoDir, ["file.txt"]); + expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n"); + expect(readFileSync(join(repoDir, "other.txt"), "utf8")).toBe("keep\n"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("treats requested filenames as literal pathspecs", async () => { + const { tempDir, repoDir } = initRepo(); + try { + writeFileSync(join(repoDir, "foo[ab].txt"), "literal original\n"); + writeFileSync(join(repoDir, "fooa.txt"), "sibling original\n"); + execFileSync("git", ["add", "foo[ab].txt", "fooa.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add pathspec files"], { + cwd: repoDir, + }); + writeFileSync(join(repoDir, "foo[ab].txt"), "literal changed\n"); + writeFileSync(join(repoDir, "fooa.txt"), "sibling changed\n"); + + await discardChanges(repoDir, ["foo[ab].txt"]); + + expect(readTextFile(join(repoDir, "foo[ab].txt"))).toBe("literal original\n"); + expect(readTextFile(join(repoDir, "fooa.txt"))).toBe("sibling changed\n"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 468600de72..16cb20775b 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -61,8 +61,10 @@ export type GitMutationRefreshReason = | "create-branch" | "stash-push" | "stash-pop" + | "discard-changes" | "create-worktree"; +const DISCARD_CHANGES_TIMEOUT_MS = 120_000; const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000; const PULL_REQUEST_STATUS_CACHE_MAX = 1_000; const DEFAULT_SHORTSTAT_CACHE_TTL_MS = 15_000; @@ -2084,6 +2086,7 @@ function buildPlaceholderParsedDiffFile( ): ParsedDiffFile { return { path: change.path, + ...(change.oldPath ? { oldPath: change.oldPath } : {}), isNew: change.isNew, isDeleted: change.isDeleted, additions: options.stat?.additions ?? 0, @@ -2918,6 +2921,7 @@ async function buildHighlightedTrackedDiffFile(input: { return { ...highlightedFile, path: change.path, + ...(change.oldPath ? { oldPath: change.oldPath } : {}), isNew: change.isNew, isDeleted: change.isDeleted, status: "ok", @@ -2993,6 +2997,7 @@ async function appendStructuredTrackedDiffs( const file = { path: change.path, + ...(change.oldPath ? { oldPath: change.oldPath } : {}), isNew: change.isNew, isDeleted: change.isDeleted, additions: stat?.additions ?? 0, @@ -3321,6 +3326,71 @@ export async function commitAll(cwd: string, message: string): Promise { await commitChanges(cwd, { message, addAll: true }); } +export async function discardChanges(cwd: string, pathspecs: string[]): Promise { + await requireGitRepo(cwd); + if (pathspecs.length === 0) { + return; + } + try { + await runGitCommand(["--literal-pathspecs", "reset", "-q", "HEAD", "--", ...pathspecs], { + cwd, + timeout: DISCARD_CHANGES_TIMEOUT_MS, + }); + } catch { + // Why: unborn HEAD has no commit for reset, so remove the paths directly from the index. + await runGitCommand( + ["--literal-pathspecs", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...pathspecs], + { + cwd, + timeout: DISCARD_CHANGES_TIMEOUT_MS, + }, + ); + } + // With everything unstaged, the remaining state is only worktree + // modifications/deletions (restore from the index) and untracked files + // (clean). Classify from porcelain so each path gets the command that + // actually applies to it. + const status = await runGitCommand( + ["--literal-pathspecs", "status", "--porcelain=v1", "-z", "--", ...pathspecs], + { + cwd, + timeout: DISCARD_CHANGES_TIMEOUT_MS, + }, + ); + const tracked: string[] = []; + const untracked: string[] = []; + const tokens = status.stdout.split("\0"); + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + if (token.length < 4) { + continue; + } + const state = token.slice(0, 2); + const filePath = token.slice(3); + if (state.startsWith("R") || state.startsWith("C")) { + // Rename/copy entries carry the source path as the next NUL token. + index += 1; + } + if (state === "??") { + untracked.push(filePath); + continue; + } + tracked.push(filePath); + } + if (tracked.length > 0) { + await runGitCommand(["--literal-pathspecs", "checkout", "-q", "--", ...tracked], { + cwd, + timeout: DISCARD_CHANGES_TIMEOUT_MS, + }); + } + if (untracked.length > 0) { + await runGitCommand(["--literal-pathspecs", "clean", "-fd", "-q", "--", ...untracked], { + cwd, + timeout: DISCARD_CHANGES_TIMEOUT_MS, + }); + } +} + interface DetectMergeToBaseConflictInput { operationCwd: string; error: unknown; From c5d5441c9c1c0cc6572d88212da74495acab400a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 20:03:28 +0200 Subject: [PATCH 04/73] Preserve dictation recordings across WebSocket interruptions (#3159) * fix(app): preserve dictation audio across transport failures Keep captured PCM in the existing local segment buffer when a WebSocket send fails. Finish only after server acknowledgements and fail an idle drain so the retry path can resend the complete recording. * fix(app): dispose dictation stream sender cleanly --- .../dictation/dictation-stream-sender.test.ts | 192 +++++++++++++++++- .../src/dictation/dictation-stream-sender.ts | 135 ++++++++++-- packages/app/src/hooks/use-dictation.ts | 1 + 3 files changed, 302 insertions(+), 26 deletions(-) diff --git a/packages/app/src/dictation/dictation-stream-sender.test.ts b/packages/app/src/dictation/dictation-stream-sender.test.ts index 0684dd2b12..1af06997a5 100644 --- a/packages/app/src/dictation/dictation-stream-sender.test.ts +++ b/packages/app/src/dictation/dictation-stream-sender.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it } from "vitest"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; - +import { describe, expect, it, vi } from "vitest"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { DictationStreamSender } from "@/dictation/dictation-stream-sender"; interface FakeFinish { @@ -18,19 +17,66 @@ interface FakeChunk { format: string; } +interface FakeAck { + type: "dictation_stream_ack"; + payload: { + dictationId: string; + ackSeq: number; + }; +} + +type FakeRawMessageListener = (message: SessionOutboundMessage) => void; + class FakeDaemonClient { isConnected = true; + autoAck = true; + throwOnNextChunk = false; + disconnectOnChunkError = false; starts: FakeStart[] = []; chunks: FakeChunk[] = []; finishes: FakeFinish[] = []; cancels: string[] = []; + private readonly rawMessageListeners = new Set(); + + get rawMessageListenerCount(): number { + return this.rawMessageListeners.size; + } async startDictationStream(dictationId: string, format: string): Promise { this.starts.push({ dictationId, format }); } sendDictationStreamChunk(dictationId: string, seq: number, audio: string, format: string): void { + if (this.throwOnNextChunk) { + this.throwOnNextChunk = false; + if (this.disconnectOnChunkError) { + this.isConnected = false; + } + throw new Error("WebSocket is closing"); + } this.chunks.push({ dictationId, seq, audio, format }); + if (this.autoAck) { + this.emitAck(dictationId, seq); + } + } + + subscribeRawMessages(handler: FakeRawMessageListener): () => void { + this.rawMessageListeners.add(handler); + return () => this.rawMessageListeners.delete(handler); + } + + setConnected(isConnected: boolean): void { + this.isConnected = isConnected; + } + + emitAck(dictationId: string, ackSeq: number): void { + const message: FakeAck = { + type: "dictation_stream_ack", + payload: { dictationId, ackSeq }, + }; + for (const listener of this.rawMessageListeners) { + listener(message); + } } async finishDictationStream( @@ -51,12 +97,22 @@ const tick = async () => { await Promise.resolve(); }; +const promiseOutcome = async (promise: Promise, timeoutMs: number) => { + return Promise.race([ + promise.then( + () => "resolved" as const, + () => "rejected" as const, + ), + new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), timeoutMs)), + ]); +}; + describe("DictationStreamSender", () => { it("enqueues segments and sends them after stream start", async () => { const client = new FakeDaemonClient(); const ids = ["d1"]; const sender = new DictationStreamSender({ - client: client as unknown as DaemonClient, + client, format: "audio/pcm;rate=16000;bits=16", createDictationId: () => ids.shift() ?? "dX", }); @@ -77,7 +133,7 @@ describe("DictationStreamSender", () => { const client = new FakeDaemonClient(); const ids = ["d1", "d2"]; const sender = new DictationStreamSender({ - client: client as unknown as DaemonClient, + client, format: "audio/pcm;rate=16000;bits=16", createDictationId: () => ids.shift() ?? "dX", }); @@ -100,7 +156,7 @@ describe("DictationStreamSender", () => { const client = new FakeDaemonClient(); const ids = ["d1"]; const sender = new DictationStreamSender({ - client: client as unknown as DaemonClient, + client, format: "audio/pcm;rate=16000;bits=16", createDictationId: () => ids.shift() ?? "dX", }); @@ -121,7 +177,7 @@ describe("DictationStreamSender", () => { client.isConnected = false; const ids = ["d1"]; const sender = new DictationStreamSender({ - client: client as unknown as DaemonClient, + client, format: "audio/pcm;rate=16000;bits=16", createDictationId: () => ids.shift() ?? "dX", }); @@ -138,11 +194,131 @@ describe("DictationStreamSender", () => { expect(client.chunks.map((c) => c.seq)).toEqual([0, 1]); }); + it("keeps capture safe and buffers later segments when a chunk send throws", async () => { + const client = new FakeDaemonClient(); + const sender = new DictationStreamSender({ + client, + format: "audio/pcm;rate=16000;bits=16", + createDictationId: () => "d1", + }); + + sender.enqueueSegment("before-outage"); + await tick(); + + client.throwOnNextChunk = true; + client.disconnectOnChunkError = true; + + expect(() => sender.enqueueSegment("during-outage")).not.toThrow(); + expect(() => sender.enqueueSegment("after-outage")).not.toThrow(); + expect(sender.getSegmentCount()).toBe(3); + }); + + it("removes its raw-message listener when disposed", () => { + const client = new FakeDaemonClient(); + const sender = new DictationStreamSender({ + client, + format: "audio/pcm;rate=16000;bits=16", + }); + + expect(client.rawMessageListenerCount).toBe(1); + sender.dispose(); + expect(client.rawMessageListenerCount).toBe(0); + }); + + it("replays segments captured before, during, and after an outage from seq 0", async () => { + const client = new FakeDaemonClient(); + const ids = ["d1", "d2"]; + const sender = new DictationStreamSender({ + client, + format: "audio/pcm;rate=16000;bits=16", + createDictationId: () => ids.shift() ?? "dX", + }); + + sender.enqueueSegment("before-outage"); + await tick(); + + client.throwOnNextChunk = true; + client.disconnectOnChunkError = true; + expect(() => sender.enqueueSegment("during-outage")).not.toThrow(); + sender.enqueueSegment("after-outage"); + + client.setConnected(true); + await sender.restartStream("reconnect"); + + const replay = client.chunks + .filter((chunk) => chunk.dictationId === "d2") + .map((chunk) => [chunk.seq, chunk.audio]); + expect(replay).toEqual([ + [0, "before-outage"], + [1, "during-outage"], + [2, "after-outage"], + ]); + }); + + it("fails finish after a transport transition without losing buffered audio", async () => { + vi.useFakeTimers(); + try { + const client = new FakeDaemonClient(); + client.setConnected(false); + const ids = ["d1", "d2"]; + const sender = new DictationStreamSender({ + client, + format: "audio/pcm;rate=16000;bits=16", + createDictationId: () => ids.shift() ?? "dX", + }); + + for (let seq = 0; seq < 129; seq += 1) { + sender.enqueueSegment(`segment-${seq}`); + } + + client.setConnected(true); + const finish = sender.finish(sender.getFinalSeq()); + const outcome = promiseOutcome(finish, 1); + + await tick(); + client.setConnected(false); + await vi.advanceTimersByTimeAsync(1); + + await expect(outcome).resolves.toBe("rejected"); + expect(sender.getSegmentCount()).toBe(129); + expect(sender.getFinalSeq()).toBe(128); + + client.setConnected(true); + sender.resetStreamForReplay(); + await sender.restartStream("retry"); + await expect(sender.finish(128)).resolves.toEqual({ dictationId: "d2", text: "ok" }); + expect(client.chunks.filter((chunk) => chunk.dictationId === "d2")).toHaveLength(129); + } finally { + vi.useRealTimers(); + } + }); + + it("waits for server delivery acknowledgement before finishing", async () => { + const client = new FakeDaemonClient(); + client.autoAck = false; + const sender = new DictationStreamSender({ + client, + format: "audio/pcm;rate=16000;bits=16", + createDictationId: () => "d1", + }); + + sender.enqueueSegment("seg0"); + sender.enqueueSegment("seg1"); + await tick(); + + const finish = sender.finish(1); + await tick(); + expect(client.finishes).toEqual([]); + + client.emitAck("d1", 1); + await expect(finish).resolves.toEqual({ dictationId: "d1", text: "ok" }); + }); + it("does not replay long buffered native dictation in one synchronous burst", async () => { const client = new FakeDaemonClient(); client.isConnected = false; const sender = new DictationStreamSender({ - client: client as unknown as DaemonClient, + client, format: "audio/pcm;rate=16000;bits=16", createDictationId: () => "d1", }); diff --git a/packages/app/src/dictation/dictation-stream-sender.ts b/packages/app/src/dictation/dictation-stream-sender.ts index e4562e0b98..2388458843 100644 --- a/packages/app/src/dictation/dictation-stream-sender.ts +++ b/packages/app/src/dictation/dictation-stream-sender.ts @@ -1,13 +1,32 @@ import { generateMessageId } from "@/types/stream"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { i18n } from "@/i18n/i18next"; const MAX_CHUNKS_PER_FLUSH_TURN = 128; +const DICTATION_DRAIN_IDLE_TIMEOUT_MS = 30_000; const waitForNextFlushTurn = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); +type DictationStreamAckMessage = Extract; + +interface DrainWaitResult { + kind: "signaled" | "timed-out"; +} + +export interface DictationStreamClient { + readonly isConnected: boolean; + startDictationStream(dictationId: string, format: string): Promise; + sendDictationStreamChunk(dictationId: string, seq: number, audio: string, format: string): void; + finishDictationStream( + dictationId: string, + finalSeq: number, + ): Promise<{ dictationId: string; text: string }>; + cancelDictationStream(dictationId: string): void; + subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void; +} + export interface DictationStreamSenderParams { - client: DaemonClient | null; + client: DictationStreamClient | null; format: string; createDictationId?: () => string; } @@ -30,28 +49,50 @@ interface DictationFinishResult { * so enqueues can't "miss" a flush due to in-flight await/coalescing bugs. */ export class DictationStreamSender { - private client: DaemonClient | null; + private client: DictationStreamClient | null = null; private readonly format: string; private readonly createDictationId: () => string; private dictationId: string | null = null; private sendSeq = 0; + private acknowledgedSeq = -1; private segments: string[] = []; private streamReady = false; private flushTimer: ReturnType | null = null; private drainWaiters: Array<() => void> = []; + private clientCleanup: (() => void) | null = null; private startGeneration = 0; private startPromise: Promise | null = null; constructor(params: DictationStreamSenderParams) { - this.client = params.client; this.format = params.format; this.createDictationId = params.createDictationId ?? generateMessageId; + this.setClient(params.client); } - setClient(client: DaemonClient | null): void { + setClient(client: DictationStreamClient | null): void { + if (this.client === client) { + return; + } + + if (this.clientCleanup) { + this.clientCleanup(); + this.clientCleanup = null; + } + this.resetStreamForReplay(); this.client = client; + + if (!client) { + return; + } + + this.clientCleanup = client.subscribeRawMessages((message) => { + if (message.type !== "dictation_stream_ack") { + return; + } + this.handleAck(message); + }); } getDictationId(): string | null { @@ -62,6 +103,10 @@ export class DictationStreamSender { return this.segments.length; } + dispose(): void { + this.setClient(null); + } + getFinalSeq(): number { return this.segments.length - 1; } @@ -71,22 +116,19 @@ export class DictationStreamSender { } clearAll(): void { - this.clearScheduledFlush(); - this.dictationId = null; - this.sendSeq = 0; + this.resetStreamForReplay(); this.segments = []; - this.streamReady = false; - this.startPromise = null; - this.startGeneration += 1; } resetStreamForReplay(): void { this.clearScheduledFlush(); this.dictationId = null; this.sendSeq = 0; + this.acknowledgedSeq = -1; this.streamReady = false; this.startPromise = null; this.startGeneration += 1; + this.resolveDrainWaiters(); } enqueueSegment(base64Pcm: string): void { @@ -113,6 +155,9 @@ export class DictationStreamSender { const client = this.client; const dictationId = this.dictationId; if (!client?.isConnected || !dictationId || !this.streamReady) { + if (!client?.isConnected && this.hasPendingSegments()) { + this.invalidateStream(); + } return 0; } @@ -120,10 +165,16 @@ export class DictationStreamSender { while (this.sendSeq < this.segments.length && sent < MAX_CHUNKS_PER_FLUSH_TURN) { const seq = this.sendSeq; const audio = this.segments[seq]; - client.sendDictationStreamChunk(dictationId, seq, audio, this.format); + try { + client.sendDictationStreamChunk(dictationId, seq, audio, this.format); + } catch { + this.invalidateStream(); + return sent; + } this.sendSeq = seq + 1; sent += 1; } + if (this.hasPendingSegments()) { this.scheduleFlush(); } else { @@ -144,6 +195,7 @@ export class DictationStreamSender { const dictationId = this.createDictationId(); this.dictationId = dictationId; this.sendSeq = 0; + this.acknowledgedSeq = -1; this.streamReady = false; const start = (async () => { @@ -161,7 +213,10 @@ export class DictationStreamSender { // If starting failed, keep the segments for retry but clear the stream so finish can error cleanly. if (this.startGeneration === generation && this.dictationId === dictationId) { this.dictationId = null; + this.sendSeq = 0; + this.acknowledgedSeq = -1; this.streamReady = false; + this.resolveDrainWaiters(); } throw error; }) @@ -198,7 +253,7 @@ export class DictationStreamSender { } this.flush(); - await this.waitForFlushDrain(); + await this.waitForFlushDrain(finalSeq); return client.finishDictationStream(dictationId, finalSeq); } @@ -215,6 +270,25 @@ export class DictationStreamSender { return this.sendSeq < this.segments.length; } + private hasPendingDelivery(finalSeq: number): boolean { + return this.hasPendingSegments() || this.acknowledgedSeq < finalSeq; + } + + private handleAck(message: DictationStreamAckMessage): void { + if (message.payload.dictationId !== this.dictationId) { + return; + } + if (message.payload.ackSeq <= this.acknowledgedSeq) { + return; + } + this.acknowledgedSeq = message.payload.ackSeq; + this.resolveDrainWaiters(); + } + + private invalidateStream(): void { + this.resetStreamForReplay(); + } + private scheduleFlush(): void { if (this.flushTimer) { return; @@ -233,19 +307,44 @@ export class DictationStreamSender { this.flushTimer = null; } - private async waitForFlushDrain(): Promise { - while (this.hasPendingSegments()) { + private async waitForFlushDrain(finalSeq: number): Promise { + while (this.hasPendingDelivery(finalSeq)) { const client = this.client; if (!client?.isConnected || !this.dictationId || !this.streamReady) { throw new Error("Failed to flush dictation stream"); } - await new Promise((resolve) => { - this.drainWaiters.push(resolve); - }); + const result = await this.waitForDrainSignal(); + if (result.kind === "timed-out") { + this.invalidateStream(); + throw new Error("Timed out waiting for dictation stream delivery"); + } await waitForNextFlushTurn(); } } + private waitForDrainSignal(): Promise { + let timeoutHandle: ReturnType | null = null; + let waiter: (() => void) | null = null; + const signal = new Promise<"signaled">((resolve) => { + waiter = () => resolve("signaled"); + this.drainWaiters.push(waiter); + }); + const timeout = new Promise<"timed-out">((resolve) => { + timeoutHandle = setTimeout(() => resolve("timed-out"), DICTATION_DRAIN_IDLE_TIMEOUT_MS); + }); + + return Promise.race([signal, timeout]) + .finally(() => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + if (waiter) { + this.drainWaiters = this.drainWaiters.filter((candidate) => candidate !== waiter); + } + }) + .then((kind) => ({ kind })); + } + private resolveDrainWaiters(): void { const waiters = this.drainWaiters; this.drainWaiters = []; diff --git a/packages/app/src/hooks/use-dictation.ts b/packages/app/src/hooks/use-dictation.ts index 09442b4c77..dde72964bc 100644 --- a/packages/app/src/hooks/use-dictation.ts +++ b/packages/app/src/hooks/use-dictation.ts @@ -449,6 +449,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { attemptGuard.cancel(); stopDurationTracking(); void audioStop.current().catch(() => undefined); + senderRef.current?.dispose(); }; }, [stopDurationTracking]); From 94bda1f92504e45b42780286d0c98b51aa4a4ef5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 21:06:47 +0200 Subject: [PATCH 05/73] Remove chat rooms and agent loops before storage migration (#3053) * refactor(storage): remove chat rooms and agent loops This is the prerequisite cleanup for the storage backend migration. Legacy wire schemas remain temporarily so mixed-version peers can still parse old messages, while daemon callers now use query-shaped AgentStorage methods that can map directly to SQL later. * test(cli): remove retired chat and loop coverage --- README.ja.md | 1 - README.ko.md | 1 - README.md | 1 - README.zh-CN.md | 1 - docs/architecture.md | 6 - docs/data-model.md | 140 +- docs/development.md | 6 +- docs/glossary.md | 2 +- docs/refactors/session-decomposition-plan.md | 35 +- packages/cli/src/cli.ts | 6 - packages/cli/src/commands/chat/create.ts | 31 - packages/cli/src/commands/chat/delete.ts | 24 - packages/cli/src/commands/chat/index.ts | 69 - packages/cli/src/commands/chat/inspect.ts | 24 - packages/cli/src/commands/chat/ls.ts | 23 - packages/cli/src/commands/chat/post.ts | 43 - packages/cli/src/commands/chat/read.ts | 60 - packages/cli/src/commands/chat/schema.ts | 128 -- packages/cli/src/commands/chat/shared.ts | 137 -- packages/cli/src/commands/chat/wait.ts | 65 - packages/cli/src/commands/loop/index.ts | 32 - packages/cli/src/commands/loop/inspect.ts | 119 -- packages/cli/src/commands/loop/logs.ts | 83 - packages/cli/src/commands/loop/ls.ts | 83 - packages/cli/src/commands/loop/run.test.ts | 89 -- packages/cli/src/commands/loop/run.ts | 208 --- packages/cli/src/commands/loop/stop.ts | 80 - packages/cli/src/commands/loop/types.ts | 151 -- packages/cli/tests/30-chat.test.ts | 95 -- ...p-schedule.test.ts => 31-schedule.test.ts} | 80 +- packages/client/src/daemon-client.test.ts | 50 - packages/client/src/daemon-client.ts | 250 --- packages/protocol/src/chat/rpc-schemas.ts | 2 + packages/protocol/src/chat/types.ts | 2 + packages/protocol/src/loop/rpc-schemas.ts | 2 + .../src/server/agent/agent-storage.test.ts | 42 + .../server/src/server/agent/agent-storage.ts | 18 + .../src/server/agent/import-sessions.ts | 34 +- packages/server/src/server/bootstrap.ts | 19 - .../src/server/chat/chat-mentions.test.ts | 205 --- .../server/src/server/chat/chat-mentions.ts | 194 --- .../src/server/chat/chat-service.test.ts | 191 --- .../server/src/server/chat/chat-service.ts | 465 ------ .../server/src/server/loop-service.test.ts | 1386 ----------------- packages/server/src/server/loop-service.ts | 1029 ------------ packages/server/src/server/session.test.ts | 55 +- packages/server/src/server/session.ts | 115 +- ...on.workspace-resolution-invariants.test.ts | 2 - .../src/server/session.workspaces.test.ts | 20 - .../chat/chat-schedule-loop-session.test.ts | 228 --- .../chat/chat-schedule-loop-session.ts | 609 -------- .../session/schedule/schedule-session.test.ts | 100 ++ .../session/schedule/schedule-session.ts | 248 +++ .../src/server/test-utils/session-stubs.ts | 14 +- .../websocket-server.browser-tools.test.ts | 4 - .../websocket-server.notifications.test.ts | 4 - .../websocket-server.relay-reconnect.test.ts | 4 - ...cket-server.terminal-notifications.test.ts | 4 - .../server/src/server/websocket-server.ts | 26 +- .../server/src/server/wire-compat.test.ts | 2 - .../server/workspace-archive-service.test.ts | 19 +- .../src/server/workspace-archive-service.ts | 8 +- ...hat-rooms-beat-agent-to-agent-prompting.md | 45 - .../website/src/components/landing-page.tsx | 13 - .../content/alternatives/claude-desktop.md | 2 +- .../src/content/alternatives/codex-app.md | 2 +- .../src/content/alternatives/conductor.md | 2 +- .../src/content/alternatives/happy-coder.md | 2 +- .../src/content/alternatives/openchamber.md | 2 +- .../content/alternatives/opencode-desktop.md | 2 +- .../src/content/alternatives/superset.md | 2 +- public-docs/skills.md | 11 - skills/paseo-loop/SKILL.md | 47 - 73 files changed, 503 insertions(+), 6801 deletions(-) delete mode 100644 packages/cli/src/commands/chat/create.ts delete mode 100644 packages/cli/src/commands/chat/delete.ts delete mode 100644 packages/cli/src/commands/chat/index.ts delete mode 100644 packages/cli/src/commands/chat/inspect.ts delete mode 100644 packages/cli/src/commands/chat/ls.ts delete mode 100644 packages/cli/src/commands/chat/post.ts delete mode 100644 packages/cli/src/commands/chat/read.ts delete mode 100644 packages/cli/src/commands/chat/schema.ts delete mode 100644 packages/cli/src/commands/chat/shared.ts delete mode 100644 packages/cli/src/commands/chat/wait.ts delete mode 100644 packages/cli/src/commands/loop/index.ts delete mode 100644 packages/cli/src/commands/loop/inspect.ts delete mode 100644 packages/cli/src/commands/loop/logs.ts delete mode 100644 packages/cli/src/commands/loop/ls.ts delete mode 100644 packages/cli/src/commands/loop/run.test.ts delete mode 100644 packages/cli/src/commands/loop/run.ts delete mode 100644 packages/cli/src/commands/loop/stop.ts delete mode 100644 packages/cli/src/commands/loop/types.ts delete mode 100644 packages/cli/tests/30-chat.test.ts rename packages/cli/tests/{31-loop-schedule.test.ts => 31-schedule.test.ts} (66%) delete mode 100644 packages/server/src/server/chat/chat-mentions.test.ts delete mode 100644 packages/server/src/server/chat/chat-mentions.ts delete mode 100644 packages/server/src/server/chat/chat-service.test.ts delete mode 100644 packages/server/src/server/chat/chat-service.ts delete mode 100644 packages/server/src/server/loop-service.test.ts delete mode 100644 packages/server/src/server/loop-service.ts delete mode 100644 packages/server/src/server/session/chat/chat-schedule-loop-session.test.ts delete mode 100644 packages/server/src/server/session/chat/chat-schedule-loop-session.ts create mode 100644 packages/server/src/server/session/schedule/schedule-session.test.ts create mode 100644 packages/server/src/server/session/schedule/schedule-session.ts delete mode 100644 packages/website/posts/why-agent-chat-rooms-beat-agent-to-agent-prompting.md delete mode 100644 skills/paseo-loop/SKILL.md diff --git a/README.ja.md b/README.ja.md index ff159843ec..0f35d47c57 100644 --- a/README.ja.md +++ b/README.ja.md @@ -118,7 +118,6 @@ npx skills add getpaseo/paseo どのエージェントとの会話でも使用できます。 - `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。 -- `/paseo-loop` — 明確な受け入れ基準に沿ってエージェントをループさせます(Ralph loops とも呼ばれます)。検証役を追加することもできます。 - `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。 - `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。 diff --git a/README.ko.md b/README.ko.md index ec1bc64cf2..9975a7dd63 100644 --- a/README.ko.md +++ b/README.ko.md @@ -128,7 +128,6 @@ npx skills add getpaseo/paseo 그런 다음 어떤 에이전트 대화에서든 아래 명령을 사용할 수 있습니다. - `/paseo-handoff` — 에이전트 간에 작업을 넘깁니다. Claude로 계획을 세운 뒤 Codex에 구현을 넘길 때 이 기능을 씁니다. -- `/paseo-loop` — 명확한 완료 조건을 기준으로 에이전트를 반복 실행합니다(Ralph loop). 검증 에이전트를 추가할 수도 있습니다. - `/paseo-advisor` — 작업 자체를 넘기지 않고, 에이전트 하나를 조언자로 띄워 두 번째 의견을 받습니다. - `/paseo-committee` — 서로 다른 관점의 에이전트 두 개로 위원회를 구성해, 한 발 물러나 근본 원인을 분석하고 계획을 세웁니다. diff --git a/README.md b/README.md index f16d2c6c43..1b26f31800 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,6 @@ npx skills add getpaseo/paseo Then use them in any agent conversation: - `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement. -- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier. - `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself. - `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan. diff --git a/README.zh-CN.md b/README.zh-CN.md index dd9e2eae65..fef0f4ff2c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -118,7 +118,6 @@ npx skills add getpaseo/paseo 然后在任意 agent 对话中使用: - `/paseo-handoff` — 在 agents 之间交接工作。我会用它先和 Claude 规划,再交给 Codex 实现。 -- `/paseo-loop` — 让 agent 按明确验收标准循环工作(也叫 Ralph loops),也可以加 verifier。 - `/paseo-advisor` — 启动单个 agent 作为 advisor,提供第二意见,但不把工作委托出去。 - `/paseo-committee` — 组建两个风格互补的 agents,让它们后退一步做根因分析并产出计划。 diff --git a/docs/architecture.md b/docs/architecture.md index 6af406f975..b4bf1a56c9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,8 +76,6 @@ not retain non-Git directories. | `server/agent/providers/` | Provider adapters (see "Agent providers" below) | | `server/relay-transport.ts` | Outbound relay connection with E2E encryption | | `server/schedule/` | Cron-based scheduled agents | -| `server/loop-service.ts` | Looping agent runs that retry until an exit condition | -| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging | ### `packages/protocol` — Wire schemas and shared protocol types @@ -119,10 +117,8 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex - `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode` - `paseo daemon start/stop/restart/status/pair/set-password` -- `paseo chat ls/create/inspect/post/read/wait/delete` - `paseo terminal ls/create/capture/send-keys/kill` - `paseo script ls/start/stop` -- `paseo loop run/ls/inspect/logs/stop` - `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` - `paseo heartbeat create/update/delete` - `paseo workspace create/ls/archive` @@ -384,9 +380,7 @@ $PASEO_HOME/ ├── projects/projects.json # Project registry ├── projects/workspaces.json # Workspace registry ├── projects/icons/ # Custom project icon images -├── chat/ # Chat rooms ├── schedules/ # Scheduled-agent definitions and runs -├── loops/ # Loop runs and logs ├── config.json # Daemon config (mutable) ├── daemon-keypair.json # Daemon identity for relay/E2EE ├── push-tokens.json # Mobile push tokens diff --git a/docs/data-model.md b/docs/data-model.md index fc22175ede..6802c29f92 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -54,10 +54,6 @@ $PASEO_HOME/ │ └── {agentId}.json # One file per agent ├── schedules/ │ └── {scheduleId}.json # One file per schedule -├── chat/ -│ └── rooms.json # All rooms + messages -├── loops/ -│ └── loops.json # All loop records ├── projects/ │ ├── projects.json # Project registry │ ├── workspaces.json # Workspace registry @@ -100,7 +96,7 @@ Each agent is stored as a separate JSON file, grouped by project directory. | `requiresAttention` | `boolean?` | Whether the agent needs user attention | | `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed | | `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged | -| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) | +| `internal` | `boolean?` | Whether this is a system-internal agent | | `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | ### Nested: SerializableConfig @@ -358,133 +354,7 @@ One file per schedule. ID is 8 hex characters. --- -## 4. Chat - -**Path:** `$PASEO_HOME/chat/rooms.json` - -Single file containing all rooms and messages. - -```json -{ - "rooms": [ ... ], - "messages": [ ... ] -} -``` - -### ChatRoom - -| Field | Type | Description | -| ----------- | ------------------- | ----------------------------------- | -| `id` | `string` (UUID) | | -| `name` | `string` | Unique room name (case-insensitive) | -| `purpose` | `string?` | Room description | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | Updated on each new message | - -### ChatMessage - -| Field | Type | Description | -| ------------------ | ------------------- | ----------------------------------- | -| `id` | `string` (UUID) | | -| `roomId` | `string` | FK to ChatRoom.id | -| `authorAgentId` | `string` | Agent ID of the author | -| `body` | `string` | Message text (supports `@mentions`) | -| `replyToMessageId` | `string?` | FK to another ChatMessage.id | -| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs | -| `createdAt` | `string` (ISO 8601) | | - ---- - -## 5. Loop - -**Path:** `$PASEO_HOME/loops/loops.json` - -Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry. - -| Field | Type | Description | -| ----------------------- | --------------------------------------------------- | ------------------------------------------ | -| `id` | `string` | 8-char UUID prefix | -| `name` | `string?` | Human-readable name | -| `prompt` | `string` | Worker prompt | -| `cwd` | `string` | Working directory | -| `provider` | `string` | Default provider | -| `model` | `string?` | Default model | -| `modeId` | `string?` | Default mode ID | -| `workerProvider` | `string?` | Override provider for workers | -| `workerModel` | `string?` | Override model for workers | -| `verifierProvider` | `string?` | Override provider for verifiers | -| `verifierModel` | `string?` | Override model for verifiers | -| `verifierModeId` | `string?` | Override mode ID for verifiers | -| `verifyPrompt` | `string?` | LLM verification prompt | -| `verifyChecks` | `string[]` | Shell commands to run as checks | -| `archive` | `boolean` | Whether to archive worker agents after use | -| `sleepMs` | `number` | Delay between iterations (ms) | -| `maxIterations` | `number?` | Cap on iterations | -| `maxTimeMs` | `number?` | Total time budget (ms) | -| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | | -| `startedAt` | `string` (ISO 8601) | | -| `completedAt` | `string?` (ISO 8601) | | -| `stopRequestedAt` | `string?` (ISO 8601) | | -| `iterations` | `LoopIteration[]` | | -| `logs` | `LoopLogEntry[]` | | -| `nextLogSeq` | `number` | Monotonic log sequence counter | -| `activeIteration` | `number?` | Currently executing iteration index | -| `activeWorkerAgentId` | `string?` | Currently running worker agent | -| `activeVerifierAgentId` | `string?` | Currently running verifier agent | - -### Nested: LoopIteration - -| Field | Type | Description | -| ------------------- | --------------------------------------------------- | ------------------------ | -| `index` | `number` | 1-based iteration index | -| `workerAgentId` | `string?` | Agent ID of the worker | -| `workerStartedAt` | `string` (ISO 8601) | | -| `workerCompletedAt` | `string?` (ISO 8601) | | -| `verifierAgentId` | `string?` | Agent ID of the verifier | -| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | | -| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | | -| `failureReason` | `string?` | | -| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results | -| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result | - -### Nested: LoopLogEntry - -| Field | Type | -| ----------- | ---------------------------------------------------- | -| `seq` | `number` (monotonic) | -| `timestamp` | `string` (ISO 8601) | -| `iteration` | `number?` | -| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` | -| `level` | `"info" \| "error"` | -| `text` | `string` | - -### Nested: LoopVerifyCheckResult - -| Field | Type | -| ------------- | ------------------- | -| `command` | `string` | -| `exitCode` | `number` | -| `passed` | `boolean` | -| `stdout` | `string` | -| `stderr` | `string` | -| `startedAt` | `string` (ISO 8601) | -| `completedAt` | `string` (ISO 8601) | - -### Nested: LoopVerifyPromptResult - -| Field | Type | -| ----------------- | ------------------- | -| `passed` | `boolean` | -| `reason` | `string` | -| `verifierAgentId` | `string?` | -| `startedAt` | `string` (ISO 8601) | -| `completedAt` | `string` (ISO 8601) | - ---- - -## 6. Project Registry +## 4. Project Registry **Path:** `$PASEO_HOME/projects/projects.json` @@ -519,7 +389,7 @@ workspace together with its owning project. --- -## 7. Workspace Registry +## 5. Workspace Registry **Path:** `$PASEO_HOME/projects/workspaces.json` @@ -553,7 +423,7 @@ than treating it as valid. --- -## 8. Push Token Store +## 6. Push Token Store **Path:** `$PASEO_HOME/push-tokens.json` @@ -567,7 +437,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil --- -## 9. Daemon meta files +## 7. Daemon meta files These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination. diff --git a/docs/development.md b/docs/development.md index 3fd7e4ff3c..5db7fb9735 100644 --- a/docs/development.md +++ b/docs/development.md @@ -302,9 +302,9 @@ Lifecycle commands run in the worktree through a stable script shell: `bash` resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on Windows. They inherit the daemon environment plus Paseo's lifecycle variables; login and interactive shell startup files are not loaded, and Bash's `BASH_ENV` -hook is unset. Daemon-run loop verify checks and ACP single-string terminal -commands use the same non-login Bash behavior on macOS/Linux, but preserve their -existing `cmd.exe /c` string semantics on Windows. Service scripts are separate: +hook is unset. ACP single-string terminal commands use the same non-login Bash +behavior on macOS/Linux, but preserve their existing `cmd.exe /c` string semantics +on Windows. Service scripts are separate: they launch in a terminal and receive the service environment described below. Because the shell differs per platform, a lifecycle command that must run diff --git a/docs/glossary.md b/docs/glossary.md index e3ae9fb2bb..ce2e9c952b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -31,7 +31,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`). - **Workspace script** — A named command a workspace can run, declared in `paseo.json`. Two types: `service` and `script` (a command that runs and finishes). UI: "Scripts" for the panel and the menu, both of which list either type. Code: `WorkspaceScriptPayload` (`packages/protocol/src/messages.ts:3126`). Don't confuse with **Service**, which is one of the two types rather than a synonym for the pair. - **Service** — A workspace script of type `service`: long-running, may bind a port, and carries `health` of `healthy | unhealthy | null` once the daemon has observed it. The only type a sidebar workspace row reports, because a running plain script says nothing about whether the workspace is up; the row names the service and turns danger when its health fails, and where several run the failing one takes the row's single slot. UI: "Services" in the sidebar's display preferences (**Show → Services**). Code: `selectWorkspaceServiceSummary` (`packages/app/src/components/sidebar/workspace-meta-row/service-summary.ts`), `WorkspaceScriptHealthSchema` (`packages/protocol/src/messages.ts:3124`). Forbidden: "Service" as a label for the Scripts panel, which lists both types. -- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent). +- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent). - **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline. - **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`). - **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`). diff --git a/docs/refactors/session-decomposition-plan.md b/docs/refactors/session-decomposition-plan.md index ff8bbfab49..8228e6ee10 100644 --- a/docs/refactors/session-decomposition-plan.md +++ b/docs/refactors/session-decomposition-plan.md @@ -19,23 +19,22 @@ The first carves shipped as **deep modules with a narrow Host seam**, not the `d - **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_ - **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it. +- **ScheduleSession** (`session/schedule/schedule-session.ts`, seam `ScheduleSessionHost`): scheduled-agent request handling. **Next carve — CheckoutSession mutation side (Slice 4 below).** The 17 checkout _write_ handlers still inline in `session.ts` (`dispatchCheckoutMessage`, ~2010) — branch switch/rename, commit, merge, merge-from-base, pull, push, PR create/merge, github set-auto-merge/get-check-details, PR status, PR timeline, github search, stash save/pop/list — move into the existing `session/checkout/checkout-session.ts` behind `CheckoutSessionHost`. The Slice-3 observer entanglement the table fears is already resolved: #1644 moved the status/diff read side into CheckoutSession, so the workspace observer delegates today and this no longer blocks on the WorkspaceController split. ### Why this is safe at the dispatch seam (verified) -`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150). +`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150). Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected. ## Slice ordering (least-coupled first) -The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates). +The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The safer first cut is **provider-catalog** (one shared collaborator + injected predicates). | # | Slice | Effort | Risk | | --- | ----------------------------------------------------------------------------- | ------ | ------ | -| 0 | Test net + disjointness tripwire (no extraction) | M | low | -| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low | | 2 | ProviderCatalogController | M | medium | | 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high | | 4 | GitCheckoutController | L | medium | @@ -47,34 +46,6 @@ The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verific --- -## Slice 0 — Test net + disjointness tripwire (prerequisite) - -No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified). - -Write RED-then-GREEN against the **current in-place** Session: - -- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`). -- one `schedule/*` and one `loop/*` round-trip. -- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame. -- a handler that **rejects async** emits the SAME pair. -- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land). - -**Tests:** `session.dispatch-seam.test.ts`, `session.test.ts`. - ---- - -## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green - -**Move:** all 21 handlers (`handleChat*` ×7, `handleSchedule*` ×9, `handleLoop*` ×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError` — **kept separate, not merged**), `toScheduleSummary` → `packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`. - -**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout. - -**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op. - -**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:** `session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`. - ---- - ## Slice 2 — ProviderCatalogController **Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead` → `packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 0455b81b2b..cb93b810a4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,8 +1,6 @@ import { Command, Option } from "commander"; import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; -import { createChatCommand } from "./commands/chat/index.js"; -import { createLoopCommand } from "./commands/loop/index.js"; import { createPermitCommand } from "./commands/permit/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; import { createScheduleCommand } from "./commands/schedule/index.js"; @@ -168,7 +166,6 @@ export function createCli(): Command { program.addCommand(createHubCommand()); // Chat commands - program.addCommand(createChatCommand()); // Terminal commands program.addCommand(createTerminalCommand()); @@ -176,9 +173,6 @@ export function createCli(): Command { // Workspace script commands program.addCommand(createScriptCommand()); - // Loop commands - program.addCommand(createLoopCommand()); - // Schedule commands program.addCommand(createScheduleCommand()); program.addCommand(createHeartbeatCommand()); diff --git a/packages/cli/src/commands/chat/create.ts b/packages/cli/src/commands/chat/create.ts deleted file mode 100644 index 2747a6cf65..0000000000 --- a/packages/cli/src/commands/chat/create.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Command } from "commander"; -import type { SingleResult } from "../../output/index.js"; -import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js"; -import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js"; - -export interface ChatCreateOptions extends ChatCommandOptions { - purpose?: string; -} - -export async function runCreateCommand( - name: string, - options: ChatCreateOptions, - _command: Command, -): Promise> { - const { client } = await connectChatClient(options.host); - try { - const payload = await client.createChatRoom({ - name, - purpose: options.purpose, - }); - return { - type: "single", - data: toChatRoomRow(payload.room!), - schema: chatRoomSchema, - }; - } catch (err) { - throw toChatCommandError("CHAT_CREATE_FAILED", "create chat room", err); - } finally { - await client.close().catch(() => {}); - } -} diff --git a/packages/cli/src/commands/chat/delete.ts b/packages/cli/src/commands/chat/delete.ts deleted file mode 100644 index f803caf4e3..0000000000 --- a/packages/cli/src/commands/chat/delete.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Command } from "commander"; -import type { SingleResult } from "../../output/index.js"; -import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js"; -import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js"; - -export async function runDeleteCommand( - room: string, - options: ChatCommandOptions, - _command: Command, -): Promise> { - const { client } = await connectChatClient(options.host); - try { - const payload = await client.deleteChatRoom({ room }); - return { - type: "single", - data: toChatRoomRow(payload.room!), - schema: chatRoomSchema, - }; - } catch (err) { - throw toChatCommandError("CHAT_DELETE_FAILED", "delete chat room", err); - } finally { - await client.close().catch(() => {}); - } -} diff --git a/packages/cli/src/commands/chat/index.ts b/packages/cli/src/commands/chat/index.ts deleted file mode 100644 index 197b5844ef..0000000000 --- a/packages/cli/src/commands/chat/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Command } from "commander"; -import { withOutput } from "../../output/index.js"; -import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; -import { runCreateCommand } from "./create.js"; -import { runLsCommand } from "./ls.js"; -import { runInspectCommand } from "./inspect.js"; -import { runDeleteCommand } from "./delete.js"; -import { runPostCommand } from "./post.js"; -import { runReadCommand } from "./read.js"; -import { runWaitCommand } from "./wait.js"; - -export function createChatCommand(): Command { - const chat = new Command("chat").description("Manage chat rooms for agent coordination"); - - addJsonAndDaemonHostOptions( - chat - .command("create") - .description("Create a chat room") - .argument("", "Room name (must be unique)") - .option("--purpose ", "Room purpose/description"), - ).action(withOutput(runCreateCommand)); - - addJsonAndDaemonHostOptions(chat.command("ls").description("List chat rooms")).action( - withOutput(runLsCommand), - ); - - addJsonAndDaemonHostOptions( - chat - .command("inspect") - .description("Inspect a chat room") - .argument("", "Room name or ID"), - ).action(withOutput(runInspectCommand)); - - addJsonAndDaemonHostOptions( - chat - .command("delete") - .description("Delete a chat room") - .argument("", "Room name or ID"), - ).action(withOutput(runDeleteCommand)); - - addJsonAndDaemonHostOptions( - chat - .command("post") - .description("Post a chat message") - .argument("", "Room name or ID") - .argument("", "Message body") - .option("--reply-to ", "Reply to a specific message ID"), - ).action(withOutput(runPostCommand)); - - addJsonAndDaemonHostOptions( - chat - .command("read") - .description("Read chat messages") - .argument("", "Room name or ID") - .option("--limit ", "Maximum number of messages to return") - .option("--since ", "Filter by relative duration or ISO timestamp") - .option("--agent ", "Filter by author agent ID"), - ).action(withOutput(runReadCommand)); - - addJsonAndDaemonHostOptions( - chat - .command("wait") - .description("Wait for new chat messages") - .argument("", "Room name or ID") - .option("--timeout ", "Maximum wait time"), - ).action(withOutput(runWaitCommand)); - - return chat; -} diff --git a/packages/cli/src/commands/chat/inspect.ts b/packages/cli/src/commands/chat/inspect.ts deleted file mode 100644 index a07039331d..0000000000 --- a/packages/cli/src/commands/chat/inspect.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Command } from "commander"; -import type { SingleResult } from "../../output/index.js"; -import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js"; -import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js"; - -export async function runInspectCommand( - room: string, - options: ChatCommandOptions, - _command: Command, -): Promise> { - const { client } = await connectChatClient(options.host); - try { - const payload = await client.inspectChatRoom({ room }); - return { - type: "single", - data: toChatRoomRow(payload.room!), - schema: chatRoomSchema, - }; - } catch (err) { - throw toChatCommandError("CHAT_INSPECT_FAILED", "inspect chat room", err); - } finally { - await client.close().catch(() => {}); - } -} diff --git a/packages/cli/src/commands/chat/ls.ts b/packages/cli/src/commands/chat/ls.ts deleted file mode 100644 index 5e323cd7f4..0000000000 --- a/packages/cli/src/commands/chat/ls.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Command } from "commander"; -import type { ListResult } from "../../output/index.js"; -import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js"; -import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js"; - -export async function runLsCommand( - options: ChatCommandOptions, - _command: Command, -): Promise> { - const { client } = await connectChatClient(options.host); - try { - const payload = await client.listChatRooms(); - return { - type: "list", - data: payload.rooms.map(toChatRoomRow), - schema: chatRoomSchema, - }; - } catch (err) { - throw toChatCommandError("CHAT_LIST_FAILED", "list chat rooms", err); - } finally { - await client.close().catch(() => {}); - } -} diff --git a/packages/cli/src/commands/chat/post.ts b/packages/cli/src/commands/chat/post.ts deleted file mode 100644 index abf8bdbbbb..0000000000 --- a/packages/cli/src/commands/chat/post.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { Command } from "commander"; -import type { SingleResult } from "../../output/index.js"; -import { - attachAgentNamesToMessages, - connectChatClient, - resolveChatAuthorAgentId, - toChatCommandError, - type ChatCommandOptions, -} from "./shared.js"; -import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js"; - -export interface ChatPostOptions extends ChatCommandOptions { - replyTo?: string; -} - -export async function runPostCommand( - room: string, - body: string, - options: ChatPostOptions, - _command: Command, -): Promise> { - const { client } = await connectChatClient(options.host); - try { - const payload = await client.postChatMessage({ - room, - body, - authorAgentId: resolveChatAuthorAgentId(), - replyToMessageId: options.replyTo, - }); - const [message] = await attachAgentNamesToMessages(client, [ - toChatMessageRow(payload.message!), - ]); - return { - type: "single", - data: message, - schema: chatMessageSchema, - }; - } catch (err) { - throw toChatCommandError("CHAT_POST_FAILED", "post chat message", err); - } finally { - await client.close().catch(() => {}); - } -} diff --git a/packages/cli/src/commands/chat/read.ts b/packages/cli/src/commands/chat/read.ts deleted file mode 100644 index 7b224ff2b6..0000000000 --- a/packages/cli/src/commands/chat/read.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Command } from "commander"; -import type { ListResult } from "../../output/index.js"; -import { - attachAgentNamesToMessages, - connectChatClient, - parseSinceValue, - toChatCommandError, - type ChatCommandOptions, -} from "./shared.js"; -import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js"; - -export interface ChatReadOptions extends ChatCommandOptions { - limit?: string; - since?: string; - agent?: string; -} - -function parseLimit(value?: string): number | undefined { - if (!value) { - return undefined; - } - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed < 0) { - throw { - code: "INVALID_LIMIT", - message: "Invalid --limit value", - details: "Use a non-negative integer.", - }; - } - return parsed; -} - -export async function runReadCommand( - room: string, - options: ChatReadOptions, - _command: Command, -): Promise> { - const { client } = await connectChatClient(options.host); - try { - const payload = await client.readChatMessages({ - room, - limit: parseLimit(options.limit), - since: parseSinceValue(options.since), - authorAgentId: options.agent, - }); - const messages = await attachAgentNamesToMessages( - client, - payload.messages.map(toChatMessageRow), - ); - return { - type: "list", - data: messages, - schema: chatMessageSchema, - }; - } catch (err) { - throw toChatCommandError("CHAT_READ_FAILED", "read chat messages", err); - } finally { - await client.close().catch(() => {}); - } -} diff --git a/packages/cli/src/commands/chat/schema.ts b/packages/cli/src/commands/chat/schema.ts deleted file mode 100644 index 269c8d9982..0000000000 --- a/packages/cli/src/commands/chat/schema.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { OutputSchema } from "../../output/index.js"; -import type { AnyCommandResult, OutputOptions } from "../../output/index.js"; - -export interface ChatRoomRow { - name: string; - id: string; - purpose: string; - messages: number; - lastMessageAt: string; -} - -export interface ChatMessageRow { - id: string; - author: string; - authorName: string | null; - createdAt: string; - replyTo: string; - mentionAgentIds: string[]; - mentionLabels: string[]; - body: string; -} - -export const chatRoomSchema: OutputSchema = { - idField: "id", - columns: [ - { header: "NAME", field: "name", width: 22 }, - { header: "ID", field: "id", width: 36 }, - { header: "PURPOSE", field: "purpose", width: 30 }, - { header: "MESSAGES", field: "messages", width: 10, align: "right" }, - { header: "LAST MESSAGE", field: "lastMessageAt", width: 24 }, - ], -}; - -export const chatMessageSchema: OutputSchema = { - idField: "id", - columns: [ - { header: "ID", field: "id", width: 36 }, - { header: "AUTHOR", field: "author", width: 16 }, - { header: "AUTHOR NAME", field: (row) => row.authorName ?? "-", width: 20 }, - { header: "CREATED", field: "createdAt", width: 24 }, - { header: "REPLY TO", field: "replyTo", width: 36 }, - { header: "MENTIONS", field: (row) => row.mentionLabels.join(", ") || "-", width: 24 }, - { header: "MESSAGE", field: "body", width: 60 }, - ], - renderHuman: renderChatTranscript, -}; - -function renderChatTranscript( - result: AnyCommandResult, - _options: OutputOptions, -): string { - const data = result.type === "list" ? result.data : [result.data]; - if (data.length === 0) { - return ""; - } - return data.map(renderChatMessageBlock).join("\n\n"); -} - -function renderChatMessageBlock(message: ChatMessageRow): string { - const authorLabel = message.authorName - ? `${message.authorName} (${message.author})` - : message.author; - const lines = [ - `┌─ ${authorLabel} ── ${formatTimestamp(message.createdAt)} ── [msg ${message.id}]`, - ]; - - if (message.replyTo !== "-") { - lines.push(`│ reply-to: msg ${message.replyTo}`); - } - if (message.mentionLabels.length > 0) { - lines.push(`│ mentions: ${message.mentionLabels.join(", ")}`); - } - - lines.push("│"); - - const bodyLines = message.body.split(/\r?\n/); - for (const line of bodyLines) { - lines.push(`│ ${line}`); - } - - lines.push("│"); - lines.push("└─"); - return lines.join("\n"); -} - -function formatTimestamp(input: string): string { - const date = new Date(input); - if (Number.isNaN(date.getTime())) { - return input; - } - return date.toISOString().replace("T", " ").replace(".000Z", "Z"); -} - -export function toChatRoomRow(room: { - id: string; - name: string; - purpose: string | null; - messageCount: number; - lastMessageAt: string | null; -}): ChatRoomRow { - return { - id: room.id, - name: room.name, - purpose: room.purpose ?? "-", - messages: room.messageCount, - lastMessageAt: room.lastMessageAt ?? "-", - }; -} - -export function toChatMessageRow(message: { - id: string; - authorAgentId: string; - createdAt: string; - replyToMessageId: string | null; - mentionAgentIds: string[]; - body: string; -}): ChatMessageRow { - return { - id: message.id, - author: message.authorAgentId, - authorName: null, - createdAt: message.createdAt, - replyTo: message.replyToMessageId ?? "-", - mentionAgentIds: message.mentionAgentIds, - mentionLabels: message.mentionAgentIds, - body: message.body, - }; -} diff --git a/packages/cli/src/commands/chat/shared.ts b/packages/cli/src/commands/chat/shared.ts deleted file mode 100644 index 52ea27096e..0000000000 --- a/packages/cli/src/commands/chat/shared.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; -import { parseDuration } from "../../utils/duration.js"; -import type { CommandError, CommandOptions } from "../../output/index.js"; -import type { ChatMessageRow } from "./schema.js"; - -export interface ChatCommandOptions extends CommandOptions { - host?: string; -} - -export async function connectChatClient(host?: string) { - const daemonHost = getDaemonHost({ host }); - try { - const client = await connectToDaemon({ host }); - return { client, daemonHost }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${daemonHost}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } -} - -export async function attachAgentNamesToMessages( - client: Awaited>, - messages: ChatMessageRow[], - options: { timeout?: number; bestEffort?: boolean } = {}, -): Promise { - const agentIds = new Set(); - for (const message of messages) { - agentIds.add(message.author); - for (const mentionId of message.mentionAgentIds) { - agentIds.add(mentionId); - } - } - - if (agentIds.size === 0) { - return messages; - } - - let payload: Awaited>; - try { - payload = await client.fetchAgents({ - filter: { includeArchived: true }, - ...(typeof options.timeout === "number" ? { timeout: options.timeout } : {}), - }); - } catch (error) { - if (options.bestEffort) { - return messages; - } - throw error; - } - const agentNames = new Map(); - for (const entry of payload.entries) { - const title = entry.agent.title?.trim(); - if (title) { - agentNames.set(entry.agent.id, title); - } - } - - return messages.map((message) => ({ - ...message, - authorName: agentNames.get(message.author) ?? null, - mentionLabels: message.mentionAgentIds.map((agentId) => { - const name = agentNames.get(agentId); - return name ? `${name} (${agentId})` : agentId; - }), - })); -} - -export function toChatCommandError(code: string, action: string, err: unknown): CommandError { - if (err && typeof err === "object" && "code" in err && "message" in err) { - return err as CommandError; - } - - const message = err instanceof Error ? err.message : String(err); - const rpcCode = - typeof err === "object" && err !== null && "code" in err && typeof err.code === "string" - ? err.code - : undefined; - - return { - code: rpcCode ?? code, - message: `Failed to ${action}: ${message}`, - }; -} - -export function parseSinceValue(input?: string): string | undefined { - if (!input) { - return undefined; - } - - try { - const durationMs = parseDuration(input); - return new Date(Date.now() - durationMs).toISOString(); - } catch { - const timestamp = new Date(input); - if (Number.isNaN(timestamp.getTime())) { - const error: CommandError = { - code: "INVALID_SINCE", - message: "Invalid --since value", - details: "Use a duration like 10m or an ISO timestamp.", - }; - throw error; - } - return timestamp.toISOString(); - } -} - -export function parseTimeoutMs(input?: string): number | undefined { - if (!input) { - return undefined; - } - - try { - const timeoutMs = parseDuration(input); - if (timeoutMs <= 0) { - throw new Error("Timeout must be positive"); - } - return timeoutMs; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "INVALID_TIMEOUT", - message: "Invalid timeout value", - details: message, - }; - throw error; - } -} - -export function resolveChatAuthorAgentId(): string { - const agentId = process.env.PASEO_AGENT_ID?.trim(); - return agentId && agentId.length > 0 ? agentId : "manual"; -} diff --git a/packages/cli/src/commands/chat/wait.ts b/packages/cli/src/commands/chat/wait.ts deleted file mode 100644 index 3db53c8970..0000000000 --- a/packages/cli/src/commands/chat/wait.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Command } from "commander"; -import type { ListResult } from "../../output/index.js"; -import { - attachAgentNamesToMessages, - connectChatClient, - parseTimeoutMs, - toChatCommandError, - type ChatCommandOptions, -} from "./shared.js"; -import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js"; - -export interface ChatWaitOptions extends ChatCommandOptions { - timeout?: string; -} - -const CHAT_WAIT_PREFLIGHT_TIMEOUT_MS = 2000; - -export async function runWaitCommand( - room: string, - options: ChatWaitOptions, - _command: Command, -): Promise> { - const timeoutMs = parseTimeoutMs(options.timeout); - const { client } = await connectChatClient(options.host); - const deadline = typeof timeoutMs === "number" ? Date.now() + timeoutMs : null; - const hasExplicitTimeout = deadline !== null; - const remainingTimeoutMs = () => - deadline === null ? undefined : Math.max(1, deadline - Date.now()); - try { - const latest = await client.readChatMessages({ - room, - limit: 1, - ...(hasExplicitTimeout - ? { - timeout: Math.min(remainingTimeoutMs() ?? 1, CHAT_WAIT_PREFLIGHT_TIMEOUT_MS), - } - : {}), - }); - const afterMessageId = latest.messages[0]?.id; - const payload = await client.waitForChatMessages({ - room, - afterMessageId, - timeoutMs: remainingTimeoutMs() ?? timeoutMs, - }); - const messages = await attachAgentNamesToMessages( - client, - payload.messages.map(toChatMessageRow), - hasExplicitTimeout - ? { - timeout: remainingTimeoutMs(), - bestEffort: true, - } - : {}, - ); - return { - type: "list", - data: messages, - schema: chatMessageSchema, - }; - } catch (err) { - throw toChatCommandError("CHAT_WAIT_FAILED", "wait for chat messages", err); - } finally { - await client.close().catch(() => {}); - } -} diff --git a/packages/cli/src/commands/loop/index.ts b/packages/cli/src/commands/loop/index.ts deleted file mode 100644 index 95359ce59e..0000000000 --- a/packages/cli/src/commands/loop/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Command } from "commander"; -import { withOutput } from "../../output/index.js"; -import { addJsonAndDaemonHostOptions, addDaemonHostOption } from "../../utils/command-options.js"; -import { addLoopRunOptions, runLoopRunCommand } from "./run.js"; -import { addLoopLsOptions, runLoopLsCommand } from "./ls.js"; -import { addLoopInspectOptions, runLoopInspectCommand } from "./inspect.js"; -import { addLoopLogsOptions, runLoopLogsCommand } from "./logs.js"; -import { addLoopStopOptions, runLoopStopCommand } from "./stop.js"; - -export function createLoopCommand(): Command { - const loop = new Command("loop").description("Run iterative worker loops"); - - addJsonAndDaemonHostOptions(addLoopRunOptions(loop.command("run"))).action( - withOutput(runLoopRunCommand), - ); - - addJsonAndDaemonHostOptions(addLoopLsOptions(loop.command("ls"))).action( - withOutput(runLoopLsCommand), - ); - - addJsonAndDaemonHostOptions(addLoopInspectOptions(loop.command("inspect"))).action( - withOutput(runLoopInspectCommand), - ); - - addDaemonHostOption(addLoopLogsOptions(loop.command("logs"))).action(runLoopLogsCommand); - - addJsonAndDaemonHostOptions(addLoopStopOptions(loop.command("stop"))).action( - withOutput(runLoopStopCommand), - ); - - return loop; -} diff --git a/packages/cli/src/commands/loop/inspect.ts b/packages/cli/src/commands/loop/inspect.ts deleted file mode 100644 index 4f4cece487..0000000000 --- a/packages/cli/src/commands/loop/inspect.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; -import type { CommandOptions, CommandError, OutputSchema, ListResult } from "../../output/index.js"; -import type { LoopDaemonClient, LoopRecord } from "./types.js"; - -interface InspectRow { - key: string; - value: string; -} - -export interface LoopInspectOptions extends CommandOptions {} - -export function addLoopInspectOptions(command: Command): Command { - return command.description("Show loop details and iteration history").argument("", "Loop ID"); -} - -function createInspectSchema(loop: LoopRecord): OutputSchema { - return { - idField: "key", - columns: [ - { header: "KEY", field: "key", width: 18 }, - { header: "VALUE", field: "value", width: 80 }, - ], - serialize: () => loop, - }; -} - -function toRows(loop: LoopRecord): InspectRow[] { - return [ - { key: "Id", value: loop.id }, - { key: "Name", value: loop.name ?? "null" }, - { key: "Status", value: loop.status }, - { key: "Cwd", value: loop.cwd }, - { key: "Provider", value: loop.provider }, - { key: "Model", value: loop.model ?? "null" }, - { key: "WorkerProvider", value: loop.workerProvider ?? "null" }, - { key: "WorkerModel", value: loop.workerModel ?? "null" }, - { key: "VerifierProvider", value: loop.verifierProvider ?? "null" }, - { key: "VerifierModel", value: loop.verifierModel ?? "null" }, - { key: "Prompt", value: loop.prompt }, - { key: "VerifyPrompt", value: loop.verifyPrompt ?? "null" }, - { - key: "VerifyChecks", - value: loop.verifyChecks.length > 0 ? loop.verifyChecks.join(" | ") : "[]", - }, - { key: "Archive", value: String(loop.archive) }, - { key: "SleepMs", value: String(loop.sleepMs) }, - { - key: "MaxIterations", - value: loop.maxIterations === null ? "null" : String(loop.maxIterations), - }, - { key: "MaxTimeMs", value: loop.maxTimeMs === null ? "null" : String(loop.maxTimeMs) }, - { key: "CreatedAt", value: loop.createdAt }, - { key: "UpdatedAt", value: loop.updatedAt }, - { key: "CompletedAt", value: loop.completedAt ?? "null" }, - { - key: "Iterations", - value: - loop.iterations.length === 0 - ? "[]" - : loop.iterations - .map((iteration) => { - const summary = [ - `#${iteration.index}`, - iteration.status, - iteration.workerAgentId ? `worker=${iteration.workerAgentId}` : null, - iteration.verifierAgentId ? `verifier=${iteration.verifierAgentId}` : null, - iteration.failureReason ? `reason=${iteration.failureReason}` : null, - ] - .filter(Boolean) - .join(" "); - return summary; - }) - .join(" | "), - }, - ]; -} - -export type LoopInspectResult = ListResult; - -export async function runLoopInspectCommand( - id: string, - options: LoopInspectOptions, - _command: Command, -): Promise { - const host = getDaemonHost({ host: options.host }); - let client; - try { - client = (await connectToDaemon({ - host: options.host, - })) as unknown as LoopDaemonClient; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - } satisfies CommandError; - } - - try { - const payload = await client.loopInspect(id); - await client.close(); - if (payload.error || !payload.loop) { - throw new Error(payload.error ?? `Loop not found: ${id}`); - } - return { - type: "list", - data: toRows(payload.loop), - schema: createInspectSchema(payload.loop), - }; - } catch (error) { - await client.close().catch(() => {}); - throw { - code: "LOOP_INSPECT_FAILED", - message: error instanceof Error ? error.message : String(error), - } satisfies CommandError; - } -} diff --git a/packages/cli/src/commands/loop/logs.ts b/packages/cli/src/commands/loop/logs.ts deleted file mode 100644 index 76a7726b74..0000000000 --- a/packages/cli/src/commands/loop/logs.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; -import type { CommandError, CommandOptions } from "../../output/index.js"; -import type { LoopDaemonClient, LoopLogEntry } from "./types.js"; - -export interface LoopLogsOptions extends CommandOptions { - pollInterval?: string; -} - -export function addLoopLogsOptions(command: Command): Command { - return command - .description("Stream loop logs") - .argument("", "Loop ID") - .option("--poll-interval ", "Polling interval in milliseconds", "1000"); -} - -function parsePollInterval(value: string): number { - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw { - code: "INVALID_POLL_INTERVAL", - message: "--poll-interval must be a positive integer", - } satisfies CommandError; - } - return parsed; -} - -function renderLogEntry(entry: LoopLogEntry): string { - const prefix = [ - entry.timestamp, - entry.source, - entry.iteration === null ? null : `iteration=${entry.iteration}`, - entry.level === "error" ? "ERROR" : null, - ] - .filter(Boolean) - .join(" "); - return `${prefix}\n${entry.text}`; -} - -export async function runLoopLogsCommand( - id: string, - options: LoopLogsOptions, - _command: Command, -): Promise { - const host = getDaemonHost({ host: options.host }); - const pollInterval = parsePollInterval(options.pollInterval ?? "1000"); - let client: LoopDaemonClient; - try { - client = (await connectToDaemon({ - host: options.host, - })) as unknown as LoopDaemonClient; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: Cannot connect to daemon at ${host}: ${message}`); - console.error("Start the daemon with: paseo daemon start"); - process.exit(1); - } - - async function streamLogs(cursor: number): Promise { - const payload = await client.loopLogs(id, cursor); - if (payload.error || !payload.loop) { - throw new Error(payload.error ?? `Loop not found: ${id}`); - } - for (const entry of payload.entries) { - console.log(renderLogEntry(entry)); - } - if (payload.loop.status !== "running") { - await client.close(); - return; - } - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - return streamLogs(payload.nextCursor); - } - - try { - await streamLogs(0); - } catch (error) { - await client.close().catch(() => {}); - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: Failed to stream loop logs: ${message}`); - process.exit(1); - } -} diff --git a/packages/cli/src/commands/loop/ls.ts b/packages/cli/src/commands/loop/ls.ts deleted file mode 100644 index 514fcdfdcc..0000000000 --- a/packages/cli/src/commands/loop/ls.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; -import type { CommandOptions, CommandError, OutputSchema, ListResult } from "../../output/index.js"; -import type { LoopDaemonClient, LoopListItem } from "./types.js"; - -interface LoopListRow { - id: string; - name: string | null; - status: string; - cwd: string; - updated: string; - activeIteration: string; -} - -export interface LoopLsOptions extends CommandOptions {} - -export const loopLsSchema: OutputSchema = { - idField: "id", - columns: [ - { header: "LOOP ID", field: "id", width: 10 }, - { header: "NAME", field: "name", width: 20 }, - { header: "STATUS", field: "status", width: 10 }, - { header: "ITER", field: "activeIteration", width: 8 }, - { header: "CWD", field: "cwd", width: 40 }, - { header: "UPDATED", field: "updated", width: 24 }, - ], -}; - -export function addLoopLsOptions(command: Command): Command { - return command.description("List loops"); -} - -function toRow(loop: LoopListItem): LoopListRow { - return { - id: loop.id, - name: loop.name, - status: loop.status, - cwd: loop.cwd, - updated: loop.updatedAt, - activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration), - }; -} - -export type LoopLsResult = ListResult; - -export async function runLoopLsCommand( - options: LoopLsOptions, - _command: Command, -): Promise { - const host = getDaemonHost({ host: options.host }); - let client; - try { - client = (await connectToDaemon({ - host: options.host, - })) as unknown as LoopDaemonClient; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - } satisfies CommandError; - } - - try { - const payload = await client.loopList(); - await client.close(); - if (payload.error) { - throw new Error(payload.error); - } - return { - type: "list", - data: payload.loops.map(toRow), - schema: loopLsSchema, - }; - } catch (error) { - await client.close().catch(() => {}); - throw { - code: "LOOP_LIST_FAILED", - message: error instanceof Error ? error.message : String(error), - } satisfies CommandError; - } -} diff --git a/packages/cli/src/commands/loop/run.test.ts b/packages/cli/src/commands/loop/run.test.ts deleted file mode 100644 index 5a2368924e..0000000000 --- a/packages/cli/src/commands/loop/run.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { buildLoopRunInput } from "./run.js"; -import type { LoopRunOptions } from "./types.js"; - -describe("buildLoopRunInput", () => { - it("should parse provider/model from provider string", () => { - const options: LoopRunOptions = { - provider: "opencode/hy3-preview-free", - model: undefined, - verifyProvider: undefined, - verifyModel: undefined, - verify: undefined, - verifyCheck: [], - archive: false, - name: undefined, - sleep: undefined, - maxIterations: undefined, - maxTime: undefined, - } as LoopRunOptions; - - const result = buildLoopRunInput("test prompt", options); - - expect(result.provider).toBe("opencode"); - expect(result.model).toBe("hy3-preview-free"); - }); - - it("should use provider only when no model in string", () => { - const options = { - provider: "opencode", - model: undefined, - verifyProvider: undefined, - verifyModel: undefined, - verify: undefined, - verifyCheck: [], - archive: false, - name: undefined, - sleep: undefined, - maxIterations: undefined, - maxTime: undefined, - } as LoopRunOptions; - - const result = buildLoopRunInput("test prompt", options); - - expect(result.provider).toBe("opencode"); - expect(result.model).toBeUndefined(); - }); - - it("should pass verifierProvider with model parsing", () => { - const options = { - provider: undefined, - model: undefined, - verifyProvider: "codex/gpt-5.4", - verifyModel: undefined, - verify: undefined, - verifyCheck: [], - archive: false, - name: undefined, - sleep: undefined, - maxIterations: undefined, - maxTime: undefined, - } as LoopRunOptions; - - const result = buildLoopRunInput("test prompt", options); - - expect(result.verifierProvider).toBe("codex"); - expect(result.verifierModel).toBe("gpt-5.4"); - }); - - it("should prefer explicit model over parsed model", () => { - const options = { - provider: "opencode/hy3-preview-free", - model: "other-model", - verifyProvider: undefined, - verifyModel: undefined, - verify: undefined, - verifyCheck: [], - archive: false, - name: undefined, - sleep: undefined, - maxIterations: undefined, - maxTime: undefined, - } as LoopRunOptions; - - const result = buildLoopRunInput("test prompt", options); - - expect(result.provider).toBe("opencode"); - expect(result.model).toBe("other-model"); - }); -}); diff --git a/packages/cli/src/commands/loop/run.ts b/packages/cli/src/commands/loop/run.ts deleted file mode 100644 index fc8c25c54b..0000000000 --- a/packages/cli/src/commands/loop/run.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; -import type { - CommandOptions, - CommandError, - OutputSchema, - SingleResult, -} from "../../output/index.js"; -import { collectMultiple } from "../../utils/command-options.js"; -import { parseDuration } from "../../utils/duration.js"; -import { resolveProviderAndModel } from "../../utils/provider-model.js"; -import type { LoopDaemonClient, LoopRecord, LoopRunInput } from "./types.js"; - -export interface LoopRunRow { - id: string; - status: string; - name: string | null; - cwd: string; -} - -export interface LoopRunOptions extends CommandOptions { - provider?: string; - model?: string; - mode?: string; - verifyProvider?: string; - verifyModel?: string; - verifyMode?: string; - verify?: string; - verifyCheck?: string[]; - archive?: boolean; - name?: string; - sleep?: string; - maxIterations?: string; - maxTime?: string; -} - -export const loopRunSchema: OutputSchema = { - idField: "id", - columns: [ - { header: "LOOP ID", field: "id", width: 10 }, - { header: "STATUS", field: "status", width: 10 }, - { header: "NAME", field: "name", width: 20 }, - { header: "CWD", field: "cwd", width: 40 }, - ], -}; - -export function addLoopRunOptions(command: Command): Command { - return command - .description("Start a loop") - .argument("", "Prompt for each fresh worker iteration") - .option("--provider ", "Default provider for worker and verifier agents") - .option("--model ", "Default model for worker and verifier agents") - .option( - "--mode ", - "Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)", - ) - .option("--verify-provider ", "Provider for the verifier agent") - .option("--verify-model ", "Model for the verifier agent") - .option("--verify-mode ", "Provider-specific mode for the verifier agent") - .option("--verify ", "Verifier agent prompt") - .option( - "--verify-check ", - "Shell command that must exit 0 (repeatable)", - collectMultiple, - [], - ) - .option("--archive", "Archive worker and verifier agents after each iteration") - .option("--name ", "Optional loop name") - .option("--sleep ", "Delay between iterations (for example: 30s, 5m)") - .option("--max-iterations ", "Maximum number of iterations") - .option("--max-time ", "Maximum total runtime (for example: 1h, 30m)"); -} - -function toRow(loop: LoopRecord): LoopRunRow { - return { - id: loop.id, - status: loop.status, - name: loop.name, - cwd: loop.cwd, - }; -} - -function parseMaxIterations(value: string | undefined): number | undefined { - if (value === undefined) { - return undefined; - } - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw { - code: "INVALID_MAX_ITERATIONS", - message: "--max-iterations must be a positive integer", - } satisfies CommandError; - } - return parsed; -} - -// oxlint-disable complexity -export function buildLoopRunInput(prompt: string, options: LoopRunOptions): LoopRunInput { - const verifyPrompt = options.verify?.trim(); - if (options.verify !== undefined && !verifyPrompt) { - throw { - code: "INVALID_VERIFY_PROMPT", - message: "--verify cannot be empty", - } satisfies CommandError; - } - - const result: LoopRunInput = { - prompt, - cwd: process.cwd(), - }; - - // Resolve provider/model - if (options.provider) { - const { provider, model } = resolveProviderAndModel({ provider: options.provider }); - if (provider) result.provider = provider; - // Explicit --model takes precedence over parsed model - if (options.model?.trim()) { - result.model = options.model.trim(); - } else if (model) { - result.model = model; - } - } else if (options.model?.trim()) { - result.model = options.model.trim(); - } - - if (options.mode?.trim()) { - result.modeId = options.mode.trim(); - } - - // Resolve verifier provider/model - if (options.verifyProvider) { - const { provider, model } = resolveProviderAndModel({ provider: options.verifyProvider }); - if (provider) result.verifierProvider = provider; - // Explicit --verify-model takes precedence over parsed model - if (options.verifyModel?.trim()) { - result.verifierModel = options.verifyModel.trim(); - } else if (model) { - result.verifierModel = model; - } - } else if (options.verifyModel?.trim()) { - result.verifierModel = options.verifyModel.trim(); - } - - if (options.verifyMode?.trim()) { - result.verifierModeId = options.verifyMode.trim(); - } - - if (verifyPrompt) result.verifyPrompt = verifyPrompt; - if (options.verifyCheck && options.verifyCheck.length > 0) { - result.verifyChecks = options.verifyCheck; - } - if (options.archive) result.archive = true; - if (options.name?.trim()) result.name = options.name.trim(); - if (options.sleep) result.sleepMs = parseDuration(options.sleep); - if (options.maxIterations) { - result.maxIterations = parseMaxIterations(options.maxIterations); - } - if (options.maxTime) result.maxTimeMs = parseDuration(options.maxTime); - - return result; -} -// oxlint-enable complexity - -export type LoopRunResult = SingleResult; - -export async function runLoopRunCommand( - prompt: string, - options: LoopRunOptions, - _command: Command, -): Promise { - const host = getDaemonHost({ host: options.host }); - const input = buildLoopRunInput(prompt, options); - let client; - try { - client = (await connectToDaemon({ - host: options.host, - })) as unknown as LoopDaemonClient; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - } satisfies CommandError; - } - - try { - const payload = await client.loopRun(input); - await client.close(); - if (payload.error || !payload.loop) { - throw new Error(payload.error ?? "Loop creation failed"); - } - return { - type: "single", - data: toRow(payload.loop), - schema: loopRunSchema, - }; - } catch (error) { - await client.close().catch(() => {}); - if (error && typeof error === "object" && "code" in error) { - throw error; - } - throw { - code: "LOOP_RUN_FAILED", - message: error instanceof Error ? error.message : String(error), - } satisfies CommandError; - } -} diff --git a/packages/cli/src/commands/loop/stop.ts b/packages/cli/src/commands/loop/stop.ts deleted file mode 100644 index 1207559ced..0000000000 --- a/packages/cli/src/commands/loop/stop.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; -import type { - CommandOptions, - CommandError, - OutputSchema, - SingleResult, -} from "../../output/index.js"; -import type { LoopDaemonClient, LoopRecord } from "./types.js"; - -interface LoopStopRow { - id: string; - status: string; - activeIteration: string; -} - -export interface LoopStopOptions extends CommandOptions {} - -export const loopStopSchema: OutputSchema = { - idField: "id", - columns: [ - { header: "LOOP ID", field: "id", width: 10 }, - { header: "STATUS", field: "status", width: 10 }, - { header: "ITER", field: "activeIteration", width: 8 }, - ], -}; - -export function addLoopStopOptions(command: Command): Command { - return command.description("Stop a running loop").argument("", "Loop ID"); -} - -function toRow(loop: LoopRecord): LoopStopRow { - return { - id: loop.id, - status: loop.status, - activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration), - }; -} - -export type LoopStopResult = SingleResult; - -export async function runLoopStopCommand( - id: string, - options: LoopStopOptions, - _command: Command, -): Promise { - const host = getDaemonHost({ host: options.host }); - let client; - try { - client = (await connectToDaemon({ - host: options.host, - })) as unknown as LoopDaemonClient; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - } satisfies CommandError; - } - - try { - const payload = await client.loopStop(id); - await client.close(); - if (payload.error || !payload.loop) { - throw new Error(payload.error ?? `Loop not found: ${id}`); - } - return { - type: "single", - data: toRow(payload.loop), - schema: loopStopSchema, - }; - } catch (error) { - await client.close().catch(() => {}); - throw { - code: "LOOP_STOP_FAILED", - message: error instanceof Error ? error.message : String(error), - } satisfies CommandError; - } -} diff --git a/packages/cli/src/commands/loop/types.ts b/packages/cli/src/commands/loop/types.ts deleted file mode 100644 index 66db601dcd..0000000000 --- a/packages/cli/src/commands/loop/types.ts +++ /dev/null @@ -1,151 +0,0 @@ -export type LoopStatus = "running" | "succeeded" | "failed" | "stopped"; - -export interface LoopLogEntry { - seq: number; - timestamp: string; - iteration: number | null; - source: "loop" | "worker" | "verifier" | "verify-check"; - level: "info" | "error"; - text: string; -} - -export interface LoopVerifyCheckResult { - command: string; - exitCode: number; - passed: boolean; - stdout: string; - stderr: string; - startedAt: string; - completedAt: string; -} - -export interface LoopVerifyPromptResult { - passed: boolean; - reason: string; - verifierAgentId: string | null; - startedAt: string; - completedAt: string; -} - -export interface LoopIterationRecord { - index: number; - workerAgentId: string | null; - workerStartedAt: string; - workerCompletedAt: string | null; - verifierAgentId: string | null; - status: "running" | "succeeded" | "failed" | "stopped"; - workerOutcome: "completed" | "failed" | "canceled" | null; - failureReason: string | null; - verifyChecks: LoopVerifyCheckResult[]; - verifyPrompt: LoopVerifyPromptResult | null; -} - -export interface LoopRecord { - id: string; - name: string | null; - prompt: string; - cwd: string; - provider: string; - model: string | null; - modeId: string | null; - workerProvider: string | null; - workerModel: string | null; - verifierProvider: string | null; - verifierModel: string | null; - verifierModeId: string | null; - verifyPrompt: string | null; - verifyChecks: string[]; - archive: boolean; - sleepMs: number; - maxIterations: number | null; - maxTimeMs: number | null; - status: LoopStatus; - createdAt: string; - updatedAt: string; - startedAt: string; - completedAt: string | null; - stopRequestedAt: string | null; - iterations: LoopIterationRecord[]; - logs: LoopLogEntry[]; - nextLogSeq: number; - activeIteration: number | null; - activeWorkerAgentId: string | null; - activeVerifierAgentId: string | null; -} - -export interface LoopListItem { - id: string; - name: string | null; - status: LoopStatus; - cwd: string; - createdAt: string; - updatedAt: string; - activeIteration: number | null; -} - -export interface LoopLogsResult { - loop: LoopRecord; - entries: LoopLogEntry[]; - nextCursor: number; -} - -export interface LoopRunPayload { - requestId: string; - loop: LoopRecord | null; - error: string | null; -} - -export interface LoopListPayload { - requestId: string; - loops: LoopListItem[]; - error: string | null; -} - -export interface LoopInspectPayload { - requestId: string; - loop: LoopRecord | null; - error: string | null; -} - -export interface LoopLogsPayload { - requestId: string; - loop: LoopRecord | null; - entries: LoopLogEntry[]; - nextCursor: number; - error: string | null; -} - -export interface LoopStopPayload { - requestId: string; - loop: LoopRecord | null; - error: string | null; -} - -export interface LoopRunInput { - prompt: string; - cwd: string; - provider?: string; - model?: string; - modeId?: string; - workerProvider?: string; - workerModel?: string; - verifierProvider?: string; - verifierModel?: string; - verifierModeId?: string; - verifyPrompt?: string; - verifyChecks?: string[]; - archive?: boolean; - name?: string; - sleepMs?: number; - maxIterations?: number; - maxTimeMs?: number; -} - -export interface LoopDaemonClient { - loopRun(input: LoopRunInput): Promise; - loopList(): Promise; - loopInspect(id: string): Promise; - loopLogs(id: string, afterSeq?: number): Promise; - loopStop(id: string): Promise; - close(): Promise; -} diff --git a/packages/cli/tests/30-chat.test.ts b/packages/cli/tests/30-chat.test.ts deleted file mode 100644 index efcf2a4fc2..0000000000 --- a/packages/cli/tests/30-chat.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env npx tsx - -import assert from "node:assert"; -import { rm } from "node:fs/promises"; -import { createE2ETestContext } from "./helpers/test-daemon.ts"; - -console.log("=== Chat Command Tests ===\n"); - -const ctx = await createE2ETestContext({ timeout: 30000 }); - -try { - { - console.log("Test 1: chat create/ls/inspect work"); - const created = await ctx.paseo(["chat", "create", "coord-room", "--purpose", "Coordination"]); - assert.strictEqual(created.exitCode, 0, created.stderr); - assert(created.stdout.includes("coord-room"), created.stdout); - - const listed = await ctx.paseo(["chat", "ls"]); - assert.strictEqual(listed.exitCode, 0, listed.stderr); - assert(listed.stdout.includes("coord-room"), listed.stdout); - - const inspected = await ctx.paseo(["chat", "inspect", "coord-room"]); - assert.strictEqual(inspected.exitCode, 0, inspected.stderr); - assert(inspected.stdout.includes("Coordination"), inspected.stdout); - console.log("chat create/ls/inspect work\n"); - } - - { - console.log("Test 2: chat post/read/wait work"); - const posted = await ctx.paseo(["chat", "post", "coord-room", "first message for @agent-1"], { - env: { PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000111" }, - }); - assert.strictEqual(posted.exitCode, 0, posted.stderr); - assert(posted.stdout.includes("first message"), posted.stdout); - assert(posted.stdout.includes("00000000-0000-4000-8000-000000000111"), posted.stdout); - - const read = await ctx.paseo(["chat", "read", "coord-room", "--limit", "10"]); - assert.strictEqual(read.exitCode, 0, read.stderr); - assert(read.stdout.includes("first message"), read.stdout); - assert(read.stdout.includes("00000000-0000-4000-8000-000000000111"), read.stdout); - - const readJson = await ctx.paseo(["chat", "read", "coord-room", "--limit", "10", "--json"]); - assert.strictEqual(readJson.exitCode, 0, readJson.stderr); - const readPayload = JSON.parse(readJson.stdout); - assert.strictEqual(readPayload[0]?.author, "00000000-0000-4000-8000-000000000111"); - - // `chat wait` reads "latest message id" then subscribes for newer ones. - // Under CI load the subprocess can take >1s to bootstrap, so a single - // delayed post races against the read. Post repeatedly and race against - // wait — every post is newer than the snapshot wait took, so one of them - // always wakes it up. - const waitPromise = ctx.paseo(["chat", "wait", "coord-room", "--timeout", "30s"]); - const waitSentinel = waitPromise.then(() => "settled" as const); - let postedSecond = false; - for (let attempt = 0; attempt < 60; attempt++) { - const post = await ctx.paseo(["chat", "post", "coord-room", "second message"]); - assert.strictEqual(post.exitCode, 0, post.stderr); - const tick = new Promise<"tick">((resolve) => setTimeout(() => resolve("tick"), 250)); - const result = await Promise.race([waitSentinel, tick]); - if (result === "settled") { - postedSecond = true; - break; - } - } - assert(postedSecond, "chat wait did not return after repeated posts"); - - const waited = await waitPromise; - assert.strictEqual(waited.exitCode, 0, waited.stderr); - assert(waited.stdout.includes("second message"), waited.stdout); - console.log("chat post/read/wait work\n"); - } - - { - console.log("Test 3: duplicate room create fails"); - const duplicate = await ctx.paseo(["chat", "create", "coord-room"]); - assert.notStrictEqual(duplicate.exitCode, 0, "duplicate create should fail"); - const combined = `${duplicate.stdout}\n${duplicate.stderr}`; - assert(combined.toLowerCase().includes("already exists"), combined); - console.log("duplicate room create fails\n"); - } - - { - console.log("Test 4: chat delete works"); - const deleted = await ctx.paseo(["chat", "delete", "coord-room"]); - assert.strictEqual(deleted.exitCode, 0, deleted.stderr); - assert(deleted.stdout.includes("coord-room"), deleted.stdout); - console.log("chat delete works\n"); - } -} finally { - await ctx.stop(); - await rm(ctx.paseoHome, { recursive: true, force: true }); - await rm(ctx.workDir, { recursive: true, force: true }); -} - -console.log("=== Chat Command Tests Passed ==="); diff --git a/packages/cli/tests/31-loop-schedule.test.ts b/packages/cli/tests/31-schedule.test.ts similarity index 66% rename from packages/cli/tests/31-loop-schedule.test.ts rename to packages/cli/tests/31-schedule.test.ts index 0120ffea68..b3d3c5ada5 100644 --- a/packages/cli/tests/31-loop-schedule.test.ts +++ b/packages/cli/tests/31-schedule.test.ts @@ -4,31 +4,7 @@ import assert from "node:assert"; import { rm } from "node:fs/promises"; import { createE2ETestContext } from "./helpers/test-daemon.ts"; -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function waitForLoopInList( - ctx: Awaited>, - id: string, -) { - for (let attempt = 0; attempt < 20; attempt++) { - const listed = await ctx.paseo(["loop", "ls", "--json"]); - assert.strictEqual(listed.exitCode, 0, listed.stderr); - const listedJson = JSON.parse(listed.stdout); - assert(Array.isArray(listedJson), listed.stdout); - if (listedJson.some((item: { id: string }) => item.id === id)) { - return listedJson; - } - await sleep(250); - } - - const listed = await ctx.paseo(["loop", "ls", "--json"]); - assert.strictEqual(listed.exitCode, 0, listed.stderr); - return JSON.parse(listed.stdout); -} - -console.log("=== Loop And Schedule Command Tests ===\n"); +console.log("=== Schedule Command Tests ===\n"); const ctx = await createE2ETestContext({ timeout: 30000 }); @@ -171,62 +147,10 @@ try { assert.strictEqual(JSON.parse(deleted.stdout).id, createdJson.id); console.log("compatibility agent-target schedules remain deletable\n"); } - - { - console.log("Test 2: loop run/ls/inspect/logs/stop work"); - const run = await ctx.paseo( - [ - "loop", - "run", - "Return any response", - "--name", - "smoke-loop", - "--verify-check", - "true", - "--json", - ], - { timeout: 30000 }, - ); - assert.strictEqual(run.exitCode, 0, run.stderr); - const runJson = JSON.parse(run.stdout); - assert.strictEqual(runJson.name, "smoke-loop"); - - const listedJson = await waitForLoopInList(ctx, runJson.id); - assert( - listedJson.some((item: { id: string }) => item.id === runJson.id), - JSON.stringify(listedJson), - ); - - async function pollStatus(attempt: number): Promise { - if (attempt >= 40) return "running"; - const inspect = await ctx.paseo(["loop", "inspect", runJson.id, "--json"]); - assert.strictEqual(inspect.exitCode, 0, inspect.stderr); - const inspectJson = JSON.parse(inspect.stdout); - const current = inspectJson.status; - if (current !== "running") { - assert.strictEqual(current, "succeeded", inspect.stdout); - return current; - } - await sleep(250); - return pollStatus(attempt + 1); - } - const status = await pollStatus(0); - assert.strictEqual(status, "succeeded"); - - const logs = await ctx.paseo(["loop", "logs", runJson.id], { timeout: 15000 }); - assert.strictEqual(logs.exitCode, 0, logs.stderr); - assert(logs.stdout.includes("verify-check"), logs.stdout); - - const stopped = await ctx.paseo(["loop", "stop", runJson.id, "--json"]); - assert.strictEqual(stopped.exitCode, 0, stopped.stderr); - const stoppedJson = JSON.parse(stopped.stdout); - assert(["succeeded", "stopped"].includes(stoppedJson.status), stopped.stdout); - console.log("loop commands work\n"); - } } finally { await ctx.stop(); await rm(ctx.paseoHome, { recursive: true, force: true }); await rm(ctx.workDir, { recursive: true, force: true }); } -console.log("=== Loop And Schedule Command Tests Passed ==="); +console.log("=== Schedule Command Tests Passed ==="); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index af3073c283..d07be4c156 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1294,56 +1294,6 @@ test("honors explicit shutdownServer timeout below the session RPC default", asy await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)"); }); -test("honors explicit readChatMessages timeout below the session RPC default", async () => { - useHeartbeatClock(); - const logger = createMockLogger(); - const mock = createMockTransport(); - - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; - - const responsePromise = client.readChatMessages({ - requestId: "req-chat-read-1", - room: "room-1", - limit: 1, - timeout: 2_500, - }); - let settled = false; - void responsePromise.then( - () => { - settled = true; - return undefined; - }, - () => { - settled = true; - return undefined; - }, - ); - - expect(parseSentFrame(mock.sent[0])).toEqual({ - type: "chat/read", - requestId: "req-chat-read-1", - room: "room-1", - limit: 1, - }); - - await vi.advanceTimersByTimeAsync(2_499); - expect(settled).toBe(false); - - await vi.advanceTimersByTimeAsync(1); - await expect(responsePromise).rejects.toThrow("Timeout waiting for message (2500ms)"); -}); - test("honors explicit getDaemonStatus timeout below the session RPC default", async () => { useHeartbeatClock(); const logger = createMockLogger(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index b8d1b26683..bcdf143fb9 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -492,30 +492,6 @@ type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; type CloseItemsPayload = CloseItemsResponse["payload"]; type KillTerminalPayload = KillTerminalResponse["payload"]; type CaptureTerminalPayload = CaptureTerminalResponse["payload"]; -type ChatCreatePayload = Extract< - SessionOutboundMessage, - { type: "chat/create/response" } ->["payload"]; -type ChatListPayload = Extract["payload"]; -type ChatInspectPayload = Extract< - SessionOutboundMessage, - { type: "chat/inspect/response" } ->["payload"]; -type ChatDeletePayload = Extract< - SessionOutboundMessage, - { type: "chat/delete/response" } ->["payload"]; -type ChatPostPayload = Extract["payload"]; -type ChatReadPayload = Extract["payload"]; -type ChatWaitPayload = Extract["payload"]; -type LoopRunPayload = Extract["payload"]; -type LoopListPayload = Extract["payload"]; -type LoopInspectPayload = Extract< - SessionOutboundMessage, - { type: "loop/inspect/response" } ->["payload"]; -type LoopLogsPayload = Extract["payload"]; -type LoopStopPayload = Extract["payload"]; type ScheduleCreatePayload = Extract< SessionOutboundMessage, { type: "schedule/create/response" } @@ -697,70 +673,6 @@ export type ProjectListPayload = Extract< SessionOutboundMessage, { type: "project.list.response" } >["payload"]; -export interface CreateChatRoomOptions { - name: string; - purpose?: string | null; - requestId?: string; -} -export interface InspectChatRoomOptions { - room: string; - requestId?: string; -} -export interface DeleteChatRoomOptions { - room: string; - requestId?: string; -} -export interface PostChatMessageOptions { - room: string; - body: string; - authorAgentId?: string; - replyToMessageId?: string | null; - requestId?: string; -} -export interface ReadChatMessagesOptions { - room: string; - limit?: number; - since?: string; - authorAgentId?: string; - requestId?: string; - timeout?: number; -} -export interface WaitForChatMessagesOptions { - room: string; - afterMessageId?: string | null; - timeoutMs?: number; - requestId?: string; -} -export interface RunLoopOptions { - prompt: string; - cwd: string; - provider?: string; - model?: string; - modeId?: string; - verifierProvider?: string; - verifierModel?: string; - verifierModeId?: string; - verifyPrompt?: string | null; - verifyChecks?: string[]; - name?: string | null; - sleepMs?: number; - maxIterations?: number; - maxTimeMs?: number; - requestId?: string; -} -export interface InspectLoopOptions { - id: string; - requestId?: string; -} -export interface LoopLogsOptions { - id: string; - afterSeq?: number; - requestId?: string; -} -export interface StopLoopOptions { - id: string; - requestId?: string; -} export interface CreateScheduleOptions { prompt: string; name?: string | null; @@ -5116,93 +5028,6 @@ export class DaemonClient { }); } - async createChatRoom(options: CreateChatRoomOptions): Promise { - return this.sendCorrelatedSessionRequest({ - requestId: options.requestId, - message: { - type: "chat/create", - name: options.name, - ...(options.purpose ? { purpose: options.purpose } : {}), - }, - responseType: "chat/create/response", - }); - } - - async listChatRooms(requestId?: string): Promise { - return this.sendCorrelatedSessionRequest({ - requestId, - message: { - type: "chat/list", - }, - responseType: "chat/list/response", - }); - } - - async inspectChatRoom(options: InspectChatRoomOptions): Promise { - return this.sendCorrelatedSessionRequest({ - requestId: options.requestId, - message: { - type: "chat/inspect", - room: options.room, - }, - responseType: "chat/inspect/response", - }); - } - - async deleteChatRoom(options: DeleteChatRoomOptions): Promise { - return this.sendCorrelatedSessionRequest({ - requestId: options.requestId, - message: { - type: "chat/delete", - room: options.room, - }, - responseType: "chat/delete/response", - }); - } - - async postChatMessage(options: PostChatMessageOptions): Promise { - return this.sendCorrelatedSessionRequest({ - requestId: options.requestId, - message: { - type: "chat/post", - room: options.room, - body: options.body, - ...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}), - ...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}), - }, - responseType: "chat/post/response", - }); - } - - async readChatMessages(options: ReadChatMessagesOptions): Promise { - return this.sendCorrelatedSessionRequest({ - requestId: options.requestId, - message: { - type: "chat/read", - room: options.room, - ...(typeof options.limit === "number" ? { limit: options.limit } : {}), - ...(options.since ? { since: options.since } : {}), - ...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}), - }, - responseType: "chat/read/response", - timeout: options.timeout, - }); - } - - async waitForChatMessages(options: WaitForChatMessagesOptions): Promise { - return this.sendCorrelatedSessionRequest({ - requestId: options.requestId, - message: { - type: "chat/wait", - room: options.room, - ...(options.afterMessageId ? { afterMessageId: options.afterMessageId } : {}), - ...(typeof options.timeoutMs === "number" ? { timeoutMs: options.timeoutMs } : {}), - }, - responseType: "chat/wait/response", - timeout: (options.timeoutMs ?? 0) + 10000, - }); - } - async scheduleCreate(options: CreateScheduleOptions): Promise { return this.sendCorrelatedSessionRequest({ requestId: options.requestId, @@ -5313,81 +5138,6 @@ export class DaemonClient { }); } - async loopRun(options: RunLoopOptions): Promise { - return this.sendCorrelatedSessionRequest({ - requestId: options.requestId, - message: { - type: "loop/run", - prompt: options.prompt, - cwd: options.cwd, - ...(options.provider ? { provider: options.provider } : {}), - ...(options.model ? { model: options.model } : {}), - ...(options.modeId ? { modeId: options.modeId } : {}), - ...(options.verifierProvider ? { verifierProvider: options.verifierProvider } : {}), - ...(options.verifierModel ? { verifierModel: options.verifierModel } : {}), - ...(options.verifierModeId ? { verifierModeId: options.verifierModeId } : {}), - ...(options.verifyPrompt ? { verifyPrompt: options.verifyPrompt } : {}), - ...(options.verifyChecks && options.verifyChecks.length > 0 - ? { verifyChecks: options.verifyChecks } - : {}), - ...(options.name ? { name: options.name } : {}), - ...(typeof options.sleepMs === "number" ? { sleepMs: options.sleepMs } : {}), - ...(typeof options.maxIterations === "number" - ? { maxIterations: options.maxIterations } - : {}), - ...(typeof options.maxTimeMs === "number" ? { maxTimeMs: options.maxTimeMs } : {}), - }, - responseType: "loop/run/response", - }); - } - - async loopList(requestId?: string): Promise { - return this.sendCorrelatedSessionRequest({ - requestId, - message: { - type: "loop/list", - }, - responseType: "loop/list/response", - }); - } - - async loopInspect(options: string | InspectLoopOptions): Promise { - const normalized = typeof options === "string" ? { id: options } : options; - return this.sendCorrelatedSessionRequest({ - requestId: normalized.requestId, - message: { - type: "loop/inspect", - id: normalized.id, - }, - responseType: "loop/inspect/response", - }); - } - - async loopLogs(options: string | LoopLogsOptions, afterSeq?: number): Promise { - const normalized = typeof options === "string" ? { id: options, afterSeq } : options; - return this.sendCorrelatedSessionRequest({ - requestId: normalized.requestId, - message: { - type: "loop/logs", - id: normalized.id, - ...(typeof normalized.afterSeq === "number" ? { afterSeq: normalized.afterSeq } : {}), - }, - responseType: "loop/logs/response", - }); - } - - async loopStop(options: string | StopLoopOptions): Promise { - const normalized = typeof options === "string" ? { id: options } : options; - return this.sendCorrelatedSessionRequest({ - requestId: normalized.requestId, - message: { - type: "loop/stop", - id: normalized.id, - }, - responseType: "loop/stop/response", - }); - } - onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void { return this.terminalStreams.onEvent(handler); } diff --git a/packages/protocol/src/chat/rpc-schemas.ts b/packages/protocol/src/chat/rpc-schemas.ts index b3520f4c50..2a44d74410 100644 --- a/packages/protocol/src/chat/rpc-schemas.ts +++ b/packages/protocol/src/chat/rpc-schemas.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { ChatMessageSchema, ChatRoomDetailSchema } from "./types.js"; +// COMPAT(chatRooms): retained after the v0.3.0 feature removal; remove after 2027-02-09 when mixed-version peers no longer send legacy messages. + export const ChatCreateRequestSchema = z.object({ type: z.literal("chat/create"), requestId: z.string(), diff --git a/packages/protocol/src/chat/types.ts b/packages/protocol/src/chat/types.ts index d8ecd05ab3..30be366631 100644 --- a/packages/protocol/src/chat/types.ts +++ b/packages/protocol/src/chat/types.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +// COMPAT(chatRooms): retained after the v0.3.0 feature removal; remove after 2027-02-09 when mixed-version peers no longer send legacy messages. + export const ChatRoomSchema = z.object({ id: z.string(), name: z.string(), diff --git a/packages/protocol/src/loop/rpc-schemas.ts b/packages/protocol/src/loop/rpc-schemas.ts index 53159afcb2..29996a2a60 100644 --- a/packages/protocol/src/loop/rpc-schemas.ts +++ b/packages/protocol/src/loop/rpc-schemas.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { AgentProviderSchema } from "../provider-manifest.js"; +// COMPAT(agentLoops): retained after the v0.3.0 feature removal; remove after 2027-02-09 when mixed-version peers no longer send legacy messages. + export const LoopLogEntrySchema = z.object({ seq: z.number().int().positive(), timestamp: z.string(), diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 2258826505..723f612085 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -102,6 +102,7 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent id: overrides.id ?? "agent-test", provider: core.provider, cwd: core.cwd, + workspaceId: overrides.workspaceId, session: core.session, capabilities: overrides.capabilities ?? buildDefaultCapabilities(), config: core.config, @@ -433,6 +434,47 @@ describe("AgentStorage", () => { expect(record?.internal).toBe(true); }); + test("queries agents by provider session and native handle", async () => { + await storage.applySnapshot( + createManagedAgent({ + id: "matching-session", + provider: "codex", + persistence: { + provider: "codex", + sessionId: "session-1", + nativeHandle: "thread-1", + }, + }), + ); + await storage.applySnapshot( + createManagedAgent({ + id: "other-session", + provider: "codex", + persistence: { provider: "codex", sessionId: "session-2" }, + }), + ); + + await expect(storage.listByProviderSession("codex", "session-1")).resolves.toMatchObject([ + { id: "matching-session" }, + ]); + await expect(storage.listByProviderSession("codex", "thread-1")).resolves.toMatchObject([ + { id: "matching-session" }, + ]); + }); + + test("queries agents by workspace", async () => { + await storage.applySnapshot( + createManagedAgent({ id: "workspace-agent", workspaceId: "workspace-1" }), + ); + await storage.applySnapshot( + createManagedAgent({ id: "other-workspace-agent", workspaceId: "workspace-2" }), + ); + + await expect(storage.listByWorkspace("workspace-1")).resolves.toMatchObject([ + { id: "workspace-agent" }, + ]); + }); + test("internal flag is persisted and reloaded", async () => { await storage.applySnapshot( createManagedAgent({ diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index f3645c7bb2..b41bc75cfc 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -126,6 +126,24 @@ export class AgentStorage { return this.cache.get(agentId) ?? null; } + async listByProviderSession( + provider: string, + providerHandleId: string, + ): Promise { + await this.load(); + return Array.from(this.cache.values()).filter( + (record) => + record.persistence?.provider === provider && + (record.persistence.sessionId === providerHandleId || + record.persistence.nativeHandle === providerHandleId), + ); + } + + async listByWorkspace(workspaceId: string): Promise { + await this.load(); + return Array.from(this.cache.values()).filter((record) => record.workspaceId === workspaceId); + } + async findByDaemonExecution(owner: DaemonAgentOwner): Promise { await this.load(); const agentId = this.daemonAgentIdsByExecution.get(daemonExecutionKey(owner)); diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index b1281c27bb..ed22fefbab 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -191,8 +191,9 @@ async function importProviderSessionNow( ): Promise { const { provider, providerHandleId, labels } = input.request; - const matchingRecords = (await input.agentStorage.list()).filter((record) => - recordMatchesProviderHandle(record, { provider, providerHandleId }), + const matchingRecords = await input.agentStorage.listByProviderSession( + provider, + providerHandleId, ); const activeRecord = matchingRecords.find((record) => !record.archivedAt); if (activeRecord) { @@ -272,16 +273,18 @@ async function serializeProviderSessionImport( async function resolveProviderSessionImportMutationKey( input: ImportProviderSessionInput, ): Promise { - const identity = { - provider: input.request.provider, - providerHandleId: input.request.providerHandleId, - }; - const matchingRecord = (await input.agentStorage.list()).find((record) => - recordMatchesProviderHandle(record, identity), - ); + const matchingRecord = ( + await input.agentStorage.listByProviderSession( + input.request.provider, + input.request.providerHandleId, + ) + ).at(0); return matchingRecord ? `agent\0${matchingRecord.id}` - : `handle\0${toProviderSessionHandleKey(identity.provider, identity.providerHandleId)}`; + : `handle\0${toProviderSessionHandleKey( + input.request.provider, + input.request.providerHandleId, + )}`; } async function rollbackArchivedImport( @@ -311,17 +314,6 @@ async function rollbackArchivedImport( } } -function recordMatchesProviderHandle( - record: StoredAgentRecord, - identity: { provider: string; providerHandleId: string }, -): boolean { - return ( - record.persistence?.provider === identity.provider && - (record.persistence.sessionId === identity.providerHandleId || - record.persistence.nativeHandle === identity.providerHandleId) - ); -} - function parseRecentProviderSessionsSince(since: string | undefined): number | null { if (!since) { return null; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 18609aca51..11618aa96d 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -144,9 +144,7 @@ import { FileBackedWorkspaceRegistry, type WorkspaceArchiveContext, } from "./workspace-registry.js"; -import { FileBackedChatService } from "./chat/chat-service.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; -import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js"; import { BrowserToolsBroker } from "./browser-tools/broker.js"; @@ -798,10 +796,6 @@ export async function createPaseoDaemon( path.join(config.paseoHome, "projects", "workspaces.json"), logger, ); - const chatService = new FileBackedChatService({ - paseoHome: config.paseoHome, - logger, - }); const github = createGitHubService(); const workspaceGitService = new WorkspaceGitServiceImpl({ logger, @@ -882,8 +876,6 @@ export async function createPaseoDaemon( void workspaceReconciliation.reconcileNow().catch((error) => { logger.warn({ err: error }, "Initial workspace reconciliation failed"); }); - await chatService.initialize(); - logger.info({ elapsed: elapsed() }, "Chat service initialized"); const checkoutDiffManager = new CheckoutDiffManager({ logger, paseoHome: config.paseoHome, @@ -1143,15 +1135,6 @@ export async function createPaseoDaemon( }), }); - const loopService = new LoopService({ - paseoHome: config.paseoHome, - logger, - agentManager, - createAgent, - ensureWorkspaceForCreate: ensureWorkspaceForCreateAndBroadcastExternal, - }); - await loopService.initialize(); - logger.info({ elapsed: elapsed() }, "Loop service initialized"); const createScheduleLocalWorkspaceExternal = async (input: { cwd: string; firstAgentContext: FirstAgentContext; @@ -1538,8 +1521,6 @@ export async function createPaseoDaemon( }, projectRegistry, workspaceRegistry, - chatService, - loopService, scheduleService, checkoutDiffManager, serviceProxy, diff --git a/packages/server/src/server/chat/chat-mentions.test.ts b/packages/server/src/server/chat/chat-mentions.test.ts deleted file mode 100644 index b209abb0d3..0000000000 --- a/packages/server/src/server/chat/chat-mentions.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import type pino from "pino"; -import type { StoredAgentRecord } from "../agent/agent-storage.js"; -import type { ManagedAgent } from "../agent/agent-manager.js"; -import { - buildChatMentionNotification, - notifyChatMentions, - prepareChatMentionFanout, -} from "./chat-mentions.js"; - -function storedAgent(overrides: Partial & { id: string }): StoredAgentRecord { - return { - internal: false, - archivedAt: null, - lastStatus: "idle", - ...overrides, - } as StoredAgentRecord; -} - -function liveAgent(overrides: Partial & { id: string }): ManagedAgent { - return { internal: false, lifecycle: "idle", ...overrides } as ManagedAgent; -} - -async function prepare(input: { - authorAgentId: string; - mentionAgentIds: string[]; - storedAgents?: StoredAgentRecord[]; - liveAgents?: ManagedAgent[]; - roomPosterAgentIds?: string[]; - limit?: number; -}) { - const result = await prepareChatMentionFanout({ - authorAgentId: input.authorAgentId, - mentionAgentIds: input.mentionAgentIds, - storedAgents: input.storedAgents ?? [], - liveAgents: input.liveAgents ?? [], - listRoomPosterAgentIds: async () => input.roomPosterAgentIds ?? [], - limit: input.limit, - }); - if (!result.ok) { - throw new Error(`expected ok prepare, got error: ${result.error}`); - } - return result.prepared; -} - -describe("chat mentions", () => { - test("@everyone in an empty room resolves to no targets", async () => { - const prepared = await prepare({ - authorAgentId: "author-agent", - mentionAgentIds: ["everyone"], - storedAgents: [storedAgent({ id: "unrelated-agent" })], - liveAgents: [liveAgent({ id: "live-unrelated-agent" })], - roomPosterAgentIds: [], - }); - - expect(prepared.targetMentionAgentIds).toEqual([]); - }); - - test("@everyone in a single-poster room excludes the author", async () => { - const prepared = await prepare({ - authorAgentId: "author-agent", - mentionAgentIds: ["everyone"], - storedAgents: [storedAgent({ id: "author-agent" })], - roomPosterAgentIds: ["author-agent"], - }); - - expect(prepared.targetMentionAgentIds).toEqual([]); - }); - - test("@everyone only expands to active posters in the room", async () => { - const prepared = await prepare({ - authorAgentId: "author-agent", - mentionAgentIds: ["everyone"], - storedAgents: [ - storedAgent({ id: "agent-a" }), - storedAgent({ id: "agent-b" }), - storedAgent({ id: "unrelated-agent" }), - ], - liveAgents: [liveAgent({ id: "agent-c" }), liveAgent({ id: "live-unrelated-agent" })], - roomPosterAgentIds: ["agent-a", "agent-b", "agent-c"], - }); - - expect([...prepared.targetMentionAgentIds].sort()).toEqual(["agent-a", "agent-b", "agent-c"]); - }); - - test("@everyone excludes archived and error-state agents", async () => { - const prepared = await prepare({ - authorAgentId: "author-agent", - mentionAgentIds: ["everyone"], - storedAgents: [ - storedAgent({ id: "active-agent" }), - storedAgent({ id: "archived-agent", archivedAt: "2026-03-28T00:00:00.000Z" }), - storedAgent({ id: "stored-error-agent", lastStatus: "error" }), - ], - liveAgents: [liveAgent({ id: "live-error-agent", lifecycle: "error" })], - roomPosterAgentIds: [ - "active-agent", - "archived-agent", - "stored-error-agent", - "live-error-agent", - ], - }); - - expect(prepared.targetMentionAgentIds).toEqual(["active-agent"]); - }); - - test("@everyone deduplicates with explicit mentions and keeps explicit non-everyone mentions", async () => { - const prepared = await prepare({ - authorAgentId: "author-agent", - mentionAgentIds: ["everyone", "agent-a", "custom-title"], - storedAgents: [storedAgent({ id: "agent-a" })], - liveAgents: [liveAgent({ id: "agent-b" })], - roomPosterAgentIds: ["agent-a", "agent-b"], - }); - - expect([...prepared.targetMentionAgentIds].sort()).toEqual([ - "agent-a", - "agent-b", - "custom-title", - ]); - }); - - test("does not list room posters when @everyone is not mentioned", async () => { - const listRoomPosterAgentIds = vi.fn(async () => []); - const result = await prepareChatMentionFanout({ - authorAgentId: "author-agent", - mentionAgentIds: ["agent-a"], - storedAgents: [storedAgent({ id: "agent-a" })], - liveAgents: [], - listRoomPosterAgentIds, - }); - - expect(result.ok).toBe(true); - expect(listRoomPosterAgentIds).not.toHaveBeenCalled(); - }); - - test("rejects @everyone fan-out above the hard cap", async () => { - const posters = Array.from({ length: 26 }, (_, index) => `agent-${index}`); - const result = await prepareChatMentionFanout({ - authorAgentId: "author-agent", - mentionAgentIds: ["everyone"], - storedAgents: posters.map((id) => storedAgent({ id })), - liveAgents: [], - listRoomPosterAgentIds: async () => posters, - }); - - expect(result).toEqual({ - ok: false, - error: - "@everyone would notify 26 agents, which exceeds the limit of 25. Narrow the room or mention specific agents.", - }); - }); - - test("notification body strips inline mentions but keeps the room context", () => { - expect( - buildChatMentionNotification({ - room: "coord-room", - authorAgentId: "author-agent", - body: "@agent-a @everyone Check the latest status.", - mentionAgentIds: ["agent-a", "everyone"], - }), - ).toContain("Check the latest status."); - }); - - test("notifyChatMentions delegates sends for resolved targets", async () => { - const resolveAgentIdentifier = vi.fn(async (identifier: string) => ({ - ok: true as const, - agentId: identifier, - })); - const sendAgentMessage = vi.fn(async () => {}); - const logger = { - warn: vi.fn(), - } as unknown as pino.Logger; - - const storedAgents = [storedAgent({ id: "agent-a" })]; - const liveAgents = [liveAgent({ id: "agent-b" })]; - - await notifyChatMentions({ - room: "coord-room", - authorAgentId: "author-agent", - body: "@everyone Check status", - mentionAgentIds: ["everyone"], - logger, - storedAgents, - liveAgents, - prepared: { - targetMentionAgentIds: ["agent-a", "agent-b"], - roomPosterAgentIds: ["agent-a", "agent-b"], - }, - resolveAgentIdentifier, - sendAgentMessage, - }); - - expect(resolveAgentIdentifier).toHaveBeenCalledTimes(2); - expect(sendAgentMessage).toHaveBeenCalledTimes(2); - expect(sendAgentMessage).toHaveBeenCalledWith( - "agent-a", - expect.stringContaining('room "coord-room"'), - ); - expect(sendAgentMessage).toHaveBeenCalledWith( - "agent-b", - expect.stringContaining("Check status"), - ); - }); -}); diff --git a/packages/server/src/server/chat/chat-mentions.ts b/packages/server/src/server/chat/chat-mentions.ts deleted file mode 100644 index f07237dc41..0000000000 --- a/packages/server/src/server/chat/chat-mentions.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type pino from "pino"; -import type { StoredAgentRecord } from "../agent/agent-storage.js"; -import type { ManagedAgent } from "../agent/agent-manager.js"; - -export const CHAT_MENTION_FANOUT_LIMIT = 25; - -export interface ChatMentionNotificationInput { - room: string; - authorAgentId: string; - body: string; - mentionAgentIds: string[]; -} - -export interface PreparedChatMentionFanout { - targetMentionAgentIds: string[]; - roomPosterAgentIds: string[]; -} - -export type PrepareChatMentionFanoutResult = - | { ok: true; prepared: PreparedChatMentionFanout } - | { ok: false; error: string }; - -export interface PrepareChatMentionFanoutInput { - authorAgentId: string; - mentionAgentIds: string[]; - storedAgents: StoredAgentRecord[]; - liveAgents: ManagedAgent[]; - listRoomPosterAgentIds: () => Promise; - limit?: number; -} - -export interface NotifyChatMentionsInput extends ChatMentionNotificationInput { - logger: pino.Logger; - storedAgents: StoredAgentRecord[]; - liveAgents: ManagedAgent[]; - prepared: PreparedChatMentionFanout; - resolveAgentIdentifier: ( - identifier: string, - ) => Promise<{ ok: true; agentId: string } | { ok: false; error: string }>; - sendAgentMessage: (agentId: string, text: string) => Promise; -} - -export async function prepareChatMentionFanout( - input: PrepareChatMentionFanoutInput, -): Promise { - const mentionsEveryone = input.mentionAgentIds.includes("everyone"); - const roomPosterAgentIds = mentionsEveryone ? await input.listRoomPosterAgentIds() : []; - const targetMentionAgentIds = expandChatMentionTargets({ - authorAgentId: input.authorAgentId, - mentionAgentIds: input.mentionAgentIds, - storedAgents: input.storedAgents, - liveAgents: input.liveAgents, - roomPosterAgentIds, - }); - - if (mentionsEveryone) { - const limit = input.limit ?? CHAT_MENTION_FANOUT_LIMIT; - if (targetMentionAgentIds.length > limit) { - return { - ok: false, - error: `@everyone would notify ${targetMentionAgentIds.length} agents, which exceeds the limit of ${limit}. Narrow the room or mention specific agents.`, - }; - } - } - - return { ok: true, prepared: { targetMentionAgentIds, roomPosterAgentIds } }; -} - -export async function notifyChatMentions(input: NotifyChatMentionsInput): Promise { - const { targetMentionAgentIds } = input.prepared; - if (targetMentionAgentIds.length === 0) { - return; - } - - const notification = buildChatMentionNotification({ - room: input.room, - authorAgentId: input.authorAgentId, - body: input.body, - mentionAgentIds: targetMentionAgentIds, - }); - - await Promise.all( - targetMentionAgentIds.map(async (mentionedAgentId) => { - const resolved = await input.resolveAgentIdentifier(mentionedAgentId); - if (!resolved.ok) { - input.logger.warn( - { mentionedAgentId, room: input.room, error: resolved.error }, - "Failed to resolve chat mention target", - ); - return; - } - - // Re-check eligibility on the resolved canonical id: explicit mentions may - // be custom titles that resolve to an archived or error-state agent. - if ( - !isChatMentionTargetEligible({ - agentId: resolved.agentId, - authorAgentId: input.authorAgentId, - storedAgents: input.storedAgents, - liveAgents: input.liveAgents, - }) - ) { - return; - } - - try { - await input.sendAgentMessage(resolved.agentId, notification); - } catch (error) { - input.logger.warn( - { err: error, mentionedAgentId: resolved.agentId, room: input.room }, - "Failed to notify mentioned agent about chat message", - ); - } - }), - ); -} - -export function buildChatMentionNotification(input: ChatMentionNotificationInput): string { - const mentioned = input.mentionAgentIds.map((agentId) => `@${agentId}`).join(", "); - const bodyWithoutMentions = input.body.replace(/(^|\s)@[A-Za-z0-9][A-Za-z0-9._-]*/g, "$1").trim(); - const body = bodyWithoutMentions.length > 0 ? bodyWithoutMentions : input.body; - - return [ - `Chat mention from ${input.authorAgentId} in room "${input.room}".`, - `Mentioned agents: ${mentioned}.`, - "Message:", - body, - `Read the room with: paseo chat read ${input.room} --limit 20`, - ].join("\n"); -} - -function expandChatMentionTargets(input: { - authorAgentId: string; - mentionAgentIds: string[]; - storedAgents: StoredAgentRecord[]; - liveAgents: ManagedAgent[]; - roomPosterAgentIds: string[]; -}): string[] { - const candidates = new Set(); - const mentionsEveryone = input.mentionAgentIds.includes("everyone"); - - for (const mentionAgentId of input.mentionAgentIds) { - if (mentionAgentId === "everyone" || mentionAgentId === input.authorAgentId) { - continue; - } - candidates.add(mentionAgentId); - } - - if (mentionsEveryone) { - for (const posterAgentId of input.roomPosterAgentIds) { - if (posterAgentId !== input.authorAgentId) { - candidates.add(posterAgentId); - } - } - } - - const targets: string[] = []; - for (const candidate of candidates) { - if ( - isChatMentionTargetEligible({ - agentId: candidate, - authorAgentId: input.authorAgentId, - storedAgents: input.storedAgents, - liveAgents: input.liveAgents, - }) - ) { - targets.push(candidate); - } - } - return targets; -} - -function isChatMentionTargetEligible(input: { - agentId: string; - authorAgentId: string; - storedAgents: StoredAgentRecord[]; - liveAgents: ManagedAgent[]; -}): boolean { - if (input.agentId === input.authorAgentId) { - return false; - } - - const stored = input.storedAgents.find((record) => record.id === input.agentId); - if (stored?.internal || stored?.archivedAt || stored?.lastStatus === "error") { - return false; - } - - const live = input.liveAgents.find((agent) => agent.id === input.agentId); - if (live) { - return !live.internal && live.lifecycle !== "error"; - } - - return true; -} diff --git a/packages/server/src/server/chat/chat-service.test.ts b/packages/server/src/server/chat/chat-service.test.ts deleted file mode 100644 index 0b9ed6a1f9..0000000000 --- a/packages/server/src/server/chat/chat-service.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import pino from "pino"; -import { - type ChatServiceError, - FileBackedChatService, - parseMentionAgentIds, - type PostChatMessageInput, -} from "./chat-service.js"; - -describe("FileBackedChatService", () => { - let paseoHome: string; - let service: FileBackedChatService; - - async function sendChatMessage(input: PostChatMessageInput) { - return await service.dispatchMessage(input); - } - - beforeEach(async () => { - paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-chat-service-")); - service = new FileBackedChatService({ - paseoHome, - logger: pino({ level: "silent" }), - }); - await service.initialize(); - }); - - afterEach(async () => { - await rm(paseoHome, { recursive: true, force: true }); - }); - - test("creates rooms, enforces unique names, and persists to disk", async () => { - const created = await service.createRoom({ - name: "cli-features-epic", - purpose: "Coordination room", - }); - - await expect( - service.createRoom({ - name: "CLI-FEATURES-EPIC", - }), - ).rejects.toMatchObject>({ - code: "chat_room_name_taken", - }); - - const raw = await readFile(path.join(paseoHome, "chat", "rooms.json"), "utf8"); - expect(raw).toContain("cli-features-epic"); - expect(created.name).toBe("cli-features-epic"); - expect(created.purpose).toBe("Coordination room"); - expect(created.messageCount).toBe(0); - }); - - test("resolves rooms by name or ID, validates replies, and reads filtered messages", async () => { - const room = await service.createRoom({ name: "auth-refactor" }); - const first = await sendChatMessage({ - room: room.name, - authorAgentId: "agent-a", - body: "first message for @agent-b and @agent-c and again @agent-b", - }); - await sendChatMessage({ - room: room.id, - authorAgentId: "agent-b", - body: "reply", - replyToMessageId: first.id, - }); - - await expect( - sendChatMessage({ - room: room.name, - authorAgentId: "agent-b", - body: "bad reply", - replyToMessageId: "missing", - }), - ).rejects.toMatchObject>({ - code: "chat_message_not_found", - }); - - const all = await service.readMessages({ room: room.name, limit: 10 }); - expect(all).toHaveLength(2); - expect(all[0]?.mentionAgentIds).toEqual(["agent-b", "agent-c"]); - - const byAuthor = await service.readMessages({ - room: room.id, - authorAgentId: "agent-b", - limit: 10, - }); - expect(byAuthor).toHaveLength(1); - expect(byAuthor[0]?.body).toBe("reply"); - - const detail = await service.inspectRoom({ room: room.name }); - expect(detail.room.messageCount).toBe(2); - expect(detail.room.lastMessageAt).toBeTruthy(); - }); - - test("lists unique agents who have posted to a room", async () => { - const room = await service.createRoom({ name: "incident-room" }); - const otherRoom = await service.createRoom({ name: "other-room" }); - await sendChatMessage({ - room: room.name, - authorAgentId: "agent-a", - body: "first", - }); - await sendChatMessage({ - room: room.name, - authorAgentId: "agent-b", - body: "second", - }); - await sendChatMessage({ - room: room.name, - authorAgentId: "agent-a", - body: "third", - }); - await sendChatMessage({ - room: otherRoom.name, - authorAgentId: "unrelated-agent", - body: "different room", - }); - - await expect(service.listRoomPosterAgentIds({ room: room.name })).resolves.toEqual([ - "agent-a", - "agent-b", - ]); - }); - - test("waits for new messages after a cursor and times out with an empty result", async () => { - const room = await service.createRoom({ name: "loop-status" }); - const first = await sendChatMessage({ - room: room.name, - authorAgentId: "agent-a", - body: "ready", - }); - - const waitPromise = service.waitForMessages({ - room: room.name, - afterMessageId: first.id, - timeoutMs: 1000, - }); - await sendChatMessage({ - room: room.name, - authorAgentId: "agent-b", - body: "new work", - }); - - const waited = await waitPromise; - expect(waited).toHaveLength(1); - expect(waited[0]?.body).toBe("new work"); - - const timedOut = await service.waitForMessages({ - room: room.name, - afterMessageId: waited[0]?.id, - timeoutMs: 10, - }); - expect(timedOut).toEqual([]); - }); - - test("deletes rooms, removes messages, and rejects pending waiters", async () => { - const room = await service.createRoom({ name: "schedule-jobs" }); - await sendChatMessage({ - room: room.name, - authorAgentId: "agent-a", - body: "hello", - }); - - const waitPromise = service.waitForMessages({ - room: room.name, - timeoutMs: 1000, - }); - const deleted = await service.deleteRoom({ room: room.id }); - expect(deleted.room.messageCount).toBe(1); - - await expect(waitPromise).rejects.toMatchObject>({ - code: "chat_room_deleted", - }); - await expect(service.inspectRoom({ room: room.name })).rejects.toMatchObject< - Partial - >({ - code: "chat_room_not_found", - }); - }); - - test("extracts inline mentions from chat bodies", () => { - expect( - parseMentionAgentIds( - "Checking with @agent-a, (@agent_b), @everyone, and duplicate @agent-a again.", - ), - ).toEqual(["agent-a", "agent_b", "everyone"]); - expect(parseMentionAgentIds("email@example.com is not a mention")).toEqual([]); - }); -}); diff --git a/packages/server/src/server/chat/chat-service.ts b/packages/server/src/server/chat/chat-service.ts deleted file mode 100644 index 08706d5ee8..0000000000 --- a/packages/server/src/server/chat/chat-service.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { promises as fs } from "node:fs"; -import path from "node:path"; -import type pino from "pino"; -import { z } from "zod"; -import { writeJsonFileAtomic } from "../atomic-file.js"; -import { - ChatMessageSchema, - ChatRoomDetailSchema, - ChatRoomSchema, - type ChatMessage, - type ChatRoom, - type ChatRoomDetail, -} from "@getpaseo/protocol/chat/types"; - -const ChatStorePayloadSchema = z.object({ - rooms: z.array(ChatRoomSchema), - messages: z.array(ChatMessageSchema), -}); - -type ChatStorePayload = z.infer; - -function normalizeRoomName(name: string): string { - return name.trim().toLocaleLowerCase(); -} - -function trimToNull(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -const CHAT_MENTION_PATTERN = /(?:^|[\s(])@([A-Za-z0-9][A-Za-z0-9._-]*)/g; - -export function parseMentionAgentIds(body: string): string[] { - const mentionAgentIds = new Set(); - for (const match of body.matchAll(CHAT_MENTION_PATTERN)) { - const agentId = match[1]?.trim(); - if (agentId) { - mentionAgentIds.add(agentId); - } - } - return Array.from(mentionAgentIds).sort(); -} - -export class ChatServiceError extends Error { - readonly code: string; - - constructor(code: string, message: string) { - super(message); - this.name = "ChatServiceError"; - this.code = code; - } -} - -interface Waiter { - roomId: string; - afterMessageId: string | null; - resolve: (messages: ChatMessage[]) => void; - reject: (error: Error) => void; - timeout: ReturnType | null; -} - -export interface CreateChatRoomInput { - name: string; - purpose?: string | null; -} - -export interface InspectChatRoomInput { - room: string; -} - -export interface DeleteChatRoomInput { - room: string; -} - -export interface PostChatMessageInput { - room: string; - authorAgentId: string; - body: string; - replyToMessageId?: string | null; -} - -export interface ReadChatMessagesInput { - room: string; - limit?: number; - since?: string; - authorAgentId?: string; -} - -export interface ListChatRoomPosterAgentIdsInput { - room: string; -} - -export interface WaitForChatMessagesInput { - room: string; - afterMessageId?: string | null; - timeoutMs?: number; -} - -export interface DeleteChatRoomResult { - room: ChatRoomDetail; -} - -export interface InspectChatRoomResult { - room: ChatRoomDetail; -} - -export class FileBackedChatService { - private readonly filePath: string; - private readonly logger: pino.Logger; - private loaded = false; - private readonly rooms = new Map(); - private readonly messagesByRoomId = new Map(); - private persistQueue: Promise = Promise.resolve(); - private readonly waitersByRoomId = new Map>(); - - constructor(options: { paseoHome: string; logger: pino.Logger }) { - this.filePath = path.join(options.paseoHome, "chat", "rooms.json"); - this.logger = options.logger.child({ component: "chat-service" }); - } - - async initialize(): Promise { - await this.load(); - } - - async createRoom(input: CreateChatRoomInput): Promise { - await this.load(); - const name = input.name.trim(); - if (name.length === 0) { - throw new ChatServiceError("invalid_chat_room_name", "Chat room name is required"); - } - if (this.findRoomByName(name)) { - throw new ChatServiceError( - "chat_room_name_taken", - `Chat room already exists with name: ${name}`, - ); - } - - const now = new Date().toISOString(); - const room = ChatRoomSchema.parse({ - id: randomUUID(), - name, - purpose: trimToNull(input.purpose), - createdAt: now, - updatedAt: now, - }); - this.rooms.set(room.id, room); - await this.enqueuePersist(); - return this.toRoomDetail(room); - } - - async listRooms(): Promise { - await this.load(); - return Array.from(this.rooms.values()) - .map((room) => this.toRoomDetail(room)) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); - } - - async inspectRoom(input: InspectChatRoomInput): Promise { - await this.load(); - const room = this.resolveRoom(input.room); - return { - room: this.toRoomDetail(room), - }; - } - - async deleteRoom(input: DeleteChatRoomInput): Promise { - await this.load(); - const room = this.resolveRoom(input.room); - const detail = this.toRoomDetail(room); - this.rooms.delete(room.id); - this.messagesByRoomId.delete(room.id); - await this.enqueuePersist(); - this.rejectWaiters( - room.id, - new ChatServiceError("chat_room_deleted", `Chat room deleted: ${room.name}`), - ); - return { room: detail }; - } - - async dispatchMessage(input: PostChatMessageInput): Promise { - await this.load(); - const room = this.resolveRoom(input.room); - const body = input.body.trim(); - if (body.length === 0) { - throw new ChatServiceError("invalid_chat_message", "Chat message body is required"); - } - const authorAgentId = input.authorAgentId.trim(); - if (authorAgentId.length === 0) { - throw new ChatServiceError("invalid_chat_author", "Chat message author is required"); - } - - const messages = this.getRoomMessages(room.id); - const replyToMessageId = trimToNull(input.replyToMessageId); - if (replyToMessageId) { - const replyTarget = messages.find((message) => message.id === replyToMessageId); - if (!replyTarget) { - throw new ChatServiceError( - "chat_message_not_found", - `Reply target not found: ${replyToMessageId}`, - ); - } - } - - const createdAt = new Date().toISOString(); - const message = ChatMessageSchema.parse({ - id: randomUUID(), - roomId: room.id, - authorAgentId, - body, - replyToMessageId, - mentionAgentIds: parseMentionAgentIds(body), - createdAt, - }); - - messages.push(message); - this.messagesByRoomId.set(room.id, messages); - this.rooms.set( - room.id, - ChatRoomSchema.parse({ - ...room, - updatedAt: createdAt, - }), - ); - await this.enqueuePersist(); - this.notifyWaiters(room.id); - return message; - } - - async readMessages(input: ReadChatMessagesInput): Promise { - await this.load(); - const room = this.resolveRoom(input.room); - const messages = [...this.getRoomMessages(room.id)]; - const since = trimToNull(input.since); - const authorAgentId = trimToNull(input.authorAgentId); - const limit = this.normalizeLimit(input.limit); - - const filtered = messages.filter((message) => { - if (since && message.createdAt < since) { - return false; - } - if (authorAgentId && message.authorAgentId !== authorAgentId) { - return false; - } - return true; - }); - - if (limit === 0 || filtered.length <= limit) { - return filtered; - } - return filtered.slice(filtered.length - limit); - } - - async listRoomPosterAgentIds(input: ListChatRoomPosterAgentIdsInput): Promise { - await this.load(); - const room = this.resolveRoom(input.room); - const posters = new Set(); - for (const message of this.getRoomMessages(room.id)) { - posters.add(message.authorAgentId); - } - return Array.from(posters); - } - - async waitForMessages(input: WaitForChatMessagesInput): Promise { - await this.load(); - const room = this.resolveRoom(input.room); - const timeoutMs = Math.max(0, Math.floor(input.timeoutMs ?? 0)); - const afterMessageId = trimToNull(input.afterMessageId); - - if (afterMessageId) { - const existing = this.selectMessagesAfter(room.id, afterMessageId); - if (existing.length > 0) { - return existing; - } - const knownMessage = this.getRoomMessages(room.id).some( - (message) => message.id === afterMessageId, - ); - if (!knownMessage) { - throw new ChatServiceError( - "chat_message_not_found", - `Wait cursor not found: ${afterMessageId}`, - ); - } - } - - return new Promise((resolve, reject) => { - const waiter: Waiter = { - roomId: room.id, - afterMessageId, - resolve: (messages) => { - if (waiter.timeout) { - clearTimeout(waiter.timeout); - waiter.timeout = null; - } - this.removeWaiter(waiter); - resolve(messages); - }, - reject: (error) => { - if (waiter.timeout) { - clearTimeout(waiter.timeout); - waiter.timeout = null; - } - this.removeWaiter(waiter); - reject(error); - }, - timeout: null, - }; - - if (timeoutMs > 0) { - waiter.timeout = setTimeout(() => { - waiter.resolve([]); - }, timeoutMs); - } - - const roomWaiters = this.waitersByRoomId.get(room.id) ?? new Set(); - roomWaiters.add(waiter); - this.waitersByRoomId.set(room.id, roomWaiters); - }); - } - - private async load(): Promise { - if (this.loaded) { - return; - } - - this.rooms.clear(); - this.messagesByRoomId.clear(); - - try { - const raw = await fs.readFile(this.filePath, "utf8"); - const parsed = ChatStorePayloadSchema.parse(JSON.parse(raw)); - for (const room of parsed.rooms) { - this.rooms.set(room.id, room); - } - for (const message of parsed.messages) { - const messages = this.messagesByRoomId.get(message.roomId) ?? []; - messages.push(message); - this.messagesByRoomId.set(message.roomId, messages); - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - this.logger.error({ err: error, filePath: this.filePath }, "Failed to load chat store"); - } - } - - this.loaded = true; - } - - private async enqueuePersist(): Promise { - const nextPersist = this.persistQueue.then(() => this.persist()); - this.persistQueue = nextPersist.catch(() => {}); - await nextPersist; - } - - private async persist(): Promise { - const payload: ChatStorePayload = { - rooms: Array.from(this.rooms.values()).sort((left, right) => - left.createdAt.localeCompare(right.createdAt), - ), - messages: Array.from(this.messagesByRoomId.values()) - .flat() - .sort((left, right) => left.createdAt.localeCompare(right.createdAt)), - }; - await writeJsonFileAtomic(this.filePath, payload); - } - - private findRoomByName(name: string): ChatRoom | null { - const normalizedName = normalizeRoomName(name); - for (const room of this.rooms.values()) { - if (normalizeRoomName(room.name) === normalizedName) { - return room; - } - } - return null; - } - - private resolveRoom(roomSelector: string): ChatRoom { - const selector = roomSelector.trim(); - if (selector.length === 0) { - throw new ChatServiceError("invalid_chat_room", "Chat room name or ID is required"); - } - const byId = this.rooms.get(selector); - if (byId) { - return byId; - } - const byName = this.findRoomByName(selector); - if (byName) { - return byName; - } - throw new ChatServiceError("chat_room_not_found", `Chat room not found: ${selector}`); - } - - private getRoomMessages(roomId: string): ChatMessage[] { - return this.messagesByRoomId.get(roomId) ?? []; - } - - private toRoomDetail(room: ChatRoom): ChatRoomDetail { - const messages = this.getRoomMessages(room.id); - return ChatRoomDetailSchema.parse({ - ...room, - messageCount: messages.length, - lastMessageAt: messages[messages.length - 1]?.createdAt ?? null, - }); - } - - private normalizeLimit(limit: number | undefined): number { - if (limit === undefined) { - return 20; - } - const normalized = Math.max(0, Math.floor(limit)); - return normalized; - } - - private selectMessagesAfter(roomId: string, afterMessageId: string): ChatMessage[] { - const messages = this.getRoomMessages(roomId); - const index = messages.findIndex((message) => message.id === afterMessageId); - if (index === -1) { - return []; - } - return messages.slice(index + 1); - } - - private notifyWaiters(roomId: string): void { - const waiters = this.waitersByRoomId.get(roomId); - if (!waiters || waiters.size === 0) { - return; - } - - for (const waiter of Array.from(waiters)) { - const messages = - waiter.afterMessageId === null - ? this.getRoomMessages(roomId).slice(-1) - : this.selectMessagesAfter(roomId, waiter.afterMessageId); - if (messages.length === 0) { - continue; - } - waiter.resolve(messages); - } - } - - private removeWaiter(waiter: Waiter): void { - const waiters = this.waitersByRoomId.get(waiter.roomId); - if (!waiters) { - return; - } - waiters.delete(waiter); - if (waiters.size === 0) { - this.waitersByRoomId.delete(waiter.roomId); - } - } - - private rejectWaiters(roomId: string, error: Error): void { - const waiters = this.waitersByRoomId.get(roomId); - if (!waiters) { - return; - } - for (const waiter of Array.from(waiters)) { - waiter.reject(error); - } - } -} diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts deleted file mode 100644 index 917a04f1e3..0000000000 --- a/packages/server/src/server/loop-service.test.ts +++ /dev/null @@ -1,1386 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { randomUUID } from "node:crypto"; -import { beforeEach, afterEach, describe, expect, test } from "vitest"; -import type { - AgentCapabilityFlags, - AgentClient, - AgentLaunchContext, - AgentMode, - AgentModelDefinition, - AgentPersistenceHandle, - AgentPromptInput, - AgentRunOptions, - AgentRunResult, - AgentSession, - AgentSessionConfig, - AgentStreamEvent, - AgentSlashCommand, - AgentRuntimeInfo, - AgentProvider, -} from "./agent/agent-sdk-types.js"; -import { AgentStorage } from "./agent/agent-storage.js"; -import { AgentManager } from "./agent/agent-manager.js"; -import { createAgentCommand } from "./agent/create-agent/create.js"; -import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; -import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; -import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; -import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; -import { LoopService } from "./loop-service.js"; -import { isPlatform } from "../test-utils/platform.js"; -import { createTestLogger } from "../test-utils/test-logger.js"; - -const TEST_CAPABILITIES: AgentCapabilityFlags = { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, -}; - -const NO_UNATTENDED_LOOP_POLICY: Pick = { - async resolveCreateConfig(input) { - expect(input).toMatchObject({ parent: null, unattended: true }); - return { - modeId: input.unattended ? input.requestedMode : "interactive", - featureValues: input.featureValues, - }; - }, -}; - -interface TestLoopServiceOptions { - paseoHome: string; - agentManager: AgentManager; - agentStorage: AgentStorage; - logger: ReturnType; - providerSnapshotManager?: Pick; - ensureWorkspaceForCreate?: ( - cwd: string, - firstAgentContext?: { prompt: string }, - ) => Promise; -} - -function createLoopService(options: TestLoopServiceOptions): LoopService { - const providerSnapshotManager = options.providerSnapshotManager ?? NO_UNATTENDED_LOOP_POLICY; - const ensureWorkspaceForCreate = - options.ensureWorkspaceForCreate ?? (async () => "workspace-created-for-loop"); - return new LoopService({ - paseoHome: options.paseoHome, - agentManager: options.agentManager, - logger: options.logger, - ensureWorkspaceForCreate, - createAgent: (input) => - createAgentCommand( - { - agentManager: options.agentManager, - agentStorage: options.agentStorage, - logger: options.logger, - providerSnapshotManager: providerSnapshotManager as ProviderSnapshotManager, - ensureWorkspaceForCreate, - }, - input, - ), - }); -} - -async function createRegistryBackedWorkspaceEnsure(rootDir: string): Promise<{ - workspaceRegistry: FileBackedWorkspaceRegistry; - ensureWorkspaceForCreate: TestLoopServiceOptions["ensureWorkspaceForCreate"]; -}> { - const workspaceRegistry = new FileBackedWorkspaceRegistry( - path.join(rootDir, "projects", "workspaces.json"), - createTestLogger(), - ); - const projectRegistry = new FileBackedProjectRegistry( - path.join(rootDir, "projects", "projects.json"), - createTestLogger(), - ); - await workspaceRegistry.initialize(); - await projectRegistry.initialize(); - const workspaceGitService = createNoopWorkspaceGitService(); - const workspaceProvisioning = createWorkspaceProvisioningService({ - projectRegistry, - workspaceRegistry, - workspaceGitService, - }); - return { - workspaceRegistry, - ensureWorkspaceForCreate: async (cwd, firstAgentContext) => { - const workspace = await workspaceProvisioning.createWorkspaceForDirectory( - cwd, - firstAgentContext?.prompt ?? null, - ); - return workspace.workspaceId; - }, - }; -} - -interface ScriptedAgentBehavior { - onRun(input: { config: AgentSessionConfig; prompt: string; turnId: string }): Promise; -} - -class ScriptedAgentClient implements AgentClient { - readonly provider: AgentProvider; - readonly capabilities = TEST_CAPABILITIES; - - constructor( - provider: AgentProvider, - private readonly behavior: ScriptedAgentBehavior, - ) { - this.provider = provider; - } - - async isAvailable(): Promise { - return true; - } - - async createSession( - config: AgentSessionConfig, - _launchContext?: AgentLaunchContext, - ): Promise { - return new ScriptedAgentSession(config, this.provider, this.behavior); - } - - async resumeSession( - _handle: AgentPersistenceHandle, - overrides?: Partial, - ): Promise { - return new ScriptedAgentSession( - { - provider: this.provider, - cwd: overrides?.cwd ?? process.cwd(), - ...overrides, - }, - this.provider, - this.behavior, - ); - } - - async fetchCatalog(): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> { - return { models: [], modes: [] }; - } -} - -class ScriptedAgentSession implements AgentSession { - readonly capabilities = TEST_CAPABILITIES; - readonly id = randomUUID(); - private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); - private turnCount = 0; - private interrupted = false; - - constructor( - private readonly config: AgentSessionConfig, - readonly provider: AgentProvider, - private readonly behavior: ScriptedAgentBehavior, - ) {} - - async run(): Promise { - return { - sessionId: this.id, - finalText: "", - timeline: [], - }; - } - - async startTurn( - prompt: AgentPromptInput, - _options?: AgentRunOptions, - ): Promise<{ turnId: string }> { - const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt); - const turnId = `turn-${++this.turnCount}`; - this.interrupted = false; - queueMicrotask(() => { - void this.runScript(promptText, turnId); - }); - return { turnId }; - } - - subscribe(callback: (event: AgentStreamEvent) => void): () => void { - this.subscribers.add(callback); - return () => { - this.subscribers.delete(callback); - }; - } - - async *streamHistory(): AsyncGenerator {} - - async getRuntimeInfo(): Promise { - return { - provider: this.provider, - sessionId: this.id, - model: this.config.model ?? null, - modeId: this.config.modeId ?? null, - }; - } - - async getAvailableModes(): Promise { - return []; - } - - async getCurrentMode(): Promise { - return this.config.modeId ?? null; - } - - async setMode(): Promise {} - - getPendingPermissions() { - return []; - } - - async respondToPermission(): Promise {} - - describePersistence(): AgentPersistenceHandle { - return { - provider: this.provider, - sessionId: this.id, - }; - } - - async interrupt(): Promise { - this.interrupted = true; - } - - async close(): Promise {} - - async listCommands(): Promise { - return []; - } - - private emit(event: AgentStreamEvent): void { - for (const subscriber of this.subscribers) { - subscriber(event); - } - } - - private async runScript(prompt: string, turnId: string): Promise { - this.emit({ type: "turn_started", provider: this.provider, turnId }); - if (this.interrupted) { - this.emit({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId }); - return; - } - - try { - const responseText = await this.behavior.onRun({ - config: this.config, - prompt, - turnId, - }); - if (this.interrupted) { - this.emit({ - type: "turn_canceled", - provider: this.provider, - reason: "interrupted", - turnId, - }); - return; - } - this.emit({ - type: "timeline", - provider: this.provider, - turnId, - item: { type: "assistant_message", text: responseText }, - }); - this.emit({ type: "turn_completed", provider: this.provider, turnId }); - } catch (error) { - this.emit({ - type: "turn_failed", - provider: this.provider, - turnId, - error: error instanceof Error ? error.message : String(error), - }); - } - } -} - -describe("LoopService", () => { - const logger = createTestLogger(); - let tmpDir: string; - let paseoHome: string; - let workspaceDir: string; - let storage: AgentStorage; - - beforeEach(() => { - tmpDir = realpathSync.native(mkdtempSync(path.join(os.tmpdir(), "loop-service-"))); - paseoHome = path.join(tmpDir, "paseo-home"); - workspaceDir = path.join(tmpDir, "workspace"); - storage = new AgentStorage(path.join(tmpDir, "agents"), logger); - mkdirSync(workspaceDir, { recursive: true }); - workspaceDir = realpathSync.native(workspaceDir); - }); - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); - }); - - // POSIX-only: real worker agent spawns a PTY whose Windows ConPTY path resolution still fails (error 267) after realpathSync; revisit when we have a Windows dev box. - test.skipIf(isPlatform("win32"))( - "runs fresh worker agents until verify-check passes", - async () => { - const state = { workerRuns: 0 }; - const verifyScriptPath = path.join(workspaceDir, "verify-check.cjs"); - writeFileSync(verifyScriptPath, 'require("fs").accessSync("done.txt");\n'); - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - state.workerRuns += 1; - if (config.title?.includes("worker") && state.workerRuns >= 2) { - writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); - } - if (config.title?.includes("worker")) { - return `worker run ${state.workerRuns}`; - } - return '{"passed":true,"reason":"not used"}'; - }, - }), - }, - registry: storage, - logger, - }); - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Create done.txt when the task is actually fixed.", - cwd: workspaceDir, - model: "test-model", - verifyChecks: [ - `${JSON.stringify(process.execPath)} ${JSON.stringify(path.basename(verifyScriptPath))}`, - ], - sleepMs: 1, - maxIterations: 3, - }); - - await waitForLoopCompletion(service, loop.id); - - const finalLoop = await service.inspectLoop(loop.id); - expect(finalLoop.status).toBe("succeeded"); - expect(finalLoop.iterations).toHaveLength(2); - expect(finalLoop.iterations[0]?.workerAgentId).not.toBe( - finalLoop.iterations[1]?.workerAgentId, - ); - expect(finalLoop.iterations[0]?.status).toBe("failed"); - expect(finalLoop.iterations[1]?.status).toBe("succeeded"); - expect(finalLoop.iterations[0]?.verifyChecks[0]?.passed).toBe(false); - expect(finalLoop.iterations[1]?.verifyChecks[0]?.passed).toBe(true); - expect(readFileSync(path.join(paseoHome, "loops", "loops.json"), "utf8")).toContain(loop.id); - }, - ); - - test("uses worker and verifier provider-model settings when provided", async () => { - const workerConfigs: AgentSessionConfig[] = []; - const verifierConfigs: AgentSessionConfig[] = []; - const manager = new AgentManager({ - clients: { - codex: new ScriptedAgentClient("codex", { - async onRun({ config }) { - workerConfigs.push(config); - writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); - return "done"; - }, - }), - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - verifierConfigs.push(config); - return '{"passed":true,"reason":"verified"}'; - }, - }), - }, - registry: storage, - logger, - }); - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Create done.txt", - cwd: workspaceDir, - provider: "codex", - model: "fallback-model", - workerModel: "gpt-5.4", - verifyPrompt: "Confirm that done.txt exists in the workspace.", - verifierProvider: "claude", - verifierModel: "sonnet", - maxIterations: 1, - }); - - await waitForLoopCompletion(service, loop.id); - - const finalLoop = await service.inspectLoop(loop.id); - expect(finalLoop.status).toBe("succeeded"); - expect(finalLoop.provider).toBe("codex"); - expect(finalLoop.model).toBe("fallback-model"); - expect(finalLoop.workerProvider).toBeNull(); - expect(finalLoop.workerModel).toBe("gpt-5.4"); - expect(finalLoop.verifierProvider).toBe("claude"); - expect(finalLoop.verifierModel).toBe("sonnet"); - expect(workerConfigs).toHaveLength(1); - expect(workerConfigs[0]).toMatchObject({ - provider: "codex", - model: "gpt-5.4", - }); - expect(verifierConfigs).toHaveLength(1); - expect(verifierConfigs[0]).toMatchObject({ - provider: "claude", - model: "sonnet", - }); - }); - - test("loop worker and verifier agents share one registry workspace across iterations", async () => { - const { workspaceRegistry, ensureWorkspaceForCreate } = - await createRegistryBackedWorkspaceEnsure(tmpDir); - let verifierCount = 0; - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - if (config.title?.includes("worker")) { - return "worker finished"; - } - verifierCount += 1; - return verifierCount >= 2 - ? '{"passed":true,"reason":"second verifier passed"}' - : '{"passed":false,"reason":"try again"}'; - }, - }), - }, - registry: storage, - logger, - }); - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - ensureWorkspaceForCreate, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Keep trying until the verifier passes.", - cwd: workspaceDir, - model: "test-model", - verifyPrompt: "Report whether the loop has passed.", - archive: true, - sleepMs: 1, - maxIterations: 2, - }); - - await waitForLoopCompletion(service, loop.id); - - const finalLoop = await service.inspectLoop(loop.id); - expect(finalLoop.status).toBe("succeeded"); - expect(finalLoop.iterations).toHaveLength(2); - const firstWorker = await storage.get(finalLoop.iterations[0]!.workerAgentId!); - const firstVerifier = await storage.get(finalLoop.iterations[0]!.verifierAgentId!); - const secondWorker = await storage.get(finalLoop.iterations[1]!.workerAgentId!); - const secondVerifier = await storage.get(finalLoop.iterations[1]!.verifierAgentId!); - const workspaceId = firstWorker?.workspaceId; - expect(workspaceId).toMatch(/^wks_/); - expect(firstVerifier?.workspaceId).toBe(workspaceId); - expect(secondWorker?.workspaceId).toBe(workspaceId); - expect(secondVerifier?.workspaceId).toBe(workspaceId); - expect(await workspaceRegistry.get(workspaceId!)).toMatchObject({ - workspaceId, - cwd: workspaceDir, - }); - expect(await workspaceRegistry.list()).toHaveLength(1); - }); - - test("rejects non-directory cwd before minting a loop workspace", async () => { - const filePath = path.join(tmpDir, "not-a-directory.txt"); - writeFileSync(filePath, "not a directory"); - let ensureCalls = 0; - const manager = new AgentManager({ registry: storage, logger }); - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - ensureWorkspaceForCreate: async () => { - ensureCalls += 1; - return "workspace-created-for-file-cwd"; - }, - }); - await service.initialize(); - - await expect( - service.runLoop({ - prompt: "Use a file as cwd", - cwd: filePath, - verifyChecks: ["true"], - }), - ).rejects.toThrow("is not a directory"); - expect(ensureCalls).toBe(0); - }); - - test("model-less loop workers use provider defaults and keep fast worker logs", async () => { - const workerConfigs: AgentSessionConfig[] = []; - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - if (config.title?.includes("worker")) { - workerConfigs.push(config); - return "worker default model output"; - } - return '{"passed":true,"reason":"ok"}'; - }, - }), - }, - registry: storage, - logger, - }); - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Use provider default model", - cwd: workspaceDir, - verifyChecks: ["true"], - maxIterations: 1, - }); - - await waitForLoopCompletion(service, loop.id); - - const finalLoop = await service.inspectLoop(loop.id); - expect(finalLoop.status).toBe("succeeded"); - expect(workerConfigs).toHaveLength(1); - expect(workerConfigs[0]).toMatchObject({ - provider: "claude", - model: undefined, - }); - expect( - finalLoop.logs.some( - (entry) => entry.source === "worker" && entry.text.includes("worker default model output"), - ), - ).toBe(true); - }); - - test("archives worker and verifier agents after each iteration when requested", async () => { - const archivedAgentIds: string[] = []; - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - if (config.title?.includes("worker")) { - writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); - return "created done.txt"; - } - return '{"passed":true,"reason":"done.txt exists"}'; - }, - }), - }, - registry: storage, - logger, - }); - const archiveAgent = manager.archiveAgent.bind(manager); - manager.archiveAgent = async (agentId) => { - archivedAgentIds.push(agentId); - await archiveAgent(agentId); - }; - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Create done.txt", - cwd: workspaceDir, - model: "test-model", - verifyPrompt: "Confirm that done.txt exists in the workspace.", - archive: true, - maxIterations: 1, - }); - - await waitForLoopCompletion(service, loop.id); - - const finalLoop = await service.inspectLoop(loop.id); - const iteration = finalLoop.iterations[0]; - expect(finalLoop.archive).toBe(true); - expect(iteration?.workerAgentId).toBeTruthy(); - expect(iteration?.verifierAgentId).toBeTruthy(); - expect(archivedAgentIds).toEqual([iteration.workerAgentId!, iteration.verifierAgentId!]); - await storage.flush(); - await expect(storage.get(iteration.workerAgentId!)).resolves.toMatchObject({ - id: iteration.workerAgentId!, - archivedAt: expect.any(String), - internal: true, - }); - await expect(storage.get(iteration.verifierAgentId!)).resolves.toMatchObject({ - id: iteration.verifierAgentId!, - archivedAt: expect.any(String), - internal: true, - }); - }); - - test("worker prompt-start failures fail the loop and archive the worker", async () => { - class StartFailureLoopSession implements AgentSession { - readonly provider = "claude"; - readonly capabilities = TEST_CAPABILITIES; - readonly id = randomUUID(); - - async run(): Promise { - return { - sessionId: this.id, - finalText: "", - timeline: [], - }; - } - - async startTurn(): Promise<{ turnId: string }> { - throw new Error("worker failed before starting"); - } - - subscribe(): () => void { - return () => {}; - } - - async *streamHistory(): AsyncGenerator {} - - async getRuntimeInfo(): Promise { - return { - provider: this.provider, - sessionId: this.id, - model: null, - modeId: null, - }; - } - - async getAvailableModes(): Promise { - return []; - } - - async getCurrentMode(): Promise { - return null; - } - - async setMode(): Promise {} - - getPendingPermissions(): AgentPermissionRequest[] { - return []; - } - - async respondToPermission(): Promise {} - - describePersistence(): AgentPersistenceHandle { - return { - provider: this.provider, - sessionId: this.id, - }; - } - - async interrupt(): Promise {} - - async close(): Promise {} - - async listCommands(): Promise { - return []; - } - } - - const manager = new AgentManager({ - clients: { - claude: { - provider: "claude", - capabilities: TEST_CAPABILITIES, - createSession: async () => new StartFailureLoopSession(), - resumeSession: async () => new StartFailureLoopSession(), - fetchCatalog: async () => ({ models: [], modes: [] }), - isAvailable: async () => true, - }, - }, - registry: storage, - logger, - }); - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Fail before starting", - cwd: workspaceDir, - model: "test-model", - verifyChecks: ["true"], - archive: true, - maxIterations: 1, - }); - - await waitForLoopCompletion(service, loop.id); - - const finalLoop = await service.inspectLoop(loop.id); - expect(finalLoop.status).toBe("failed"); - expect(finalLoop.iterations[0]).toMatchObject({ - status: "failed", - workerOutcome: "failed", - failureReason: expect.stringContaining("worker failed before starting"), - }); - const workerAgentId = finalLoop.iterations[0]?.workerAgentId; - expect(workerAgentId).toMatch(/^[0-9a-f-]{36}$/); - expect(await storage.get(workerAgentId!)).toMatchObject({ - archivedAt: expect.any(String), - }); - }); - - test("uses verifier prompt when provided", async () => { - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - if (config.title?.includes("worker")) { - await fsMkdir(workspaceDir); - writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); - return "created done.txt"; - } - const exists = pathExists(path.join(workspaceDir, "done.txt")); - return exists - ? '{"passed":true,"reason":"done.txt exists"}' - : '{"passed":false,"reason":"done.txt missing"}'; - }, - }), - }, - registry: storage, - logger, - }); - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Create done.txt", - cwd: workspaceDir, - model: "test-model", - verifyPrompt: "Confirm that done.txt exists in the workspace.", - maxIterations: 1, - }); - - await waitForLoopCompletion(service, loop.id); - - const finalLoop = await service.inspectLoop(loop.id); - expect(finalLoop.status).toBe("succeeded"); - expect(finalLoop.iterations[0]?.verifyPrompt).toMatchObject({ - passed: true, - reason: "done.txt exists", - }); - const logs = await service.getLoopLogs(loop.id); - expect(logs.entries.some((entry) => entry.text.includes("Verifier result"))).toBe(true); - }); - - test("defaults worker and verifier modeId to provider's unattended mode", async () => { - const workerConfigs: AgentSessionConfig[] = []; - const verifierConfigs: AgentSessionConfig[] = []; - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - if (config.title?.includes("worker")) { - workerConfigs.push(config); - writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); - return "created done.txt"; - } - verifierConfigs.push(config); - return '{"passed":true,"reason":"ok"}'; - }, - }), - }, - registry: storage, - logger, - }); - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - providerSnapshotManager: { - async resolveCreateConfig(input) { - expect(input).toMatchObject({ - parent: null, - unattended: true, - requestedMode: undefined, - }); - return { - modeId: input.unattended ? "bypassPermissions" : "interactive", - featureValues: input.featureValues, - }; - }, - }, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Create done.txt", - cwd: workspaceDir, - model: "test-model", - verifyPrompt: "Confirm that done.txt exists in the workspace.", - maxIterations: 1, - }); - - await waitForLoopCompletion(service, loop.id); - - expect(workerConfigs[0]?.modeId).toBe("bypassPermissions"); - expect(verifierConfigs[0]?.modeId).toBe("bypassPermissions"); - }); - - test("defaults OpenCode workers and verifiers to build plus auto accept", async () => { - class CapturingScriptedAgentClient extends ScriptedAgentClient { - readonly createdConfigs: AgentSessionConfig[] = []; - - override async createSession( - config: AgentSessionConfig, - launchContext?: AgentLaunchContext, - ): Promise { - this.createdConfigs.push(config); - return super.createSession(config, launchContext); - } - } - - const opencodeClient = new CapturingScriptedAgentClient("opencode", { - async onRun({ config }) { - if (config.title?.includes("worker")) { - writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); - return "created done.txt"; - } - return '{"passed":true,"reason":"ok"}'; - }, - }); - const manager = new AgentManager({ - clients: { - opencode: opencodeClient, - }, - registry: storage, - logger, - }); - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - providerSnapshotManager: { - async resolveCreateConfig(input) { - expect(input).toMatchObject({ - parent: null, - unattended: true, - requestedMode: undefined, - }); - return { - modeId: input.unattended ? "build" : "interactive", - featureValues: input.unattended - ? { ...input.featureValues, auto_accept: true } - : input.featureValues, - }; - }, - }, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Create done.txt", - cwd: workspaceDir, - provider: "opencode", - model: "test-model", - verifyPrompt: "Confirm that done.txt exists in the workspace.", - maxIterations: 1, - }); - - await waitForLoopCompletion(service, loop.id); - - expect(opencodeClient.createdConfigs[0]).toMatchObject({ - modeId: "build", - featureValues: { auto_accept: true }, - }); - expect(opencodeClient.createdConfigs[1]).toMatchObject({ - modeId: "build", - featureValues: { auto_accept: true }, - }); - }); - - test("explicit modeId wins over unattended default", async () => { - const workerConfigs: AgentSessionConfig[] = []; - const verifierConfigs: AgentSessionConfig[] = []; - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - if (config.title?.includes("worker")) { - workerConfigs.push(config); - writeFileSync(path.join(workspaceDir, "done.txt"), "ok"); - return "created done.txt"; - } - verifierConfigs.push(config); - return '{"passed":true,"reason":"ok"}'; - }, - }), - }, - registry: storage, - logger, - }); - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Create done.txt", - cwd: workspaceDir, - model: "test-model", - modeId: "acceptEdits", - verifierModeId: "plan", - verifyPrompt: "Confirm that done.txt exists in the workspace.", - maxIterations: 1, - }); - - await waitForLoopCompletion(service, loop.id); - - expect(workerConfigs[0]?.modeId).toBe("acceptEdits"); - expect(verifierConfigs[0]?.modeId).toBe("plan"); - }); - - test("stops a running loop and cancels the active worker", async () => { - let release: (() => void) | null = null; - const cancelledAgentIds: string[] = []; - const blocker = new Promise((resolve) => { - release = resolve; - }); - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - if (config.title?.includes("worker")) { - await blocker; - return "finished"; - } - return '{"passed":true,"reason":"ok"}'; - }, - }), - }, - registry: storage, - logger, - }); - const cancelAgentRun = manager.cancelAgentRun.bind(manager); - manager.cancelAgentRun = async (agentId) => { - cancelledAgentIds.push(agentId); - return cancelAgentRun(agentId); - }; - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Wait forever", - cwd: workspaceDir, - model: "test-model", - verifyChecks: ["test -f never.txt"], - }); - - const workerAgentId = await waitForActiveWorkerRun(service, manager, loop.id); - const stopPromise = service.stopLoop(loop.id); - let cancelWaitError: unknown; - try { - await waitForCancelledAgent(cancelledAgentIds, workerAgentId); - } catch (error) { - cancelWaitError = error; - } finally { - release?.(); - } - const stopped = await stopPromise; - if (cancelWaitError) { - throw cancelWaitError; - } - - expect(stopped.status).toBe("stopped"); - const finalLoop = await service.inspectLoop(loop.id); - expect(finalLoop.status).toBe("stopped"); - expect(finalLoop.iterations[0]?.status).toBe("stopped"); - expect(cancelledAgentIds).toEqual([workerAgentId]); - expect(finalLoop.logs.some((entry) => entry.text.includes("Stop requested"))).toBe(true); - }); - - test("force-closes a loop worker when graceful cancellation is refused", async () => { - let release: (() => void) | null = null; - const blocker = new Promise((resolve) => { - release = resolve; - }); - const cancelledAgentIds: string[] = []; - const closedAgentIds: string[] = []; - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - if (config.title?.includes("worker")) { - await blocker; - return "finished"; - } - return '{"passed":true,"reason":"ok"}'; - }, - }), - }, - registry: storage, - logger, - }); - manager.cancelAgentRun = async (agentId) => { - cancelledAgentIds.push(agentId); - return { status: "refused" }; - }; - const closeAgent = manager.closeAgent.bind(manager); - manager.closeAgent = async (agentId) => { - closedAgentIds.push(agentId); - await closeAgent(agentId); - }; - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Wait forever", - cwd: workspaceDir, - model: "test-model", - verifyChecks: ["test -f never.txt"], - }); - const workerAgentId = await waitForActiveWorkerRun(service, manager, loop.id); - const stopPromise = service.stopLoop(loop.id); - let closeWaitError: unknown; - - try { - await waitForCancelledAgent(cancelledAgentIds, workerAgentId); - await waitForCancelledAgent(closedAgentIds, workerAgentId); - } catch (error) { - closeWaitError = error; - } finally { - release?.(); - } - - const stopped = await stopPromise; - if (closeWaitError) { - throw closeWaitError; - } - expect(stopped.status).toBe("stopped"); - expect(cancelledAgentIds).toEqual([workerAgentId]); - expect(closedAgentIds).toContain(workerAgentId); - }); - - test("tolerates a loop worker closing while graceful cancellation is refused", async () => { - let release: (() => void) | null = null; - const blocker = new Promise((resolve) => { - release = resolve; - }); - const cancelledAgentIds: string[] = []; - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - if (config.title?.includes("worker")) { - await blocker; - return "finished"; - } - return '{"passed":true,"reason":"ok"}'; - }, - }), - }, - registry: storage, - logger, - }); - const closeAgent = manager.closeAgent.bind(manager); - manager.cancelAgentRun = async (agentId) => { - cancelledAgentIds.push(agentId); - await closeAgent(agentId); - return { status: "refused" }; - }; - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Finish while Stop is canceling", - cwd: workspaceDir, - model: "test-model", - verifyChecks: ["test -f never.txt"], - }); - const workerAgentId = await waitForActiveWorkerRun(service, manager, loop.id); - const stopPromise = service.stopLoop(loop.id); - - await waitForCancelledAgent(cancelledAgentIds, workerAgentId); - release?.(); - - await expect(stopPromise).resolves.toMatchObject({ status: "stopped" }); - }); - - test("reports unexpected loop worker cancellation errors", async () => { - let release: (() => void) | null = null; - const blocker = new Promise((resolve) => { - release = resolve; - }); - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - if (config.title?.includes("worker")) { - await blocker; - return "finished"; - } - return '{"passed":true,"reason":"ok"}'; - }, - }), - }, - registry: storage, - logger, - }); - manager.cancelAgentRun = async () => { - throw new Error("cancellation transport failed"); - }; - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Fail while Stop is canceling", - cwd: workspaceDir, - model: "test-model", - verifyChecks: ["test -f never.txt"], - }); - await waitForActiveWorkerRun(service, manager, loop.id); - const execution = ( - service as unknown as { running: Map }> } - ).running.get(loop.id)?.promise; - - try { - await expect(service.stopLoop(loop.id)).rejects.toThrow("cancellation transport failed"); - } finally { - release?.(); - await execution; - } - }); - - test("stops while waiting for loop workspace provisioning without starting a worker", async () => { - let resolveWorkspace: ((workspaceId: string) => void) | null = null; - const workspaceProvisioned = new Promise((resolve) => { - resolveWorkspace = resolve; - }); - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun() { - return "worker should not start"; - }, - }), - }, - registry: storage, - logger, - }); - const createAgent = manager.createAgent.bind(manager); - let createAgentCalls = 0; - manager.createAgent = async (...args) => { - createAgentCalls += 1; - return createAgent(...args); - }; - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - ensureWorkspaceForCreate: async () => workspaceProvisioned, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Stop before workspace is ready", - cwd: workspaceDir, - model: "test-model", - verifyPrompt: "Should not run.", - maxIterations: 1, - }); - await waitForLoopIteration(service, loop.id); - - const stopPromise = service.stopLoop(loop.id); - await waitForStopRequested(service, loop.id); - resolveWorkspace?.("workspace-created-after-stop"); - const stopped = await stopPromise; - - expect(stopped.status).toBe("stopped"); - expect(createAgentCalls).toBe(0); - const finalLoop = await service.inspectLoop(loop.id); - expect(finalLoop.status).toBe("stopped"); - expect(finalLoop.activeWorkerAgentId).toBeNull(); - expect(finalLoop.iterations[0]).toMatchObject({ - workerAgentId: null, - status: "stopped", - failureReason: "Loop stopped", - }); - }); - - test("treats externally canceled worker turns as failures", async () => { - let release: (() => void) | null = null; - const blocker = new Promise((resolve) => { - release = resolve; - }); - const manager = new AgentManager({ - clients: { - claude: new ScriptedAgentClient("claude", { - async onRun({ config }) { - if (config.title?.includes("worker")) { - await blocker; - return "finished"; - } - return '{"passed":true,"reason":"should not verify canceled worker"}'; - }, - }), - }, - registry: storage, - logger, - }); - const service = createLoopService({ - paseoHome, - agentManager: manager, - agentStorage: storage, - logger, - }); - await service.initialize(); - - const loop = await service.runLoop({ - prompt: "Wait until canceled", - cwd: workspaceDir, - model: "test-model", - verifyPrompt: "Should not run.", - maxIterations: 1, - }); - - const workerAgentId = await waitForActiveWorkerRun(service, manager, loop.id); - await manager.cancelAgentRun(workerAgentId); - release?.(); - await waitForLoopCompletion(service, loop.id); - - const finalLoop = await service.inspectLoop(loop.id); - expect(finalLoop.status).toBe("failed"); - expect(finalLoop.iterations).toHaveLength(1); - expect(finalLoop.iterations[0]).toMatchObject({ - workerAgentId, - workerOutcome: "failed", - status: "failed", - }); - expect(finalLoop.iterations[0]?.failureReason).toContain("was canceled"); - expect(finalLoop.iterations[0]?.verifierAgentId).toBeNull(); - }); -}); - -async function fsMkdir(target: string): Promise { - await import("node:fs/promises").then(({ mkdir }) => mkdir(target, { recursive: true })); -} - -function pathExists(target: string): boolean { - return existsSync(target); -} - -async function waitForLoopCompletion(service: LoopService, loopId: string): Promise { - while ((await service.inspectLoop(loopId)).status === "running") { - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - -async function waitForActiveWorkerRun( - service: LoopService, - manager: AgentManager, - loopId: string, -): Promise { - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - const loop = await service.inspectLoop(loopId); - const workerAgentId = loop.activeWorkerAgentId; - if (workerAgentId && manager.getAgent(workerAgentId)?.activeForegroundTurnId) { - return workerAgentId; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error("Timed out waiting for loop worker run to start"); -} - -async function waitForLoopIteration(service: LoopService, loopId: string): Promise { - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - const loop = await service.inspectLoop(loopId); - if (loop.iterations.length > 0) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error("Timed out waiting for loop iteration to start"); -} - -async function waitForStopRequested(service: LoopService, loopId: string): Promise { - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - const loop = await service.inspectLoop(loopId); - if (loop.stopRequestedAt) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error("Timed out waiting for loop stop request"); -} - -async function waitForCancelledAgent( - cancelledAgentIds: readonly string[], - agentId: string, -): Promise { - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - if (cancelledAgentIds.includes(agentId)) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error("Timed out waiting for loop worker cancellation"); -} diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts deleted file mode 100644 index 43474796cb..0000000000 --- a/packages/server/src/server/loop-service.ts +++ /dev/null @@ -1,1029 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { promises as fs } from "node:fs"; -import path from "node:path"; -import { z } from "zod"; -import type { Logger } from "pino"; -import { writeJsonFileAtomic } from "./atomic-file.js"; -import { curateAgentActivity } from "./agent/activity-curator.js"; -import { - type BoundCreateAgentCommand, - type EnsureWorkspaceForCreate, - formatProviderModel, -} from "./agent/create-agent/create.js"; -import type { AgentManager, AgentRunCancellationResult } from "./agent/agent-manager.js"; -import { - buildStructuredAgentResponsePrompt, - getStructuredAgentResponse, -} from "./agent/agent-response-loop.js"; -import type { - AgentPromptInput, - AgentStreamEvent, - AgentTimelineItem, - AgentProvider, -} from "./agent/agent-sdk-types.js"; -import { - buildStringCommandShellInvocation, - createStringCommandShellEnvOverlay, -} from "../utils/string-command-shell.js"; -import { execCommand } from "../utils/spawn.js"; - -const LOOP_ID_LENGTH = 8; -const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude"; -const MAX_VERIFY_OUTPUT_BYTES = 64 * 1024; - -const LoopVerifyPromptSchema = z.object({ - passed: z.boolean(), - reason: z.string().min(1), -}); - -const LoopLogEntrySchema = z.object({ - seq: z.number().int().positive(), - timestamp: z.string(), - iteration: z.number().int().positive().nullable(), - source: z.enum(["loop", "worker", "verifier", "verify-check"]), - level: z.enum(["info", "error"]), - text: z.string(), -}); - -const LoopVerifyCheckResultSchema = z.object({ - command: z.string(), - exitCode: z.number().int(), - passed: z.boolean(), - stdout: z.string(), - stderr: z.string(), - startedAt: z.string(), - completedAt: z.string(), -}); - -const LoopVerifyPromptResultSchema = z.object({ - passed: z.boolean(), - reason: z.string(), - verifierAgentId: z.string().nullable(), - startedAt: z.string(), - completedAt: z.string(), -}); - -const LoopIterationRecordSchema = z.object({ - index: z.number().int().positive(), - workerAgentId: z.string().nullable(), - workerStartedAt: z.string(), - workerCompletedAt: z.string().nullable(), - verifierAgentId: z.string().nullable(), - status: z.enum(["running", "succeeded", "failed", "stopped"]), - workerOutcome: z.enum(["completed", "failed", "canceled"]).nullable(), - failureReason: z.string().nullable(), - verifyChecks: z.array(LoopVerifyCheckResultSchema), - verifyPrompt: LoopVerifyPromptResultSchema.nullable(), -}); - -const LoopRecordSchema = z.object({ - id: z.string(), - name: z.string().nullable(), - prompt: z.string(), - cwd: z.string(), - provider: z.string(), - model: z.string().nullable(), - modeId: z.string().nullable().default(null), - workerProvider: z.string().nullable(), - workerModel: z.string().nullable(), - verifierProvider: z.string().nullable(), - verifierModel: z.string().nullable(), - verifierModeId: z.string().nullable().default(null), - verifyPrompt: z.string().nullable(), - verifyChecks: z.array(z.string()), - archive: z.boolean(), - sleepMs: z.number().int().nonnegative(), - maxIterations: z.number().int().positive().nullable(), - maxTimeMs: z.number().int().positive().nullable(), - status: z.enum(["running", "succeeded", "failed", "stopped"]), - createdAt: z.string(), - updatedAt: z.string(), - startedAt: z.string(), - completedAt: z.string().nullable(), - stopRequestedAt: z.string().nullable(), - iterations: z.array(LoopIterationRecordSchema), - logs: z.array(LoopLogEntrySchema), - nextLogSeq: z.number().int().positive(), - activeIteration: z.number().int().positive().nullable(), - activeWorkerAgentId: z.string().nullable(), - activeVerifierAgentId: z.string().nullable(), -}); - -const StoredLoopsSchema = z.array(LoopRecordSchema); - -export type LoopStatus = z.infer["status"]; -export type LoopLogEntry = z.infer; -export type LoopVerifyCheckResult = z.infer; -export type LoopVerifyPromptResult = z.infer; -export type LoopIterationRecord = z.infer; -export type LoopRecord = z.infer; - -export interface LoopRunOptions { - prompt: string; - cwd: string; - provider?: AgentProvider; - model?: string; - modeId?: string; - workerProvider?: AgentProvider; - workerModel?: string; - verifierProvider?: AgentProvider; - verifierModel?: string; - verifierModeId?: string; - verifyPrompt?: string; - verifyChecks?: string[]; - archive?: boolean; - name?: string; - sleepMs?: number; - maxIterations?: number; - maxTimeMs?: number; -} - -export interface LoopListItem { - id: string; - name: string | null; - status: LoopStatus; - cwd: string; - createdAt: string; - updatedAt: string; - activeIteration: number | null; -} - -export interface LoopLogsResult { - loop: LoopRecord; - entries: LoopLogEntry[]; - nextCursor: number; -} - -interface RunningLoopState { - abortController: AbortController; - promise: Promise; -} - -function nowIso(): string { - return new Date().toISOString(); -} - -function cloneLoop(record: LoopRecord): LoopRecord { - return LoopRecordSchema.parse(record); -} - -function createLoopId(): string { - return randomUUID().replace(/-/g, "").slice(0, LOOP_ID_LENGTH); -} - -function normalizeName(name: string | undefined): string | null { - const trimmed = name?.trim(); - return trimmed ? trimmed : null; -} - -function normalizePrompt(value: string | undefined, field: string): string | null { - if (value === undefined) { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - throw new Error(`${field} cannot be empty`); - } - return trimmed; -} - -function ensurePositiveInteger(value: number | undefined, field: string): number | null { - if (value === undefined) { - return null; - } - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`${field} must be a positive integer`); - } - return value; -} - -function ensureNonNegativeInteger(value: number | undefined, field: string): number { - if (value === undefined) { - return 0; - } - if (!Number.isInteger(value) || value < 0) { - throw new Error(`${field} must be a non-negative integer`); - } - return value; -} - -async function assertLoopCwdDirectory(cwd: string): Promise { - let stats; - try { - stats = await fs.stat(cwd); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new Error(`Working directory ${cwd} no longer exists`, { cause: error }); - } - throw error; - } - if (!stats.isDirectory()) { - throw new Error(`Working directory ${cwd} is not a directory`); - } -} - -function buildWorkerTitle(loop: LoopRecord, iterationIndex: number): string { - const prefix = loop.name ?? loop.id; - return `${prefix} [loop ${iterationIndex} worker]`; -} - -function buildVerifierTitle(loop: LoopRecord, iterationIndex: number): string { - const prefix = loop.name ?? loop.id; - return `${prefix} [loop ${iterationIndex} verifier]`; -} - -function isUnknownLoopAgentError(error: unknown, agentId: string): boolean { - return error instanceof Error && error.message === `Unknown agent '${agentId}'`; -} - -type LoopAgentManager = Pick< - AgentManager, - | "archiveAgent" - | "cancelAgentRun" - | "closeAgent" - | "deleteAgentState" - | "getAgent" - | "runAgent" - | "subscribe" - | "waitForAgentEvent" ->; - -interface LoopExecutionContext { - workspaceId: Promise; -} - -function formatStreamLog(event: AgentStreamEvent): string | null { - switch (event.type) { - case "timeline": { - const rendered = curateAgentActivity([event.item]); - return rendered === "No activity to display." ? null : rendered; - } - case "turn_failed": - return `[Turn Failed] ${event.error}`; - case "turn_canceled": - return `[Turn Canceled] ${event.reason}`; - case "permission_requested": - return `[Permission Requested] ${event.request.name}`; - case "permission_resolved": - return `[Permission Resolved] ${event.requestId}`; - default: - return null; - } -} - -async function sleepWithAbort(ms: number, signal: AbortSignal): Promise { - if (ms <= 0) { - return; - } - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - resolve(); - }, ms); - const onAbort = () => { - cleanup(); - reject(signal.reason instanceof Error ? signal.reason : new Error("Loop aborted")); - }; - const cleanup = () => { - clearTimeout(timer); - signal.removeEventListener("abort", onAbort); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); -} - -function isAbortError(error: unknown): boolean { - if (error instanceof Error && error.message === "Loop aborted") { - return true; - } - return false; -} - -async function runVerifyCheck(options: { - cwd: string; - command: string; -}): Promise { - const startedAt = nowIso(); - try { - const shell = buildStringCommandShellInvocation({ - command: options.command, - windowsShell: "cmd", - }); - const result = await execCommand(shell.shell, shell.args, { - cwd: options.cwd, - envOverlay: createStringCommandShellEnvOverlay(), - maxBuffer: MAX_VERIFY_OUTPUT_BYTES, - shell: false, - }); - return { - command: options.command, - exitCode: 0, - passed: true, - stdout: result.stdout ?? "", - stderr: result.stderr ?? "", - startedAt, - completedAt: nowIso(), - }; - } catch (error) { - const childError = error as Error & { - code?: number | string; - stdout?: string; - stderr?: string; - }; - return { - command: options.command, - exitCode: typeof childError.code === "number" ? childError.code : 1, - passed: false, - stdout: childError.stdout ?? "", - stderr: childError.stderr ?? "", - startedAt, - completedAt: nowIso(), - }; - } -} - -export class LoopService { - private readonly storePath: string; - private readonly logger: Logger; - private loaded = false; - private readonly loops = new Map(); - private persistQueue: Promise = Promise.resolve(); - private readonly running = new Map(); - - constructor( - private readonly options: { - paseoHome: string; - agentManager: LoopAgentManager; - logger: Logger; - createAgent: BoundCreateAgentCommand; - ensureWorkspaceForCreate: EnsureWorkspaceForCreate; - }, - ) { - this.storePath = path.join(options.paseoHome, "loops", "loops.json"); - this.logger = options.logger.child({ module: "loop-service" }); - } - - async initialize(): Promise { - if (this.loaded) { - return; - } - this.loops.clear(); - try { - const raw = await fs.readFile(this.storePath, "utf8"); - const parsed = StoredLoopsSchema.parse(JSON.parse(raw)); - for (const record of parsed) { - if (record.status === "running") { - const recovered = cloneLoop(record); - recovered.status = "stopped"; - recovered.updatedAt = nowIso(); - recovered.completedAt = recovered.updatedAt; - recovered.stopRequestedAt = recovered.updatedAt; - recovered.activeIteration = null; - recovered.activeWorkerAgentId = null; - recovered.activeVerifierAgentId = null; - this.appendLog(recovered, { - iteration: null, - source: "loop", - level: "error", - text: "Loop was interrupted by daemon restart.", - }); - const lastIteration = recovered.iterations.at(-1); - if (lastIteration && lastIteration.status === "running") { - lastIteration.status = "stopped"; - lastIteration.failureReason = "Daemon restarted"; - lastIteration.workerCompletedAt = recovered.updatedAt; - } - this.loops.set(recovered.id, recovered); - continue; - } - this.loops.set(record.id, record); - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - this.logger.error({ err: error, storePath: this.storePath }, "Failed to load loops"); - } - } - this.loaded = true; - await this.persist(); - } - - async runLoop(input: LoopRunOptions): Promise { - await this.initialize(); - const prompt = normalizePrompt(input.prompt, "prompt"); - if (!prompt) { - throw new Error("prompt cannot be empty"); - } - const verifyPrompt = normalizePrompt(input.verifyPrompt, "verifyPrompt"); - const verifyChecks = (input.verifyChecks ?? []).map((command) => { - const trimmed = command.trim(); - if (!trimmed) { - throw new Error("verifyChecks cannot contain empty commands"); - } - return trimmed; - }); - if (!verifyPrompt && verifyChecks.length === 0) { - throw new Error("Loop requires --verify or at least one --verify-check"); - } - const cwd = path.resolve(input.cwd); - await assertLoopCwdDirectory(cwd); - - const createdAt = nowIso(); - const record = LoopRecordSchema.parse({ - id: createLoopId(), - name: normalizeName(input.name), - prompt, - cwd, - provider: input.provider ?? DEFAULT_LOOP_PROVIDER, - model: normalizePrompt(input.model, "model"), - modeId: normalizePrompt(input.modeId, "modeId"), - workerProvider: input.workerProvider ?? null, - workerModel: normalizePrompt(input.workerModel, "workerModel"), - verifierProvider: input.verifierProvider ?? null, - verifierModel: normalizePrompt(input.verifierModel, "verifierModel"), - verifierModeId: normalizePrompt(input.verifierModeId, "verifierModeId"), - verifyPrompt, - verifyChecks, - archive: input.archive ?? false, - sleepMs: ensureNonNegativeInteger(input.sleepMs, "sleepMs"), - maxIterations: ensurePositiveInteger(input.maxIterations, "maxIterations"), - maxTimeMs: ensurePositiveInteger(input.maxTimeMs, "maxTimeMs"), - status: "running", - createdAt, - updatedAt: createdAt, - startedAt: createdAt, - completedAt: null, - stopRequestedAt: null, - iterations: [], - logs: [], - nextLogSeq: 1, - activeIteration: null, - activeWorkerAgentId: null, - activeVerifierAgentId: null, - }); - - this.loops.set(record.id, record); - this.appendLog(record, { - iteration: null, - source: "loop", - level: "info", - text: `Loop created in ${record.cwd}`, - }); - await this.persist(); - - const abortController = new AbortController(); - const promise = this.executeLoop(record.id, abortController.signal).finally(() => { - this.running.delete(record.id); - }); - this.running.set(record.id, { abortController, promise }); - - return cloneLoop(record); - } - - async listLoops(): Promise { - await this.initialize(); - return Array.from(this.loops.values()) - .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) - .map((record) => ({ - id: record.id, - name: record.name, - status: record.status, - cwd: record.cwd, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - activeIteration: record.activeIteration, - })); - } - - async inspectLoop(idOrPrefix: string): Promise { - await this.initialize(); - const loop = this.requireLoop(idOrPrefix); - return cloneLoop(loop); - } - - async getLoopLogs(idOrPrefix: string, afterSeq = 0): Promise { - await this.initialize(); - const loop = this.requireLoop(idOrPrefix); - const entries = loop.logs.filter((entry) => entry.seq > afterSeq); - return { - loop: cloneLoop(loop), - entries: entries.map((entry) => LoopLogEntrySchema.parse(entry)), - nextCursor: loop.nextLogSeq - 1, - }; - } - - async stopLoop(idOrPrefix: string): Promise { - await this.initialize(); - const loop = this.requireLoop(idOrPrefix); - if (loop.status !== "running") { - return cloneLoop(loop); - } - const running = this.running.get(loop.id); - loop.stopRequestedAt = loop.stopRequestedAt ?? nowIso(); - loop.updatedAt = loop.stopRequestedAt; - this.appendLog(loop, { - iteration: loop.activeIteration, - source: "loop", - level: "info", - text: "Stop requested.", - }); - if (running) { - running.abortController.abort(new Error("Loop aborted")); - } - await this.persist(); - - if (running) { - if (loop.activeWorkerAgentId) { - await this.stopInternalAgent(loop.activeWorkerAgentId, loop.archive); - } - if (loop.activeVerifierAgentId) { - await this.stopInternalAgent(loop.activeVerifierAgentId, loop.archive); - } - await running.promise.catch(() => {}); - } else { - loop.status = "stopped"; - loop.completedAt = nowIso(); - loop.updatedAt = loop.completedAt; - await this.persist(); - } - - return cloneLoop(loop); - } - - private async stopInternalAgent(agentId: string, archive: boolean): Promise { - let cancellation: AgentRunCancellationResult; - try { - cancellation = await this.options.agentManager.cancelAgentRun(agentId); - } catch (error) { - if (isUnknownLoopAgentError(error, agentId)) { - return; - } - throw error; - } - if (cancellation.status !== "refused") { - return; - } - if (!this.options.agentManager.getAgent(agentId)) { - return; - } - try { - if (archive) { - await this.options.agentManager.archiveAgent(agentId); - return; - } - await this.closeInternalAgent(agentId); - } catch (error) { - if (!isUnknownLoopAgentError(error, agentId)) { - throw error; - } - } - } - - private async closeInternalAgent(agentId: string): Promise { - try { - await this.options.agentManager.closeAgent(agentId); - } finally { - await this.options.agentManager.deleteAgentState(agentId); - } - } - - private async executeLoop(loopId: string, signal: AbortSignal): Promise { - const loop = this.requireLoop(loopId); - const deadline = loop.maxTimeMs ? Date.now() + loop.maxTimeMs : null; - const workspaceId = this.options.ensureWorkspaceForCreate(loop.cwd, { prompt: loop.prompt }); - workspaceId.catch(() => {}); - const context: LoopExecutionContext = { - workspaceId, - }; - - try { - for (let index = 1; ; index += 1) { - if (signal.aborted) { - throw new Error("Loop aborted"); - } - if (loop.maxIterations && index > loop.maxIterations) { - this.finishLoop(loop, "failed", `Reached max iterations (${loop.maxIterations}).`); - return; - } - if (deadline !== null && Date.now() > deadline) { - this.finishLoop(loop, "failed", `Reached max time (${loop.maxTimeMs}ms).`); - return; - } - - const iteration = LoopIterationRecordSchema.parse({ - index, - workerAgentId: null, - workerStartedAt: nowIso(), - workerCompletedAt: null, - verifierAgentId: null, - status: "running", - workerOutcome: null, - failureReason: null, - verifyChecks: [], - verifyPrompt: null, - }); - loop.iterations.push(iteration); - loop.activeIteration = index; - loop.updatedAt = nowIso(); - this.appendLog(loop, { - iteration: index, - source: "loop", - level: "info", - text: `Starting iteration ${index}.`, - }); - await this.persist(); - - const workerPassed = await this.runWorkerIteration(loop, iteration, signal, context); - if (signal.aborted) { - throw new Error("Loop aborted"); - } - if (!workerPassed) { - iteration.status = iteration.status === "stopped" ? "stopped" : "failed"; - } else { - const verificationPassed = await this.runVerification(loop, iteration, signal, context); - if (verificationPassed) { - iteration.status = "succeeded"; - this.finishLoop(loop, "succeeded", `Iteration ${index} passed verification.`); - return; - } - if (iteration.status === "running") { - iteration.status = "failed"; - } - } - - loop.activeIteration = null; - loop.activeWorkerAgentId = null; - loop.activeVerifierAgentId = null; - loop.updatedAt = nowIso(); - await this.persist(); - - if (loop.sleepMs > 0) { - this.appendLog(loop, { - iteration: index, - source: "loop", - level: "info", - text: `Sleeping ${loop.sleepMs}ms before next iteration.`, - }); - await this.persist(); - await sleepWithAbort(loop.sleepMs, signal); - } - } - } catch (error) { - await this.handleExecuteLoopError(loop, loopId, error); - } - } - - private async handleExecuteLoopError( - loop: LoopRecord, - loopId: string, - error: unknown, - ): Promise { - const iteration = loop.activeIteration - ? loop.iterations.find((candidate) => candidate.index === loop.activeIteration) - : null; - if (isAbortError(error)) { - this.finishLoop(loop, "stopped", "Loop stopped."); - if (iteration && iteration.status === "running") { - iteration.status = "stopped"; - iteration.failureReason = "Loop stopped"; - iteration.workerCompletedAt = nowIso(); - } - await this.persist(); - return; - } - - const message = error instanceof Error ? error.message : String(error); - this.logger.error({ err: error, loopId }, "Loop execution failed"); - this.finishLoop(loop, "failed", message); - if (iteration && iteration.status === "running") { - iteration.status = "failed"; - iteration.failureReason = message; - iteration.workerCompletedAt = nowIso(); - } - await this.persist(); - } - - private async runWorkerIteration( - loop: LoopRecord, - iteration: LoopIterationRecord, - signal: AbortSignal, - context: LoopExecutionContext, - ): Promise { - const workspaceId = await context.workspaceId; - if (signal.aborted) { - throw new Error("Loop aborted"); - } - const created = await this.options.createAgent({ - kind: "mcp", - provider: this.formatWorkerProviderModel(loop), - cwd: loop.cwd, - workspaceId, - title: buildWorkerTitle(loop, iteration.index), - mode: loop.modeId ?? undefined, - unattended: true, - promptFailure: "return-error", - background: true, - notifyOnFinish: false, - internal: true, - }); - const agent = created.snapshot; - iteration.workerAgentId = agent.id; - loop.activeWorkerAgentId = agent.id; - loop.updatedAt = nowIso(); - await this.persist(); - - let workerCanceledReason: string | null = null; - const unsubscribe = this.options.agentManager.subscribe( - (event) => { - if (event.type !== "agent_stream") { - return; - } - if (event.event.type === "turn_canceled") { - workerCanceledReason = event.event.reason; - } - const text = formatStreamLog(event.event); - if (!text) { - return; - } - this.appendLog(loop, { - iteration: iteration.index, - source: "worker", - level: event.event.type === "turn_failed" ? "error" : "info", - text, - }); - void this.persist(); - }, - { agentId: agent.id, replayState: false }, - ); - - try { - const result = await this.options.agentManager.runAgent(agent.id, this.toPrompt(loop.prompt)); - iteration.workerCompletedAt = nowIso(); - iteration.workerOutcome = "completed"; - if (result.canceled || workerCanceledReason) { - throw new Error(`Loop worker ${agent.id} was canceled: ${workerCanceledReason}`); - } - return true; - } catch (error) { - iteration.workerCompletedAt = nowIso(); - iteration.workerOutcome = "failed"; - iteration.failureReason = error instanceof Error ? error.message : String(error); - this.appendLog(loop, { - iteration: iteration.index, - source: "loop", - level: "error", - text: `Worker failed: ${iteration.failureReason}`, - }); - return false; - } finally { - unsubscribe(); - loop.activeWorkerAgentId = null; - loop.updatedAt = nowIso(); - await this.persist(); - try { - if (loop.archive) { - await this.options.agentManager.archiveAgent(agent.id); - } else { - await this.closeInternalAgent(agent.id); - } - } catch { - // Ignore cleanup errors for internal loop workers. - } - if (signal.aborted && iteration.status === "running") { - iteration.status = "stopped"; - } - } - } - - private async runVerification( - loop: LoopRecord, - iteration: LoopIterationRecord, - signal: AbortSignal, - context: LoopExecutionContext, - ): Promise { - for (const command of loop.verifyChecks) { - if (signal.aborted) { - throw new Error("Loop aborted"); - } - this.appendLog(loop, { - iteration: iteration.index, - source: "verify-check", - level: "info", - text: `$ ${command}`, - }); - const result = await runVerifyCheck({ cwd: loop.cwd, command }); - iteration.verifyChecks.push(result); - const output = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n"); - this.appendLog(loop, { - iteration: iteration.index, - source: "verify-check", - level: result.passed ? "info" : "error", - text: output ? `exit ${result.exitCode}\n${output}` : `exit ${result.exitCode}`, - }); - loop.updatedAt = nowIso(); - await this.persist(); - if (!result.passed) { - iteration.failureReason = `Verify check failed: ${command}`; - return false; - } - } - - if (!loop.verifyPrompt) { - return true; - } - - const startedAt = nowIso(); - const initialVerifierPrompt = buildStructuredAgentResponsePrompt({ - prompt: loop.verifyPrompt, - schema: LoopVerifyPromptSchema, - schemaName: "LoopVerifierResult", - }); - const created = await this.options.createAgent({ - kind: "mcp", - provider: this.formatVerifierProviderModel(loop), - cwd: loop.cwd, - workspaceId: await context.workspaceId, - title: buildVerifierTitle(loop, iteration.index), - mode: loop.verifierModeId ?? loop.modeId ?? undefined, - unattended: true, - promptFailure: "return-error", - background: true, - notifyOnFinish: false, - internal: true, - }); - const verifierAgent = created.snapshot; - iteration.verifierAgentId = verifierAgent.id; - loop.activeVerifierAgentId = verifierAgent.id; - loop.updatedAt = nowIso(); - await this.persist(); - - const unsubscribe = this.options.agentManager.subscribe( - (event) => { - if (event.type !== "agent_stream") { - return; - } - const text = formatStreamLog(event.event); - if (!text) { - return; - } - this.appendLog(loop, { - iteration: iteration.index, - source: "verifier", - level: event.event.type === "turn_failed" ? "error" : "info", - text, - }); - void this.persist(); - }, - { agentId: verifierAgent.id, replayState: false }, - ); - - try { - let waitingForInitialResponse = true; - const result = await getStructuredAgentResponse({ - caller: async (nextPrompt) => { - if (waitingForInitialResponse) { - waitingForInitialResponse = false; - const run = await this.options.agentManager.runAgent( - verifierAgent.id, - initialVerifierPrompt, - ); - return this.resolveFinalText(run.timeline, run.finalText); - } - const run = await this.options.agentManager.runAgent( - verifierAgent.id, - this.toPrompt(nextPrompt), - ); - return this.resolveFinalText(run.timeline, run.finalText); - }, - prompt: loop.verifyPrompt, - schema: LoopVerifyPromptSchema, - maxRetries: 2, - schemaName: "LoopVerifierResult", - }); - iteration.verifyPrompt = { - passed: result.passed, - reason: result.reason, - verifierAgentId: verifierAgent.id, - startedAt, - completedAt: nowIso(), - }; - this.appendLog(loop, { - iteration: iteration.index, - source: "loop", - level: result.passed ? "info" : "error", - text: `Verifier result: ${result.reason}`, - }); - if (!result.passed) { - iteration.failureReason = result.reason; - } - return result.passed; - } finally { - unsubscribe(); - loop.activeVerifierAgentId = null; - loop.updatedAt = nowIso(); - await this.persist(); - try { - if (loop.archive) { - await this.options.agentManager.archiveAgent(verifierAgent.id); - } else { - await this.closeInternalAgent(verifierAgent.id); - } - } catch { - // Ignore cleanup errors for internal loop verifiers. - } - } - } - - private formatWorkerProviderModel(loop: LoopRecord): string { - return formatProviderModel( - loop.workerProvider ?? loop.provider, - loop.workerModel ?? loop.model, - ); - } - - private formatVerifierProviderModel(loop: LoopRecord): string { - return formatProviderModel( - loop.verifierProvider ?? loop.provider, - loop.verifierModel ?? loop.model, - ); - } - - private resolveFinalText(timeline: AgentTimelineItem[], finalText: string): string { - if (finalText.trim()) { - return finalText; - } - for (let index = timeline.length - 1; index >= 0; index -= 1) { - const item = timeline[index]; - if (item?.type === "assistant_message" && item.text.trim()) { - return item.text; - } - } - return ""; - } - - private toPrompt(text: string): AgentPromptInput { - return text; - } - - private finishLoop( - loop: LoopRecord, - status: Exclude, - message: string, - ): void { - loop.status = status; - loop.completedAt = nowIso(); - loop.updatedAt = loop.completedAt; - loop.activeIteration = null; - loop.activeWorkerAgentId = null; - loop.activeVerifierAgentId = null; - this.appendLog(loop, { - iteration: null, - source: "loop", - level: status === "succeeded" ? "info" : "error", - text: message, - }); - } - - private appendLog(loop: LoopRecord, entry: Omit): void { - loop.logs.push({ - seq: loop.nextLogSeq, - timestamp: nowIso(), - ...entry, - }); - loop.nextLogSeq += 1; - loop.updatedAt = nowIso(); - } - - private requireLoop(idOrPrefix: string): LoopRecord { - const trimmed = idOrPrefix.trim(); - if (!trimmed) { - throw new Error("Loop id is required"); - } - const exact = this.loops.get(trimmed); - if (exact) { - return exact; - } - const matches = Array.from(this.loops.values()).filter((record) => - record.id.startsWith(trimmed), - ); - if (matches.length === 1) { - return matches[0]; - } - if (matches.length > 1) { - throw new Error(`Loop id prefix is ambiguous: ${trimmed}`); - } - throw new Error(`Loop not found: ${trimmed}`); - } - - private async persist(): Promise { - const nextPersist = this.persistQueue.then(async () => { - const records = Array.from(this.loops.values()).sort((left, right) => - left.createdAt.localeCompare(right.createdAt), - ); - await writeJsonFileAtomic(this.storePath, records); - return; - }); - this.persistQueue = nextPersist.catch(() => {}); - await nextPersist; - } -} diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index e1544b1b85..a648bf1b78 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -34,9 +34,7 @@ import { asAgentStorage, asDownloadTokenStore, asPushTokenStore, - asChatService, asScheduleService, - asLoopService, asCheckoutDiffManager, asGitHubService, asWorkspaceGitService, @@ -395,9 +393,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { get: vi.fn(), list: vi.fn().mockResolvedValue([]), }, - chatService: asChatService(), scheduleService: asScheduleService(), - loopService: asLoopService(), checkoutDiffManager: asCheckoutDiffManager(checkoutDiffManager), github: asGitHubService(github), workspaceGitService: asWorkspaceGitService(workspaceGitService), @@ -4615,57 +4611,12 @@ describe("session pull request timeline handling", () => { }); }); -describe("chat/schedule/loop dispatch routing (behavior preservation)", () => { - // Each chat/*, loop/*, and schedule/* type must reach its domain handler. The - // injected service stubs are unstubbed, so every handler's own try/catch fires - // and emits its domain rpc_error code — proving the message routed (a dropped - // case would silently no-op and emit nothing). schedule/* historically routed - // via the chat dispatcher's fall-through arm; this guards that path explicitly. +describe("schedule dispatch routing", () => { + // Each schedule/* type must reach its domain handler. The injected service stub + // is unstubbed, so every handler's own try/catch emits its domain rpc_error code. // handleMessage receives already-parsed messages, so these fixtures only need to // satisfy the TS union here — zod parsing happens upstream at the transport. const routingCases: Array<{ msg: SessionInboundMessage; code: string }> = [ - { - msg: { type: "chat/create", requestId: "rt-chat-create", name: "room" }, - code: "chat_request_failed", - }, - { msg: { type: "chat/list", requestId: "rt-chat-list" }, code: "chat_request_failed" }, - { - msg: { type: "chat/inspect", requestId: "rt-chat-inspect", room: "room" }, - code: "chat_request_failed", - }, - { - msg: { type: "chat/delete", requestId: "rt-chat-delete", room: "room" }, - code: "chat_request_failed", - }, - { - msg: { type: "chat/post", requestId: "rt-chat-post", room: "room", body: "hi" }, - code: "chat_request_failed", - }, - { - msg: { type: "chat/read", requestId: "rt-chat-read", room: "room" }, - code: "chat_request_failed", - }, - { - msg: { type: "chat/wait", requestId: "rt-chat-wait", room: "room" }, - code: "chat_request_failed", - }, - { - msg: { type: "loop/run", requestId: "rt-loop-run", prompt: "p", cwd: "/tmp/loop" }, - code: "loop_request_failed", - }, - { msg: { type: "loop/list", requestId: "rt-loop-list" }, code: "loop_request_failed" }, - { - msg: { type: "loop/inspect", requestId: "rt-loop-inspect", id: "loop-1" }, - code: "loop_request_failed", - }, - { - msg: { type: "loop/logs", requestId: "rt-loop-logs", id: "loop-1" }, - code: "loop_request_failed", - }, - { - msg: { type: "loop/stop", requestId: "rt-loop-stop", id: "loop-1" }, - code: "loop_request_failed", - }, { msg: { type: "schedule/create", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 30a90f5527..17ef78add1 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -41,7 +41,6 @@ import { } from "./persistence-hooks.js"; import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent/agent-loading.js"; import { - formatSystemNotificationPrompt, sendPromptToAgent, waitForAgentRunStartWithTimeout, unarchiveAgentState, @@ -159,7 +158,7 @@ import { createAgentStructuredTextGeneration, createGitMetadataGenerator, } from "./session/checkout/git-metadata-generator.js"; -import { ChatScheduleLoopSession } from "./session/chat/chat-schedule-loop-session.js"; +import { ScheduleSession } from "./session/schedule/schedule-session.js"; import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js"; import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js"; import { AgentConfigSession } from "./session/agent-config/agent-config-session.js"; @@ -204,8 +203,6 @@ import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { Resolvable } from "./speech/provider-resolver.js"; import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js"; import type pino from "pino"; -import { FileBackedChatService } from "./chat/chat-service.js"; -import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; import { createGitHubService, @@ -450,9 +447,7 @@ export interface SessionOptions { projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; filesystem?: SessionFileSystem; - chatService: FileBackedChatService; scheduleService: ScheduleService; - loopService: LoopService; checkoutDiffManager: CheckoutDiffManager; github?: ForgeService; createAgentMcpTransport?: AgentMcpTransportFactory; @@ -671,7 +666,7 @@ export class Session { private readonly workspaceDirectory: WorkspaceDirectory; private readonly voiceSession: VoiceSession; private readonly checkoutSession: CheckoutSession; - private readonly chatScheduleLoopSession: ChatScheduleLoopSession; + private readonly scheduleSession: ScheduleSession; private readonly providerCatalogSession: ProviderCatalogSession; private readonly workspaceFilesSession: WorkspaceFilesSession; private readonly agentConfigSession: AgentConfigSession; @@ -704,9 +699,7 @@ export class Session { projectRegistry, workspaceRegistry, filesystem, - chatService, scheduleService, - loopService, checkoutDiffManager, github, renameCurrentBranch, @@ -832,27 +825,9 @@ export class Session { onBranchChanged, logger: this.sessionLogger, }); - this.chatScheduleLoopSession = new ChatScheduleLoopSession({ - host: { - emit: (msg) => this.emit(msg), - listStoredAgents: () => this.agentStorage.list(), - listLiveAgents: () => this.agentManager.listAgents(), - resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier), - sendAgentMessage: async (agentId, text) => { - await sendPromptToAgent({ - agentManager: this.agentManager, - agentStorage: this.agentStorage, - agentId, - prompt: formatSystemNotificationPrompt(text), - unarchive: false, - logger: this.sessionLogger, - }); - }, - }, - chatService, + this.scheduleSession = new ScheduleSession({ + host: { emit: (msg) => this.emit(msg) }, scheduleService, - loopService, - clientId: this.clientId, logger: this.sessionLogger, }); this.providerCatalogSession = new ProviderCatalogSession({ @@ -1856,7 +1831,7 @@ export class Session { this.dispatchWorkspaceFileMessage(msg, source) ?? this.dispatchProviderMessage(msg) ?? this.dispatchTerminalMessage(msg) ?? - this.dispatchChatScheduleLoopMessage(msg) ?? + this.dispatchScheduleMessage(msg) ?? this.dispatchMiscMessage(msg); if (promise) await promise; } @@ -2269,51 +2244,26 @@ export class Session { } } - // eslint-disable-next-line complexity - private dispatchChatScheduleLoopMessage(msg: SessionInboundMessage): Promise | undefined { + private dispatchScheduleMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { - case "chat/create": - return this.chatScheduleLoopSession.handleChatCreateRequest(msg); - case "chat/list": - return this.chatScheduleLoopSession.handleChatListRequest(msg); - case "chat/inspect": - return this.chatScheduleLoopSession.handleChatInspectRequest(msg); - case "chat/delete": - return this.chatScheduleLoopSession.handleChatDeleteRequest(msg); - case "chat/post": - return this.chatScheduleLoopSession.handleChatPostRequest(msg); - case "chat/read": - return this.chatScheduleLoopSession.handleChatReadRequest(msg); - case "chat/wait": - return this.chatScheduleLoopSession.handleChatWaitRequest(msg); - case "loop/run": - return this.chatScheduleLoopSession.handleLoopRunRequest(msg); - case "loop/list": - return this.chatScheduleLoopSession.handleLoopListRequest(msg); - case "loop/inspect": - return this.chatScheduleLoopSession.handleLoopInspectRequest(msg); - case "loop/logs": - return this.chatScheduleLoopSession.handleLoopLogsRequest(msg); - case "loop/stop": - return this.chatScheduleLoopSession.handleLoopStopRequest(msg); case "schedule/create": - return this.chatScheduleLoopSession.handleScheduleCreateRequest(msg); + return this.scheduleSession.handleScheduleCreateRequest(msg); case "schedule/list": - return this.chatScheduleLoopSession.handleScheduleListRequest(msg); + return this.scheduleSession.handleScheduleListRequest(msg); case "schedule/inspect": - return this.chatScheduleLoopSession.handleScheduleInspectRequest(msg); + return this.scheduleSession.handleScheduleInspectRequest(msg); case "schedule/logs": - return this.chatScheduleLoopSession.handleScheduleLogsRequest(msg); + return this.scheduleSession.handleScheduleLogsRequest(msg); case "schedule/pause": - return this.chatScheduleLoopSession.handleSchedulePauseRequest(msg); + return this.scheduleSession.handleSchedulePauseRequest(msg); case "schedule/resume": - return this.chatScheduleLoopSession.handleScheduleResumeRequest(msg); + return this.scheduleSession.handleScheduleResumeRequest(msg); case "schedule/delete": - return this.chatScheduleLoopSession.handleScheduleDeleteRequest(msg); + return this.scheduleSession.handleScheduleDeleteRequest(msg); case "schedule/run-once": - return this.chatScheduleLoopSession.handleScheduleRunOnceRequest(msg); + return this.scheduleSession.handleScheduleRunOnceRequest(msg); case "schedule/update": - return this.chatScheduleLoopSession.handleScheduleUpdateRequest(msg); + return this.scheduleSession.handleScheduleUpdateRequest(msg); default: return undefined; } @@ -2584,25 +2534,22 @@ export class Session { didUnarchive: boolean; originalArchivedAt: string | null; } | null> { - const records = await this.agentStorage.list(); - const matched = records - .filter( - (record) => - record.persistence?.provider === handle.provider && - record.persistence?.sessionId === handle.sessionId, - ) - .reduce((latest, candidate) => { - if (!latest) { - return candidate; - } - const updatedDelta = - Date.parse(resolveStoredAgentPayloadUpdatedAt(candidate)) - - Date.parse(resolveStoredAgentPayloadUpdatedAt(latest)); - if (updatedDelta !== 0) { - return updatedDelta > 0 ? candidate : latest; - } - return Date.parse(candidate.createdAt) > Date.parse(latest.createdAt) ? candidate : latest; - }, null); + const records = await this.agentStorage.listByProviderSession( + handle.provider, + handle.sessionId, + ); + const matched = records.reduce((latest, candidate) => { + if (!latest) { + return candidate; + } + const updatedDelta = + Date.parse(resolveStoredAgentPayloadUpdatedAt(candidate)) - + Date.parse(resolveStoredAgentPayloadUpdatedAt(latest)); + if (updatedDelta !== 0) { + return updatedDelta > 0 ? candidate : latest; + } + return Date.parse(candidate.createdAt) > Date.parse(latest.createdAt) ? candidate : latest; + }, null); if (!matched) { return null; } diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index fdc3a8aa42..bdf71e9de5 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -159,9 +159,7 @@ function createHarness(input: { }, }), filesystem: { isDirectory: async () => true }, - chatService: createStub({}), scheduleService: createStub({}), - loopService: createStub({}), checkoutDiffManager: createStub({ subscribe: async () => ({ initial: { cwd: "/tmp", files: [], error: null }, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index f43a63f1f3..53597b4426 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -54,9 +54,7 @@ import { asAgentStorage, asDownloadTokenStore, asPushTokenStore, - asChatService, asScheduleService, - asLoopService, asCheckoutDiffManager, asDaemonConfigStore, asTerminalManager, @@ -692,9 +690,7 @@ function createSessionForWorkspaceTests( }, workspaceRegistry, filesystem: { isDirectory: async () => true }, - chatService: asChatService(), scheduleService: asScheduleService(), - loopService: asLoopService(), checkoutDiffManager: asCheckoutDiffManager({ subscribe: async () => ({ initial: { cwd: "/tmp", files: [], error: null }, @@ -956,9 +952,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist agentStorage, projectRegistry, workspaceRegistry, - chatService: asChatService(), scheduleService: asScheduleService(), - loopService: asLoopService(), checkoutDiffManager: asCheckoutDiffManager({ subscribe: async () => ({ initial: { cwd: child, files: [], error: null }, @@ -1110,9 +1104,7 @@ test("create_agent_request launches from an exact subdirectory in a created work agentStorage, projectRegistry, workspaceRegistry, - chatService: asChatService(), scheduleService: asScheduleService(), - loopService: asLoopService(), checkoutDiffManager: asCheckoutDiffManager({ subscribe: async () => ({ initial: { cwd: child, files: [], error: null }, @@ -1249,9 +1241,7 @@ test("create_agent_request does not title an existing workspace from the agent p agentStorage, projectRegistry, workspaceRegistry, - chatService: asChatService(), scheduleService: asScheduleService(), - loopService: asLoopService(), checkoutDiffManager: asCheckoutDiffManager({ subscribe: async () => ({ initial: { cwd, files: [], error: null }, @@ -1580,9 +1570,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" remove: async () => {}, }; })(), - chatService: asChatService(), scheduleService: asScheduleService(), - loopService: asLoopService(), checkoutDiffManager: asCheckoutDiffManager({ subscribe: async () => ({ initial: { cwd: REPO_CWD, files: [], error: null }, @@ -1942,9 +1930,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy remove: async () => {}, }; })(), - chatService: asChatService(), scheduleService: asScheduleService(), - loopService: asLoopService(), checkoutDiffManager: asCheckoutDiffManager({ subscribe: async () => ({ initial: { cwd: "/tmp", files: [], error: null }, @@ -2129,9 +2115,7 @@ test("close_items_request archives stored agents that are not currently loaded", remove: async () => {}, }; })(), - chatService: asChatService(), scheduleService: asScheduleService(), - loopService: asLoopService(), checkoutDiffManager: asCheckoutDiffManager({ subscribe: async () => ({ initial: { cwd: "/tmp", files: [], error: null }, @@ -2278,9 +2262,7 @@ test("close_items_request continues after an archive failure", async () => { remove: async () => {}, }; })(), - chatService: asChatService(), scheduleService: asScheduleService(), - loopService: asLoopService(), checkoutDiffManager: asCheckoutDiffManager({ subscribe: async () => ({ initial: { cwd: "/tmp", files: [], error: null }, @@ -3524,9 +3506,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto archive: async () => {}, remove: async () => {}, }, - chatService: asChatService(), scheduleService: asScheduleService(), - loopService: asLoopService(), checkoutDiffManager: asCheckoutDiffManager({ subscribe: async () => ({ initial: { cwd: "/tmp", files: [], error: null }, diff --git a/packages/server/src/server/session/chat/chat-schedule-loop-session.test.ts b/packages/server/src/server/session/chat/chat-schedule-loop-session.test.ts deleted file mode 100644 index a121d967e3..0000000000 --- a/packages/server/src/server/session/chat/chat-schedule-loop-session.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, expect, it } from "vitest"; -import pino from "pino"; -import { - ChatScheduleLoopSession, - type ChatScheduleLoopSessionHost, -} from "./chat-schedule-loop-session.js"; -import { createStub } from "../../test-utils/class-mocks.js"; -import { findByType } from "../../test-utils/session-stubs.js"; -import type { SessionOutboundMessage } from "../../messages.js"; -import type { FileBackedChatService } from "../../chat/chat-service.js"; -import type { ScheduleService } from "../../schedule/service.js"; -import type { LoopService } from "../../loop-service.js"; - -type ChatMessageFixture = Awaited>; - -interface MakeOptions { - chat?: { [K in keyof FileBackedChatService]?: unknown }; - schedule?: { [K in keyof ScheduleService]?: unknown }; - loop?: { [K in keyof LoopService]?: unknown }; - host?: Partial; -} - -function makeSubsystem(options: MakeOptions = {}) { - const emitted: SessionOutboundMessage[] = []; - const sentAgentMessages: Array<{ agentId: string; text: string }> = []; - let onSend: (() => void) | null = null; - const host: ChatScheduleLoopSessionHost = { - emit: (msg) => emitted.push(msg), - listStoredAgents: async () => [], - listLiveAgents: () => [], - resolveAgentIdentifier: async (identifier) => ({ ok: true, agentId: identifier }), - sendAgentMessage: async (agentId, text) => { - sentAgentMessages.push({ agentId, text }); - onSend?.(); - }, - ...options.host, - }; - const subsystem = new ChatScheduleLoopSession({ - host, - chatService: createStub(options.chat ?? {}), - scheduleService: createStub(options.schedule ?? {}), - loopService: createStub(options.loop ?? {}), - clientId: "client-1", - logger: pino({ level: "silent" }), - }); - // notifyChatMentions is fire-and-forget; arm the signal before dispatching so the - // mentioned-agent send is observed deterministically without polling. - function waitForSend(): Promise { - return new Promise((resolve) => { - onSend = resolve; - }); - } - return { subsystem, emitted, sentAgentMessages, waitForSend }; -} - -describe("ChatScheduleLoopSession", () => { - it("chat/post emits the stored message and does not fan out without mentions", async () => { - const message: ChatMessageFixture = { - id: "m1", - roomId: "r1", - authorAgentId: "client-1", - body: "hello", - replyToMessageId: null, - mentionAgentIds: [], - createdAt: "2026-01-01T00:00:00.000Z", - }; - const { subsystem, emitted, sentAgentMessages } = makeSubsystem({ - chat: { dispatchMessage: async () => message, listRoomPosterAgentIds: async () => [] }, - }); - - await subsystem.handleChatPostRequest({ - type: "chat/post", - requestId: "p1", - room: "r1", - body: "hello", - }); - - const res = findByType(emitted, "chat/post/response"); - expect(res?.payload.message).toEqual(message); - expect(res?.payload.error).toBeNull(); - expect(sentAgentMessages).toEqual([]); - }); - - it("chat/post notifies a mentioned agent through the host send seam", async () => { - const message: ChatMessageFixture = { - id: "m2", - roomId: "r1", - authorAgentId: "client-1", - body: "@agent-2 ping", - replyToMessageId: null, - mentionAgentIds: ["agent-2"], - createdAt: "2026-01-01T00:00:00.000Z", - }; - const { subsystem, emitted, sentAgentMessages, waitForSend } = makeSubsystem({ - chat: { dispatchMessage: async () => message, listRoomPosterAgentIds: async () => [] }, - }); - - const sent = waitForSend(); - await subsystem.handleChatPostRequest({ - type: "chat/post", - requestId: "p2", - room: "r1", - body: "@agent-2 ping", - }); - await sent; - - expect(findByType(emitted, "chat/post/response")?.payload.error).toBeNull(); - expect(sentAgentMessages).toHaveLength(1); - expect(sentAgentMessages[0]?.agentId).toBe("agent-2"); - expect(sentAgentMessages[0]?.text).toContain('in room "r1"'); - }); - - it("chat/post rejects @everyone past the fanout limit with the chat error code", async () => { - const posters = Array.from({ length: 26 }, (_, i) => `poster-${i}`); - const { subsystem, emitted } = makeSubsystem({ - chat: { listRoomPosterAgentIds: async () => posters }, - }); - - await subsystem.handleChatPostRequest({ - type: "chat/post", - requestId: "p3", - room: "r1", - body: "@everyone go", - }); - - const err = findByType(emitted, "rpc_error"); - expect(err?.payload.code).toBe("chat_mention_fanout_limit_exceeded"); - expect(err?.payload.requestId).toBe("p3"); - }); - - it("schedule/create returns a summary with the runs stripped", async () => { - const stored = { - id: "s1", - name: null, - prompt: "p", - cadence: { type: "every" as const, everyMs: 1000 }, - target: { type: "agent" as const, agentId: "a" }, - status: "active" as const, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - nextRunAt: null, - lastRunAt: null, - pausedAt: null, - expiresAt: null, - maxRuns: null, - runs: [ - { - id: "run-1", - scheduledFor: "2026-01-01T00:00:00.000Z", - startedAt: "2026-01-01T00:00:00.000Z", - endedAt: null, - status: "running" as const, - agentId: null, - output: null, - error: null, - }, - ], - }; - const { subsystem, emitted } = makeSubsystem({ schedule: { create: async () => stored } }); - - await subsystem.handleScheduleCreateRequest({ - type: "schedule/create", - requestId: "sc1", - prompt: "p", - cadence: { type: "every", everyMs: 1000 }, - target: { type: "agent", agentId: "a" }, - }); - - const res = findByType(emitted, "schedule/create/response"); - expect(res?.payload.schedule).toBeDefined(); - expect(res?.payload.schedule).not.toHaveProperty("runs"); - expect(res?.payload.schedule.id).toBe("s1"); - }); - - it("schedule/create remaps a self target to an agent target before creating", async () => { - let received: Parameters[0] | undefined; - const stored = { - id: "s2", - name: null, - prompt: "p", - cadence: { type: "every" as const, everyMs: 1000 }, - target: { type: "agent" as const, agentId: "agent-9" }, - status: "active" as const, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - nextRunAt: null, - lastRunAt: null, - pausedAt: null, - expiresAt: null, - maxRuns: null, - runs: [], - }; - const { subsystem, emitted } = makeSubsystem({ - schedule: { - create: async (input: Parameters[0]) => { - received = input; - return stored; - }, - }, - }); - - await subsystem.handleScheduleCreateRequest({ - type: "schedule/create", - requestId: "sc2", - prompt: "p", - cadence: { type: "every", everyMs: 1000 }, - target: { type: "self", agentId: "agent-9" }, - }); - - expect(received?.target).toEqual({ type: "agent", agentId: "agent-9" }); - expect(findByType(emitted, "schedule/create/response")?.payload.error).toBeNull(); - }); - - it("loop/run emits the loop summary from the loop service", async () => { - const loop = { id: "loop-1", status: "running" }; - const { subsystem, emitted } = makeSubsystem({ loop: { runLoop: async () => loop } }); - - await subsystem.handleLoopRunRequest({ - type: "loop/run", - requestId: "l1", - prompt: "p", - cwd: "/tmp/loop", - }); - - const res = findByType(emitted, "loop/run/response"); - expect(res?.payload.loop).toEqual(loop); - }); -}); diff --git a/packages/server/src/server/session/chat/chat-schedule-loop-session.ts b/packages/server/src/server/session/chat/chat-schedule-loop-session.ts deleted file mode 100644 index 70a2417a5b..0000000000 --- a/packages/server/src/server/session/chat/chat-schedule-loop-session.ts +++ /dev/null @@ -1,609 +0,0 @@ -import type pino from "pino"; -import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; -import type { StoredAgentRecord } from "../../agent/agent-storage.js"; -import type { ManagedAgent } from "../../agent/agent-manager.js"; -import { - ChatServiceError, - type FileBackedChatService, - parseMentionAgentIds, -} from "../../chat/chat-service.js"; -import { notifyChatMentions, prepareChatMentionFanout } from "../../chat/chat-mentions.js"; -import type { LoopService } from "../../loop-service.js"; -import type { ScheduleService } from "../../schedule/service.js"; - -/** - * The collaborators a chat command reaches that are NOT part of the chat/schedule/loop - * domain: the agent roster reads and the agent-message send used only by chat/post - * mention fanout. The Session shell owns the agent lifecycle; this subsystem orchestrates - * a notification through it but does not own it. - */ -export interface ChatScheduleLoopSessionHost { - emit(msg: SessionOutboundMessage): void; - listStoredAgents(): Promise; - listLiveAgents(): ManagedAgent[]; - resolveAgentIdentifier( - identifier: string, - ): Promise<{ ok: true; agentId: string } | { ok: false; error: string }>; - sendAgentMessage(agentId: string, text: string): Promise; -} - -export interface ChatScheduleLoopSessionOptions { - host: ChatScheduleLoopSessionHost; - chatService: FileBackedChatService; - scheduleService: ScheduleService; - loopService: LoopService; - clientId: string; - logger: pino.Logger; -} - -/** - * A client's chat, schedule, and loop request surface. The three families are the - * least-coupled in the session: each is a stateless request/response over its own - * service (chat rooms, cron routines, autonomous loops), with no shared observer, - * git, or voice state and no subscriptions to tear down. They live in one subsystem - * because they are dispatched together — schedule/* was historically reached through - * the chat dispatcher's fall-through arm. The three rpc-error emitters stay separate: - * they differ by default code, and only the chat one reads ChatServiceError.code. - */ -export class ChatScheduleLoopSession { - private readonly host: ChatScheduleLoopSessionHost; - private readonly chatService: FileBackedChatService; - private readonly scheduleService: ScheduleService; - private readonly loopService: LoopService; - private readonly clientId: string; - private readonly logger: pino.Logger; - - constructor(options: ChatScheduleLoopSessionOptions) { - this.host = options.host; - this.chatService = options.chatService; - this.scheduleService = options.scheduleService; - this.loopService = options.loopService; - this.clientId = options.clientId; - this.logger = options.logger; - } - - private emitChatRpcError(request: { requestId: string; type: string }, error: unknown): void { - const message = error instanceof Error ? error.message : "Chat request failed"; - const code = error instanceof ChatServiceError ? error.code : "chat_request_failed"; - this.logger.error({ err: error, requestType: request.type }, "Chat request failed"); - this.host.emit({ - type: "rpc_error", - payload: { - requestId: request.requestId, - requestType: request.type, - error: message, - code, - }, - }); - } - - async handleChatCreateRequest( - request: Extract, - ): Promise { - try { - const room = await this.chatService.createRoom({ - name: request.name, - purpose: request.purpose, - }); - this.host.emit({ - type: "chat/create/response", - payload: { - requestId: request.requestId, - room, - error: null, - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - async handleChatListRequest( - request: Extract, - ): Promise { - try { - const rooms = await this.chatService.listRooms(); - this.host.emit({ - type: "chat/list/response", - payload: { - requestId: request.requestId, - rooms, - error: null, - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - async handleChatInspectRequest( - request: Extract, - ): Promise { - try { - const result = await this.chatService.inspectRoom({ - room: request.room, - }); - this.host.emit({ - type: "chat/inspect/response", - payload: { - requestId: request.requestId, - room: result.room, - error: null, - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - async handleChatDeleteRequest( - request: Extract, - ): Promise { - try { - const result = await this.chatService.deleteRoom({ - room: request.room, - }); - this.host.emit({ - type: "chat/delete/response", - payload: { - requestId: request.requestId, - room: result.room, - error: null, - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - async handleChatPostRequest( - request: Extract, - ): Promise { - try { - const authorAgentId = request.authorAgentId?.trim() || this.clientId; - const mentionAgentIds = parseMentionAgentIds(request.body); - const storedAgents = await this.host.listStoredAgents(); - const liveAgents = this.host.listLiveAgents(); - const fanout = await prepareChatMentionFanout({ - authorAgentId, - mentionAgentIds, - storedAgents, - liveAgents, - listRoomPosterAgentIds: () => - this.chatService.listRoomPosterAgentIds({ room: request.room }), - }); - if (!fanout.ok) { - throw new ChatServiceError("chat_mention_fanout_limit_exceeded", fanout.error); - } - const message = await this.chatService.dispatchMessage({ - room: request.room, - authorAgentId, - body: request.body, - replyToMessageId: request.replyToMessageId, - }); - this.host.emit({ - type: "chat/post/response", - payload: { - requestId: request.requestId, - message, - error: null, - }, - }); - void notifyChatMentions({ - room: request.room, - authorAgentId, - body: request.body, - mentionAgentIds: message.mentionAgentIds, - logger: this.logger, - storedAgents, - liveAgents, - prepared: fanout.prepared, - resolveAgentIdentifier: (identifier) => this.host.resolveAgentIdentifier(identifier), - sendAgentMessage: (agentId, text) => this.host.sendAgentMessage(agentId, text), - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - async handleChatReadRequest( - request: Extract, - ): Promise { - try { - const messages = await this.chatService.readMessages({ - room: request.room, - limit: request.limit, - since: request.since, - authorAgentId: request.authorAgentId, - }); - this.host.emit({ - type: "chat/read/response", - payload: { - requestId: request.requestId, - messages, - error: null, - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - async handleChatWaitRequest( - request: Extract, - ): Promise { - try { - const messages = await this.chatService.waitForMessages({ - room: request.room, - afterMessageId: request.afterMessageId, - timeoutMs: request.timeoutMs, - }); - this.host.emit({ - type: "chat/wait/response", - payload: { - requestId: request.requestId, - messages, - timedOut: messages.length === 0, - error: null, - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - private toScheduleSummary( - schedule: Awaited>, - ): Extract< - SessionOutboundMessage, - { type: "schedule/list/response" } - >["payload"]["schedules"][number] { - const { runs: _runs, ...summary } = schedule; - return summary; - } - - private emitScheduleRpcError( - request: Extract< - SessionInboundMessage, - { - type: - | "schedule/create" - | "schedule/list" - | "schedule/inspect" - | "schedule/logs" - | "schedule/pause" - | "schedule/resume" - | "schedule/delete" - | "schedule/run-once" - | "schedule/update"; - } - >, - error: unknown, - ): void { - const message = error instanceof Error ? error.message : String(error); - this.logger.error({ err: error, requestType: request.type }, "Schedule request failed"); - this.host.emit({ - type: "rpc_error", - payload: { - requestId: request.requestId, - requestType: request.type, - error: message, - code: "schedule_request_failed", - }, - }); - } - - async handleScheduleCreateRequest( - request: Extract, - ): Promise { - try { - const target = - request.target.type === "self" - ? { type: "agent" as const, agentId: request.target.agentId } - : request.target; - const schedule = await this.scheduleService.create({ - prompt: request.prompt, - name: request.name, - cadence: request.cadence, - target, - maxRuns: request.maxRuns, - expiresAt: request.expiresAt, - runOnCreate: request.runOnCreate, - }); - this.host.emit({ - type: "schedule/create/response", - payload: { - requestId: request.requestId, - schedule: this.toScheduleSummary(schedule), - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - async handleScheduleListRequest( - request: Extract, - ): Promise { - try { - const schedules = await this.scheduleService.list(); - this.host.emit({ - type: "schedule/list/response", - payload: { - requestId: request.requestId, - schedules: schedules.map((schedule) => this.toScheduleSummary(schedule)), - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - async handleScheduleInspectRequest( - request: Extract, - ): Promise { - try { - const schedule = await this.scheduleService.inspect(request.scheduleId); - this.host.emit({ - type: "schedule/inspect/response", - payload: { - requestId: request.requestId, - schedule, - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - async handleScheduleLogsRequest( - request: Extract, - ): Promise { - try { - const runs = await this.scheduleService.logs(request.scheduleId); - this.host.emit({ - type: "schedule/logs/response", - payload: { - requestId: request.requestId, - runs, - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - async handleSchedulePauseRequest( - request: Extract, - ): Promise { - try { - const schedule = await this.scheduleService.pause(request.scheduleId); - this.host.emit({ - type: "schedule/pause/response", - payload: { - requestId: request.requestId, - schedule: this.toScheduleSummary(schedule), - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - async handleScheduleResumeRequest( - request: Extract, - ): Promise { - try { - const schedule = await this.scheduleService.resume(request.scheduleId); - this.host.emit({ - type: "schedule/resume/response", - payload: { - requestId: request.requestId, - schedule: this.toScheduleSummary(schedule), - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - async handleScheduleDeleteRequest( - request: Extract, - ): Promise { - try { - await this.scheduleService.delete(request.scheduleId); - this.host.emit({ - type: "schedule/delete/response", - payload: { - requestId: request.requestId, - scheduleId: request.scheduleId, - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - async handleScheduleRunOnceRequest( - request: Extract, - ): Promise { - try { - const schedule = await this.scheduleService.runOnce(request.scheduleId); - this.host.emit({ - type: "schedule/run-once/response", - payload: { - requestId: request.requestId, - schedule, - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - async handleScheduleUpdateRequest( - request: Extract, - ): Promise { - try { - const schedule = await this.scheduleService.update({ - id: request.scheduleId, - ...(request.name !== undefined ? { name: request.name } : {}), - ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), - ...(request.cadence !== undefined ? { cadence: request.cadence } : {}), - ...(request.newAgentConfig !== undefined ? { newAgentConfig: request.newAgentConfig } : {}), - ...(request.maxRuns !== undefined ? { maxRuns: request.maxRuns } : {}), - ...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}), - }); - this.host.emit({ - type: "schedule/update/response", - payload: { - requestId: request.requestId, - schedule, - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - private emitLoopRpcError( - request: Extract< - SessionInboundMessage, - { - type: "loop/run" | "loop/list" | "loop/inspect" | "loop/logs" | "loop/stop"; - } - >, - error: unknown, - ): void { - const message = error instanceof Error ? error.message : String(error); - this.logger.error({ err: error, requestType: request.type }, "Loop request failed"); - this.host.emit({ - type: "rpc_error", - payload: { - requestId: request.requestId, - requestType: request.type, - error: message, - code: "loop_request_failed", - }, - }); - } - - async handleLoopRunRequest( - request: Extract, - ): Promise { - try { - const loop = await this.loopService.runLoop({ - prompt: request.prompt, - cwd: request.cwd, - provider: request.provider, - model: request.model, - modeId: request.modeId, - workerProvider: request.workerProvider, - workerModel: request.workerModel, - verifierProvider: request.verifierProvider, - verifierModel: request.verifierModel, - verifierModeId: request.verifierModeId, - verifyPrompt: request.verifyPrompt, - verifyChecks: request.verifyChecks, - archive: request.archive, - name: request.name, - sleepMs: request.sleepMs, - maxIterations: request.maxIterations, - maxTimeMs: request.maxTimeMs, - }); - this.host.emit({ - type: "loop/run/response", - payload: { - requestId: request.requestId, - loop, - error: null, - }, - }); - } catch (error) { - this.emitLoopRpcError(request, error); - } - } - - async handleLoopListRequest( - request: Extract, - ): Promise { - try { - const loops = await this.loopService.listLoops(); - this.host.emit({ - type: "loop/list/response", - payload: { - requestId: request.requestId, - loops, - error: null, - }, - }); - } catch (error) { - this.emitLoopRpcError(request, error); - } - } - - async handleLoopInspectRequest( - request: Extract, - ): Promise { - try { - const loop = await this.loopService.inspectLoop(request.id); - this.host.emit({ - type: "loop/inspect/response", - payload: { - requestId: request.requestId, - loop, - error: null, - }, - }); - } catch (error) { - this.emitLoopRpcError(request, error); - } - } - - async handleLoopLogsRequest( - request: Extract, - ): Promise { - try { - const result = await this.loopService.getLoopLogs(request.id, request.afterSeq ?? 0); - this.host.emit({ - type: "loop/logs/response", - payload: { - requestId: request.requestId, - loop: result.loop, - entries: result.entries, - nextCursor: result.nextCursor, - error: null, - }, - }); - } catch (error) { - this.emitLoopRpcError(request, error); - } - } - - async handleLoopStopRequest( - request: Extract, - ): Promise { - try { - const loop = await this.loopService.stopLoop(request.id); - this.host.emit({ - type: "loop/stop/response", - payload: { - requestId: request.requestId, - loop, - error: null, - }, - }); - } catch (error) { - this.emitLoopRpcError(request, error); - } - } -} diff --git a/packages/server/src/server/session/schedule/schedule-session.test.ts b/packages/server/src/server/session/schedule/schedule-session.test.ts new file mode 100644 index 0000000000..361d2318ff --- /dev/null +++ b/packages/server/src/server/session/schedule/schedule-session.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import pino from "pino"; +import { ScheduleSession } from "./schedule-session.js"; +import { createStub } from "../../test-utils/class-mocks.js"; +import { findByType } from "../../test-utils/session-stubs.js"; +import type { SessionOutboundMessage } from "../../messages.js"; +import type { ScheduleService } from "../../schedule/service.js"; + +function makeSession(schedule: { [K in keyof ScheduleService]?: unknown }) { + const emitted: SessionOutboundMessage[] = []; + const session = new ScheduleSession({ + host: { emit: (message) => emitted.push(message) }, + scheduleService: createStub(schedule), + logger: pino({ level: "silent" }), + }); + return { session, emitted }; +} + +describe("ScheduleSession", () => { + it("schedule/create returns a summary with the runs stripped", async () => { + const stored = { + id: "s1", + name: null, + prompt: "p", + cadence: { type: "every" as const, everyMs: 1000 }, + target: { type: "agent" as const, agentId: "a" }, + status: "active" as const, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [ + { + id: "run-1", + scheduledFor: "2026-01-01T00:00:00.000Z", + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + status: "running" as const, + agentId: null, + output: null, + error: null, + }, + ], + }; + const { session, emitted } = makeSession({ create: async () => stored }); + + await session.handleScheduleCreateRequest({ + type: "schedule/create", + requestId: "sc1", + prompt: "p", + cadence: { type: "every", everyMs: 1000 }, + target: { type: "agent", agentId: "a" }, + }); + + const response = findByType(emitted, "schedule/create/response"); + expect(response?.payload.schedule).toBeDefined(); + expect(response?.payload.schedule).not.toHaveProperty("runs"); + expect(response?.payload.schedule.id).toBe("s1"); + }); + + it("schedule/create remaps a self target to an agent target before creating", async () => { + let received: Parameters[0] | undefined; + const stored = { + id: "s2", + name: null, + prompt: "p", + cadence: { type: "every" as const, everyMs: 1000 }, + target: { type: "agent" as const, agentId: "agent-9" }, + status: "active" as const, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }; + const { session, emitted } = makeSession({ + create: async (input: Parameters[0]) => { + received = input; + return stored; + }, + }); + + await session.handleScheduleCreateRequest({ + type: "schedule/create", + requestId: "sc2", + prompt: "p", + cadence: { type: "every", everyMs: 1000 }, + target: { type: "self", agentId: "agent-9" }, + }); + + expect(received?.target).toEqual({ type: "agent", agentId: "agent-9" }); + expect(findByType(emitted, "schedule/create/response")?.payload.error).toBeNull(); + }); +}); diff --git a/packages/server/src/server/session/schedule/schedule-session.ts b/packages/server/src/server/session/schedule/schedule-session.ts new file mode 100644 index 0000000000..ef1d02d1d4 --- /dev/null +++ b/packages/server/src/server/session/schedule/schedule-session.ts @@ -0,0 +1,248 @@ +import type pino from "pino"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import type { ScheduleService } from "../../schedule/service.js"; + +export interface ScheduleSessionHost { + emit(msg: SessionOutboundMessage): void; +} + +export interface ScheduleSessionOptions { + host: ScheduleSessionHost; + scheduleService: ScheduleService; + logger: pino.Logger; +} + +export class ScheduleSession { + private readonly host: ScheduleSessionHost; + private readonly scheduleService: ScheduleService; + private readonly logger: pino.Logger; + + constructor(options: ScheduleSessionOptions) { + this.host = options.host; + this.scheduleService = options.scheduleService; + this.logger = options.logger; + } + + private toScheduleSummary( + schedule: Awaited>, + ): Extract< + SessionOutboundMessage, + { type: "schedule/list/response" } + >["payload"]["schedules"][number] { + const { runs: _runs, ...summary } = schedule; + return summary; + } + + private emitScheduleRpcError( + request: Extract< + SessionInboundMessage, + { + type: + | "schedule/create" + | "schedule/list" + | "schedule/inspect" + | "schedule/logs" + | "schedule/pause" + | "schedule/resume" + | "schedule/delete" + | "schedule/run-once" + | "schedule/update"; + } + >, + error: unknown, + ): void { + const message = error instanceof Error ? error.message : String(error); + this.logger.error({ err: error, requestType: request.type }, "Schedule request failed"); + this.host.emit({ + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: message, + code: "schedule_request_failed", + }, + }); + } + + async handleScheduleCreateRequest( + request: Extract, + ): Promise { + try { + const target = + request.target.type === "self" + ? { type: "agent" as const, agentId: request.target.agentId } + : request.target; + const schedule = await this.scheduleService.create({ + prompt: request.prompt, + name: request.name, + cadence: request.cadence, + target, + maxRuns: request.maxRuns, + expiresAt: request.expiresAt, + runOnCreate: request.runOnCreate, + }); + this.host.emit({ + type: "schedule/create/response", + payload: { + requestId: request.requestId, + schedule: this.toScheduleSummary(schedule), + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleListRequest( + request: Extract, + ): Promise { + try { + const schedules = await this.scheduleService.list(); + this.host.emit({ + type: "schedule/list/response", + payload: { + requestId: request.requestId, + schedules: schedules.map((schedule) => this.toScheduleSummary(schedule)), + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleInspectRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.inspect(request.scheduleId); + this.host.emit({ + type: "schedule/inspect/response", + payload: { + requestId: request.requestId, + schedule, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleLogsRequest( + request: Extract, + ): Promise { + try { + const runs = await this.scheduleService.logs(request.scheduleId); + this.host.emit({ + type: "schedule/logs/response", + payload: { + requestId: request.requestId, + runs, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleSchedulePauseRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.pause(request.scheduleId); + this.host.emit({ + type: "schedule/pause/response", + payload: { + requestId: request.requestId, + schedule: this.toScheduleSummary(schedule), + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleResumeRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.resume(request.scheduleId); + this.host.emit({ + type: "schedule/resume/response", + payload: { + requestId: request.requestId, + schedule: this.toScheduleSummary(schedule), + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleDeleteRequest( + request: Extract, + ): Promise { + try { + await this.scheduleService.delete(request.scheduleId); + this.host.emit({ + type: "schedule/delete/response", + payload: { + requestId: request.requestId, + scheduleId: request.scheduleId, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleRunOnceRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.runOnce(request.scheduleId); + this.host.emit({ + type: "schedule/run-once/response", + payload: { + requestId: request.requestId, + schedule, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleUpdateRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.update({ + id: request.scheduleId, + ...(request.name !== undefined ? { name: request.name } : {}), + ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), + ...(request.cadence !== undefined ? { cadence: request.cadence } : {}), + ...(request.newAgentConfig !== undefined ? { newAgentConfig: request.newAgentConfig } : {}), + ...(request.maxRuns !== undefined ? { maxRuns: request.maxRuns } : {}), + ...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}), + }); + this.host.emit({ + type: "schedule/update/response", + payload: { + requestId: request.requestId, + schedule, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } +} diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 333c7bb40b..f22f35069e 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -42,7 +42,11 @@ export function asAgentManager(stub: { export function asAgentStorage(stub: { [K in keyof SessionOptions["agentStorage"]]?: unknown; }): SessionOptions["agentStorage"] { - return createStub(stub); + return createStub({ + listByProviderSession: async () => [], + listByWorkspace: async () => [], + ...stub, + }); } export function asDownloadTokenStore(): SessionOptions["downloadTokenStore"] { @@ -53,18 +57,10 @@ export function asPushTokenStore(): SessionOptions["pushTokenStore"] { return createStub({}); } -export function asChatService(): SessionOptions["chatService"] { - return createStub({}); -} - export function asScheduleService(): SessionOptions["scheduleService"] { return createStub({}); } -export function asLoopService(): SessionOptions["loopService"] { - return createStub({}); -} - export function asCheckoutDiffManager(stub: { [K in keyof SessionOptions["checkoutDiffManager"]]?: unknown; }): SessionOptions["checkoutDiffManager"] { diff --git a/packages/server/src/server/websocket-server.browser-tools.test.ts b/packages/server/src/server/websocket-server.browser-tools.test.ts index 4ee596c420..7eb238cc66 100644 --- a/packages/server/src/server/websocket-server.browser-tools.test.ts +++ b/packages/server/src/server/websocket-server.browser-tools.test.ts @@ -15,10 +15,8 @@ import type { AgentManager } from "./agent/agent-manager.js"; import type { AgentStorage } from "./agent/agent-storage.js"; import { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; -import type { FileBackedChatService } from "./chat/chat-service.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; import type { DownloadTokenStore } from "./file-download/token-store.js"; -import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import { createStub } from "./test-utils/class-mocks.js"; import { DaemonClient } from "./test-utils/daemon-client.js"; @@ -307,8 +305,6 @@ function createVoiceAssistantWebSocketServer(params: { undefined, undefined, undefined, - createStub({}), - createStub({}), createStub({}), createStub({ subscribe: () => {}, diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index f662a044a9..5ec7a0fdbf 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -5,8 +5,6 @@ import type { AgentManager } from "./agent/agent-manager.js"; import type { AgentStorage } from "./agent/agent-storage.js"; import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; -import type { FileBackedChatService } from "./chat/chat-service.js"; -import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; @@ -125,8 +123,6 @@ function createServer(agentManagerOverrides?: Record) { undefined, undefined, undefined, - createStub({}), - createStub({}), createStub({}), createStub({ subscribe: vi.fn(), diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 790d06338c..5473fa2c44 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -5,8 +5,6 @@ import type { AgentManager } from "./agent/agent-manager.js"; import type { AgentStorage } from "./agent/agent-storage.js"; import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; -import type { FileBackedChatService } from "./chat/chat-service.js"; -import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; @@ -281,8 +279,6 @@ function createServer(options?: { undefined, undefined, undefined, - createStub({}), - createStub({}), createStub({}), createStub({ subscribe: vi.fn(), diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 7d3cba294f..231aaa714d 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -5,8 +5,6 @@ import type { AgentManager } from "./agent/agent-manager.js"; import type { AgentStorage } from "./agent/agent-storage.js"; import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; -import type { FileBackedChatService } from "./chat/chat-service.js"; -import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import type { @@ -156,8 +154,6 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work undefined, undefined, workspaceRegistry, - createStub({}), - createStub({}), createStub({}), createStub({ subscribe: vi.fn(), diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ff39bedae3..3026b60a34 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -11,8 +11,6 @@ import type { TerminalManager } from "../terminal/terminal-manager.js"; import type pino from "pino"; import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js"; import type { ProjectUpdate } from "./workspace-reconciliation-service.js"; -import type { FileBackedChatService } from "./chat/chat-service.js"; -import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js"; import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-store.js"; @@ -492,32 +490,22 @@ export class MissingDaemonVersionError extends Error { } interface RequiredWebSocketServices { - chatService: FileBackedChatService; - loopService: LoopService; scheduleService: ScheduleService; checkoutDiffManager: CheckoutDiffManager; } function requireWebSocketServices(params: { - chatService?: FileBackedChatService; - loopService?: LoopService; scheduleService?: ScheduleService; checkoutDiffManager?: CheckoutDiffManager; }): RequiredWebSocketServices { - const { chatService, loopService, scheduleService, checkoutDiffManager } = params; - if (!chatService) { - throw new Error("VoiceAssistantWebSocketServer requires a chat service."); - } - if (!loopService) { - throw new Error("VoiceAssistantWebSocketServer requires a loop service."); - } + const { scheduleService, checkoutDiffManager } = params; if (!scheduleService) { throw new Error("VoiceAssistantWebSocketServer requires a schedule service."); } if (!checkoutDiffManager) { throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager."); } - return { chatService, loopService, scheduleService, checkoutDiffManager }; + return { scheduleService, checkoutDiffManager }; } /** @@ -537,8 +525,6 @@ export class VoiceAssistantWebSocketServer { private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; - private readonly chatService: FileBackedChatService; - private readonly loopService: LoopService; private readonly scheduleService: ScheduleService; private readonly checkoutDiffManager: CheckoutDiffManager; private readonly github: ForgeService; @@ -611,8 +597,6 @@ export class VoiceAssistantWebSocketServer { onLifecycleIntent?: (intent: SessionLifecycleIntent) => void, projectRegistry?: ProjectRegistry, workspaceRegistry?: WorkspaceRegistry, - chatService?: FileBackedChatService, - loopService?: LoopService, scheduleService?: ScheduleService, checkoutDiffManager?: CheckoutDiffManager, serviceProxy?: ServiceProxySubsystem | null, @@ -652,13 +636,9 @@ export class VoiceAssistantWebSocketServer { this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); const requiredServices = requireWebSocketServices({ - chatService, - loopService, scheduleService, checkoutDiffManager, }); - this.chatService = requiredServices.chatService; - this.loopService = requiredServices.loopService; this.scheduleService = requiredServices.scheduleService; this.checkoutDiffManager = requiredServices.checkoutDiffManager; this.github = github ?? createGitHubService(); @@ -1359,8 +1339,6 @@ export class VoiceAssistantWebSocketServer { agentStorage: this.agentStorage, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, - chatService: this.chatService, - loopService: this.loopService, scheduleService: this.scheduleService, checkoutDiffManager: this.checkoutDiffManager, github: this.github, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 85e402e61c..503bfc6a16 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -218,9 +218,7 @@ function createSessionForWireCompatTest(options?: { projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"], workspaceRegistry: new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"], - chatService: {} as SessionOptions["chatService"], scheduleService: {} as SessionOptions["scheduleService"], - loopService: {} as SessionOptions["loopService"], checkoutDiffManager: { scheduleRefreshForCwd() {}, onWorkspaceStateMayHaveChanged() {}, diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index cac3bc38df..ac70ecbefb 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -153,8 +153,8 @@ function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies { }), }, agentStorage: { - list: async (): Promise => [], - } as Pick, + listByWorkspace: async (): Promise => [], + } as Pick, findWorkspaceIdForCwd: input.findWorkspaceIdForCwd ?? vi.fn(async () => null), listActiveWorkspaces: async () => active.filter((workspace) => !archivedWorkspaceIds.has(workspace.workspaceId)), @@ -703,12 +703,15 @@ describe("archiveByScope", () => { }), }; deps.agentStorage = { - list: async () => - [ - { id: targetStoredAgentId, workspaceId: targetWorkspaceId, archivedAt: null }, - { id: otherStoredAgentId, workspaceId: otherWorkspaceId, archivedAt: null }, - ] as StoredAgentRecord[], - } as Pick; + listByWorkspace: async (workspaceId: string) => + workspaceId === targetWorkspaceId + ? ([ + { id: targetStoredAgentId, workspaceId: targetWorkspaceId, archivedAt: null }, + ] as StoredAgentRecord[]) + : ([ + { id: otherStoredAgentId, workspaceId: otherWorkspaceId, archivedAt: null }, + ] as StoredAgentRecord[]), + } as Pick; const result = await archiveByScope(deps, { scope: { kind: "workspace", workspaceId: targetWorkspaceId }, diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 6fc841d84b..8b37cae3f1 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -33,7 +33,7 @@ export interface ArchiveDependencies { github: ForgeService; workspaceGitService: Pick; agentManager: Pick; - agentStorage: Pick; + agentStorage: Pick; // Resolves the worktree at a path to its workspaceId for archive-by-path. The // path uniquely identifies a worktree workspace; this is a directory lookup for // the archive target, not status/ownership. @@ -452,16 +452,14 @@ export async function archiveWorkspaceContents( let storedRecords: StoredAgentRecord[] = []; try { - storedRecords = await dependencies.agentStorage.list(); + storedRecords = await dependencies.agentStorage.listByWorkspace(workspaceId); } catch (error) { dependencies.sessionLogger?.warn( { err: error, workspaceId }, "Failed to list stored agents during workspace archive; continuing", ); } - const matchingStoredRecords = storedRecords.filter( - (record) => record.workspaceId === workspaceId, - ); + const matchingStoredRecords = storedRecords; for (const record of matchingStoredRecords) { archivedAgents.add(record.id); } diff --git a/packages/website/posts/why-agent-chat-rooms-beat-agent-to-agent-prompting.md b/packages/website/posts/why-agent-chat-rooms-beat-agent-to-agent-prompting.md deleted file mode 100644 index 308317cc05..0000000000 --- a/packages/website/posts/why-agent-chat-rooms-beat-agent-to-agent-prompting.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: "Why agent chat rooms beat agent-to-agent prompting" -description: "Why shared chat rooms are a better coordination model than hidden agent-to-agent prompt chains once tasks stop being trivial." -date: "2026-03-29" -draft: "true" ---- - -I keep seeing people try to wire agents together by having one agent prompt another directly. - -Planner prompts implementer. Implementer prompts reviewer. Reviewer prompts planner again. Maybe it works for a demo. Maybe it even works for a narrow task. - -But I think it breaks down pretty quickly. - -The problem is that agent-to-agent prompting turns coordination into hidden private messages. The whole workflow disappears into a chain of opaque handoffs. Context gets duplicated, instructions drift, and it becomes hard for the human to see what is going on, step in, or redirect things cleanly. - -What has been working much better for me is a shared chat room model. - -Instead of agents calling each other directly, they all communicate in a room that the human can also read and participate in. The room becomes the coordination surface. Not just a log, but the place where work gets assigned, clarified, reviewed, and handed off. - -That changes a few things. - -First, coordination becomes visible. If a planning agent says "I think we should split this into 3 steps", that is just there in the room. If an implementation agent finishes and asks for review, that is there too. The human does not need to reconstruct the workflow from tool logs or imagine what one agent told another 5 minutes ago. - -Second, handoff gets cleaner. You are no longer depending on one giant prompt chain staying intact. Agents can join, leave, catch up, and respond to the current shared context. That matters a lot once tasks stop being trivial. - -Third, the human stays in the loop without micromanaging. This is the part I care about most. I do not want to disappear into a black box where agents recursively talk to each other and I only see the final result. I want to be able to glance at the room, redirect something, answer a question, or spawn another agent when needed. - -Fourth, it matches how collaborative work already feels. A lot of software development is not one linear prompt. It is more like a room with planning, implementation, review, clarification, waiting, and resuming. If that is true for humans, I think it will be true for agents too. - -This also connects to a broader point I keep coming back to: a real agent development environment probably needs organizational entities above a single chat. - -If every task is just one long chat with one agent, things get messy fast. Once you have parallel tasks, multiple models, review loops, different hosts, or work that continues while you are away from your desk, you need structure. Projects, workspaces, rooms, whatever you want to call them. Some stable place where the work lives. - -I think people underestimate this because current demos are still too simple. One agent edits files, another reviews, done. But once you actually live in these tools every day, the bottleneck is not just model quality. It is coordination. - -My current belief is that the future is less "one super agent in one chat box" and more "teams of agents collaborating in shared contexts that humans can also inhabit". - -Not because it sounds grand. Just because it seems to work better. - -If you're building in this space, I'm curious what has been working for you: - -- direct agent-to-agent prompting -- shared rooms -- workspaces -- something else entirely diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index a3195f1777..87f12f69d9 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -1157,19 +1157,6 @@ paseo run --host devbox:6767 "run the full test suite" paseo ls # list running agents paseo attach abc123 # stream live output paseo send abc123 "also add tests" # follow-up task`, - }, - { - title: "Loops", - description: - "Have one agent do the work, another verify the result, and loop until it passes. Built-in, no shell scripting needed.", - code: `# Worker-verifier loop: fix tests until they pass -paseo loop run "make all tests pass" \\ - --verify "verify tests pass and the code is production-ready" \\ - --verify-check "npm test" \\ - --max-iterations 5 - -paseo loop ls # list running loops -paseo loop logs abc123 # stream loop output`, }, { title: "Schedules", diff --git a/packages/website/src/content/alternatives/claude-desktop.md b/packages/website/src/content/alternatives/claude-desktop.md index c03cf14c96..b4f55f9fba 100644 --- a/packages/website/src/content/alternatives/claude-desktop.md +++ b/packages/website/src/content/alternatives/claude-desktop.md @@ -79,7 +79,7 @@ paseo send "add tests" paseo schedule create --cron "0 9 * * 1" "audit the codebase" ``` -`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes. The MCP server lets other agents create worktrees, launch agents, open terminals, and send prompts. +`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. The MCP server lets other agents create worktrees, launch agents, open terminals, and send prompts. ## Worktrees and services diff --git a/packages/website/src/content/alternatives/codex-app.md b/packages/website/src/content/alternatives/codex-app.md index 1b6f67116a..af29184926 100644 --- a/packages/website/src/content/alternatives/codex-app.md +++ b/packages/website/src/content/alternatives/codex-app.md @@ -79,7 +79,7 @@ paseo send "add tests" paseo schedule create --cron "0 9 * * 1" "audit the codebase" ``` -`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes. The MCP server lets other agents create worktrees, launch agents, open terminals, and send prompts. +`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. The MCP server lets other agents create worktrees, launch agents, open terminals, and send prompts. ## Voice diff --git a/packages/website/src/content/alternatives/conductor.md b/packages/website/src/content/alternatives/conductor.md index a72dc6b36b..49ffb867f2 100644 --- a/packages/website/src/content/alternatives/conductor.md +++ b/packages/website/src/content/alternatives/conductor.md @@ -58,7 +58,7 @@ paseo send "add tests" paseo schedule create --cron "0 9 * * 1" "audit the codebase" ``` -`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes. +`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. Conductor does not have a CLI. diff --git a/packages/website/src/content/alternatives/happy-coder.md b/packages/website/src/content/alternatives/happy-coder.md index 29e8ebed89..8af4c2f21f 100644 --- a/packages/website/src/content/alternatives/happy-coder.md +++ b/packages/website/src/content/alternatives/happy-coder.md @@ -70,7 +70,7 @@ paseo send "add tests" paseo schedule create --cron "0 9 * * 1" "audit the codebase" ``` -`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes. +`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. Happy Coder has a CLI to launch the wrapped session. It does not have schedules or loops. diff --git a/packages/website/src/content/alternatives/openchamber.md b/packages/website/src/content/alternatives/openchamber.md index 77ca2f0219..6980a97d15 100644 --- a/packages/website/src/content/alternatives/openchamber.md +++ b/packages/website/src/content/alternatives/openchamber.md @@ -60,7 +60,7 @@ paseo send "add tests" paseo schedule create --cron "0 9 * * 1" "audit the codebase" ``` -`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes. +`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. OpenChamber does not have a CLI. diff --git a/packages/website/src/content/alternatives/opencode-desktop.md b/packages/website/src/content/alternatives/opencode-desktop.md index 1555cbaf86..eac0e40d09 100644 --- a/packages/website/src/content/alternatives/opencode-desktop.md +++ b/packages/website/src/content/alternatives/opencode-desktop.md @@ -75,7 +75,7 @@ paseo send "add tests" paseo schedule create --cron "0 9 * * 1" "audit the codebase" ``` -`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes. The MCP server lets other agents create worktrees, launch agents, open terminals, and send prompts. +`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. The MCP server lets other agents create worktrees, launch agents, open terminals, and send prompts. ## Worktrees and services diff --git a/packages/website/src/content/alternatives/superset.md b/packages/website/src/content/alternatives/superset.md index 7c338dd5c1..43c440cb10 100644 --- a/packages/website/src/content/alternatives/superset.md +++ b/packages/website/src/content/alternatives/superset.md @@ -70,7 +70,7 @@ paseo send "add tests" paseo schedule create --cron "0 9 * * 1" "audit the codebase" ``` -`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes. +`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. Superset is a desktop app and does not have a CLI. diff --git a/public-docs/skills.md b/public-docs/skills.md index 1c4bd6f86c..2cdeaf03ad 100644 --- a/public-docs/skills.md +++ b/public-docs/skills.md @@ -42,17 +42,6 @@ The receiving agent gets a self-contained briefing with the task, context, relev /paseo-handoff hand this to claude opus for review ``` -## `/paseo-loop`, Iterative Loops - -Runs an agent loop until an exit condition is met. Use it when you say "loop", "babysit", "keep trying until", "check every X", "watch", or want iterative autonomous execution. - -A loop is a worker/verifier cycle: launch a worker, check verification, repeat until done or limits hit. It can use a shell check, a verifier prompt, or both. Set a sensible `--max-iterations` or `--max-time`. - -``` -/paseo-loop keep trying until the changed test file passes, max 5 iterations -/paseo-loop babysit PR 123 until checks are green, check every 2m, max-time 1h -``` - ## `/paseo-committee`, Committee Planning Forms a committee of two high-reasoning agents to step back, do root cause analysis, and produce a plan. Use it when stuck, looping, tunnel-visioning, or facing a hard planning problem. diff --git a/skills/paseo-loop/SKILL.md b/skills/paseo-loop/SKILL.md deleted file mode 100644 index 2ee7be549e..0000000000 --- a/skills/paseo-loop/SKILL.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: paseo-loop -description: Run an agent loop until an exit condition is met. Use when the user says "loop", "babysit", "keep trying until", "check every X", "watch", or wants iterative autonomous execution. -user-invocable: true ---- - -# Paseo Loop Skill - -A loop is a worker/verifier cycle: launch a worker → check verification → repeat until done or limits hit. Use for "keep trying", "babysit", or "watch this until X." - -For lightweight recurring checks that should return to this same conversation, prefer a cron heartbeat (`create_heartbeat`, then `delete_heartbeat` when finished). To change it, delete and recreate it. Use a loop when each iteration needs a worker/verifier lifecycle; use a schedule when each firing should create a fresh independent agent and workspace. - -**User's arguments:** $ARGUMENTS - -## Prerequisites - -Read the **paseo** skill. Before choosing worker or verifier providers, read `~/.paseo/orchestration-preferences.json` unless the user explicitly named providers in this request. Do not start the loop until you have read it. - -Loops are a CLI primitive: `paseo loop run`. Manage with `paseo loop ls`, `paseo loop inspect `, `paseo loop logs `, `paseo loop stop `. - -## Your job - -1. Understand the user's intent from `$ARGUMENTS` and the conversation. -2. **Worker prompt** — self-contained, concrete about what to do this iteration, explicit about what counts as progress. -3. **Verification** — pick the right shape: - - Shell check (`--verify-check`) for objective criteria a command can answer (`gh pr checks --fail-fast`, `npm test`). - - Verifier prompt (`--verify`) for judgment ("Return done=true only if all tests pass and the changed files are coherent. Cite the command and the outcome."). - - Both, when shell rules out the obvious failures and the verifier judges the rest. -4. **Providers** — `--provider` for the worker, `--verify-provider` for the verifier. From preferences unless the user named them. For implementation loops, pair worker and verifier on different providers — each catches the other's blind spots. -5. **Sleep** — `--sleep` only when polling something external. Otherwise let it run as fast as the loop completes. -6. **Stops** — set a sensible `--max-iterations` and/or `--max-time`. Open-ended loops are how runaways happen. -7. **Archive** — `--archive` keeps agents after each iteration for inspection. -8. Launch with `paseo loop run`. - -## Common shapes - -**Babysit a PR** — worker checks PR state and fixes issues; shell check is `gh pr checks --fail-fast`; sleep 2m; max-time 1h. - -**Drive tests to green** — worker investigates failures and fixes code; shell check is the test command; verifier confirms all tests pass; max-iterations 10. - -**Cross-provider implementation** — worker on `impl` provider, verifier on a different provider; verifier checks changed files, runs typecheck and tests; max-iterations and max-time both bounded; archive on so iterations can be inspected. - -## Prompt rules - -**Worker** — self-contained, concrete (commands, files, branches, tests, PRs, systems), explicit about what counts as progress this iteration. - -**Verifier** — checks facts, doesn't suggest fixes, cites commands/outputs/file evidence, specific about what "done" means. From df9e8e8f06d33527e4a63feabb63bb3725892b9f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 21:55:55 +0200 Subject: [PATCH 06/73] Use daemon hostnames for initial Hub slugs (#3172) * fix(hub): send hostname during daemon enrollment Pass the local hostname as daemon enrollment metadata so Hub can assign useful default slugs without coupling daemon authority to the human CLI login. Document the rollout dependency on Hub accepting the optional field. * test(hub): verify enrollment hostname payload --- packages/server/src/server/bootstrap.ts | 1 + .../hub/relationship-controller.test.ts | 3 ++- .../src/server/hub/relationship-controller.ts | 2 ++ .../server/hub/relationship-remote.test.ts | 20 ++++++++++++++++--- .../src/server/hub/relationship-remote.ts | 2 ++ public-docs/hub/daemons.md | 4 +++- 6 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 11618aa96d..0321d3295f 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1111,6 +1111,7 @@ export async function createPaseoDaemon( }); const hubRelationships = new HubRelationshipController({ paseoHome: config.paseoHome, + hostname: getHostname(), serverId, daemonPublicKey: daemonKeyPair.publicKeyB64, logger, diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index 78b4481ece..c7ffbd8338 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { platform } from "node:os"; +import { hostname, platform } from "node:os"; import { afterEach, describe, expect, test } from "vitest"; import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; @@ -88,6 +88,7 @@ describe("Hub relationship", () => { .update(secret ?? "") .digest("base64url"), ); + expect(enrollment.hostname).toBe(hostname()); relationship.completeEnrollment(); await connecting.result; await relationship.socketDialed(); diff --git a/packages/server/src/server/hub/relationship-controller.ts b/packages/server/src/server/hub/relationship-controller.ts index 457a5b701d..2c213aae66 100644 --- a/packages/server/src/server/hub/relationship-controller.ts +++ b/packages/server/src/server/hub/relationship-controller.ts @@ -137,6 +137,7 @@ export interface HubRelationshipRetryPolicy { export interface HubRelationshipControllerOptions { paseoHome: string; + hostname: string; serverId: string; daemonPublicKey: string; logger: pino.Logger; @@ -324,6 +325,7 @@ export class HubRelationshipController implements HubRelationshipManagement { idempotencyKey: pending.relationship.idempotencyKey, hubOrigin: pending.relationship.hubOrigin, token: pending.enrollment.token, + hostname: this.options.hostname, serverId: pending.identity.serverId, daemonPublicKey: pending.identity.daemonPublicKey, credentialVerifier: verifier, diff --git a/packages/server/src/server/hub/relationship-remote.test.ts b/packages/server/src/server/hub/relationship-remote.test.ts index e4950adaba..8591ad8adc 100644 --- a/packages/server/src/server/hub/relationship-remote.test.ts +++ b/packages/server/src/server/hub/relationship-remote.test.ts @@ -61,6 +61,7 @@ test.each([408, 429])("transient enrollment status %s remains retryable", async idempotencyKey: "ceremony-1", hubOrigin, token: "token", + hostname: "test-daemon.local", serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", @@ -74,7 +75,10 @@ test.each([408, 429])("transient enrollment status %s remains retryable", async }); test("enrollment rejects a transport URL that cannot open a WebSocket", async () => { - const hubOrigin = await startEnrollmentHub("ftp://hub.test/daemon"); + let enrolledHostname: string | undefined; + const hubOrigin = await startEnrollmentHub("ftp://hub.test/daemon", (enrollment) => { + enrolledHostname = enrollment.hostname; + }); const remote = new DirectHubRelationshipRemote(); await expect( @@ -83,12 +87,14 @@ test("enrollment rejects a transport URL that cannot open a WebSocket", async () idempotencyKey: "ceremony-1", hubOrigin, token: "token", + hostname: "test-daemon.local", serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", scopes: ["hub.execution.*"], }), ).rejects.toThrow("Hub WebSocket URL must use ws or wss"); + expect(enrolledHostname).toBe("test-daemon.local"); }); test("enrollment rejects a WebSocket URL with a fragment", async () => { @@ -101,6 +107,7 @@ test("enrollment rejects a WebSocket URL with a fragment", async () => { idempotencyKey: "ceremony-1", hubOrigin, token: "token", + hostname: "test-daemon.local", serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", @@ -119,6 +126,7 @@ test("enrollment rejects a WebSocket outside the enrolled Hub authority", async idempotencyKey: "ceremony-1", hubOrigin, token: "token", + hostname: "test-daemon.local", serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", @@ -138,6 +146,7 @@ test.each(["enrollment", "revocation"])("%s HTTP calls are bounded", async (oper idempotencyKey: "ceremony-1", hubOrigin, token: "token", + hostname: "test-daemon.local", serverId: "server-1", daemonPublicKey: "public-key", credentialVerifier: "verifier", @@ -290,11 +299,15 @@ async function startHubReturning(status: number): Promise { return `http://127.0.0.1:${address.port}`; } -async function startEnrollmentHub(webSocketUrl: string): Promise { +async function startEnrollmentHub( + webSocketUrl: string, + onEnrollment?: (enrollment: { daemonId: string; hostname: string }) => void, +): Promise { const server = createServer(async (request, response) => { let body = ""; for await (const chunk of request) body += chunk; - const enrollment = JSON.parse(body) as { daemonId: string }; + const enrollment = JSON.parse(body) as { daemonId: string; hostname: string }; + onEnrollment?.(enrollment); response.writeHead(200, { "content-type": "application/json" }).end( JSON.stringify({ daemonId: enrollment.daemonId, @@ -523,6 +536,7 @@ async function connectController( openPaseoHomes.push(paseoHome); const controller = new HubRelationshipController({ paseoHome, + hostname: "test-daemon.local", serverId: "server-1", daemonPublicKey: "daemon-public-key", logger: pino({ level: "silent" }), diff --git a/packages/server/src/server/hub/relationship-remote.ts b/packages/server/src/server/hub/relationship-remote.ts index 82172a411a..a5321634fa 100644 --- a/packages/server/src/server/hub/relationship-remote.ts +++ b/packages/server/src/server/hub/relationship-remote.ts @@ -7,6 +7,7 @@ export interface HubEnrollment { idempotencyKey: string; hubOrigin: string; token: string; + hostname: string; serverId: string; daemonPublicKey: string; credentialVerifier: string; @@ -96,6 +97,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote { body: JSON.stringify({ daemonId: input.daemonId, idempotencyKey: input.idempotencyKey, + hostname: input.hostname, serverId: input.serverId, daemonPublicKey: input.daemonPublicKey, credentialVerifier: input.credentialVerifier, diff --git a/public-docs/hub/daemons.md b/public-docs/hub/daemons.md index 2b9ae1ec3c..2e0dce8595 100644 --- a/public-docs/hub/daemons.md +++ b/public-docs/hub/daemons.md @@ -26,7 +26,9 @@ paseo hub connect `connect` uses the active login to request a single-use enrollment token. The daemon exchanges it for its own relationship credential; your CLI login is never stored as daemon authority. -Each daemon has two identifiers: an immutable generated ID and a friendly slug. Hub normalizes the slug you enter with lowercase words joined by hyphens, so `Build Studio` becomes `build-studio`. The slug is what the dashboard shows and what configuration references. +Hub derives the daemon's initial slug from its hostname. If that slug is already used in the organization, Hub adds a short daemon ID suffix. You can rename the daemon later in Hub. + +Each daemon has two identifiers: an immutable generated ID and a friendly slug. Hub normalizes slugs with lowercase words joined by hyphens, so `Build Studio` becomes `build-studio`. The slug is what the dashboard shows and what configuration references. You can rename the slug later without changing the daemon ID. Renaming after a configuration is active means updating that configuration. From 29ae25d7aa44dadc91cfc77cd4c64d475bd18d8c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 21:57:50 +0200 Subject: [PATCH 07/73] Show tooltips for all composer controls (#3173) * fix(app): restore composer control tooltips Composite composer triggers dropped tooltip interaction props or the anchor ref, so hover either never opened the tooltip or left it off-screen. Preserve pressable props and compose menu refs at the trigger boundaries. * fix(app): align mode tooltip typography --- .../src/components/ui/menu/menu-root.test.tsx | 26 ++++++++++++ .../app/src/components/ui/menu/menu-root.tsx | 41 +++++++++++++++---- .../composer/agent-controls/control.test.tsx | 39 ++++++++++++++++++ .../src/composer/agent-controls/control.tsx | 13 +++--- .../composer/agent-controls/mode-control.tsx | 3 +- 5 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/components/ui/menu/menu-root.test.tsx create mode 100644 packages/app/src/composer/agent-controls/control.test.tsx diff --git a/packages/app/src/components/ui/menu/menu-root.test.tsx b/packages/app/src/components/ui/menu/menu-root.test.tsx new file mode 100644 index 0000000000..2b24fa122d --- /dev/null +++ b/packages/app/src/components/ui/menu/menu-root.test.tsx @@ -0,0 +1,26 @@ +/** + * @vitest-environment jsdom + */ +import React, { createRef } from "react"; +import { render } from "@testing-library/react"; +import { Text, type View } from "react-native"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MenuRoot, MenuTrigger } from "./menu-root"; + +beforeEach(() => vi.stubGlobal("React", React)); + +describe("MenuTrigger", () => { + it("forwards its rendered trigger to callers", () => { + const triggerRef = createRef(); + + render( + + + Open + + , + ); + + expect(triggerRef.current).not.toBeNull(); + }); +}); diff --git a/packages/app/src/components/ui/menu/menu-root.tsx b/packages/app/src/components/ui/menu/menu-root.tsx index ca801c8caa..08c4b2407b 100644 --- a/packages/app/src/components/ui/menu/menu-root.tsx +++ b/packages/app/src/components/ui/menu/menu-root.tsx @@ -1,6 +1,14 @@ -import { useCallback, type PropsWithChildren, type ReactElement, type ReactNode } from "react"; +import { + forwardRef, + useCallback, + type PropsWithChildren, + type ReactElement, + type ReactNode, + type Ref, +} from "react"; import { Pressable, + type View, type PressableProps, type PressableStateCallbackType, type StyleProp, @@ -49,14 +57,29 @@ export interface MenuTriggerProps extends Omit ReactNode); } -export function MenuTrigger({ - children, - disabled, - style, - ...props -}: MenuTriggerProps): ReactElement { +function assignRef(ref: Ref | undefined, value: T | null): void { + if (!ref) return; + if (typeof ref === "function") { + ref(value); + return; + } + Object.assign(ref, { current: value }); +} + +export const MenuTrigger = forwardRef(function MenuTrigger( + { children, disabled, style, ...props }, + forwardedRef, +): ReactElement { const ctx = useMenuContext("MenuTrigger"); + const handleTriggerRef = useCallback( + (node: View | null) => { + assignRef(ctx.triggerRef, node); + assignRef(forwardedRef, node); + }, + [ctx.triggerRef, forwardedRef], + ); + const handlePress = useCallback(() => { if (disabled) return; ctx.setOpen(!ctx.open); @@ -83,7 +106,7 @@ export function MenuTrigger({ return ( ); -} +}); diff --git a/packages/app/src/composer/agent-controls/control.test.tsx b/packages/app/src/composer/agent-controls/control.test.tsx new file mode 100644 index 0000000000..70eb679a23 --- /dev/null +++ b/packages/app/src/composer/agent-controls/control.test.tsx @@ -0,0 +1,39 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { fireEvent, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AgentControlTrigger } from "./control"; + +beforeEach(() => vi.stubGlobal("React", React)); + +function TestIcon() { + return null; +} + +describe("AgentControlTrigger", () => { + it("forwards interaction handlers to the rendered trigger", () => { + const onPointerEnter = vi.fn(); + const onFocus = vi.fn(); + const onPress = vi.fn(); + const view = render( + , + ); + const trigger = view.getByRole("button", { name: "Select mode" }); + + fireEvent.pointerEnter(trigger); + fireEvent.focus(trigger); + + expect(onPointerEnter).toHaveBeenCalledTimes(1); + expect(onFocus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/composer/agent-controls/control.tsx b/packages/app/src/composer/agent-controls/control.tsx index 2077c86ab0..bef4bc891d 100644 --- a/packages/app/src/composer/agent-controls/control.tsx +++ b/packages/app/src/composer/agent-controls/control.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useCallback } from "react"; +import { forwardRef, useCallback, type ComponentProps } from "react"; import { Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; @@ -6,7 +6,10 @@ import { useComposerControlLayout } from "@/composer/agent-controls/layout-conte import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; import type { AgentControlIcon } from "@/agent-controls/icons"; -interface AgentControlTriggerProps { +type AgentControlTriggerProps = Omit< + ComponentProps, + "accessibilityLabel" | "block" | "children" | "chevron" | "onPress" | "style" +> & { icon: AgentControlIcon; iconColor?: string; surface: "toolbar" | "sheet"; @@ -15,11 +18,9 @@ interface AgentControlTriggerProps { showToolbarLabel?: boolean; showCaret?: boolean; open?: boolean; - disabled?: boolean; onPress: () => void; accessibilityLabel: string; - testID?: string; -} +}; export const AgentControlTrigger = forwardRef( function AgentControlTrigger( @@ -36,6 +37,7 @@ export const AgentControlTrigger = forwardRef( onPress, accessibilityLabel, testID, + ...triggerProps }, ref, ) { @@ -57,6 +59,7 @@ export const AgentControlTrigger = forwardRef( return ( ({ }, tooltipText: { color: theme.colors.foreground, - fontSize: theme.fontSize.xs, + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.4, }, })); From 6aa9f921560b0ee2333706a7756a39776be64042 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 23:33:31 +0200 Subject: [PATCH 08/73] Stop notifications after removing a host (#3176) * fix(notifications): stop pushes after host removal Push tokens were persisted indefinitely with no relationship to app pairing state. Give registrations a 48-hour renewable lease so offline unpairing converges, and revoke immediately when the host is reachable. * fix(notifications): make token revocation bounded and durable --- packages/app/src/contexts/session-context.tsx | 4 +- .../src/hooks/use-push-token-registration.ts | 111 ----------------- .../src/push-notifications/index.native.ts | 12 ++ packages/app/src/push-notifications/index.ts | 9 ++ .../app/src/push-notifications/index.web.ts | 9 ++ .../internal/subscriptions.ts | 103 ++++++++++++++++ .../src/push-notifications/internal/types.ts | 11 ++ packages/app/src/runtime/host-runtime.test.ts | 48 ++++++++ packages/app/src/runtime/host-runtime.ts | 15 ++- packages/client/src/daemon-client.test.ts | 53 ++++++++ packages/client/src/daemon-client.ts | 11 ++ packages/protocol/src/messages.ts | 19 +++ packages/server/src/server/bootstrap.ts | 2 +- .../src/server/client-activity.e2e.test.ts | 2 +- packages/server/src/server/push/index.test.ts | 67 ++++++++++ packages/server/src/server/push/index.ts | 45 +++++++ .../server/src/server/push/notifications.ts | 29 ----- .../server/src/server/push/push-service.ts | 9 +- .../src/server/push/token-store.test.ts | 57 ++++++++- .../server/src/server/push/token-store.ts | 116 ++++++++++++++---- packages/server/src/server/session.test.ts | 87 ++++++++++++- packages/server/src/server/session.ts | 27 +++- .../session.workspace-git-watch.test.ts | 2 +- ...on.workspace-resolution-invariants.test.ts | 2 +- .../src/server/session.workspaces.test.ts | 20 +-- .../snapshot-mutation-ownership.test.ts | 2 +- .../src/server/test-utils/paseo-daemon.ts | 2 +- .../src/server/test-utils/session-stubs.ts | 8 +- .../websocket-server.notifications.test.ts | 2 +- .../websocket-server.relay-reconnect.test.ts | 20 +-- ...cket-server.terminal-notifications.test.ts | 2 +- .../server/src/server/websocket-server.ts | 21 ++-- .../server/src/server/wire-compat.test.ts | 2 +- 33 files changed, 702 insertions(+), 227 deletions(-) delete mode 100644 packages/app/src/hooks/use-push-token-registration.ts create mode 100644 packages/app/src/push-notifications/index.native.ts create mode 100644 packages/app/src/push-notifications/index.ts create mode 100644 packages/app/src/push-notifications/index.web.ts create mode 100644 packages/app/src/push-notifications/internal/subscriptions.ts create mode 100644 packages/app/src/push-notifications/internal/types.ts create mode 100644 packages/server/src/server/push/index.test.ts create mode 100644 packages/server/src/server/push/index.ts delete mode 100644 packages/server/src/server/push/notifications.ts diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 8a93e9626f..76eb785210 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -5,7 +5,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useClientActivity } from "@/hooks/use-client-activity"; import { useAppVisible } from "@/hooks/use-app-visible"; -import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; +import { startPushNotifications } from "@/push-notifications"; import { createSetAgentInitializing, refreshAgentInitializationTimeout, @@ -418,7 +418,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Client activity tracking (heartbeat, push token registration) useClientActivity({ client, focusedAgentId, focusedTerminalId, onAppResumed: handleAppResumed }); - usePushTokenRegistration({ client, serverId }); + useEffect(() => startPushNotifications({ client, serverId }), [client, serverId]); const notifyAgentAttention = useCallback( (params: { diff --git a/packages/app/src/hooks/use-push-token-registration.ts b/packages/app/src/hooks/use-push-token-registration.ts deleted file mode 100644 index 4dd1209f9d..0000000000 --- a/packages/app/src/hooks/use-push-token-registration.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import { Platform } from "react-native"; -import AsyncStorage from "@react-native-async-storage/async-storage"; -import * as Notifications from "expo-notifications"; -import Constants from "expo-constants"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { isWeb } from "@/constants/platform"; - -const STORAGE_PREFIX = "@paseo:expo-push-token:"; - -function getExpoProjectId(): string | null { - const constants = Constants as unknown as { - easConfig?: { projectId?: unknown }; - expoConfig?: { extra?: { eas?: { projectId?: unknown } } }; - }; - const fromEas = constants?.easConfig?.projectId; - if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim(); - - const fromExtra = constants?.expoConfig?.extra?.eas?.projectId; - if (typeof fromExtra === "string" && fromExtra.trim()) return fromExtra.trim(); - - return null; -} - -async function ensurePushPermission(): Promise { - const existing = await Notifications.getPermissionsAsync(); - if (existing.status === Notifications.PermissionStatus.GRANTED) return true; - if (!existing.canAskAgain) return false; - const requested = await Notifications.requestPermissionsAsync(); - return requested.status === Notifications.PermissionStatus.GRANTED; -} - -export function usePushTokenRegistration(params: { client: DaemonClient; serverId: string }): void { - const { client, serverId } = params; - const tokenRef = useRef(null); - const lastSentTokenRef = useRef(null); - - const registerIfPossible = useCallback(async () => { - if (isWeb) return; - if (!client.isConnected) return; - const token = tokenRef.current; - if (!token) return; - if (lastSentTokenRef.current === token) return; - lastSentTokenRef.current = token; - client.registerPushToken(token); - }, [client]); - - useEffect(() => { - if (isWeb) return; - - const storageKey = `${STORAGE_PREFIX}${serverId}`; - let cancelled = false; - - const run = async () => { - const cached = await AsyncStorage.getItem(storageKey); - if (cancelled) return; - if (cached && typeof cached === "string") { - tokenRef.current = cached; - } - - const granted = await ensurePushPermission(); - if (!granted || cancelled) return; - - if (Platform.OS === "android") { - await Notifications.setNotificationChannelAsync("default", { - name: "default", - importance: Notifications.AndroidImportance.DEFAULT, - }); - } - - const projectId = getExpoProjectId(); - if (!projectId) { - console.warn("[PushToken] Missing EAS projectId; cannot fetch Expo push token"); - return; - } - - const result = await Notifications.getExpoPushTokenAsync({ projectId }); - if (cancelled) return; - - const token = result.data; - if (typeof token !== "string" || !token.trim()) return; - - tokenRef.current = token; - await AsyncStorage.setItem(storageKey, token); - await registerIfPossible(); - }; - - void run().catch((error) => { - console.warn("[PushToken] Failed to register push token", error); - }); - - return () => { - cancelled = true; - }; - }, [registerIfPossible, serverId]); - - useEffect(() => { - const unsubscribe = client.subscribeConnectionStatus((state) => { - if (state.status === "connected") { - void registerIfPossible(); - } else { - // Re-register on the next successful connect. - lastSentTokenRef.current = null; - } - }); - if (client.isConnected) { - void registerIfPossible(); - } - return unsubscribe; - }, [client, registerIfPossible]); -} diff --git a/packages/app/src/push-notifications/index.native.ts b/packages/app/src/push-notifications/index.native.ts new file mode 100644 index 0000000000..579c9a0738 --- /dev/null +++ b/packages/app/src/push-notifications/index.native.ts @@ -0,0 +1,12 @@ +import { revokeSubscription, startSubscription } from "./internal/subscriptions"; +import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./internal/types"; + +export function startPushNotifications(input: StartPushNotificationsInput): () => void { + return startSubscription(input); +} + +export function revokePushNotifications(input: RevokePushNotificationsInput): Promise { + return revokeSubscription(input).catch((error) => { + console.warn("[PushNotifications] Failed to remove local push subscription", error); + }); +} diff --git a/packages/app/src/push-notifications/index.ts b/packages/app/src/push-notifications/index.ts new file mode 100644 index 0000000000..9aebb187d6 --- /dev/null +++ b/packages/app/src/push-notifications/index.ts @@ -0,0 +1,9 @@ +import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./internal/types"; + +export function startPushNotifications(_input: StartPushNotificationsInput): () => void { + return () => undefined; +} + +export async function revokePushNotifications(_input: RevokePushNotificationsInput): Promise { + // Push notifications are native-only. +} diff --git a/packages/app/src/push-notifications/index.web.ts b/packages/app/src/push-notifications/index.web.ts new file mode 100644 index 0000000000..9aebb187d6 --- /dev/null +++ b/packages/app/src/push-notifications/index.web.ts @@ -0,0 +1,9 @@ +import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./internal/types"; + +export function startPushNotifications(_input: StartPushNotificationsInput): () => void { + return () => undefined; +} + +export async function revokePushNotifications(_input: RevokePushNotificationsInput): Promise { + // Push notifications are native-only. +} diff --git a/packages/app/src/push-notifications/internal/subscriptions.ts b/packages/app/src/push-notifications/internal/subscriptions.ts new file mode 100644 index 0000000000..7a29347674 --- /dev/null +++ b/packages/app/src/push-notifications/internal/subscriptions.ts @@ -0,0 +1,103 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import Constants from "expo-constants"; +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; +import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./types"; + +const STORAGE_PREFIX = "@paseo:expo-push-token:"; + +function storageKey(serverId: string): string { + return `${STORAGE_PREFIX}${serverId}`; +} + +function getExpoProjectId(): string | null { + const constants = Constants as unknown as { + easConfig?: { projectId?: unknown }; + expoConfig?: { extra?: { eas?: { projectId?: unknown } } }; + }; + const fromEas = constants.easConfig?.projectId; + if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim(); + const fromExtra = constants.expoConfig?.extra?.eas?.projectId; + return typeof fromExtra === "string" && fromExtra.trim() ? fromExtra.trim() : null; +} + +async function ensurePushPermission(): Promise { + const existing = await Notifications.getPermissionsAsync(); + if (existing.status === Notifications.PermissionStatus.GRANTED) return true; + if (!existing.canAskAgain) return false; + const requested = await Notifications.requestPermissionsAsync(); + return requested.status === Notifications.PermissionStatus.GRANTED; +} + +async function resolveToken(serverId: string): Promise { + const key = storageKey(serverId); + const cached = await AsyncStorage.getItem(key); + if (!(await ensurePushPermission())) { + await AsyncStorage.removeItem(key); + return null; + } + + if (Platform.OS === "android") { + await Notifications.setNotificationChannelAsync("default", { + name: "default", + importance: Notifications.AndroidImportance.DEFAULT, + }); + } + + const projectId = getExpoProjectId(); + if (!projectId) { + console.warn("[PushNotifications] Missing EAS projectId; cannot fetch Expo push token"); + return cached; + } + + const result = await Notifications.getExpoPushTokenAsync({ projectId }); + const token = result.data.trim(); + if (!token) return cached; + await AsyncStorage.setItem(key, token); + return token; +} + +export function startSubscription(input: StartPushNotificationsInput): () => void { + let stopped = false; + let token: string | null = null; + const register = () => { + if (!stopped && token && input.client.isConnected) { + input.client.registerPushToken(token); + } + }; + + void resolveToken(input.serverId) + .then((resolved) => { + if (stopped) return undefined; + token = resolved; + register(); + return undefined; + }) + .catch((error) => console.warn("[PushNotifications] Failed to register push token", error)); + + const unsubscribe = input.client.subscribeConnectionStatus((state) => { + if (state.status === "connected") register(); + }); + + return () => { + stopped = true; + unsubscribe(); + }; +} + +export async function revokeSubscription(input: RevokePushNotificationsInput): Promise { + const key = storageKey(input.serverId); + const token = await AsyncStorage.getItem(key); + if ( + token && + input.client?.isConnected && + input.client.getLastServerInfoMessage()?.features?.pushTokenRevocation === true + ) { + try { + await input.client.unregisterPushToken(token); + } catch (error) { + console.warn("[PushNotifications] Failed to revoke push token", error); + } + } + await AsyncStorage.removeItem(key); +} diff --git a/packages/app/src/push-notifications/internal/types.ts b/packages/app/src/push-notifications/internal/types.ts new file mode 100644 index 0000000000..6256c298aa --- /dev/null +++ b/packages/app/src/push-notifications/internal/types.ts @@ -0,0 +1,11 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; + +export interface StartPushNotificationsInput { + client: DaemonClient; + serverId: string; +} + +export interface RevokePushNotificationsInput { + client: DaemonClient | null; + serverId: string; +} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 697872f095..1fe16b72c0 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1384,6 +1384,54 @@ describe("HostRuntimeController", () => { }); describe("HostRuntimeStore", () => { + it("revokes push notifications before removing a host", async () => { + const host = makeHost({ connections: [makeHost().connections[0]!] }); + const revocation = createDeferred(); + const storage = createMemoryHostRuntimeStorage({ + "@paseo:daemon-registry": JSON.stringify([host]), + "@paseo:e2e": "1", + }); + const store = new HostRuntimeStore({ + storage, + deps: makeDeps({}, []), + revokePushNotifications: async ({ serverId }) => { + expect(serverId).toBe(host.serverId); + expect(store.getHosts().map((candidate) => candidate.serverId)).toEqual([host.serverId]); + await revocation.promise; + }, + }); + await store.boot(); + + const removal = store.removeHost(host.serverId); + expect(store.getHosts().map((candidate) => candidate.serverId)).toEqual([host.serverId]); + revocation.resolve(); + await removal; + + expect(store.getHosts()).toEqual([]); + }); + + it("revokes push notifications when removing a host's final connection", async () => { + const host = makeHost({ connections: [makeHost().connections[0]!] }); + const revokedServerIds: string[] = []; + const storage = createMemoryHostRuntimeStorage({ + "@paseo:daemon-registry": JSON.stringify([host]), + "@paseo:e2e": "1", + }); + const store = new HostRuntimeStore({ + storage, + deps: makeDeps({}, []), + revokePushNotifications: async ({ serverId }) => { + revokedServerIds.push(serverId); + }, + }); + await store.boot(); + + await store.removeConnection(host.serverId, host.connections[0]!.id); + + expect(revokedServerIds).toEqual([host.serverId]); + expect(store.getHosts()).toEqual([]); + }); + it("restores the display replica before declaring the host registry loaded", async () => { const host = makeHost(); const storage = createMemoryHostRuntimeStorage(); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 982d411e46..5bd205074d 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -57,6 +57,7 @@ import { encodeImages } from "@/utils/encode-images"; import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; import { ReplicaCache } from "@/runtime/replica-cache"; import { nativePerformanceTrace } from "@/performance/native-trace"; +import { revokePushNotifications } from "@/push-notifications"; import { createAppWebSocketFactory } from "./websocket-factory"; export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; @@ -1366,11 +1367,17 @@ export class HostRuntimeStore { private bootPromise: Promise | null = null; private storage: HostRuntimeStorage; private replicaCache: ReplicaCache; + private readonly revokePushNotifications: typeof revokePushNotifications; - constructor(input?: { deps?: HostRuntimeControllerDeps; storage?: HostRuntimeStorage }) { + constructor(input?: { + deps?: HostRuntimeControllerDeps; + storage?: HostRuntimeStorage; + revokePushNotifications?: typeof revokePushNotifications; + }) { this.deps = input?.deps ?? createDefaultDeps(); this.storage = input?.storage ?? AsyncStorage; this.replicaCache = new ReplicaCache(this.storage); + this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications; } // --- Host registry --- @@ -1849,12 +1856,18 @@ export class HostRuntimeStore { } async removeHost(serverId: string): Promise { + await this.revokePushNotifications({ client: this.getClient(serverId), serverId }); const remaining = this.hosts.filter((daemon) => daemon.serverId !== serverId); this.setHostsAndSync(remaining); await this.persistHosts(); } async removeConnection(serverId: string, connectionId: string): Promise { + const host = this.hosts.find((candidate) => candidate.serverId === serverId); + if (host?.connections.length === 1 && host.connections[0]?.id === connectionId) { + await this.removeHost(serverId); + return; + } const now = new Date().toISOString(); const next = this.hosts .map((daemon) => { diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index d07be4c156..9739e04a5b 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -967,6 +967,59 @@ test("keeps the transport connected when a session RPC ping times out", async () expect(client.getConnectionState().status).toBe("connected"); }); +test("waits for the daemon to acknowledge push token revocation", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_push_revocation", + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen({ features: { pushTokenRevocation: true } }); + await connectPromise; + + const revocation = client.unregisterPushToken("ExponentPushToken[test-device]"); + const request = parseSentFrame(mock.sent.at(-1)); + expect(request).toMatchObject({ + type: "push.unregister.request", + token: "ExponentPushToken[test-device]", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "push.unregister.response", + payload: { requestId: request.requestId }, + }), + ); + + await revocation; +}); + +test("bounds the wait for push token revocation", async () => { + useHeartbeatClock(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_push_revocation_timeout", + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen({ features: { pushTokenRevocation: true } }); + await connectPromise; + + const revocation = client.unregisterPushToken("ExponentPushToken[test-device]"); + const rejection = expect(revocation).rejects.toThrow("Timeout waiting for message (2000ms)"); + await vi.advanceTimersByTimeAsync(1_999); + expect(client.getConnectionState().status).toBe("connected"); + + await vi.advanceTimersByTimeAsync(1); + await rejection; + expect(client.getConnectionState().status).toBe("connected"); +}); + test("defaults session RPC waiters to sixty seconds", async () => { useHeartbeatClock(); const logger = createMockLogger(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index bcdf143fb9..d078d84739 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -876,6 +876,7 @@ function toTimeoutError(error: unknown, label: string, timeoutMs: number): Error const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500; const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000; const DEFAULT_SESSION_RPC_TIMEOUT_MS = 60_000; +const PUSH_TOKEN_REVOCATION_TIMEOUT_MS = 2_000; const DEFAULT_CONNECT_TIMEOUT_MS = 15_000; const DEFAULT_LIVENESS_TIMEOUT_MS = 5000; const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000; @@ -1840,6 +1841,16 @@ export class DaemonClient { }); } + async unregisterPushToken(token: string): Promise { + const requestId = this.createRequestId(); + await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "push.unregister.request", token, requestId }, + responseType: "push.unregister.response", + timeout: PUSH_TOKEN_REVOCATION_TIMEOUT_MS, + }); + } + async ping(params?: { requestId?: string; timeoutMs?: number }): Promise<{ requestId: string; clientSentAt: number; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index aa297cc941..5cf5f50990 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2409,6 +2409,19 @@ export const RegisterPushTokenMessageSchema = z.object({ token: z.string(), }); +export const PushUnregisterRequestSchema = z.object({ + type: z.literal("push.unregister.request"), + token: z.string(), + requestId: z.string(), +}); + +export const PushUnregisterResponseSchema = z.object({ + type: z.literal("push.unregister.response"), + payload: z.object({ + requestId: z.string(), + }), +}); + // ============================================================================ // Terminal Messages // ============================================================================ @@ -2753,6 +2766,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PingMessageSchema, ListCommandsRequestSchema, RegisterPushTokenMessageSchema, + PushUnregisterRequestSchema, ListTerminalsRequestSchema, SubscribeTerminalsRequestSchema, UnsubscribeTerminalsRequestSchema, @@ -2971,6 +2985,8 @@ export const ServerInfoStatusPayloadSchema = z daemonStatusRpc: z.boolean().optional(), // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. relayConfig: z.boolean().optional(), + // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. + pushTokenRevocation: z.boolean().optional(), // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": z.boolean().optional(), // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. @@ -5575,6 +5591,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ DictationStreamErrorMessageSchema, StatusMessageSchema, PongMessageSchema, + PushUnregisterResponseSchema, RpcErrorMessageSchema, ArtifactMessageSchema, AgentUpdateMessageSchema, @@ -6143,6 +6160,8 @@ export type ClientHeartbeatMessage = z.infer; export type ListCommandsResponse = z.infer; export type RegisterPushTokenMessage = z.infer; +export type PushUnregisterRequest = z.infer; +export type PushUnregisterResponse = z.infer; // Terminal message types export type ListTerminalsRequest = z.infer; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 0321d3295f..e5f12972c0 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -164,7 +164,7 @@ import { createConfiguredTerminalManager } from "../terminal/terminal-manager-fa import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js"; -import type { PushNotificationSender } from "./push/notifications.js"; +import type { PushNotificationSender } from "./push/index.js"; import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; diff --git a/packages/server/src/server/client-activity.e2e.test.ts b/packages/server/src/server/client-activity.e2e.test.ts index eaf5ae0541..5348ea44d8 100644 --- a/packages/server/src/server/client-activity.e2e.test.ts +++ b/packages/server/src/server/client-activity.e2e.test.ts @@ -3,7 +3,7 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo- import { DaemonClient } from "./test-utils/daemon-client.js"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { AgentSnapshotPayload } from "./messages.js"; -import type { PushNotificationSender, PushPayload } from "./push/notifications.js"; +import type { PushNotificationSender, PushPayload } from "./push/index.js"; import { PRESENCE_THRESHOLD_MS } from "./agent-attention-policy.js"; class RecordingPushNotificationSender implements PushNotificationSender { diff --git a/packages/server/src/server/push/index.test.ts b/packages/server/src/server/push/index.test.ts new file mode 100644 index 0000000000..1cd4c1baa6 --- /dev/null +++ b/packages/server/src/server/push/index.test.ts @@ -0,0 +1,67 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type pino from "pino"; +import { afterEach, describe, expect, test } from "vitest"; + +import { createPushNotifications } from "./index.js"; + +function createLogger(): pino.Logger { + const logger = { + child: () => logger, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + }; + return logger as unknown as pino.Logger; +} + +describe("push notifications", () => { + const homes: string[] = []; + + afterEach(() => { + for (const home of homes.splice(0)) { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("an offline device stops receiving notifications after 48 hours", async () => { + const home = mkdtempSync(path.join(tmpdir(), "paseo-push-notifications-")); + homes.push(home); + const filePath = path.join(home, "push-tokens.json"); + let now = Date.parse("2026-08-10T00:00:00.000Z"); + const deliveries: string[][] = []; + const pushNotifications = createPushNotifications({ + logger: createLogger(), + filePath, + now: () => now, + deliver: async (tokens) => deliveries.push(tokens), + }); + + pushNotifications.renew("ExponentPushToken[offline-device]"); + now += 48 * 60 * 60 * 1000; + await pushNotifications.send({ title: "Agent finished", body: "Done" }); + + expect(deliveries).toEqual([]); + expect(JSON.parse(readFileSync(filePath, "utf8"))).toEqual({ subscriptions: [] }); + }); + + test("online revocation stops notifications immediately", async () => { + const home = mkdtempSync(path.join(tmpdir(), "paseo-push-notifications-")); + homes.push(home); + const deliveries: string[][] = []; + const pushNotifications = createPushNotifications({ + logger: createLogger(), + filePath: path.join(home, "push-tokens.json"), + now: () => Date.parse("2026-08-10T00:00:00.000Z"), + deliver: async (tokens) => deliveries.push(tokens), + }); + + pushNotifications.renew("ExponentPushToken[online-device]"); + pushNotifications.revoke("ExponentPushToken[online-device]"); + await pushNotifications.send({ title: "Agent finished", body: "Done" }); + + expect(deliveries).toEqual([]); + }); +}); diff --git a/packages/server/src/server/push/index.ts b/packages/server/src/server/push/index.ts new file mode 100644 index 0000000000..3f1a8e0900 --- /dev/null +++ b/packages/server/src/server/push/index.ts @@ -0,0 +1,45 @@ +import type pino from "pino"; + +import { PushService, type PushPayload } from "./push-service.js"; +import { PushTokenStore } from "./token-store.js"; + +export type { PushPayload }; + +const PUSH_TOKEN_LEASE_MS = 48 * 60 * 60 * 1000; + +export interface PushNotifications { + renew(token: string): void; + revoke(token: string): void; + send(payload: PushPayload): Promise; +} + +export type PushNotificationSender = Pick; + +export function createPushNotifications(options: { + logger: pino.Logger; + filePath: string; + now?: () => number; + deliver?: (tokens: string[], payload: PushPayload) => Promise; +}): PushNotifications { + const now = options.now ?? Date.now; + const store = new PushTokenStore(options.logger, options.filePath, now, PUSH_TOKEN_LEASE_MS); + const service = new PushService(options.logger, (token) => store.revokeToken(token)); + const deliver = + options.deliver ?? + ((tokens: string[], payload: PushPayload) => service.sendPush(tokens, payload)); + + return { + renew(token) { + store.renewToken(token); + }, + revoke(token) { + store.revokeToken(token); + }, + async send(payload) { + const tokens = store.getActiveTokens(); + options.logger.info({ tokenCount: tokens.length }, "Sending push notification"); + if (tokens.length === 0) return; + await deliver(tokens, payload); + }, + }; +} diff --git a/packages/server/src/server/push/notifications.ts b/packages/server/src/server/push/notifications.ts deleted file mode 100644 index 07c3a5bcd4..0000000000 --- a/packages/server/src/server/push/notifications.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type pino from "pino"; - -import { PushService, type PushPayload } from "./push-service.js"; -import type { PushTokenStore } from "./token-store.js"; - -export type { PushPayload }; - -export interface PushNotificationSender { - send(payload: PushPayload): Promise; -} - -export function createPushNotificationSender( - logger: pino.Logger, - tokenStore: PushTokenStore, -): PushNotificationSender { - const pushService = new PushService(logger, tokenStore); - - return { - async send(payload) { - const tokens = tokenStore.getAllTokens(); - logger.info({ tokenCount: tokens.length }, "Sending push notification"); - if (tokens.length === 0) { - return; - } - - await pushService.sendPush(tokens, payload); - }, - }; -} diff --git a/packages/server/src/server/push/push-service.ts b/packages/server/src/server/push/push-service.ts index dcfadc1225..b91de5fbd0 100644 --- a/packages/server/src/server/push/push-service.ts +++ b/packages/server/src/server/push/push-service.ts @@ -1,4 +1,3 @@ -import type { PushTokenStore } from "./token-store.js"; import type pino from "pino"; export interface PushPayload { @@ -31,11 +30,11 @@ const MAX_BATCH_SIZE = 100; */ export class PushService { private readonly logger: pino.Logger; - private readonly tokenStore: PushTokenStore; + private readonly revokeToken: (token: string) => void; - constructor(logger: pino.Logger, tokenStore: PushTokenStore) { + constructor(logger: pino.Logger, revokeToken: (token: string) => void) { this.logger = logger.child({ component: "push-service" }); - this.tokenStore = tokenStore; + this.revokeToken = revokeToken; } async sendPush(tokens: string[], payload: PushPayload): Promise { @@ -102,7 +101,7 @@ export class PushService { ticket.details?.error === "DeviceNotRegistered" || ticket.details?.error === "InvalidCredentials" ) { - this.tokenStore.removeToken(message.to); + this.revokeToken(message.to); } } } diff --git a/packages/server/src/server/push/token-store.test.ts b/packages/server/src/server/push/token-store.test.ts index b5c66e10cb..afd7d601c3 100644 --- a/packages/server/src/server/push/token-store.test.ts +++ b/packages/server/src/server/push/token-store.test.ts @@ -5,6 +5,7 @@ import type pino from "pino"; import { describe, expect, test } from "vitest"; import { PRIVATE_FILE_MODE } from "../private-files.js"; +import { createPushNotifications } from "./index.js"; import { PushTokenStore } from "./token-store.js"; const MODE_MASK = 0o777; @@ -16,6 +17,7 @@ function createLogger(): pino.Logger { debug: () => undefined, info: () => undefined, warn: () => undefined, + error: () => undefined, }; return logger as unknown as pino.Logger; } @@ -29,9 +31,12 @@ describe.skipIf(process.platform === "win32")("PushTokenStore file permissions", const home = mkdtempSync(path.join(tmpdir(), "paseo-push-tokens-")); const tokenPath = path.join(home, "push-tokens.json"); try { - const store = new PushTokenStore(createLogger(), tokenPath); + const pushNotifications = createPushNotifications({ + logger: createLogger(), + filePath: tokenPath, + }); - store.addToken("ExponentPushToken[test]"); + pushNotifications.renew("ExponentPushToken[test]"); expect(modeOf(tokenPath)).toBe(PRIVATE_FILE_MODE); } finally { @@ -39,19 +44,61 @@ describe.skipIf(process.platform === "win32")("PushTokenStore file permissions", } }); - test("repairs existing push token file permissions when loading", () => { + test("repairs existing push token file permissions when loading", async () => { const home = mkdtempSync(path.join(tmpdir(), "paseo-push-tokens-")); const tokenPath = path.join(home, "push-tokens.json"); try { writeFileSync(tokenPath, JSON.stringify({ tokens: ["ExponentPushToken[test]"] })); chmodSync(tokenPath, PERMISSIVE_FILE_MODE); - const store = new PushTokenStore(createLogger(), tokenPath); + const deliveries: string[][] = []; + const pushNotifications = createPushNotifications({ + logger: createLogger(), + filePath: tokenPath, + deliver: async (tokens) => deliveries.push(tokens), + }); + await pushNotifications.send({ title: "Agent finished", body: "Done" }); - expect(store.getAllTokens()).toEqual(["ExponentPushToken[test]"]); + expect(deliveries).toEqual([["ExponentPushToken[test]"]]); expect(modeOf(tokenPath)).toBe(PRIVATE_FILE_MODE); } finally { rmSync(home, { recursive: true, force: true }); } }); }); + +describe("PushTokenStore persistence", () => { + test("does not apply a revocation until the updated subscriptions are durable", () => { + let rejectWrites = false; + let persisted = ""; + const store = new PushTokenStore( + createLogger(), + path.join(tmpdir(), `missing-push-token-store-${process.pid}`, "push-tokens.json"), + () => Date.parse("2026-08-10T00:00:00.000Z"), + 48 * 60 * 60 * 1000, + (_filePath, payload) => { + if (rejectWrites) throw new Error("disk full"); + persisted = String(payload); + }, + ); + + store.renewToken("ExponentPushToken[test]"); + rejectWrites = true; + + expect(() => store.revokeToken("ExponentPushToken[test]")).toThrow("disk full"); + expect(store.getActiveTokens()).toEqual(["ExponentPushToken[test]"]); + expect(JSON.parse(persisted)).toEqual({ + subscriptions: [ + { + token: "ExponentPushToken[test]", + expiresAt: "2026-08-12T00:00:00.000Z", + }, + ], + }); + + rejectWrites = false; + store.revokeToken("ExponentPushToken[test]"); + expect(store.getActiveTokens()).toEqual([]); + expect(JSON.parse(persisted)).toEqual({ subscriptions: [] }); + }); +}); diff --git a/packages/server/src/server/push/token-store.ts b/packages/server/src/server/push/token-store.ts index 438c87af27..540f053138 100644 --- a/packages/server/src/server/push/token-store.ts +++ b/packages/server/src/server/push/token-store.ts @@ -10,36 +10,69 @@ import { ensurePrivateFile, writePrivateFileAtomicSync } from "../private-files. */ export class PushTokenStore { private readonly logger: pino.Logger; - private tokens: Set = new Set(); + private subscriptions = new Map(); private readonly filePath: string; + private readonly now: () => number; + private readonly leaseMs: number; + private readonly write: typeof writePrivateFileAtomicSync; - constructor(logger: pino.Logger, filePath: string) { + constructor( + logger: pino.Logger, + filePath: string, + now: () => number, + leaseMs: number, + write: typeof writePrivateFileAtomicSync = writePrivateFileAtomicSync, + ) { this.logger = logger.child({ component: "token-store" }); this.filePath = filePath; + this.now = now; + this.leaseMs = leaseMs; + this.write = write; this.loadFromDisk(); } - addToken(token: string): void { + renewToken(token: string): void { const normalized = token.trim(); if (!normalized) return; - if (this.tokens.has(normalized)) return; - this.tokens.add(normalized); - this.persist(); - this.logger.debug({ total: this.tokens.size }, "Added token"); + const now = this.now(); + const currentExpiry = this.subscriptions.get(normalized); + if (currentExpiry !== undefined && currentExpiry - now > this.leaseMs / 2) return; + const next = new Map(this.subscriptions); + next.set(normalized, now + this.leaseMs); + this.persist(next); + this.subscriptions = next; + this.logger.debug({ total: this.subscriptions.size }, "Renewed token"); } - removeToken(token: string): void { + revokeToken(token: string): void { const normalized = token.trim(); if (!normalized) return; - const deleted = this.tokens.delete(normalized); - if (deleted) { - this.persist(); - this.logger.debug({ total: this.tokens.size }, "Removed token"); + if (!this.subscriptions.has(normalized)) return; + const next = new Map(this.subscriptions); + next.delete(normalized); + this.persist(next); + this.subscriptions = next; + this.logger.debug({ total: this.subscriptions.size }, "Revoked token"); + } + + getActiveTokens(): string[] { + const now = this.now(); + const active = new Map(this.subscriptions); + for (const [token, expiresAt] of this.subscriptions) { + if (expiresAt <= now) { + active.delete(token); + } } - } - - getAllTokens(): string[] { - return Array.from(this.tokens); + if (active.size !== this.subscriptions.size) { + try { + this.persist(active); + this.subscriptions = active; + } catch { + // Keep the previous state so a later send retries pruning. Expired tokens + // are still excluded from this delivery. + } + } + return Array.from(active.keys()); } private loadFromDisk(): void { @@ -49,25 +82,60 @@ export class PushTokenStore { } ensurePrivateFile(this.filePath); const raw = readFileSync(this.filePath, "utf-8"); - const parsed = JSON.parse(raw) as { tokens?: unknown }; - const tokens = Array.isArray(parsed.tokens) - ? parsed.tokens.filter((t): t is string => typeof t === "string" && t.trim().length > 0) + const parsed = JSON.parse(raw) as { subscriptions?: unknown; tokens?: unknown }; + const loaded = new Map(); + const subscriptions = Array.isArray(parsed.subscriptions) ? parsed.subscriptions : []; + for (const value of subscriptions) { + if (!value || typeof value !== "object") continue; + const candidate = value as { token?: unknown; expiresAt?: unknown }; + if (typeof candidate.token !== "string" || typeof candidate.expiresAt !== "string") + continue; + const token = candidate.token.trim(); + const expiresAt = Date.parse(candidate.expiresAt); + if (token && Number.isFinite(expiresAt)) { + loaded.set(token, expiresAt); + } + } + this.subscriptions = loaded; + + const legacyTokens = Array.isArray(parsed.tokens) + ? parsed.tokens.filter((token): token is string => typeof token === "string") : []; - this.tokens = new Set(tokens.map((t) => t.trim())); - this.logger.info({ total: this.tokens.size }, "Loaded push tokens"); + if (legacyTokens.length > 0) { + const migrated = new Map(loaded); + const expiresAt = this.now() + this.leaseMs; + for (const token of legacyTokens) { + const normalized = token.trim(); + if (normalized) migrated.set(normalized, expiresAt); + } + this.persist(migrated); + this.subscriptions = migrated; + } + this.logger.info({ total: this.subscriptions.size }, "Loaded push tokens"); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.logger.warn({ err }, "Failed to load push tokens"); } } - private persist(): void { + private persist(subscriptions: ReadonlyMap): void { try { - const payload = JSON.stringify({ tokens: Array.from(this.tokens) }, null, 2) + "\n"; - writePrivateFileAtomicSync(this.filePath, payload); + const payload = + JSON.stringify( + { + subscriptions: Array.from(subscriptions, ([token, expiresAt]) => ({ + token, + expiresAt: new Date(expiresAt).toISOString(), + })), + }, + null, + 2, + ) + "\n"; + this.write(this.filePath, payload); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.logger.warn({ err }, "Failed to persist push tokens"); + throw err; } } } diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index a648bf1b78..81312a757b 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -33,7 +33,7 @@ import { asAgentManager, asAgentStorage, asDownloadTokenStore, - asPushTokenStore, + asPushNotifications, asScheduleService, asCheckoutDiffManager, asGitHubService, @@ -316,6 +316,7 @@ interface SessionForTestOptions { daemonVersion?: SessionOptions["daemonVersion"]; daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"]; downloadTokenStore?: SessionOptions["downloadTokenStore"]; + pushNotifications?: SessionOptions["pushNotifications"]; messages?: unknown[]; targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>; binaryMessages?: Uint8Array[]; @@ -365,7 +366,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { onBinaryMessage: createBinaryMessageHandler(options.binaryMessages), logger, downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(), - pushTokenStore: asPushTokenStore(), + pushNotifications: options.pushNotifications ?? asPushNotifications(), paseoHome: options.paseoHome ?? "/tmp/paseo-home", agentManager: asAgentManager({ listAgents: vi.fn(() => []), @@ -1443,6 +1444,88 @@ describe("project config RPC authorization", () => { }); }); +test("push token registration can be revoked by the connected client", async () => { + const renewed: string[] = []; + const revoked: string[] = []; + const messages: unknown[] = []; + const session = createSessionForTest({ + messages, + pushNotifications: asPushNotifications({ + renew: (token: string) => renewed.push(token), + revoke: (token: string) => revoked.push(token), + }), + }); + + await session.handleMessage({ + type: "register_push_token", + token: "ExponentPushToken[test-device]", + }); + await session.handleMessage({ + type: "push.unregister.request", + token: "ExponentPushToken[test-device]", + requestId: "revoke-1", + }); + await session.handleMessage({ + type: "client_heartbeat", + deviceType: "mobile", + focusedAgentId: null, + lastActivityAt: "2026-08-10T00:00:00.000Z", + appVisible: false, + }); + + expect(renewed).toEqual(["ExponentPushToken[test-device]"]); + expect(revoked).toEqual(["ExponentPushToken[test-device]"]); + expect(messages).toEqual([ + { + type: "push.unregister.response", + payload: { requestId: "revoke-1" }, + }, + ]); +}); + +test("push token revocation only acknowledges durable removal", async () => { + const renewed: string[] = []; + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + pushNotifications: asPushNotifications({ + renew: (token: string) => renewed.push(token), + revoke: () => { + throw new Error("disk full"); + }, + }), + }); + + await session.handleMessage({ + type: "register_push_token", + token: "ExponentPushToken[test-device]", + }); + await session.handleMessage({ + type: "push.unregister.request", + token: "ExponentPushToken[test-device]", + requestId: "revoke-failed", + }); + await session.handleMessage({ + type: "client_heartbeat", + deviceType: "mobile", + focusedAgentId: null, + lastActivityAt: "2026-08-10T00:00:00.000Z", + appVisible: false, + }); + + expect(renewed).toEqual(["ExponentPushToken[test-device]", "ExponentPushToken[test-device]"]); + expect(messages.some((message) => message.type === "push.unregister.response")).toBe(false); + expect(messages).toContainEqual({ + type: "rpc_error", + payload: { + requestId: "revoke-failed", + requestType: "push.unregister.request", + error: "Request failed: disk full", + code: "handler_error", + }, + }); +}); + describe("daemon status + pairing RPC", () => { const tempDirs: string[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 17ef78add1..3f47aeb3b6 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -169,7 +169,7 @@ import type { HubRelationshipManagement } from "./hub/relationship-controller.js import { HubExecutionController } from "./hub/execution-controller.js"; import type { HubExecutionAgents } from "./hub/daemon-executions.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; -import { PushTokenStore } from "./push/token-store.js"; +import type { PushNotifications } from "./push/index.js"; import { archivePersistedWorkspaceRecord, archiveWorkspaceContents, @@ -439,7 +439,7 @@ export interface SessionOptions { onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise; logger: pino.Logger; downloadTokenStore: DownloadTokenStore; - pushTokenStore: PushTokenStore; + pushNotifications: PushNotifications; paseoHome: string; worktreesRoot?: string; agentManager: AgentManager; @@ -627,7 +627,7 @@ export class Session { private readonly workspaceProvisioning: WorkspaceProvisioningService; private readonly workspaceRecovery: WorkspaceRecoveryService; private readonly daemonConfigStore: DaemonConfigStore; - private readonly pushTokenStore: PushTokenStore; + private readonly pushNotifications: PushNotifications; private unsubscribeAgentEvents: (() => void) | null = null; private unsubscribeProjectMutations: (() => void) | null = null; private unsubscribeWorkspaceMutations: (() => void) | null = null; @@ -649,6 +649,7 @@ export class Session { appVisible: boolean; appVisibilityChangedAt: Date; } | null = null; + private registeredPushToken: string | null = null; private readonly terminalManager: TerminalManager | null; private readonly providerSnapshotManager: ProviderSnapshotManager; private readonly serviceProxy: ServiceProxySubsystem | null; @@ -691,7 +692,7 @@ export class Session { onWorkspaceRecovered, logger, downloadTokenStore, - pushTokenStore, + pushNotifications, paseoHome, worktreesRoot, agentManager, @@ -741,7 +742,7 @@ export class Session { this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0); this.onLifecycleIntent = onLifecycleIntent ?? null; this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; - this.pushTokenStore = pushTokenStore; + this.pushNotifications = pushNotifications; this.paseoHome = paseoHome; this.worktreesRoot = worktreesRoot; this.sessionLogger = logger.child({ @@ -2277,6 +2278,16 @@ export class Session { case "register_push_token": this.handleRegisterPushToken(msg.token); return; + case "push.unregister.request": + this.pushNotifications.revoke(msg.token); + if (this.registeredPushToken?.trim() === msg.token.trim()) { + this.registeredPushToken = null; + } + this.emit({ + type: "push.unregister.response", + payload: { requestId: msg.requestId }, + }); + return; } } @@ -3722,6 +3733,9 @@ export class Session { if (msg.appVisible && focusedTerminalId) { void this.clearFocusedTerminalAttention(focusedTerminalId); } + if (this.registeredPushToken) { + this.pushNotifications.renew(this.registeredPushToken); + } } private async clearFocusedTerminalAttention(terminalId: string): Promise { @@ -3740,7 +3754,8 @@ export class Session { * Handle push token registration */ private handleRegisterPushToken(token: string): void { - this.pushTokenStore.addToken(token); + this.registeredPushToken = token; + this.pushNotifications.renew(token); this.sessionLogger.info("Registered push token"); } diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index c038559fce..f3ae292f39 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -195,7 +195,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { onMessage: (message) => emitted.push(message as { type: string; payload: unknown }), logger: createStub(logger), downloadTokenStore: createStub({}), - pushTokenStore: createStub({}), + pushNotifications: createStub({}), paseoHome: "/tmp/paseo-test", agentManager: createStub({ subscribe: () => () => {}, diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index bdf71e9de5..911890fdc8 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -93,7 +93,7 @@ function createHarness(input: { onMessage: (m) => emitted.push(m), logger: createStub(logger), downloadTokenStore: createStub({}), - pushTokenStore: createStub({}), + pushNotifications: createStub({}), paseoHome: mkdtempSync(path.join(tmpdir(), "paseo-invariant-test-")), agentManager: createStub({ subscribe: () => () => {}, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 53597b4426..572680f9aa 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -53,7 +53,7 @@ import { asAgentManager, asAgentStorage, asDownloadTokenStore, - asPushTokenStore, + asPushNotifications, asScheduleService, asCheckoutDiffManager, asDaemonConfigStore, @@ -639,7 +639,7 @@ function createSessionForWorkspaceTests( onWorkspaceRecovered: options.onWorkspaceRecovered, logger: asSessionLogger(logger), downloadTokenStore: asDownloadTokenStore(), - pushTokenStore: asPushTokenStore(), + pushNotifications: asPushNotifications(), paseoHome: options.paseoHome ?? "/tmp/paseo-test", worktreesRoot: options.worktreesRoot, agentManager, @@ -946,7 +946,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist onMessage: (message) => emitted.push(message), logger: asSessionLogger(logger), downloadTokenStore: asDownloadTokenStore(), - pushTokenStore: asPushTokenStore(), + pushNotifications: asPushNotifications(), paseoHome: path.join(workdir, "paseo-home"), agentManager, agentStorage, @@ -1098,7 +1098,7 @@ test("create_agent_request launches from an exact subdirectory in a created work onMessage: (message) => emitted.push(message), logger: asSessionLogger(logger), downloadTokenStore: asDownloadTokenStore(), - pushTokenStore: asPushTokenStore(), + pushNotifications: asPushNotifications(), paseoHome: path.join(workdir, "paseo-home"), agentManager, agentStorage, @@ -1235,7 +1235,7 @@ test("create_agent_request does not title an existing workspace from the agent p onMessage: vi.fn(), logger: asSessionLogger(logger), downloadTokenStore: asDownloadTokenStore(), - pushTokenStore: asPushTokenStore(), + pushNotifications: asPushNotifications(), paseoHome: path.join(workdir, "paseo-home"), agentManager, agentStorage, @@ -1503,7 +1503,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" onMessage: (message) => emitted.push(message), logger: asSessionLogger(logger), downloadTokenStore: asDownloadTokenStore(), - pushTokenStore: asPushTokenStore(), + pushNotifications: asPushNotifications(), paseoHome: "/tmp/paseo-test", agentManager: asAgentManager({ subscribe: () => () => {}, @@ -1868,7 +1868,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy onMessage: (message) => emitted.push(message), logger: asSessionLogger(sessionLogger), downloadTokenStore: asDownloadTokenStore(), - pushTokenStore: asPushTokenStore(), + pushNotifications: asPushNotifications(), paseoHome: "/tmp/paseo-test", agentManager: asAgentManager({ subscribe: () => () => {}, @@ -2036,7 +2036,7 @@ test("close_items_request archives stored agents that are not currently loaded", onMessage: (message) => emitted.push(message), logger: asSessionLogger(sessionLogger), downloadTokenStore: asDownloadTokenStore(), - pushTokenStore: asPushTokenStore(), + pushNotifications: asPushNotifications(), paseoHome: "/tmp/paseo-test", agentManager: asAgentManager({ subscribe: () => () => {}, @@ -2195,7 +2195,7 @@ test("close_items_request continues after an archive failure", async () => { onMessage: (message) => emitted.push(message), logger: asSessionLogger(sessionLogger), downloadTokenStore: asDownloadTokenStore(), - pushTokenStore: asPushTokenStore(), + pushNotifications: asPushNotifications(), paseoHome: "/tmp/paseo-test", agentManager: asAgentManager({ subscribe: () => () => {}, @@ -3456,7 +3456,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto onMessage: (message) => emitted.push(message), logger: asSessionLogger(logger), downloadTokenStore: asDownloadTokenStore(), - pushTokenStore: asPushTokenStore(), + pushNotifications: asPushNotifications(), paseoHome: "/tmp/paseo-test", agentManager: asAgentManager({ subscribe: () => () => {}, diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index 3733643484..126f06678b 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -103,7 +103,7 @@ describe("snapshot mutation ownership boundary", () => { onMessage, logger: createStub(logger), downloadTokenStore: createStub({}), - pushTokenStore: createStub({}), + pushNotifications: createStub({}), paseoHome: "/tmp/paseo-test", agentManager: createStub({ subscribe: () => () => {}, diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index ed4708ec7b..8481c4876d 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -11,7 +11,7 @@ import { } from "../bootstrap.js"; import type { AgentClient, AgentProvider } from "../agent/agent-sdk-types.js"; import { createTestAgentClients } from "./fake-agent-client.js"; -import type { PushNotificationSender } from "../push/notifications.js"; +import type { PushNotificationSender } from "../push/index.js"; interface TestPaseoDaemonOptions { daemonVersion?: string; diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index f22f35069e..46db4e1749 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -53,8 +53,12 @@ export function asDownloadTokenStore(): SessionOptions["downloadTokenStore"] { return createStub({}); } -export function asPushTokenStore(): SessionOptions["pushTokenStore"] { - return createStub({}); +export function asPushNotifications( + stub: { + [K in keyof SessionOptions["pushNotifications"]]?: unknown; + } = {}, +): SessionOptions["pushNotifications"] { + return createStub(stub); } export function asScheduleService(): SessionOptions["scheduleService"] { diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 5ec7a0fdbf..e2bec9c710 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -9,7 +9,7 @@ import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; -import type { PushNotificationSender, PushPayload } from "./push/notifications.js"; +import type { PushNotificationSender, PushPayload } from "./push/index.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; const WORKSPACE_ID = "workspace-1"; diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 5473fa2c44..b14e90c7a0 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -86,20 +86,12 @@ vi.mock("./session.js", () => ({ Session: sessionMock.MockSession, })); -vi.mock("./push/token-store.js", () => ({ - PushTokenStore: class { - getAllTokens(): string[] { - return []; - } - }, -})); - -vi.mock("./push/push-service.js", () => ({ - PushService: class { - async sendPush(): Promise { - // no-op - } - }, +vi.mock("./push/index.js", () => ({ + createPushNotifications: () => ({ + renew: () => undefined, + revoke: () => undefined, + send: async () => undefined, + }), })); import { z } from "zod"; diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 231aaa714d..1d7160ed30 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -15,7 +15,7 @@ import type { import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; -import type { PushNotificationSender, PushPayload } from "./push/notifications.js"; +import type { PushNotificationSender, PushPayload } from "./push/index.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; const wsModuleMock = vi.hoisted(() => { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 3026b60a34..e3d643d484 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -45,8 +45,11 @@ import type { GitCommandRuntimeMetricsSnapshot } from "../utils/git-command-runt import { snapshotGitCommandRuntimeMetrics } from "../utils/run-git-command.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; import { deriveProjectSlug } from "./workspace-git-metadata.js"; -import { PushTokenStore } from "./push/token-store.js"; -import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js"; +import { + createPushNotifications, + type PushNotifications, + type PushNotificationSender, +} from "./push/index.js"; import type { ScriptHealthState } from "./script-health-monitor.js"; import type { ServiceProxySubsystem } from "./service-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; @@ -534,7 +537,7 @@ export class VoiceAssistantWebSocketServer { private readonly paseoHome: string; private readonly worktreesRoot: string | undefined; private readonly daemonConfigStore: DaemonConfigStore; - private readonly pushTokenStore: PushTokenStore; + private readonly pushNotifications: PushNotifications; private readonly pushNotificationSender: PushNotificationSender; private readonly mcpBaseUrl: string | null; private speech!: SpeechService | null; @@ -683,9 +686,11 @@ export class VoiceAssistantWebSocketServer { }); const pushLogger = this.logger.child({ module: "push" }); - this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json")); - this.pushNotificationSender = - pushNotificationSender ?? createPushNotificationSender(pushLogger, this.pushTokenStore); + this.pushNotifications = createPushNotifications({ + logger: pushLogger, + filePath: join(paseoHome, "push-tokens.json"), + }); + this.pushNotificationSender = pushNotificationSender ?? this.pushNotifications; this.agentManager.setAgentAttentionCallback((params) => { void this.broadcastAgentAttention(params).catch((err) => { @@ -1332,7 +1337,7 @@ export class VoiceAssistantWebSocketServer { ); }, downloadTokenStore: this.downloadTokenStore, - pushTokenStore: this.pushTokenStore, + pushNotifications: this.pushNotifications, paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, agentManager: this.agentManager, @@ -1542,6 +1547,8 @@ export class VoiceAssistantWebSocketServer { ...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}), // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. ...(this.advertiseRelayConfig ? { relayConfig: true } : {}), + // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. + pushTokenRevocation: true, // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": true, // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 503bfc6a16..623e2be2c7 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -209,7 +209,7 @@ function createSessionForWireCompatTest(options?: { onMessage: (message) => messages.push(message), logger: pino({ level: "silent" }), downloadTokenStore: {} as SessionOptions["downloadTokenStore"], - pushTokenStore: {} as SessionOptions["pushTokenStore"], + pushNotifications: {} as SessionOptions["pushNotifications"], paseoHome: "/tmp/paseo-home", agentManager: new InMemoryAgentManager( options?.rows ?? rows, From 334bf623709d91b9b9d5217fb17acc289ed16622 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 00:14:27 +0200 Subject: [PATCH 09/73] Keep delegated-agent notifications alive through permission prompts (#3177) * fix(agents): preserve permission notification callbacks Permission requests consumed the one-shot completion watcher, so callers never heard about later approvals or the final result. Keep the watcher alive across permission checkpoints and include the actionable request payload in each notification. * fix(agents): notify concurrent permission requests --- docs/agent-lifecycle.md | 2 + .../src/server/agent/agent-prompt.test.ts | 172 ++++++++++++++++++ .../server/src/server/agent/agent-prompt.ts | 111 ++++++++--- 3 files changed, 261 insertions(+), 24 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index eeed68322d..ac3ceb0539 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -49,6 +49,8 @@ Runtime ownership is resolved from explicit workspace ID and caller context, nev Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. `notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. +Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. +The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status. ## Provider-managed child agents diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index ba3c64f5e1..742e19bfb4 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -46,8 +46,13 @@ interface FinishNotificationScenarioOptions { interface FinishNotificationScenario { startWatchingChild(): void; + requestChildPermission(requestId?: string): void; + resolveChildPermission(requestId?: string): void; + resolveChildPermissionFromState(requestId?: string): void; + resolveChildPermissionWhileIdle(requestId?: string): void; finishChild(): void; finishChildAndReadParentPrompt(): Promise; + parentPrompts(): string[]; wasParentPrompted(): boolean; } @@ -57,11 +62,13 @@ function createFinishNotificationScenario( let subscriber: ((event: AgentManagerEvent) => void) | null = null; let resolveParentPrompt: ((prompt: string) => void) | null = null; let parentPrompted = false; + const parentPrompts: string[] = []; const childAgent: ManagedAgent = Object.create(null); Reflect.set(childAgent, "id", "child-agent"); Reflect.set(childAgent, "lifecycle", "idle"); Reflect.set(childAgent, "config", { title: "Child Agent" }); + Reflect.set(childAgent, "pendingPermissions", new Map()); const callerAgent: ManagedAgent = Object.create(null); Reflect.set(callerAgent, "id", "caller-agent"); @@ -91,6 +98,7 @@ function createFinishNotificationScenario( Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError)); Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => { parentPrompted = true; + parentPrompts.push(prompt); resolveParentPrompt?.(prompt); return (async function* noop() {})(); }); @@ -123,6 +131,65 @@ function createFinishNotificationScenario( logger: options?.logger ?? createTestLogger(), }); }, + requestChildPermission(requestId = "permission-1") { + childAgent.lifecycle = "running"; + childAgent.pendingPermissions.set(requestId, { + id: requestId, + provider: "claude", + kind: "tool", + name: "Run command", + description: "Write the QA sentinel", + input: { + file_path: "/tmp/permission-qa.txt", + content: "PASEO_PERMISSION_NOTIFY_QA_OK\n", + }, + }); + subscriber?.({ + type: "agent_state", + agent: childAgent, + }); + subscriber?.({ + type: "agent_stream", + agentId: "child-agent", + event: { + type: "permission_requested", + provider: "codex", + request: childAgent.pendingPermissions.get(requestId)!, + }, + }); + }, + resolveChildPermission(requestId = "permission-1") { + childAgent.pendingPermissions.delete(requestId); + subscriber?.({ + type: "agent_stream", + agentId: "child-agent", + event: { + type: "permission_resolved", + provider: "codex", + requestId, + resolution: { behavior: "allow" }, + }, + }); + }, + resolveChildPermissionFromState(requestId = "permission-1") { + childAgent.pendingPermissions.delete(requestId); + subscriber?.({ type: "agent_state", agent: childAgent }); + }, + resolveChildPermissionWhileIdle(requestId = "permission-1") { + childAgent.pendingPermissions.delete(requestId); + childAgent.lifecycle = "idle"; + subscriber?.({ type: "agent_state", agent: childAgent }); + subscriber?.({ + type: "agent_stream", + agentId: "child-agent", + event: { + type: "permission_resolved", + provider: "codex", + requestId, + resolution: { behavior: "allow" }, + }, + }); + }, finishChild() { childAgent.lifecycle = "running"; subscriber?.({ @@ -144,6 +211,9 @@ function createFinishNotificationScenario( return parentPrompt; }, + parentPrompts() { + return parentPrompts; + }, wasParentPrompted() { return parentPrompted; }, @@ -209,6 +279,107 @@ test("finish notifications tell the parent the child's last assistant message", ); }); +test("finish notifications survive permission responses", async () => { + const scenario = createFinishNotificationScenario(); + + scenario.startWatchingChild(); + scenario.requestChildPermission(); + + await vi.waitFor(() => { + expect(scenario.parentPrompts()).toHaveLength(1); + }); + expect(scenario.parentPrompts()[0]).toContain("needs permission."); + const permissionPayload = scenario + .parentPrompts()[0] + .match(/\n([\s\S]+?)\n<\/permission-request>/)?.[1]; + expect(permissionPayload).toBeDefined(); + expect(JSON.parse(permissionPayload!)).toEqual({ + agentId: "child-agent", + requestId: "permission-1", + request: { + id: "permission-1", + provider: "claude", + kind: "tool", + name: "Run command", + description: "Write the QA sentinel", + input: { + file_path: "/tmp/permission-qa.txt", + content: "PASEO_PERMISSION_NOTIFY_QA_OK\n", + }, + }, + }); + + scenario.resolveChildPermission(); + scenario.finishChild(); + + await vi.waitFor(() => { + expect(scenario.parentPrompts()).toHaveLength(2); + }); + expect(scenario.parentPrompts()[1]).toContain("finished."); +}); + +test("an idle permission resolution waits for the resumed run to finish", async () => { + const scenario = createFinishNotificationScenario(); + + scenario.startWatchingChild(); + scenario.requestChildPermission(); + await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1)); + + scenario.resolveChildPermissionWhileIdle(); + scenario.requestChildPermission("permission-2"); + await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); + expect(scenario.parentPrompts().every((prompt) => prompt.includes("needs permission."))).toBe( + true, + ); + + scenario.resolveChildPermission("permission-2"); + scenario.finishChild(); + await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3)); + expect(scenario.parentPrompts()[2]).toContain("finished."); +}); + +test("finish notifications report every concurrently pending permission", async () => { + const scenario = createFinishNotificationScenario(); + + scenario.startWatchingChild(); + scenario.requestChildPermission("permission-1"); + scenario.requestChildPermission("permission-2"); + + await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); + expect( + scenario.parentPrompts().map((prompt) => { + const payload = prompt.match(/\n([\s\S]+?)\n<\/permission-request>/)?.[1]; + return JSON.parse(payload!).requestId; + }), + ).toEqual(["permission-1", "permission-2"]); + + scenario.resolveChildPermission("permission-1"); + scenario.resolveChildPermission("permission-2"); + scenario.finishChild(); + + await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3)); + expect(scenario.parentPrompts()[2]).toContain("finished."); +}); + +test("finish notifications survive repeated permission cycles", async () => { + const scenario = createFinishNotificationScenario(); + + scenario.startWatchingChild(); + scenario.requestChildPermission(); + await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1)); + scenario.resolveChildPermissionFromState(); + + scenario.requestChildPermission(); + await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); + scenario.resolveChildPermission(); + scenario.finishChild(); + + await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3)); + expect( + scenario.parentPrompts().map((prompt) => prompt.match(/(needs permission|finished)\./)?.[1]), + ).toEqual(["needs permission", "needs permission", "finished"]); +}); + test("detaching a child ends its parent-owned finish notification", async () => { const scenario = createFinishNotificationScenario({ childParentAgentId: null, @@ -258,6 +429,7 @@ it("does not notify archived callers", async () => { Reflect.set(childAgent, "id", "child-agent"); Reflect.set(childAgent, "lifecycle", "idle"); Reflect.set(childAgent, "config", { title: "Child Agent" }); + Reflect.set(childAgent, "pendingPermissions", new Map()); const callerAgent: ManagedAgent = Object.create(null); Reflect.set(callerAgent, "id", "caller-agent"); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 53d3674162..375f643b0b 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -1,6 +1,10 @@ import type { Logger } from "pino"; -import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js"; +import type { + AgentPermissionRequest, + AgentPromptInput, + AgentRunOptions, +} from "./agent-sdk-types.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; @@ -254,15 +258,36 @@ interface FinishNotificationBodyInput { title: string; reason: "finished" | "errored" | "needs permission"; lastAssistantMessage: string | null; + permissionRequest?: AgentPermissionRequest; } function formatFinishNotificationBody(params: FinishNotificationBodyInput): string { const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`; - const lastAssistantMessage = params.lastAssistantMessage?.trim(); - if (!lastAssistantMessage) { - return statusLine; + const sections = [statusLine]; + if (params.reason === "needs permission" && params.permissionRequest) { + sections.push( + "Respond with `respond_to_permission` using the `agentId` and `requestId` below.", + `\n${JSON.stringify( + { + agentId: params.childAgentId, + requestId: params.permissionRequest.id, + request: params.permissionRequest, + }, + null, + 2, + )}\n`, + ); } - return `${statusLine}\n\n\n${lastAssistantMessage}\n`; + const lastAssistantMessage = params.lastAssistantMessage?.trim(); + if (lastAssistantMessage) { + sections.push(`\n${lastAssistantMessage}\n`); + } + return sections.join("\n\n"); +} + +interface NotifySafelyOptions { + terminal?: boolean; + permissionRequest?: AgentPermissionRequest; } export function setupFinishNotification(params: SetupFinishNotificationParams): void { @@ -275,16 +300,21 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): logger, } = params; let hasSeenRunning = false; - let fired = false; + let stopped = false; + const notifiedPermissionRequestIds = new Set(); let unsubscribe: (() => void) | null = null; + let notificationQueue = Promise.resolve(); - async function notify(reason: "finished" | "errored" | "needs permission"): Promise { - if (fired) { - return; - } - fired = true; + function stop(): void { + if (stopped) return; + stopped = true; unsubscribe?.(); + } + async function notify( + reason: "finished" | "errored" | "needs permission", + permissionRequest?: AgentPermissionRequest, + ): Promise { const callerRecord = await agentStorage.get(callerAgentId); if (callerRecord?.archivedAt) { return; @@ -301,6 +331,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): title, reason, lastAssistantMessage, + permissionRequest, }); await sendPromptToAgent({ @@ -313,24 +344,38 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): }); } - function notifySafely(reason: "finished" | "errored" | "needs permission"): void { - void notify(reason).catch((error) => { - logger.error( - { err: error, childAgentId, callerAgentId, reason }, - "Failed to notify caller agent", - ); - }); + function notifySafely( + reason: "finished" | "errored" | "needs permission", + options: NotifySafelyOptions = {}, + ): void { + if (stopped) return; + if (options.terminal ?? true) stop(); + notificationQueue = notificationQueue + .then(() => notify(reason, options.permissionRequest)) + .catch((error) => { + logger.error( + { err: error, childAgentId, callerAgentId, reason }, + "Failed to notify caller agent", + ); + }); } unsubscribe = agentManager.subscribe( (event) => { - if (fired) { + if (stopped) { return; } if (event.type === "agent_state") { + for (const requestId of notifiedPermissionRequestIds) { + if (!event.agent.pendingPermissions.has(requestId)) { + notifiedPermissionRequestIds.delete(requestId); + } + } if (event.agent.lifecycle === "running") { - hasSeenRunning = true; + if (event.agent.pendingPermissions.size === 0) { + hasSeenRunning = true; + } return; } if (event.agent.lifecycle === "error") { @@ -342,15 +387,33 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): return; } if (event.agent.lifecycle === "closed") { - fired = true; - unsubscribe?.(); + stop(); return; } return; } if (event.event.type === "permission_requested") { - notifySafely("needs permission"); + // A permission pause is an intermediate checkpoint. Forget the run + // observed before it so an idle state during follow-up startup cannot + // masquerade as the final completion. + hasSeenRunning = false; + if (!notifiedPermissionRequestIds.has(event.event.request.id)) { + notifiedPermissionRequestIds.add(event.event.request.id); + notifySafely("needs permission", { + terminal: false, + permissionRequest: event.event.request, + }); + } + return; + } + + if (event.event.type === "permission_resolved") { + notifiedPermissionRequestIds.delete(event.event.requestId); + const childAgent = agentManager.getAgent(childAgentId); + if (childAgent?.pendingPermissions.size === 0) { + hasSeenRunning = childAgent.lifecycle === "running"; + } } }, { agentId: childAgentId, replayState: false }, @@ -363,7 +426,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): // transitioning to "running"). const childSnapshot = agentManager.getAgent(childAgentId); if (!childSnapshot || childSnapshot.lifecycle === "closed") { - unsubscribe(); + stop(); return; } if (childSnapshot.lifecycle === "running") { From 7253228dc537de4b612a26621e21a958f05de708 Mon Sep 17 00:00:00 2001 From: Damon <17677912+dmeledon@users.noreply.github.com> Date: Tue, 11 Aug 2026 06:46:05 +0000 Subject: [PATCH 10/73] feat(app): render mermaid diagrams in agent chat with pan/zoom (#2306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): render mermaid diagrams in agent chat with pan/zoom Fenced mermaid blocks in assistant messages now render as diagrams instead of code blocks. Web: mermaid renders inline via dynamic import (code-split, loads on first diagram). Drag to pan, ctrl/cmd+wheel or trackpad pinch to zoom about the cursor, double-click to reset; plain wheel still scrolls the chat. Rendered SVGs are cached across virtualization remounts. iOS/Android: diagrams render in a WebView running a self-contained generated bundle (build:mermaid-webview, mirrors the native terminal's xterm pattern — no CDN, mermaid ships in the app). The inline preview is touch-shielded and height-driven by a bridge message; tapping opens a fullscreen viewer where the platform WebView owns pinch-zoom. Streaming source that doesn't parse yet falls back to the highlighted code block and keeps the last good render; renders are serialized through one queue with stale-task skipping so token-by-token updates cost at most one in-flight layout. Security: mermaid can fetch external resources during layout (image shapes, CSS url()) before output sanitization runs — see mermaid-js/mermaid#7645 — so resource-bearing source is rejected up front (containsUnsafeMermaidSource) on every platform, htmlLabels stay off, securityLevel is strict, and the mermaid secure list is extended so directives can't override app theming or themeCSS. The lockfile diff includes npm 11.17 normalization churn beyond the mermaid closure; a no-op npm install produces the same rewrite. Co-Authored-By: Claude Fable 5 * fix(app): serialize native mermaid renders and pin viewer to last good source Serialize render messages inside the mermaid webview entry so a newer streamed update can't re-initialize global mermaid state (e.g. flip the theme) while a prior layout is still running; superseded queued renders are skipped. Track the last successfully rendered source on the RN side and open the fullscreen viewer with that, so tapping mid-stream while the latest chunk is still invalid can't show a blank viewer. Addresses PR review feedback. Co-Authored-By: Claude Fable 5 * fix(app): pair native mermaid renders with their source via request id A "rendered" bridge event was credited to the newest sent source, but an older render can complete while a newer (possibly invalid) chunk's send is in flight, mis-crediting the older render's success to the newer source. Render messages now carry a request id that the entry echoes on rendered/renderError; the RN side resolves the source by id, so the fullscreen viewer always opens the exact source that produced the visible render. Addresses PR review feedback. Co-Authored-By: Claude Fable 5 * fix(app): harden mermaid guard and native preview per adversarial review - Guard bypass (blocker): mermaid accepts quoted and unicode-escaped object keys, so quoted "img": evaded the resource denylist while still parsing into a real img property. The guard now decodes u/x escapes and strips quoting characters before matching, and tests both raw and normalized source. - structuredClone polyfill banner in the webview bundle: the app's iOS floor is 15.1 but structuredClone exists only from 15.4; esbuild targets don't polyfill runtime APIs. - Preview height sizes the inner content view so fence padding/borders no longer clip the reported SVG height, and measurement happens at the final content width (measuring overlay keeps the fence insets). - The invisible measuring overlay is hit-test transparent, so an invalid diagram's fallback code block keeps its copy button and gestures. - renderError now prunes all superseded source-map entries, matching the rendered path, so a long broken stream can't accumulate sources. - The entry's render chain can no longer be poisoned: the whole task body is inside try/catch and the chain appends a trailing catch. - "Mermaid diagram" accessibility label is translated in all locales (message.mermaidDiagram) on web and native. - esbuild pinned in packages/app devDependencies so the generated webview artifact doesn't depend on hoisting layout; * feat(app): mermaid source toggle and visible zoom controls Web: a hover control cluster on the diagram (zoom in/out anchored to the viewport center, reset view, view source) mirroring the code block's hover-copy idiom; always visible on compact form factors where hover doesn't exist. View source swaps to the highlighted code block (with its own copy button) and a view-diagram button swaps back. Native: the fullscreen viewer gains a source toggle beside the close button, swapping the webview for the highlighted source in a scrollable view. All control labels are translated in every locale under message.mermaid.*; the flat mermaidDiagram key from the previous commit is folded into the same group. Co-Authored-By: Claude Fable 5 * fix(app): align mermaid control icons with the app's icon vocabulary Network is the settings Connections icon — reusing it for "view diagram" gave one glyph two meanings; use Workflow instead. RotateCw is the app's established reset/reload glyph (RotateCcw had no precedent). Co-Authored-By: Claude Fable 5 * fix(app): use Scan for mermaid reset-view control RotateCw is the app's reload glyph (19 uses) — a reset-zoom button wearing it invites a reload misread. Scan (corner-bracket fit-to-frame) is unused and carries the intended fit/reset semantics. Co-Authored-By: Claude Fable 5 * fix(app): remove raw NUL byte from mermaid cache key separator A literal 0x00 in the template string made git classify the file as binary, rendering the PR diff for it unreviewable. Use the escaped sequence instead — identical runtime key, plain-text source. Co-Authored-By: Claude Fable 5 * fix(app): align mermaid source-view toggle with the copy button The source view wrapped the code block while the fence margins stayed inside it, so the floating view-diagram toggle hung in the margin whitespace above the visible box — misaligned with the code block's copy button and outside its hover region. Hoist the margins onto the wrapper (both buttons now share the box edge) and render the toggle in the copy button's plain style so the pair reads as one control row. Co-Authored-By: Claude Fable 5 * fix(app): close mermaid review findings * chore(app): finish Mermaid PR rebase cleanup * refactor(app): isolate diagram rendering and stabilize streaming * fix(app): keep Mermaid previews stable while streaming * fix(nix): update npm dependency hash * fix(app): preserve streamed rows through completion --------- Co-authored-by: Damon Meledones Co-authored-by: Claude Fable 5 Co-authored-by: Mohamed Boudra --- nix/npm-deps.hash | 2 +- package-lock.json | 1429 ++++++++++++----- .../app/e2e/browser/mermaid-streaming.spec.ts | 68 + packages/app/e2e/support/helpers/diagram.ts | 48 + packages/app/package.json | 5 +- packages/app/src/agent-stream/layout.test.ts | 24 + packages/app/src/agent-stream/layout.ts | 5 + .../src/agent-stream/strategy-web.test.tsx | 72 + .../app/src/agent-stream/strategy-web.tsx | 7 +- packages/app/src/agent-stream/view.tsx | 1 + .../src/components/markdown/fence/index.tsx | 42 + .../markdown/fence/language.test.ts | 15 + .../src/components/markdown/fence/language.ts | 3 + .../markdown/fence/mermaid/build-runtime.mjs | 76 + .../markdown/fence/mermaid/host.native.tsx | 379 +++++ .../markdown/fence/mermaid/host.tsx | 13 + .../markdown/fence/mermaid/host.web.tsx | 490 ++++++ .../markdown/fence/mermaid/index.tsx | 6 + .../markdown/fence/mermaid/presentation.ts | 14 + .../fence/mermaid/render-model.test.ts | 163 ++ .../markdown/fence/mermaid/render-model.ts | 170 ++ .../markdown/fence/mermaid/runtime/entry.ts | 131 ++ .../fence/mermaid/runtime/html.gen.ts | 4 + .../fence/mermaid/runtime/messages.ts | 91 ++ .../mermaid/runtime/request-driver.test.ts | 52 + .../fence/mermaid/runtime/request-driver.ts | 63 + .../mermaid/runtime/runtime-html.test.ts | 12 + .../mermaid/runtime/runtime.browser.test.ts | 119 ++ .../fence/mermaid/source-policy.test.ts | 72 + .../markdown/fence/mermaid/source-policy.ts | 52 + .../fence/mermaid/use-render-model.ts | 96 ++ .../src/components/markdown/fence/types.ts | 10 + .../app/src/components/markdown/renderer.tsx | 6 +- packages/app/src/components/message.tsx | 13 +- packages/app/src/i18n/resources/ar.ts | 8 + packages/app/src/i18n/resources/en.ts | 8 + packages/app/src/i18n/resources/es.ts | 8 + packages/app/src/i18n/resources/fr.ts | 8 + packages/app/src/i18n/resources/ja.ts | 8 + packages/app/src/i18n/resources/ko.ts | 8 + packages/app/src/i18n/resources/pt-BR.ts | 8 + packages/app/src/i18n/resources/ru.ts | 8 + packages/app/src/i18n/resources/zh-CN.ts | 8 + .../providers/mock-load-test-agent.test.ts | 41 + .../agent/providers/mock-load-test-agent.ts | 42 + 45 files changed, 3528 insertions(+), 380 deletions(-) create mode 100644 packages/app/e2e/browser/mermaid-streaming.spec.ts create mode 100644 packages/app/e2e/support/helpers/diagram.ts create mode 100644 packages/app/src/components/markdown/fence/index.tsx create mode 100644 packages/app/src/components/markdown/fence/language.test.ts create mode 100644 packages/app/src/components/markdown/fence/language.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs create mode 100644 packages/app/src/components/markdown/fence/mermaid/host.native.tsx create mode 100644 packages/app/src/components/markdown/fence/mermaid/host.tsx create mode 100644 packages/app/src/components/markdown/fence/mermaid/host.web.tsx create mode 100644 packages/app/src/components/markdown/fence/mermaid/index.tsx create mode 100644 packages/app/src/components/markdown/fence/mermaid/presentation.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/render-model.test.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/render-model.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/entry.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/html.gen.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/messages.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.test.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/runtime-html.test.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/source-policy.test.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/source-policy.ts create mode 100644 packages/app/src/components/markdown/fence/mermaid/use-render-model.ts create mode 100644 packages/app/src/components/markdown/fence/types.ts diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index cb16a408df..c955eb4271 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-tB0WZG6F6VFMS5zFqo73giSAn7SqfkJISF5fMjDioJQ= +sha256-m6anpp3ESvWCVQAoTsOhnTMpoewhpJTeVha2Gv4Xnc4= diff --git a/package-lock.json b/package-lock.json index 2147d093a2..de6b745bf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -109,6 +109,19 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.104.2", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.2.tgz", @@ -2362,6 +2375,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, "node_modules/@clack/core": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.1.0.tgz", @@ -2523,14 +2548,6 @@ "node": ">=16" } }, - "node_modules/@cloudflare/workers-types": { - "version": "4.20260509.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260509.1.tgz", - "integrity": "sha512-jFlTTD+0MK/01TdL5sHIsQ8RqzfmvBsGl4hSp87INv2+JIs/JF6EL9J8enuCz6z3fNdfOKISNbGCIrzZRXVrcw==", - "license": "MIT OR Apache-2.0", - "optional": true, - "peer": true - }, "node_modules/@codemirror/autocomplete": { "version": "6.20.3", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", @@ -6422,6 +6439,23 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@ide/backoff": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", @@ -7728,6 +7762,15 @@ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "license": "MIT" }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", + "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, "node_modules/@mixmark-io/domino": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", @@ -12313,6 +12356,259 @@ "@types/node": "*" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -12380,6 +12676,12 @@ "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -12684,6 +12986,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/turndown": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", @@ -13459,6 +13768,16 @@ "win32" ] }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@urql/core": { "version": "4.0.11", "resolved": "https://registry.npmjs.org/@urql/core/-/core-4.0.11.tgz", @@ -16389,6 +16708,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/crc": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", @@ -16612,6 +16940,505 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -16737,6 +17564,12 @@ "node": "*" } }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -16979,6 +17812,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -17288,6 +18130,15 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -18118,6 +18969,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -18337,21 +19198,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-config-prettier": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz", - "integrity": "sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -22002,6 +22848,12 @@ } } }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/hard-rejection": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", @@ -22561,7 +23413,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -22649,6 +23500,16 @@ "node": ">=4" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -22737,6 +23598,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -24344,6 +25214,31 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keychain": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/keychain/-/keychain-1.5.0.tgz", @@ -24361,6 +25256,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -24442,6 +25342,12 @@ "lan-network": "dist/lan-network-cli.js" } }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", @@ -24965,6 +25871,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -25247,6 +26159,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", @@ -25710,6 +26634,48 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz", + "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.45", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/mermaid/node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -28213,6 +29179,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/package-manager-detector": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz", + "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==", + "license": "MIT" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -28378,6 +29350,12 @@ "npm": ">5" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -28744,6 +29722,22 @@ "node": ">=14.19.0" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -29673,18 +30667,6 @@ "react-native-worklets": "0.8.x" } }, - "node_modules/react-native-reanimated/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/react-native-safe-area-context": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", @@ -29836,18 +30818,6 @@ "react-native": "0.81 - 0.85" } }, - "node_modules/react-native-worklets/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/react-native/node_modules/@react-native/normalize-colors": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz", @@ -30729,6 +31699,12 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -30791,6 +31767,18 @@ "integrity": "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==", "license": "MIT" }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -30847,6 +31835,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -32318,6 +33312,12 @@ "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", "license": "MIT" }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -32765,7 +33765,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -32980,6 +33979,15 @@ "typescript": ">=4.0.0" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/ts-deepmerge": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.0.tgz", @@ -35145,6 +36153,7 @@ "i18next": "^26.3.0", "lucide-react-native": "^0.546.0", "markdown-it": "^10.0.0", + "mermaid": "^11.16.0", "mnemonic-id": "^3.2.7", "qrcode": "^1.5.4", "react": "19.1.0", @@ -35187,6 +36196,7 @@ "@xterm/headless": "^6.1.0-beta.213", "dotenv": "^17.2.3", "eas-cli": "^16.24.1", + "esbuild": "0.28.1", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", "expo-gradle-jvmargs": "^1.1.2", @@ -35285,15 +36295,6 @@ "node": ">=16" } }, - "packages/app/node_modules/@cloudflare/workers-types": { - "version": "4.20260627.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260627.1.tgz", - "integrity": "sha512-QhVvier1eW9Ydw96N/Ez79i5CSGy7h39JucrUejQmAWQw9qBdlB1aOTjaD93EGYrV0t9U81YWetI3LcaxxILNw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "peer": true - }, "packages/app/node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -37569,209 +38570,6 @@ "vitest": "^4.1.6" } }, - "packages/highlight/node_modules/@vitest/expect": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", - "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/highlight/node_modules/@vitest/pretty-format": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/highlight/node_modules/@vitest/runner": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", - "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.6", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/highlight/node_modules/@vitest/snapshot": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", - "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.6", - "@vitest/utils": "4.1.6", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/highlight/node_modules/@vitest/spy": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", - "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/highlight/node_modules/@vitest/utils": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.6", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "packages/highlight/node_modules/vitest": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", - "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.6", - "@vitest/mocker": "4.1.6", - "@vitest/pretty-format": "4.1.6", - "@vitest/runner": "4.1.6", - "@vitest/snapshot": "4.1.6", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.6", - "@vitest/browser-preview": "4.1.6", - "@vitest/browser-webdriverio": "4.1.6", - "@vitest/coverage-istanbul": "4.1.6", - "@vitest/coverage-v8": "4.1.6", - "@vitest/ui": "4.1.6", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "packages/highlight/node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", - "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.6", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, "packages/protocol": { "name": "@getpaseo/protocol", "version": "0.3.1", @@ -39525,115 +40323,6 @@ "undici-types": "~6.21.0" } }, - "packages/website/node_modules/@vitest/browser": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", - "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pngjs": "^7.0.0", - "sirv": "^3.0.2", - "tinyrainbow": "^3.1.0", - "ws": "^8.19.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "4.1.10" - } - }, - "packages/website/node_modules/@vitest/browser-playwright": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", - "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vitest/browser": "4.1.10", - "@vitest/mocker": "4.1.10", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "playwright": "*", - "vitest": "4.1.10" - }, - "peerDependenciesMeta": { - "playwright": { - "optional": false - } - } - }, - "packages/website/node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "packages/website/node_modules/@vitest/browser/node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, "packages/website/node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", diff --git a/packages/app/e2e/browser/mermaid-streaming.spec.ts b/packages/app/e2e/browser/mermaid-streaming.spec.ts new file mode 100644 index 0000000000..a9f87862c6 --- /dev/null +++ b/packages/app/e2e/browser/mermaid-streaming.spec.ts @@ -0,0 +1,68 @@ +import { test } from "../support/fixtures"; +import { + expectCompletedDiagram, + expectDiagramRemainsRenderedWhileStreaming, + expectDiagramWithLabels, + reloadConversation, + requestDiagram, + waitForDiagramTurnToComplete, +} from "../support/helpers/diagram"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +const STREAMED_MERMAID = [ + "```mermaid", + "flowchart LR", + " Start --> Middle", + ' Middle --> Done["Done"]', + " Middle --> Review", + " Review --> Verify", + " Verify --> Ship", + " Ship --> Package", + " Package --> Sign", + " Sign --> Upload", + " Upload --> Publish", + " Publish --> Deploy", + " Deploy --> Observe", + " Observe --> Validate", + " Validate --> Announce", + " Announce --> Document", + " Document --> Archive", + " Archive --> Release", + "```", +].join("\n"); + +test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "mermaid-streaming-", + title: "Streaming Mermaid", + featureValues: { + mockStreamingAssistantResponse: STREAMED_MERMAID, + mockStreamingAssistantIntervalMs: 150, + }, + }); + try { + await test.step("Open the conversation and request a diagram", async () => { + await openAgentRoute(page, agent); + await requestDiagram(agent); + }); + + await test.step("The diagram renders and stays rendered while tokens arrive", async () => { + await expectDiagramWithLabels(page, ["Start", "Middle"]); + await expectDiagramRemainsRenderedWhileStreaming(page); + }); + + await test.step("The completed diagram shows the final streamed content", async () => { + await waitForDiagramTurnToComplete(agent); + await expectCompletedDiagram(page, ["Start", "Done", "Release"]); + }); + + await test.step("The completed diagram remains rendered after reload", async () => { + await reloadConversation(page); + await expectCompletedDiagram(page, ["Start", "Done", "Release"]); + }); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/support/helpers/diagram.ts b/packages/app/e2e/support/helpers/diagram.ts new file mode 100644 index 0000000000..f3d2742c12 --- /dev/null +++ b/packages/app/e2e/support/helpers/diagram.ts @@ -0,0 +1,48 @@ +import { expect, type Page } from "@playwright/test"; +import type { MockAgentWorkspace } from "./mock-agent"; + +const DIAGRAM_NAME = "Diagram"; + +function renderedDiagram(page: Page) { + const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last(); + const svg = diagram.locator("iframe").contentFrame().locator("#diagram svg"); + return { diagram, svg }; +} + +export async function requestDiagram(agent: MockAgentWorkspace): Promise { + await agent.client.sendAgentMessage(agent.agentId, "Render the requested Mermaid diagram."); +} + +export async function expectDiagramWithLabels( + page: Page, + labels: readonly string[], +): Promise { + const { diagram, svg } = renderedDiagram(page); + await expect(diagram).toBeVisible({ timeout: 30_000 }); + await expect(svg).toBeVisible({ timeout: 30_000 }); + for (const label of labels) { + await expect(svg).toContainText(label); + } +} + +export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Promise { + const { diagram, svg } = renderedDiagram(page); + const samples = 80; + for (let sample = 0; sample < samples; sample += 1) { + await expect(diagram).toBeVisible({ timeout: 100 }); + await expect(svg).toBeVisible({ timeout: 100 }); + await page.waitForTimeout(25); + } +} + +export async function waitForDiagramTurnToComplete(agent: MockAgentWorkspace): Promise { + await agent.client.waitForFinish(agent.agentId, 30_000); +} + +export async function expectCompletedDiagram(page: Page, labels: readonly string[]): Promise { + await expectDiagramWithLabels(page, labels); +} + +export async function reloadConversation(page: Page): Promise { + await page.reload(); +} diff --git a/packages/app/package.json b/packages/app/package.json index 6b1901f7cd..cd15f23a4e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -29,7 +29,8 @@ "build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web", "profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs", "deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main", - "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs" + "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs", + "build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs" }, "dependencies": { "@codemirror/commands": "6.10.4", @@ -92,6 +93,7 @@ "i18next": "^26.3.0", "lucide-react-native": "^0.546.0", "markdown-it": "^10.0.0", + "mermaid": "^11.16.0", "mnemonic-id": "^3.2.7", "qrcode": "^1.5.4", "react": "19.1.0", @@ -134,6 +136,7 @@ "@xterm/headless": "^6.1.0-beta.213", "dotenv": "^17.2.3", "eas-cli": "^16.24.1", + "esbuild": "0.28.1", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", "expo-gradle-jvmargs": "^1.1.2", diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index 2fce5c4379..1cc645f167 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -143,6 +143,30 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem { } describe("layoutStream", () => { + it.each(["web", "android"] as const)( + "marks only the active live-head assistant block as streaming on %s", + (platform) => { + const completed = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 }); + const live = assistantMessage("turn:block:1", 3, { groupId: "turn", index: 1 }); + const active = layoutFor({ + platform, + isTurnActive: true, + tail: [userMessage("u1", 1), completed], + head: [live], + }); + const complete = layoutFor({ + platform, + isTurnActive: false, + tail: [userMessage("u1", 1), completed], + head: [live], + }); + + expect(findLayoutItem(active, completed.id).phase).toBe("complete"); + expect(findLayoutItem(active, live.id).phase).toBe("streaming"); + expect(findLayoutItem(complete, live.id).phase).toBe("complete"); + }, + ); + it.each(["web", "android"] as const)( "keeps split assistant block spacing identical to unsplit history on %s", (platform) => { diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index 9249f81592..990b1f8071 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -26,6 +26,7 @@ export interface StreamLayoutItem { isLastInUserGroup: boolean; isLastInToolSequence: boolean; frameOrder: StreamFrameChildOrder; + phase: "streaming" | "complete"; } export interface StreamLayout { @@ -53,6 +54,7 @@ interface LayoutSegmentInput { boundaryBelowItem: StreamItem | null; boundaryAboveItems: StreamItem[] | null; boundaryAboveIndex: number | null; + phase: "streaming" | "complete"; } interface AssistantFooterSource { @@ -273,6 +275,7 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] { isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message", isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem), frameOrder: input.frameOrder, + phase: input.phase, }; }); } @@ -324,6 +327,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout { boundaryBelowItem: liveHeadBoundaryItem, boundaryAboveItems: null, boundaryAboveIndex: null, + phase: "complete", }); byKey.set(historyCacheKey, history); } @@ -342,6 +346,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout { boundaryBelowItem: null, boundaryAboveItems: input.history, boundaryAboveIndex: historyBoundaryIndex, + phase: input.isTurnActive ? "streaming" : "complete", }); return { diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 61777cb21a..98be7dd916 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -206,6 +206,78 @@ describe("createWebStreamStrategy", () => { expect(renderLiveHeadRow).toHaveBeenCalledTimes(2); }); + it("keeps a row mounted when it moves from the live head into mounted history", () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); + const viewportRef = React.createRef(); + const item = userMessage(1); + const mounted = vi.fn(); + const unmounted = vi.fn(); + function StatefulRow() { + React.useEffect(() => { + mounted(); + return unmounted; + }, []); + return
{item.id}
; + } + const renderers: StreamSegmentRenderers = { + ...createRenderers(vi.fn()), + renderHistoryMountedRow: () => , + renderLiveHeadRow: () => , + }; + const renderInput: Omit = { + agentId: "agent", + renderers, + listEmptyComponent: null, + viewportRef, + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: vi.fn(), + onNearHistoryStart: vi.fn().mockReturnValue(true), + isLoadingOlderHistory: false, + hasOlderHistory: false, + olderHistoryProgressKey: null, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + strategy.render({ + ...renderInput, + segments: { historyVirtualized: [], historyMounted: [], liveHead: [item] }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: false, + hasLiveHead: true, + }, + }), + ); + }); + expect(mounted).toHaveBeenCalledTimes(1); + + act(() => { + root?.render( + strategy.render({ + ...renderInput, + segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] }, + boundary: { + hasVirtualizedHistory: false, + hasMountedHistory: true, + hasLiveHead: false, + }, + }), + ); + }); + + expect(mounted).toHaveBeenCalledTimes(1); + expect(unmounted).not.toHaveBeenCalled(); + }); + it("reports the live-head row as the reading position", () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); const viewportRef = React.createRef(); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 08781f80b0..db244719b6 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -1068,6 +1068,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool )); }, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]); + const mountedRows = useMemo( + () => [...mountedHistoryRows, ...liveHeadRows], + [liveHeadRows, mountedHistoryRows], + ); const liveAuxiliary = useMemo(() => { return renderLiveAuxiliary(); }, [renderLiveAuxiliary]); @@ -1119,8 +1123,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool })} ) : null} - {mountedHistoryRows} - {liveHeadRows} + {mountedRows} {liveAuxiliary} {shouldRenderEmpty ? listEmptyComponent : null} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 35a8afef5b..7203ca380a 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -653,6 +653,7 @@ const AgentStreamViewComponent = forwardRef ); diff --git a/packages/app/src/components/markdown/fence/index.tsx b/packages/app/src/components/markdown/fence/index.tsx new file mode 100644 index 0000000000..846aa91355 --- /dev/null +++ b/packages/app/src/components/markdown/fence/index.tsx @@ -0,0 +1,42 @@ +import type { ComponentType } from "react"; +import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; +import { getMarkdownFenceLanguage } from "./language"; +import { MermaidFence } from "./mermaid"; +import type { MarkdownFenceRendererProps } from "./types"; + +export interface MarkdownFenceBlockProps extends MarkdownFenceRendererProps { + info: string | null | undefined; +} + +const diagramFences: Partial>> = { + mermaid: MermaidFence, +}; + +export function MarkdownFenceBlock({ + code, + info, + phase, + inheritedStyles, + textStyle, +}: MarkdownFenceBlockProps) { + const language = getMarkdownFenceLanguage(info); + const DiagramFence = language ? diagramFences[language] : undefined; + if (DiagramFence) { + return ( + + ); + } + return ( + + ); +} diff --git a/packages/app/src/components/markdown/fence/language.test.ts b/packages/app/src/components/markdown/fence/language.test.ts new file mode 100644 index 0000000000..172f72c310 --- /dev/null +++ b/packages/app/src/components/markdown/fence/language.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { getMarkdownFenceLanguage } from "./language"; + +describe("getMarkdownFenceLanguage", () => { + it("normalizes the first info-string token", () => { + expect(getMarkdownFenceLanguage(" Sirena theme=dark ")).toBe("sirena"); + expect(getMarkdownFenceLanguage("TypeScript title=example")).toBe("typescript"); + }); + + it("returns null when no language is declared", () => { + expect(getMarkdownFenceLanguage("")).toBeNull(); + expect(getMarkdownFenceLanguage(null)).toBeNull(); + expect(getMarkdownFenceLanguage(undefined)).toBeNull(); + }); +}); diff --git a/packages/app/src/components/markdown/fence/language.ts b/packages/app/src/components/markdown/fence/language.ts new file mode 100644 index 0000000000..fd414a5ac2 --- /dev/null +++ b/packages/app/src/components/markdown/fence/language.ts @@ -0,0 +1,3 @@ +export function getMarkdownFenceLanguage(info: string | null | undefined): string | null { + return info?.trim().split(/\s+/)[0]?.toLowerCase() || null; +} diff --git a/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs b/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs new file mode 100644 index 0000000000..e4f61b8899 --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs @@ -0,0 +1,76 @@ +import esbuild from "esbuild"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const appRoot = path.resolve(__dirname, "../../../../.."); +const repoRoot = path.resolve(appRoot, "../.."); +const entry = path.join(__dirname, "runtime/entry.ts"); +const output = path.join(__dirname, "runtime/html.gen.ts"); + +const result = await esbuild.build({ + entryPoints: [entry], + bundle: true, + write: false, + format: "iife", + platform: "browser", + target: ["ios15", "chrome100"], + minify: true, + // The app's deployment floor is iOS 15.1 but structuredClone (used by + // mermaid) only exists from iOS 15.4; esbuild targets transform syntax, + // not runtime APIs. Mermaid only clones plain config objects, so a + // JSON-based fallback is sufficient. + banner: { + js: 'if(typeof globalThis.structuredClone!=="function"){globalThis.structuredClone=function(v){return v===undefined?v:JSON.parse(JSON.stringify(v));};}', + }, + logLevel: "info", +}); + +const rawJs = result.outputFiles[0]?.text; +if (!rawJs) { + throw new Error("Mermaid runtime bundle produced no JavaScript"); +} +// JSON.stringify below escapes the TypeScript representation, not the runtime +// HTML — an embedded closing-script sequence would still terminate the inline +// + +`; + +const contents = `// Generated by packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs. +// Do not edit by hand. + +export const mermaidRuntimeHtml = ${JSON.stringify(html)}; +`; + +await fs.writeFile(output, contents); +console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`); diff --git a/packages/app/src/components/markdown/fence/mermaid/host.native.tsx b/packages/app/src/components/markdown/fence/mermaid/host.native.tsx new file mode 100644 index 0000000000..044341f3aa --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/host.native.tsx @@ -0,0 +1,379 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Modal, + Pressable, + StyleSheet as RNStyleSheet, + ScrollView, + View, + type TextStyle, + type ViewStyle, +} from "react-native"; +import { WebView, type WebViewMessageEvent } from "react-native-webview"; +import { Code, Workflow, X } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; +import type { Theme } from "@/styles/theme"; +import type { MarkdownFenceRendererProps } from "../types"; +import type { MermaidRenderRequest } from "./render-model"; +import { mermaidRuntimeHtml } from "./runtime/html.gen"; +import { + parseMermaidRuntimeMessage, + serializeMermaidRuntimeRenderMessage, + type MermaidRuntimeRenderMessage, +} from "./runtime/messages"; +import { MermaidRuntimeRequestDriver } from "./runtime/request-driver"; +import { useMermaidRenderModel } from "./use-render-model"; +import { getDiagramBoxStyle } from "./presentation"; + +interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps { + colorScheme?: "light" | "dark"; +} + +const WEBVIEW_SOURCE = { html: mermaidRuntimeHtml }; +const WEBVIEW_ORIGIN_WHITELIST = ["*"]; +const MAX_PREVIEW_HEIGHT = 480; + +interface MermaidWebViewProps { + request: MermaidRenderRequest | null; + interactive: boolean; + onRendered: (message: { + revision: number; + source: string; + colorScheme: "light" | "dark"; + height: number; + width: number; + }) => void; + onRenderFailed: (revision: number) => void; + style?: ViewStyle; +} + +function MermaidWebView({ + request, + interactive, + onRendered, + onRenderFailed, + style, +}: MermaidWebViewProps) { + const webViewRef = useRef(null); + const driverRef = useRef(null); + driverRef.current ??= new MermaidRuntimeRequestDriver(); + + const sendRequest = useCallback( + (current: MermaidRenderRequest | null) => { + if (!current || !webViewRef.current) { + return; + } + const message: MermaidRuntimeRenderMessage = { + type: "render", + revision: current.revision, + source: current.source, + colorScheme: current.colorScheme, + interactive, + }; + const payload = serializeMermaidRuntimeRenderMessage(message); + webViewRef.current.injectJavaScript( + `window.__PASEO_MERMAID_RUNTIME_RECEIVE__ && window.__PASEO_MERMAID_RUNTIME_RECEIVE__(${payload}); true;`, + ); + }, + [interactive], + ); + + useEffect(() => { + sendRequest(driverRef.current?.update(request) ?? null); + }, [request, sendRequest]); + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + let value: unknown; + try { + value = JSON.parse(event.nativeEvent.data); + } catch { + return; + } + const message = parseMermaidRuntimeMessage(value); + if (!message) { + return; + } + if (message.type === "bridgeReady") { + sendRequest(driverRef.current?.ready() ?? null); + return; + } + if (message.type === "renderError") { + onRenderFailed(message.revision); + sendRequest(driverRef.current?.settled(message.revision, false) ?? null); + return; + } + onRendered(message); + sendRequest(driverRef.current?.settled(message.revision, true) ?? null); + }, + [onRenderFailed, onRendered, sendRequest], + ); + const handleShouldStartLoad = useCallback( + (load: { url: string }) => load.url === "about:blank" || load.url.startsWith("data:"), + [], + ); + + return ( + + ); +} + +const webViewStyles = RNStyleSheet.create({ + webView: { backgroundColor: "transparent" }, +}); + +interface MermaidDiagramViewerProps { + code: string; + colorScheme: "light" | "dark"; + onClose: () => void; + inheritedStyles: TextStyle; + textStyle: TextStyle; +} + +function MermaidDiagramViewer({ + code, + colorScheme, + onClose, + inheritedStyles, + textStyle, +}: MermaidDiagramViewerProps) { + const { t } = useTranslation(); + const [showSource, setShowSource] = useState(false); + const toggleSource = useCallback(() => setShowSource((current) => !current), []); + const { request, rendered, renderFailed } = useMermaidRenderModel({ + source: code, + phase: "complete", + colorScheme, + }); + const handleRendered = useCallback( + (message: { + revision: number; + source: string; + colorScheme: "light" | "dark"; + height: number; + width: number; + }) => { + rendered({ + revision: message.revision, + source: message.source, + colorScheme: message.colorScheme, + dimensions: { height: message.height, width: message.width }, + }); + }, + [rendered], + ); + + return ( + + + + + {showSource ? ( + + + + ) : null} + + + + {showSource ? ( + + ) : ( + + )} + + + + + + + + ); +} + +const ThemedCloseIcon = withUnistyles(X); +const ThemedSourceIcon = withUnistyles(Code); +const ThemedDiagramIcon = withUnistyles(Workflow); +const closeIconColor = (theme: Theme) => ({ color: theme.colors.foreground }); + +const viewerStyles = StyleSheet.create((theme, runtime) => ({ + backdrop: { flex: 1, backgroundColor: theme.colors.surface0 }, + webView: { + flex: 1, + marginTop: runtime.insets.top, + marginBottom: runtime.insets.bottom, + }, + runtime: { flex: 1 }, + sourceOverlay: { + position: "absolute", + inset: 0, + backgroundColor: theme.colors.surface0, + }, + source: { + paddingTop: theme.spacing[12], + paddingHorizontal: theme.spacing[4], + paddingBottom: theme.spacing[4], + }, + actions: { + position: "absolute", + top: runtime.insets.top + theme.spacing[3], + right: theme.spacing[4], + flexDirection: "row", + gap: theme.spacing[2], + }, + actionButton: { + padding: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface2, + }, +})); + +function MermaidFenceHostImpl({ + code, + phase, + inheritedStyles, + textStyle, + colorScheme = "dark", +}: MermaidFenceHostImplProps) { + const { t } = useTranslation(); + const { state, request, rendered, renderFailed } = useMermaidRenderModel({ + source: code, + phase, + colorScheme, + }); + const [hasRuntimeContent, setHasRuntimeContent] = useState(false); + const handleRendered = useCallback( + (message: { + revision: number; + source: string; + colorScheme: "light" | "dark"; + height: number; + width: number; + }) => { + setHasRuntimeContent(true); + rendered({ + revision: message.revision, + source: message.source, + colorScheme: message.colorScheme, + dimensions: { height: message.height, width: message.width }, + }); + }, + [rendered], + ); + const [viewerOpen, setViewerOpen] = useState(false); + const openViewer = useCallback(() => setViewerOpen(true), []); + const closeViewer = useCallback(() => setViewerOpen(false), []); + const visible = state.visible; + const canShowDiagram = visible !== null && hasRuntimeContent; + const previewInnerStyle = useMemo( + () => + canShowDiagram && visible + ? { height: Math.min(visible.height, MAX_PREVIEW_HEIGHT) } + : previewStyles.measuringInner, + [canShowDiagram, visible], + ); + const diagramBoxStyle = getDiagramBoxStyle(textStyle); + + return ( + <> + {!canShowDiagram ? ( + + ) : null} + + + + + + {viewerOpen && canShowDiagram && visible ? ( + + ) : null} + + ); +} + +const previewStyles = RNStyleSheet.create({ + measuring: { + position: "absolute", + left: 0, + right: 0, + opacity: 0, + pointerEvents: "none", + }, + measuringInner: { height: 240 }, + preview: { overflow: "hidden" }, +}); + +const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme }); +const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl); + +export function MermaidFenceHost(props: MarkdownFenceRendererProps) { + return ; +} diff --git a/packages/app/src/components/markdown/fence/mermaid/host.tsx b/packages/app/src/components/markdown/fence/mermaid/host.tsx new file mode 100644 index 0000000000..50e9596740 --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/host.tsx @@ -0,0 +1,13 @@ +import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; +import type { MarkdownFenceRendererProps } from "../types"; + +export function MermaidFenceHost({ code, inheritedStyles, textStyle }: MarkdownFenceRendererProps) { + return ( + + ); +} diff --git a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx new file mode 100644 index 0000000000..c51ab75428 --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx @@ -0,0 +1,490 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ComponentType, +} from "react"; +import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; +import { useTranslation } from "react-i18next"; +import { Code, Scan, Workflow, ZoomIn, ZoomOut } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import type { Theme } from "@/styles/theme"; +import type { MarkdownFenceRendererProps } from "../types"; +import type { MermaidRenderRequest } from "./render-model"; +import { mermaidRuntimeHtml } from "./runtime/html.gen"; +import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages"; +import { MermaidRuntimeRequestDriver } from "./runtime/request-driver"; +import { useMermaidRenderModel } from "./use-render-model"; +import { getDiagramBoxStyle } from "./presentation"; + +interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps { + colorScheme?: "light" | "dark"; +} + +interface MermaidIframeRuntimeProps { + request: MermaidRenderRequest | null; + height: number; + onRendered: (message: { + revision: number; + source: string; + colorScheme: "light" | "dark"; + height: number; + width: number; + }) => void; + onRenderFailed: (revision: number) => void; +} + +function MermaidIframeRuntime({ + request, + height, + onRendered, + onRenderFailed, +}: MermaidIframeRuntimeProps) { + const iframeRef = useRef(null); + const driverRef = useRef(null); + driverRef.current ??= new MermaidRuntimeRequestDriver(); + const iframeStyle = useMemo( + () => ({ + display: "block", + width: "100%", + height, + border: 0, + pointerEvents: "none", + background: "transparent", + }), + [height], + ); + + const sendRequest = useCallback((current: MermaidRenderRequest | null) => { + const target = iframeRef.current?.contentWindow; + if (!current || !target) { + return; + } + const message: MermaidRuntimeRenderMessage = { + type: "render", + revision: current.revision, + source: current.source, + colorScheme: current.colorScheme, + interactive: false, + }; + target.postMessage(message, "*"); + }, []); + + useEffect(() => { + sendRequest(driverRef.current?.update(request) ?? null); + }, [request, sendRequest]); + + useEffect(() => { + function receiveMessage(event: MessageEvent): void { + if (event.source !== iframeRef.current?.contentWindow) { + return; + } + const message = parseMermaidRuntimeMessage(event.data); + if (!message) { + return; + } + if (message.type === "bridgeReady") { + sendRequest(driverRef.current?.ready() ?? null); + return; + } + if (message.type === "renderError") { + onRenderFailed(message.revision); + sendRequest(driverRef.current?.settled(message.revision, false) ?? null); + return; + } + onRendered(message); + sendRequest(driverRef.current?.settled(message.revision, true) ?? null); + } + window.addEventListener("message", receiveMessage); + return () => window.removeEventListener("message", receiveMessage); + }, [onRenderFailed, onRendered, sendRequest]); + + return ( + `},\"putIntoIFrame\"),n$e=v((t,e,r,n,i)=>{let a=t.append(\"div\");a.attr(\"id\",r),n&&a.attr(\"style\",n);let s=a.append(\"svg\").attr(\"id\",e).attr(\"width\",\"100%\").attr(\"xmlns\",gNt);return i&&s.attr(\"xmlns:xlink\",i),s.append(\"g\"),t},\"appendDivSvgG\");function VK(t,e){return t.append(\"iframe\").attr(\"id\",e).attr(\"style\",\"width: 100%; height: 100%;\").attr(\"sandbox\",\"\")}v(VK,\"sandboxedIframe\");var INt=v((t,e,r,n)=>{t.getElementById(e)?.remove(),t.getElementById(r)?.remove(),t.getElementById(n)?.remove()},\"removeExistingElements\"),MNt=v(async function(t,e,r){lM();let n=qK(e);e=n.code;let i=It();ne.debug(i),e.length>(i?.maxTextSize??hNt)&&(e=dNt);let a=`#${t}`,s=\"i\"+t,o=\"#\"+s,l=\"d\"+t,u=\"#\"+l,h=v(()=>{let _=tt(f?o:u).node();_&&\"remove\"in _&&_.remove()},\"removeTempElements\"),d=tt(document.body),f=i.securityLevel===fNt,p=i.securityLevel===pNt,g=i.fontFamily;if(r!==void 0){if(r&&(r.innerHTML=\"\"),f){let R=VK(tt(r),s);d=tt(R.nodes()[0].contentDocument.body),d.node().style.margin=\"0\"}else d=tt(r);n$e(d,t,l,`font-family: ${g}`,mNt)}else{if(INt(document,t,l,s),f){let R=VK(tt(document.body),s);d=tt(R.nodes()[0].contentDocument.body),d.node().style.margin=\"0\"}else d=tt(\"body\");n$e(d,t,l)}let m,y;try{m=await GK.fromText(e,{title:n.title})}catch(R){if(i.suppressErrorRendering)throw h(),R;m=await GK.fromText(\"error\"),y=R}let x=d.select(u).node(),b=m.type,T=x.firstChild,k=T.firstChild,C=m.renderer.getClasses?.(e,m),w=_Nt(i,b,C,a),S=document.createElement(\"style\");S.innerHTML=w,T.insertBefore(S,k);try{await m.renderer.draw(e,t,\"11.16.0\",m)}catch(R){throw i.suppressErrorRendering?h():$Mt.draw(e,t,\"11.16.0\"),R}let A=d.select(`${u} svg`),L=m.db.getAccTitle?.(),M=m.db.getAccDescription?.();K$e(b,A,L,M),d.select(`[id=\"${t}\"]`).selectAll(\"foreignobject > *\").attr(\"xmlns\",yNt);let P=d.select(u).node().innerHTML;if(ne.debug(\"config.arrowMarkerAbsolute\",i.arrowMarkerAbsolute),P=RNt(P,f,yl(i.arrowMarkerAbsolute)),f){let R=d.select(u+\" svg\").node();P=LNt(P,R)}else p||(P=to.sanitize(P,{ADD_TAGS:kNt,ADD_ATTR:SNt,HTML_INTEGRATION_POINTS:{foreignobject:!0}}));if(sNt(),y)throw y;return h(),{diagramType:b,svg:P,bindFunctions:m.db.bindFunctions}},\"render\");function X$e(t={}){let e=Jn({},t);e?.fontFamily&&!e.themeVariables?.fontFamily&&(e.themeVariables||(e.themeVariables={}),e.themeVariables.fontFamily=e.fontFamily),NJ(e),e?.theme&&e.theme in qc?e.themeVariables=qc[e.theme].getThemeVariables(e.themeVariables):e&&(e.themeVariables=qc.default.getThemeVariables(e.themeVariables));let r=typeof e==\"object\"?DJ(e):AD();rC(r.logLevel),lM()}v(X$e,\"initialize\");var j$e=v((t,e={})=>{let{code:r}=WK(t);return GK.fromText(r,e)},\"getDiagramFromText\");function K$e(t,e,r,n){W$e(e,t),q$e(e,r,n,e.attr(\"id\"))}v(K$e,\"addA11yInfo\");var Im=Object.freeze({render:MNt,parse:Y$e,getDiagramFromText:j$e,initialize:X$e,getConfig:It,setConfig:_D,getSiteConfig:AD,updateSiteConfig:OJ,reset:v(()=>{CC()},\"reset\"),globalReset:v(()=>{CC(Gp)},\"globalReset\"),defaultConfig:Gp});rC(It().logLevel);CC(It());var DNt=v((t,e,r)=>{ne.warn(t),fA(t)?(r&&r(t.str,t.hash),e.push({...t,message:t.str,error:t})):(r&&r(t),t instanceof Error&&e.push({str:t.message,message:t.message,hash:t.name,error:t}))},\"handleError\"),Z$e=v(async function(t={querySelector:\".mermaid\"}){try{await NNt(t)}catch(e){if(fA(e)&&ne.error(e.str),wd.parseError&&wd.parseError(e),!t.suppressErrors)throw ne.error(\"Use the suppressErrors option to suppress these errors\"),e}},\"run\"),NNt=v(async function({postRenderCallback:t,querySelector:e,nodes:r}={querySelector:\".mermaid\"}){let n=Im.getConfig();ne.debug(`${t?\"\":\"No \"}Callback function found`);let i;if(r)i=r;else if(e)i=document.querySelectorAll(e);else throw new Error(\"Nodes and querySelector are both undefined\");ne.debug(`Found ${i.length} diagrams`),n?.startOnLoad!==void 0&&(ne.debug(\"Start On Load: \"+n?.startOnLoad),Im.updateSiteConfig({startOnLoad:n?.startOnLoad}));let a=new Zt.InitIDGenerator(n.deterministicIds,n.deterministicIDSeed),s,o=[];for(let l of Array.from(i)){if(ne.info(\"Rendering diagram: \"+l.id),l.getAttribute(\"data-processed\"))continue;l.setAttribute(\"data-processed\",\"true\");let u=`mermaid-${a.next()}`;s=l.innerHTML,s=CA(Zt.entityDecode(s)).trim().replace(//gi,\"
\");let h=Zt.detectInit(s);h&&ne.debug(\"Detected early reinit: \",h);try{let{svg:d,bindFunctions:f}=await tFe(u,s,l);l.innerHTML=d,t&&await t(u),f&&f(l)}catch(d){DNt(d,o,wd.parseError)}}if(o.length>0)throw o[0]},\"runThrowsErrors\"),Q$e=v(function(t){Im.initialize(t)},\"initialize\"),ONt=v(async function(t,e,r){ne.warn(\"mermaid.init is deprecated. Please use run instead.\"),t&&Q$e(t);let n={postRenderCallback:r,querySelector:\".mermaid\"};typeof e==\"string\"?n.querySelector=e:e&&(e instanceof HTMLElement?n.nodes=[e]:n.nodes=e),await Z$e(n)},\"init\"),PNt=v(async(t,{lazyLoad:e=!0}={})=>{lM(),c5(...t),e===!1&&await iNt()},\"registerExternalDiagrams\"),J$e=v(function(){if(wd.startOnLoad){let{startOnLoad:t}=Im.getConfig();t&&wd.run().catch(e=>ne.error(\"Mermaid failed to initialize\",e))}},\"contentLoaded\");typeof document<\"u\"&&window.addEventListener(\"load\",J$e,!1);var BNt=v(function(t){wd.parseError=t},\"setParseErrorHandler\"),oM=[],zK=!1,eFe=v(async()=>{if(!zK){for(zK=!0;oM.length>0;){let t=oM.shift();if(t)try{await t()}catch(e){ne.error(\"Error executing queue\",e)}}zK=!1}},\"executeQueue\"),$Nt=v(async(t,e)=>new Promise((r,n)=>{let i=v(()=>new Promise((a,s)=>{Im.parse(t,e).then(o=>{a(o),r(o)},o=>{ne.error(\"Error parsing\",o),wd.parseError?.(o),s(o),n(o)})}),\"performCall\");oM.push(i),eFe().catch(n)}),\"parse\"),tFe=v((t,e,r)=>new Promise((n,i)=>{let a=v(()=>new Promise((s,o)=>{Im.render(t,e,r).then(l=>{s(l),n(l)},l=>{ne.error(\"Error parsing\",l),wd.parseError?.(l),o(l),i(l)})}),\"performCall\");oM.push(a),eFe().catch(i)}),\"render\"),FNt=v(()=>Object.keys(Bd).map(t=>({id:t})),\"getRegisteredDiagramsMetadata\"),wd={startOnLoad:!0,mermaidAPI:Im,parse:$Nt,render:tFe,init:ONt,run:Z$e,registerExternalDiagrams:PNt,registerLayoutLoaders:_G,initialize:Q$e,parseError:void 0,contentLoaded:J$e,setParseErrorHandler:BNt,detectType:wC,registerIconPacks:qy,getRegisteredDiagramsMetadata:FNt},UK=wd;function zNt(t){return typeof t==\"object\"&&t!==null}function GNt(t){return t===\"light\"||t===\"dark\"}function rFe(t){return!zNt(t)||t.type!==\"render\"||typeof t.revision!=\"number\"||!Number.isInteger(t.revision)||typeof t.source!=\"string\"||!GNt(t.colorScheme)||typeof t.interactive!=\"boolean\"?null:{type:\"render\",revision:t.revision,source:t.source,colorScheme:t.colorScheme,interactive:t.interactive}}function YK(t){window.ReactNativeWebView?.postMessage?.(JSON.stringify(t)),window.parent!==window&&window.parent.postMessage(t,\"*\")}function VNt(t){UK.initialize({startOnLoad:!1,securityLevel:\"strict\",suppressErrorRendering:!0,theme:t===\"dark\"?\"dark\":\"default\",secure:[\"secure\",\"securityLevel\",\"startOnLoad\",\"maxTextSize\",\"suppressErrorRendering\",\"maxEdges\",\"theme\",\"themeVariables\",\"themeCSS\"],flowchart:{htmlLabels:!1},class:{htmlLabels:!1}})}function WNt(t){document.querySelector('meta[name=\"viewport\"]')?.setAttribute(\"content\",t?\"width=device-width, initial-scale=1, maximum-scale=8\":\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no\")}var XK=0,cM=null,uM=!1,HK=!1;async function qNt(t){try{VNt(t.colorScheme);let{svg:e}=await UK.render(`paseo-mermaid-${t.revision}`,t.source);if(t.revision!==XK)return;WNt(t.interactive);let r=document.getElementById(\"diagram\");if(!r)return;r.innerHTML=e;let n=r.querySelector(\"svg\")?.getBoundingClientRect();YK({type:\"rendered\",revision:t.revision,source:t.source,colorScheme:t.colorScheme,height:Math.ceil(n?.height??r.scrollHeight),width:Math.ceil(n?.width??r.scrollWidth)})}catch{t.revision===XK&&YK({type:\"renderError\",revision:t.revision})}}async function UNt(){if(!uM){uM=!0;try{for(;cM;){let t=cM;cM=null,await qNt(t)}}finally{uM=!1}}}function nFe(t){let e=rFe(t);e&&(XK=e.revision,cM=e,!(uM||HK)&&(HK=!0,window.setTimeout(()=>{HK=!1,UNt()},0)))}window.__PASEO_MERMAID_RUNTIME_RECEIVE__=nFe;window.addEventListener(\"message\",t=>{t.source===window.parent&&nFe(t.data)});YK({type:\"bridgeReady\"});})();\n/*! Bundled license information:\n\ndompurify/dist/purify.es.mjs:\n (*! @license DOMPurify 3.4.12 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.12/LICENSE *)\n\nmermaid/dist/chunks/mermaid.core/chunk-ZIRB5QZD.mjs:\n (*! Bundled license information:\n \n js-yaml/dist/js-yaml.mjs:\n (*! js-yaml 4.1.1 https://github.com/nodeca/js-yaml @license MIT *)\n *)\n\nlodash-es/lodash.js:\n (**\n * @license\n * Lodash (Custom Build) \n * Build: `lodash modularize exports=\"es\" --repo lodash/lodash#4.18.1 -o ./`\n * Copyright OpenJS Foundation and other contributors \n * Released under MIT license \n * Based on Underscore.js 1.8.3 \n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n *)\n\ncytoscape/dist/cytoscape.esm.mjs:\n (*!\n Embeddable Minimum Strictly-Compliant Promises/A+ 1.1.1 Thenable\n Copyright (c) 2013-2014 Ralf S. Engelschall (http://engelschall.com)\n Licensed under The MIT License (http://opensource.org/licenses/MIT)\n *)\n (*!\n Event object based on jQuery events, MIT license\n \n https://jquery.org/license/\n https://tldrlegal.com/license/mit-license\n https://github.com/jquery/jquery/blob/master/src/event.js\n *)\n (*! Bezier curve function generator. Copyright Gaetan Renaudeau. MIT License: http://en.wikipedia.org/wiki/MIT_License *)\n (*! Runge-Kutta spring physics function generator. Adapted from Framer.js, copyright Koen Bok. MIT License: http://en.wikipedia.org/wiki/MIT_License *)\n\n@mermaid-js/parser/dist/chunks/mermaid-parser.core/chunk-KEIR6QF5.mjs:\n (*! Bundled license information:\n \n lodash-es/lodash.js:\n (**\n * @license\n * Lodash (Custom Build) \n * Build: `lodash modularize exports=\"es\" -o ./`\n * Copyright OpenJS Foundation and other contributors \n * Released under MIT license \n * Based on Underscore.js 1.8.3 \n * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n *)\n *)\n\nmermaid/dist/mermaid.core.mjs:\n (*! Check if previously processed *)\n (*!\n * Wait for document loaded before starting the execution\n *)\n*/\n\n \n"; diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/messages.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/messages.ts new file mode 100644 index 0000000000..99b23f9d6d --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/runtime/messages.ts @@ -0,0 +1,91 @@ +import type { DiagramColorScheme, DiagramDimensions } from "../render-model"; + +export interface MermaidRuntimeRenderMessage { + type: "render"; + revision: number; + source: string; + colorScheme: DiagramColorScheme; + interactive: boolean; +} + +export type MermaidRuntimeMessage = + | { type: "bridgeReady" } + | ({ + type: "rendered"; + revision: number; + source: string; + colorScheme: DiagramColorScheme; + } & DiagramDimensions) + | { type: "renderError"; revision: number }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isColorScheme(value: unknown): value is DiagramColorScheme { + return value === "light" || value === "dark"; +} + +export function parseMermaidRuntimeRenderMessage( + value: unknown, +): MermaidRuntimeRenderMessage | null { + if ( + !isRecord(value) || + value.type !== "render" || + typeof value.revision !== "number" || + !Number.isInteger(value.revision) || + typeof value.source !== "string" || + !isColorScheme(value.colorScheme) || + typeof value.interactive !== "boolean" + ) { + return null; + } + return { + type: "render", + revision: value.revision, + source: value.source, + colorScheme: value.colorScheme, + interactive: value.interactive, + }; +} + +export function parseMermaidRuntimeMessage(value: unknown): MermaidRuntimeMessage | null { + if (!isRecord(value) || typeof value.type !== "string") { + return null; + } + if (value.type === "bridgeReady") { + return { type: "bridgeReady" }; + } + if ( + value.type === "renderError" && + typeof value.revision === "number" && + Number.isInteger(value.revision) + ) { + return { type: "renderError", revision: value.revision }; + } + if ( + value.type === "rendered" && + typeof value.revision === "number" && + Number.isInteger(value.revision) && + typeof value.source === "string" && + isColorScheme(value.colorScheme) && + typeof value.height === "number" && + Number.isFinite(value.height) && + typeof value.width === "number" && + Number.isFinite(value.width) + ) { + return { + type: "rendered", + revision: value.revision, + source: value.source, + colorScheme: value.colorScheme, + height: value.height, + width: value.width, + }; + } + return null; +} + +export function serializeMermaidRuntimeRenderMessage(message: MermaidRuntimeRenderMessage): string { + return JSON.stringify(message).replace(/<\/script/gi, "<\\/script"); +} diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.test.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.test.ts new file mode 100644 index 0000000000..f2e93e2437 --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import type { MermaidRenderRequest } from "../render-model"; +import { MermaidRuntimeRequestDriver } from "./request-driver"; + +function request(revision: number, source: string): MermaidRenderRequest { + return { revision, source, colorScheme: "dark" }; +} + +describe("MermaidRuntimeRequestDriver", () => { + it("tries queued streaming prefixes in order until the runtime renders one", () => { + const driver = new MermaidRuntimeRequestDriver(); + const first = request(1, "flow"); + const valid = request(2, "flowchart TD\nA --> B\n"); + const transient = request(3, "flowchart TD\nA --> B\nB --"); + + expect(driver.update(first)).toBeNull(); + expect(driver.update(valid)).toBeNull(); + expect(driver.update(transient)).toBeNull(); + expect(driver.ready()).toBe(valid); + expect(driver.settled(valid.revision, true)).toBe(transient); + }); + + it("keeps completed lines but collapses incomplete token revisions before readiness", () => { + const driver = new MermaidRuntimeRequestDriver(); + const partial = request(1, "flowchart TD\nA --"); + const completed = request(2, "flowchart TD\nA --> B\n"); + const nextPartial = request(3, "flowchart TD\nA --> B\nB --"); + const latestPartial = request(4, "flowchart TD\nA --> B\nB -->"); + + driver.update(partial); + driver.update(completed); + driver.update(nextPartial); + driver.update(latestPartial); + + expect(driver.ready()).toBe(completed); + expect(driver.settled(completed.revision, true)).toBe(latestPartial); + }); + + it("collapses later updates to the newest request after the first render", () => { + const driver = new MermaidRuntimeRequestDriver(); + const first = request(1, "flowchart TD\nA --> B"); + const second = request(2, "flowchart TD\nA --> B\nB --> C"); + const latest = request(3, "flowchart TD\nA --> B\nB --> D"); + + driver.update(first); + expect(driver.ready()).toBe(first); + expect(driver.settled(first.revision, true)).toBeNull(); + expect(driver.update(second)).toBe(second); + expect(driver.update(latest)).toBeNull(); + expect(driver.settled(second.revision, false)).toBe(latest); + }); +}); diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.ts new file mode 100644 index 0000000000..8e30587017 --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/runtime/request-driver.ts @@ -0,0 +1,63 @@ +import type { MermaidRenderRequest } from "../render-model"; + +function isCompletedLineCandidate(source: string): boolean { + return source.endsWith("\n") && source.trim().includes("\n"); +} + +export class MermaidRuntimeRequestDriver { + private readyState = false; + private renderedOnce = false; + private inFlight: MermaidRenderRequest | null = null; + private queued: MermaidRenderRequest[] = []; + + update(request: MermaidRenderRequest | null): MermaidRenderRequest | null { + if (!request || this.hasRevision(request.revision)) { + return null; + } + if (this.renderedOnce) { + this.queued = [request]; + } else { + const lastIndex = this.queued.length - 1; + const last = this.queued[lastIndex]; + if (last && !isCompletedLineCandidate(last.source)) { + this.queued[lastIndex] = request; + } else { + this.queued.push(request); + } + } + return this.takeNext(); + } + + ready(): MermaidRenderRequest | null { + this.readyState = true; + return this.takeNext(); + } + + settled(revision: number, rendered: boolean): MermaidRenderRequest | null { + if (this.inFlight?.revision !== revision) { + return null; + } + this.inFlight = null; + if (rendered) { + this.renderedOnce = true; + const latest = this.queued.at(-1); + this.queued = latest ? [latest] : []; + } + return this.takeNext(); + } + + private hasRevision(revision: number): boolean { + return ( + this.inFlight?.revision === revision || + this.queued.some((request) => request.revision === revision) + ); + } + + private takeNext(): MermaidRenderRequest | null { + if (!this.readyState || this.inFlight || this.queued.length === 0) { + return null; + } + this.inFlight = this.queued.shift() ?? null; + return this.inFlight; + } +} diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/runtime-html.test.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/runtime-html.test.ts new file mode 100644 index 0000000000..a11ffd5092 --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/runtime/runtime-html.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { mermaidRuntimeHtml } from "./html.gen"; + +describe("Mermaid runtime document", () => { + it("ships a closed network policy", () => { + expect(mermaidRuntimeHtml).toContain("default-src 'none'"); + expect(mermaidRuntimeHtml).toContain("connect-src 'none'"); + expect(mermaidRuntimeHtml).toContain("img-src data: blob:"); + expect(mermaidRuntimeHtml).toContain("font-src 'none'"); + expect(mermaidRuntimeHtml).toContain("frame-src 'none'"); + }); +}); diff --git a/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts b/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts new file mode 100644 index 0000000000..1dda832eb3 --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/runtime/runtime.browser.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mermaidRuntimeHtml } from "./html.gen"; +import { parseMermaidRuntimeMessage, type MermaidRuntimeMessage } from "./messages"; + +const mountedFrames: HTMLIFrameElement[] = []; + +function waitForRuntimeMessage( + frame: HTMLIFrameElement, + predicate: (message: MermaidRuntimeMessage) => boolean, +): Promise { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + window.removeEventListener("message", receive); + reject(new Error("Timed out waiting for Mermaid runtime")); + }, 10_000); + function receive(event: MessageEvent): void { + if (event.source !== frame.contentWindow) { + return; + } + const message = parseMermaidRuntimeMessage(event.data); + if (!message || !predicate(message)) { + return; + } + window.clearTimeout(timeout); + window.removeEventListener("message", receive); + resolve(message); + } + window.addEventListener("message", receive); + }); +} + +async function mountRuntime(): Promise { + const frame = document.createElement("iframe"); + frame.sandbox.add("allow-scripts"); + const ready = waitForRuntimeMessage(frame, (message) => message.type === "bridgeReady"); + frame.srcdoc = mermaidRuntimeHtml; + document.body.append(frame); + mountedFrames.push(frame); + await ready; + return frame; +} + +function render( + frame: HTMLIFrameElement, + input: { revision: number; source: string }, +): Promise { + const response = waitForRuntimeMessage( + frame, + (message) => + message.type !== "bridgeReady" && + "revision" in message && + message.revision === input.revision, + ); + frame.contentWindow?.postMessage( + { + type: "render", + revision: input.revision, + source: input.source, + colorScheme: "dark", + interactive: false, + }, + "*", + ); + return response; +} + +afterEach(() => { + for (const frame of mountedFrames.splice(0)) { + frame.remove(); + } +}); + +describe("Mermaid sandbox runtime", () => { + it("renders successive valid streaming prefixes and reports an invalid prefix", async () => { + const frame = await mountRuntime(); + const firstSource = "flowchart TD\nA --> B"; + const secondSource = `${firstSource}\nB --> C`; + + const first = await render(frame, { revision: 1, source: firstSource }); + const invalid = await render(frame, { revision: 2, source: "not a mermaid diagram" }); + const second = await render(frame, { revision: 3, source: secondSource }); + + expect(first).toMatchObject({ type: "rendered", revision: 1, source: firstSource }); + expect(invalid).toEqual({ type: "renderError", revision: 2 }); + expect(second).toMatchObject({ type: "rendered", revision: 3, source: secondSource }); + }); + + it("coalesces queued input and never reports an obsolete result", async () => { + const frame = await mountRuntime(); + const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`; + const currentSource = "flowchart TD\nCurrent --> Result"; + const obsoleteResponses: MermaidRuntimeMessage[] = []; + function collect(event: MessageEvent): void { + if (event.source !== frame.contentWindow) { + return; + } + const message = parseMermaidRuntimeMessage(event.data); + if (message && message.type !== "bridgeReady" && message.revision === 10) { + obsoleteResponses.push(message); + } + } + window.addEventListener("message", collect); + frame.contentWindow?.postMessage( + { + type: "render", + revision: 10, + source: obsoleteSource, + colorScheme: "dark", + interactive: false, + }, + "*", + ); + const current = await render(frame, { revision: 11, source: currentSource }); + window.removeEventListener("message", collect); + + expect(current).toMatchObject({ type: "rendered", revision: 11, source: currentSource }); + expect(obsoleteResponses).toEqual([]); + }); +}); diff --git a/packages/app/src/components/markdown/fence/mermaid/source-policy.test.ts b/packages/app/src/components/markdown/fence/mermaid/source-policy.test.ts new file mode 100644 index 0000000000..48b844e1e3 --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/source-policy.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { containsUnsafeMermaidSource } from "./source-policy"; + +describe("containsUnsafeMermaidSource", () => { + it("rejects resource-bearing constructs", () => { + expect(containsUnsafeMermaidSource('flowchart TD\n A@{ img: "https://x/y.png" }')).toBe(true); + expect(containsUnsafeMermaidSource("flowchart TD\n A@{ icon: 'pack:name' }")).toBe(true); + expect( + containsUnsafeMermaidSource('%%{init: {"themeCSS": "a { color: red }"}}%%\ngraph TD'), + ).toBe(true); + expect(containsUnsafeMermaidSource("graph TD\n A[url(http://x)]")).toBe(true); + expect(containsUnsafeMermaidSource("graph TD\n A[@import 'x']")).toBe(true); + expect(containsUnsafeMermaidSource('graph TD\n A[""]')).toBe(true); + expect(containsUnsafeMermaidSource('graph TD\n A["styled"]')).toBe(true); + expect(containsUnsafeMermaidSource('graph TD\n A["<img src=x>"]')).toBe(true); + expect(containsUnsafeMermaidSource('graph TD\n A[""]')).toBe(true); + }); + + it("rejects all shape-data constructs, including yaml key evasions", () => { + expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "img": "https://x/y.png" }')).toBe( + true, + ); + expect(containsUnsafeMermaidSource("flowchart TD\n A@{ 'img': 'https://x/y.png' }")).toBe( + true, + ); + expect( + containsUnsafeMermaidSource('flowchart TD\n A@{ "\\u0069mg": "https://x/y.png" }'), + ).toBe(true); + expect( + containsUnsafeMermaidSource('flowchart TD\n A@{ "\\u{69}mg": "https://x/y.png" }'), + ).toBe(true); + expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "\\x69mg": "https://x/y.png" }')).toBe( + true, + ); + expect( + containsUnsafeMermaidSource('flowchart TD\n A@{ "\\U00000069mg": "https://attacker/x" }'), + ).toBe(true); + expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "icon": "pack:name" }')).toBe(true); + expect( + containsUnsafeMermaidSource( + 'flowchart TD\n A@{ ? img # comment\n : "https://attacker/x" }', + ), + ).toBe(true); + expect( + containsUnsafeMermaidSource( + 'flowchart TD\n A@{ dummy: &k img\n ? *k # comment\n : "https://attacker/x" }', + ), + ).toBe(true); + }); + + it("fails closed for malformed or out-of-range escapes without throwing", () => { + expect(() => + containsUnsafeMermaidSource('graph TD\n A["\\u{110000} disguised"]'), + ).not.toThrow(); + expect(containsUnsafeMermaidSource('graph TD\n A["\\u{110000} disguised"]')).toBe(true); + expect(() => + containsUnsafeMermaidSource('graph TD\n A["\\u{FFFFFF} disguised"]'), + ).not.toThrow(); + expect(containsUnsafeMermaidSource('graph TD\n A["\\u{FFFFFF} disguised"]')).toBe(true); + }); + + it("allows ordinary diagrams including formatting-only labels", () => { + expect(containsUnsafeMermaidSource("flowchart TD\n A[Start] --> B{Choice}")).toBe(false); + expect(containsUnsafeMermaidSource('graph TD\n A["line one
line two"]')).toBe(false); + expect(containsUnsafeMermaidSource('graph TD\n A["line one
line two"]')).toBe(false); + expect(containsUnsafeMermaidSource('graph TD\n A["formatted"]')).toBe(false); + expect(containsUnsafeMermaidSource("sequenceDiagram\n Alice->>Bob: a < b and x > y")).toBe( + false, + ); + expect(containsUnsafeMermaidSource("flowchart TD\n A@{ shape: rect }")).toBe(true); + }); +}); diff --git a/packages/app/src/components/markdown/fence/mermaid/source-policy.ts b/packages/app/src/components/markdown/fence/mermaid/source-policy.ts new file mode 100644 index 0000000000..df6ad0312b --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/source-policy.ts @@ -0,0 +1,52 @@ +// Mermaid can fetch external resources while *rendering* — image shapes +// (`A@{ img: "url" }`) construct an Image and await decode before any output +// sanitization runs, and CSS can pull url()/@import — so a prompt-injected +// diagram could exfiltrate data in a request URL. securityLevel "strict" does +// not prevent this (mermaid-js/mermaid#7645). Mermaid 11.16 parses the shape +// metadata object with yaml.JSON_SCHEMA semantics, including aliases and +// explicit keys; matching those keys safely would require a real parser. We +// therefore reject any `@{ ... }` shape-data construct up front and fall back +// to the source code block. Formatting-only `
` and `` tags are common +// in generated labels and carry no resource-bearing attributes; all other +// tags (and entity-encoded text that could smuggle one) are rejected. +const UNSAFE_MERMAID_SOURCE = + /@\s*\{|url\s*\(|@import\b|themeCSS|&#|<(?!\/?(?:br|i)\s*\/?>)[a-z!/]/i; + +// Mermaid labels can contain escaped text. Decode the escape forms we care +// about before running the denylist so disguised HTML still gets caught. +// Invalid escape sequences fail closed and fall back to the source block. +function normalizeMermaidSource(code: string): string | null { + try { + return code + .replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (_, hex: string) => + decodeCodePointEscape(Number.parseInt(hex, 16)), + ) + .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)), + ) + .replace(/\\x([0-9a-fA-F]{2})/g, (_, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)), + ) + .replace(/["'`\\]/g, ""); + } catch { + return null; + } +} + +function decodeCodePointEscape(value: number): string { + if (!Number.isInteger(value) || value < 0 || value > 0x10ffff) { + throw new RangeError("Invalid Unicode code point"); + } + return String.fromCodePoint(value); +} + +export function containsUnsafeMermaidSource(code: string): boolean { + if (UNSAFE_MERMAID_SOURCE.test(code)) { + return true; + } + const normalized = normalizeMermaidSource(code); + if (normalized === null) { + return true; + } + return UNSAFE_MERMAID_SOURCE.test(normalized); +} diff --git a/packages/app/src/components/markdown/fence/mermaid/use-render-model.ts b/packages/app/src/components/markdown/fence/mermaid/use-render-model.ts new file mode 100644 index 0000000000..cb35549b10 --- /dev/null +++ b/packages/app/src/components/markdown/fence/mermaid/use-render-model.ts @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useMemo, useReducer } from "react"; +import { + createMermaidRenderModel, + getMermaidRenderRequest, + reduceMermaidRenderModel, + type DiagramColorScheme, + type DiagramDimensions, + type RenderedDiagram, +} from "./render-model"; +import { containsUnsafeMermaidSource } from "./source-policy"; +import type { MarkdownPhase } from "../types"; + +const renderCache = new Map(); +const RENDER_CACHE_LIMIT = 50; + +function cacheKey(source: string, colorScheme: DiagramColorScheme): string { + return `${colorScheme}\u0000${source}`; +} + +function readCachedRender(source: string, colorScheme: DiagramColorScheme): RenderedDiagram | null { + return renderCache.get(cacheKey(source, colorScheme)) ?? null; +} + +function cacheRender(rendered: RenderedDiagram): void { + if (renderCache.size >= RENDER_CACHE_LIMIT) { + const oldest = renderCache.keys().next().value; + if (oldest !== undefined) { + renderCache.delete(oldest); + } + } + renderCache.set(cacheKey(rendered.source, rendered.colorScheme), rendered); +} + +export function useMermaidRenderModel({ + source, + phase, + colorScheme, +}: { + source: string; + phase: MarkdownPhase; + colorScheme: DiagramColorScheme; +}) { + const renderInput = useMemo( + () => ({ + source, + phase, + colorScheme, + rejected: containsUnsafeMermaidSource(source), + cached: readCachedRender(source, colorScheme), + }), + [colorScheme, phase, source], + ); + const [state, dispatch] = useReducer( + reduceMermaidRenderModel, + renderInput, + createMermaidRenderModel, + ); + + useEffect(() => { + dispatch({ type: "inputChanged", input: renderInput }); + }, [renderInput]); + + const rendered = useCallback( + (response: { + revision: number; + source: string; + colorScheme: DiagramColorScheme; + dimensions: DiagramDimensions; + }) => { + const cached: RenderedDiagram = { + source: response.source, + colorScheme: response.colorScheme, + ...response.dimensions, + }; + cacheRender(cached); + dispatch({ + type: "rendered", + revision: response.revision, + source: response.source, + colorScheme: response.colorScheme, + dimensions: response.dimensions, + }); + }, + [], + ); + const renderFailed = useCallback((revision: number) => { + dispatch({ type: "renderFailed", revision }); + }, []); + + return { + state, + request: getMermaidRenderRequest(state), + rendered, + renderFailed, + }; +} diff --git a/packages/app/src/components/markdown/fence/types.ts b/packages/app/src/components/markdown/fence/types.ts new file mode 100644 index 0000000000..b499bc893b --- /dev/null +++ b/packages/app/src/components/markdown/fence/types.ts @@ -0,0 +1,10 @@ +import type { TextStyle } from "react-native"; + +export type MarkdownPhase = "streaming" | "complete"; + +export interface MarkdownFenceRendererProps { + code: string; + phase: MarkdownPhase; + inheritedStyles: TextStyle; + textStyle: TextStyle; +} diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index 292ea6f76e..ddb82ee337 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -23,6 +23,7 @@ import Markdown, { } from "react-native-markdown-display"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; +import { MarkdownFenceBlock } from "@/components/markdown/fence"; import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text"; import { MarkdownTableCellText } from "@/components/markdown-text-selection"; import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list"; @@ -606,10 +607,11 @@ export function createSharedMarkdownRules(): RenderRules { styles: MarkdownStyles, inheritedStyles: TextStyle = {}, ) => ( - diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index e2e54fa778..1b19994c7d 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -71,6 +71,8 @@ import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown- import { markdownNodeContainsType } from "@/utils/markdown-ast"; import { useStableEvent } from "@/hooks/use-stable-event"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; +import { MarkdownFenceBlock } from "@/components/markdown/fence"; +import type { MarkdownPhase } from "@/components/markdown/fence/types"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; @@ -739,6 +741,7 @@ interface AssistantMessageProps { serverId?: string; client?: DaemonClient | null; spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth"; + phase: MarkdownPhase; } export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ @@ -1450,6 +1453,7 @@ export const AssistantMessage = memo(function AssistantMessage({ serverId, client, spacing = "default", + phase, }: AssistantMessageProps) { const markdownParser = useMemo(createAssistantMarkdownParser, []); @@ -1695,10 +1699,11 @@ export const AssistantMessage = memo(function AssistantMessage({ styles: MarkdownStyles, inheritedStyles: TextStyle = {}, ) => ( - @@ -1893,11 +1898,11 @@ export const AssistantMessage = memo(function AssistantMessage({ ); }, }; - }, [client, fileLinkActions, markdownParser, occurrenceKey, serverId, workspaceRoot]); + }, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); const keyedBlocks = useMemo( - () => blocks.map((block, index) => ({ key: `${index}:${block.slice(0, 32)}`, block })), + () => blocks.map((block, index) => ({ key: `block:${index}`, block })), [blocks], ); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c38381b728..810c8d3a10 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -272,6 +272,14 @@ export const ar: TranslationResources = { }, }, message: { + diagram: { + diagram: "مخطط", + zoomIn: "تكبير", + zoomOut: "تصغير", + resetZoom: "إعادة تعيين العرض", + viewSource: "عرض المصدر", + viewDiagram: "عرض المخطط", + }, actions: { copyCode: "نسخ الرمز", copyTurn: "نسخ بدوره", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index c6954b9757..9caf7292b1 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -271,6 +271,14 @@ export const en = { }, }, message: { + diagram: { + diagram: "Diagram", + zoomIn: "Zoom in", + zoomOut: "Zoom out", + resetZoom: "Reset view", + viewSource: "View source", + viewDiagram: "View diagram", + }, actions: { copyCode: "Copy code", copyTurn: "Copy turn", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7b0fa38752..c5d4a2d27d 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -275,6 +275,14 @@ export const es: TranslationResources = { }, }, message: { + diagram: { + diagram: "Diagrama", + zoomIn: "Acercar", + zoomOut: "Alejar", + resetZoom: "Restablecer vista", + viewSource: "Ver fuente", + viewDiagram: "Ver diagrama", + }, actions: { copyCode: "Copiar código", copyTurn: "Copiar turno", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 70d7e2cc1a..bc22d3afeb 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -276,6 +276,14 @@ export const fr: TranslationResources = { }, }, message: { + diagram: { + diagram: "Diagramme", + zoomIn: "Zoomer", + zoomOut: "Dézoomer", + resetZoom: "Réinitialiser la vue", + viewSource: "Afficher la source", + viewDiagram: "Afficher le diagramme", + }, actions: { copyCode: "Copier le code", copyTurn: "Copier le tour", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 85b95d85e6..07308bb8f1 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -275,6 +275,14 @@ export const ja: TranslationResources = { }, }, message: { + diagram: { + diagram: "図", + zoomIn: "拡大", + zoomOut: "縮小", + resetZoom: "表示をリセット", + viewSource: "ソースを表示", + viewDiagram: "図を表示", + }, actions: { copyCode: "コードをコピー", copyTurn: "ターンをコピー", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index cd89699554..2762f0174d 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -273,6 +273,14 @@ export const ko: TranslationResources = { }, }, message: { + diagram: { + diagram: "다이어그램", + zoomIn: "확대", + zoomOut: "축소", + resetZoom: "보기 초기화", + viewSource: "소스 보기", + viewDiagram: "다이어그램 보기", + }, actions: { copyCode: "코드 복사", copyTurn: "턴 복사", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index aa9fb7a9aa..4aeba0209f 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -275,6 +275,14 @@ export const ptBR: TranslationResources = { }, }, message: { + diagram: { + diagram: "Diagrama", + zoomIn: "Aproximar", + zoomOut: "Afastar", + resetZoom: "Redefinir visualização", + viewSource: "Ver fonte", + viewDiagram: "Ver diagrama", + }, actions: { copyCode: "Copiar código", copyTurn: "Copiar turno", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 753ddafc27..37ab6b1cf1 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -274,6 +274,14 @@ export const ru: TranslationResources = { }, }, message: { + diagram: { + diagram: "Диаграмма", + zoomIn: "Приблизить", + zoomOut: "Отдалить", + resetZoom: "Сбросить вид", + viewSource: "Показать код", + viewDiagram: "Показать диаграмму", + }, actions: { copyCode: "Скопировать код", copyTurn: "Копировать ход", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 29167ff8ef..1d2bed9259 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -272,6 +272,14 @@ export const zhCN: TranslationResources = { }, }, message: { + diagram: { + diagram: "图表", + zoomIn: "放大", + zoomOut: "缩小", + resetZoom: "重置视图", + viewSource: "查看源码", + viewDiagram: "查看图表", + }, actions: { copyCode: "复制代码", copyTurn: "复制回合", diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 99ce0596c3..3e2374282b 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -68,6 +68,47 @@ describe("MockLoadTestAgentClient", () => { await session.interrupt(); }); + test("streams a configured assistant response through the normal timeline", async () => { + vi.useFakeTimers(); + const response = [ + "```mermaid", + "flowchart LR", + " Start --> Middle", + ' Middle --> End["Done"]', + "```", + ].join("\n"); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + featureValues: { + mockStreamingAssistantResponse: response, + mockStreamingAssistantIntervalMs: 20, + }, + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const resultPromise = session.run("Render a diagram while streaming."); + await vi.runAllTimersAsync(); + + await expect(resultPromise).resolves.toMatchObject({ finalText: response, canceled: false }); + const streamedText = events + .flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" + ? [event.item.text] + : [], + ) + .join(""); + expect(streamedText).toBe(response); + expect( + events.filter((event) => event.type === "timeline" && event.item.type === "assistant_message") + .length, + ).toBeGreaterThan(5); + expect(events.at(-1)).toMatchObject({ type: "turn_completed", provider: "mock" }); + }); + test("can withhold the provider user-message echo until an immediate interrupt", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 147045ea0d..78a13ea188 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -644,6 +644,8 @@ export class MockLoadTestAgentSession implements AgentSession { private modeId: string | null; private modelId: string | null; private readonly assistantResponse: string | null; + private readonly streamingAssistantResponse: string | null; + private readonly streamingAssistantIntervalMs: number; private readonly rewindError: string | null; private remainingPromptRejections: number; @@ -656,6 +658,18 @@ export class MockLoadTestAgentSession implements AgentSession { typeof options.config.featureValues?.mockAssistantResponse === "string" ? options.config.featureValues.mockAssistantResponse : null; + this.streamingAssistantResponse = + typeof options.config.featureValues?.mockStreamingAssistantResponse === "string" + ? options.config.featureValues.mockStreamingAssistantResponse + : null; + const requestedStreamingInterval = + options.config.featureValues?.mockStreamingAssistantIntervalMs; + this.streamingAssistantIntervalMs = + typeof requestedStreamingInterval === "number" && + Number.isFinite(requestedStreamingInterval) && + requestedStreamingInterval >= 1 + ? Math.min(requestedStreamingInterval, 1_000) + : MOCK_LOAD_TEST_INTERVAL_MS; this.rewindError = typeof options.config.featureValues?.mockRewindError === "string" ? options.config.featureValues.mockRewindError @@ -721,6 +735,8 @@ export class MockLoadTestAgentSession implements AgentSession { const scheduleTurn = () => { if (shouldEmitTurnFailure(prompt)) { this.scheduleFailedTurn(turn); + } else if (this.streamingAssistantResponse !== null) { + this.scheduleStreamingAssistantTurn(turn, this.streamingAssistantResponse); } else if (this.assistantResponse !== null) { this.scheduleSettledAssistantTurn(turn, this.assistantResponse); } else if (structuredBranchName) { @@ -1040,6 +1056,32 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } + private scheduleStreamingAssistantTurn(turn: ActiveTurn, finalText: string): void { + const tokens = tokenize(finalText); + const emitNext = () => { + if (this.activeTurn !== turn) { + return; + } + this.clearTurnTimer(turn); + this.emitTurnStarted(turn); + const token = tokens.shift(); + if (token === undefined) { + this.finishTurnWithText(turn, finalText); + return; + } + turn.emittedTokens += 1; + this.emitTimeline(turn.turnId, { + type: "assistant_message", + text: token, + messageId: turn.assistantMessageId, + }); + turn.timer = setTimeout(emitNext, this.streamingAssistantIntervalMs); + turn.timer.unref?.(); + }; + turn.timer = setTimeout(emitNext, 0); + turn.timer.unref?.(); + } + private emitSettledAssistantTurn(turn: ActiveTurn, finalText: string): void { if (this.activeTurn !== turn) { return; From b599d38a772f621e0001abfb90a769de11c8cd8b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 12:18:48 +0200 Subject: [PATCH 11/73] Make large diffs open quickly and scroll as one surface (#3212) * perf(app): virtualize large diff rendering Render bounded diff row groups through one vertical list and one horizontal surface. Bound retained list ranges when expansion grows the data set so large diffs paint promptly without sacrificing scrolling behavior. * refactor(app): normalize working tree diff options * fix(app): keep wide diff text reachable --- packages/app/e2e/browser/changes-pane.spec.ts | 161 +++- .../app/e2e/browser/commit-diff-panel.spec.ts | 2 +- packages/app/src/components/diff-scroll.tsx | 59 +- .../app/src/components/diff-scroll.web.tsx | 46 +- packages/app/src/git/diff-flat-items.test.ts | 134 --- packages/app/src/git/diff-flat-items.ts | 121 --- .../app/src/git/diff-horizontal-surface.tsx | 80 ++ packages/app/src/git/diff-pane.tsx | 906 ++++++++++-------- packages/app/src/git/diff-rows/model.test.ts | 286 ++++++ packages/app/src/git/diff-rows/model.ts | 422 ++++++++ .../diff-rows/virtualized-list-range.test.ts | 24 + packages/app/src/review/surface.tsx | 34 +- .../src/types/virtualized-list-internals.d.ts | 8 + packages/app/src/utils/diff-rendering.test.ts | 20 + packages/app/src/utils/diff-rendering.ts | 22 + ...eact-native+virtualized-lists+0.81.5.patch | 105 ++ scripts/postinstall-patches.mjs | 4 + 17 files changed, 1717 insertions(+), 717 deletions(-) delete mode 100644 packages/app/src/git/diff-flat-items.test.ts delete mode 100644 packages/app/src/git/diff-flat-items.ts create mode 100644 packages/app/src/git/diff-horizontal-surface.tsx create mode 100644 packages/app/src/git/diff-rows/model.test.ts create mode 100644 packages/app/src/git/diff-rows/model.ts create mode 100644 packages/app/src/git/diff-rows/virtualized-list-range.test.ts create mode 100644 packages/app/src/types/virtualized-list-internals.d.ts create mode 100644 patches/@react-native+virtualized-lists+0.81.5.patch diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 98f69808b6..c3b68b1459 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import { readFile, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; -import { type Page } from "@playwright/test"; +import { type Locator, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes"; import { test, expect } from "../support/fixtures"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; @@ -63,6 +63,10 @@ async function failNextDiscardRequest(page: Page): Promise { const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences"; +function diffGroups(scope: Page | Locator, fileIndex = 0): Locator { + return scope.getByTestId(new RegExp(`^diff-file-${fileIndex}-group-\\d+$`)); +} + const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react"; interface UseMountedTabSetInput { @@ -465,12 +469,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page }) await expect(visiblePanel).toBeVisible(); await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible(); await expect(visiblePanel).toContainText("zz-deleted.ts"); - await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); + await expect(diffGroups(visiblePanel).first()).toBeVisible(); await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0); await visiblePanel.getByTestId("diff-file-0-toggle").click(); - await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0); + await expect(diffGroups(visiblePanel)).toHaveCount(0); await visiblePanel.getByTestId("diff-file-0-toggle").click(); - await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); + await expect(diffGroups(visiblePanel).first()).toBeVisible(); const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout"); await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff"); await workingDiffLayoutToggle.click(); @@ -486,14 +490,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page }) ); await page.keyboard.press("Escape"); await visiblePanel.getByTestId("working-diff-toggle-expand-all").click(); - await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0); + await expect(visiblePanel.getByTestId(/^diff-file-\d+-group-\d+$/)).toHaveCount(0); await visiblePanel.getByTestId("working-diff-toggle-expand-all").click(); - await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); + await expect(diffGroups(visiblePanel).first()).toBeVisible(); await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click(); - await expect( - page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), - ).toHaveCount(0); + await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0); await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); @@ -516,13 +518,9 @@ test("Changes switches between inline and full-tab navigation", async ({ page }) await changesTabToggle.click(); await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0); - await expect( - page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), - ).toBeVisible(); + await expect(diffGroups(page.getByTestId("explorer-content-area")).first()).toBeVisible(); await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click(); - await expect( - page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), - ).toHaveCount(0); + await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0); }); test("changes diff switches between flat and tree file lists", async ({ page }) => { @@ -585,7 +583,7 @@ test("changes diff switches between flat and tree file lists", async ({ page }) await page.getByRole("button", { name: "Collapse all" }).click(); await expect(page.getByTestId("diff-file-0")).toHaveCount(0); await page.getByRole("button", { name: "Expand all" }).click(); - await expect(page.getByTestId("diff-file-0-body")).toBeVisible(); + await expect(diffGroups(page).first()).toBeVisible(); await page.getByTestId("diff-folder-src-toggle").click(); await expect(page.getByTestId("diff-file-0")).toHaveCount(0); @@ -609,6 +607,90 @@ test("changes diff applies code size changes to gutter and code typography", asy await expectVisibleDiffRowsShareTypography(page); }); +const LARGE_DIFF_WIDE_LINE_INDEX = 900; + +async function createWorkspaceWithLargeUnwrappedDiff(): Promise { + const workspace = await createWorkspaceWithMountedTabDiff(); + const addedLines = Array.from({ length: 1_200 }, (_, index) => { + if (index === 0) return `\t\t\t\tconst tabbed_${index} = "tabbed";`; + if (index === LARGE_DIFF_WIDE_LINE_INDEX) { + return `const wide_${index} = "${"界".repeat(80)}";`; + } + return `const generated_${index} = ${index};`; + }).join("\n"); + await writeFile( + path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), + `${AFTER}${addedLines}\n`, + ); + return workspace; +} + +async function scrollToWideDiffLine(page: Page): Promise { + await page.getByTestId("git-diff-scroll").evaluate((element, lineIndex) => { + element.scrollTop = lineIndex * 18; + element.dispatchEvent(new Event("scroll", { bubbles: false })); + }, LARGE_DIFF_WIDE_LINE_INDEX); + const wideLine = page.getByText(new RegExp(`const wide_${LARGE_DIFF_WIDE_LINE_INDEX}`)); + await expect(wideLine).toHaveCount(1); + return wideLine; +} + +test("unwrapped virtualized rows use one horizontal file surface", async ({ page }) => { + const workspace = await createWorkspaceWithLargeUnwrappedDiff(); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + const horizontalSurface = page.getByTestId("diff-horizontal-scroll"); + await expect(horizontalSurface).toHaveCount(1); + await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0); + const codeBefore = await page.getByTestId("diff-code-row-0").boundingBox(); + const gutterBefore = await page.getByTestId("diff-gutter-row-0").boundingBox(); + expect(codeBefore).not.toBeNull(); + expect(gutterBefore).not.toBeNull(); + await horizontalSurface.evaluate((element, left) => { + element.scrollLeft = left; + element.dispatchEvent(new Event("scroll", { bubbles: false })); + }, 120); + await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120); + await expect + .poll(async () => (await page.getByTestId("diff-code-row-0").boundingBox())?.x ?? 0) + .toBeLessThanOrEqual(codeBefore!.x - 119); + await expect + .poll(async () => (await page.getByTestId("diff-gutter-row-0").boundingBox())?.x ?? 0) + .toBeCloseTo(gutterBefore!.x, 0); + const initiallyMountedGroups = await readMountedDiffGroupIds(page); + await page.getByTestId("git-diff-scroll").evaluate((element) => { + element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight); + element.dispatchEvent(new Event("scroll", { bubbles: false })); + }); + await expect + .poll(async () => { + const mountedGroups = await readMountedDiffGroupIds(page); + return mountedGroups.some((group) => !initiallyMountedGroups.includes(group)); + }) + .toBe(true); + await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120); + await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0); + + const wideLine = await scrollToWideDiffLine(page); + await horizontalSurface.evaluate((element) => { + element.scrollLeft = element.scrollWidth; + element.dispatchEvent(new Event("scroll", { bubbles: false })); + }); + await expect + .poll(async () => { + const surfaceBounds = await horizontalSurface.boundingBox(); + const wideTextRight = await wideLine.evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + return range.getBoundingClientRect().right; + }); + if (!surfaceBounds) return false; + return wideTextRight <= surfaceBounds.x + surfaceBounds.width + 1; + }) + .toBe(true); +}); + async function useCodeFont(page: Page, codeFontSize: number): Promise { await page.addInitScript( ({ settingsKey, fontSize }) => { @@ -634,6 +716,12 @@ async function useCodeFont(page: Page, codeFontSize: number): Promise { ); } +async function readMountedDiffGroupIds(page: Page): Promise<(string | null)[]> { + return diffGroups(page).evaluateAll((groups) => + groups.map((group) => group.getAttribute("data-testid")), + ); +} + async function useUnwrappedDiffLines(page: Page): Promise { await page.addInitScript( ({ preferencesKey }) => { @@ -682,21 +770,22 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{ codeLineHeight: number; }[]; }> { - return page.locator("body").evaluate(({ ownerDocument }) => { - const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]'); - if (!root) { + return diffGroups(page).evaluateAll((groups) => { + if (groups.length === 0) { throw new Error("Expanded diff body is not mounted"); } const readRows = (prefix: string, textPrefix: string) => - Array.from(root.querySelectorAll(`[data-testid^="${prefix}"]`)).map((row) => { - const testId = row.getAttribute("data-testid") ?? ""; - const index = Number(testId.slice(prefix.length)); - const rect = row.getBoundingClientRect(); - const text = root.querySelector(`[data-testid="${textPrefix}${index}"]`); - const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0; - return { index, top: rect.top, height: rect.height, lineHeight }; - }); + groups.flatMap((group) => + Array.from(group.querySelectorAll(`[data-testid^="${prefix}"]`)).map((row) => { + const testId = row.getAttribute("data-testid") ?? ""; + const index = Number(testId.slice(prefix.length)); + const rect = row.getBoundingClientRect(); + const text = row.querySelector(`[data-testid="${textPrefix}${index}"]`); + const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0; + return { index, top: rect.top, height: rect.height, lineHeight }; + }), + ); const gutters = new Map( readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]), @@ -800,7 +889,7 @@ async function openChangesInVisibleExplorer(page: Page): Promise { } async function expectExpandedMountedTabDiff(page: Page): Promise { - await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 }); + await expect(diffGroups(page).first()).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({ timeout: 30_000, }); @@ -839,15 +928,9 @@ async function returnToWorkspaceChanges(page: Page): Promise { } async function scrollToLowerUnwrappedDiffRows(page: Page): Promise { - const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => { - const rows = Array.from(root.querySelectorAll('[data-testid^="diff-code-row-"]')); - if (rows.length === 0) { - throw new Error("No unwrapped code rows are mounted"); - } - return Math.max( - ...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))), - ); - }); - await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded(); - await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible(); + const groups = diffGroups(page); + await expect.poll(() => groups.count()).toBeGreaterThan(1); + const lastMountedGroup = groups.last(); + await lastMountedGroup.scrollIntoViewIfNeeded(); + await expect(lastMountedGroup.locator('[data-testid^="diff-code-row-"]').last()).toBeVisible(); } diff --git a/packages/app/e2e/browser/commit-diff-panel.spec.ts b/packages/app/e2e/browser/commit-diff-panel.spec.ts index 1f658fac0d..aa416bb115 100644 --- a/packages/app/e2e/browser/commit-diff-panel.spec.ts +++ b/packages/app/e2e/browser/commit-diff-panel.spec.ts @@ -55,7 +55,7 @@ test("commit history shows dates and shares diff layout preferences", async ({ await layoutToggle.click(); await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff"); await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0); - await expect(panel.getByTestId("diff-file-0-body")).toBeVisible(); + await expect(panel.getByTestId(/^diff-file-0-group-\d+$/).first()).toBeVisible(); await page.getByTestId(/^workspace-commit-diff-close-/).click(); await expect(panel).toHaveCount(0); diff --git a/packages/app/src/components/diff-scroll.tsx b/packages/app/src/components/diff-scroll.tsx index 49110c3c7b..d6e3a2a8d3 100644 --- a/packages/app/src/components/diff-scroll.tsx +++ b/packages/app/src/components/diff-scroll.tsx @@ -1,58 +1,75 @@ -import { useState, useCallback, useEffect, useId, useRef } from "react"; +import { useState, useCallback, useEffect, useId } from "react"; +import { type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; +import { ScrollView } from "react-native-gesture-handler"; import { - type LayoutChangeEvent, - type NativeSyntheticEvent, - type NativeScrollEvent, - type StyleProp, - type ViewStyle, -} from "react-native"; -import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; + createAnimatedComponent, + useAnimatedScrollHandler, + useSharedValue, + type SharedValue, +} from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures"; +const AnimatedScrollView = createAnimatedComponent(ScrollView); + interface DiffScrollProps { children: React.ReactNode; - scrollViewWidth: number; onScrollViewWidthChange: (width: number) => void; style?: StyleProp; contentContainerStyle?: StyleProp; + offset: SharedValue; + testID?: string; } export function DiffScroll({ children, - scrollViewWidth: _scrollViewWidth, onScrollViewWidthChange, style, contentContainerStyle, + offset, + testID, }: DiffScrollProps) { - const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); const horizontalScroll = useHorizontalScrollOptional(); const scrollId = useId(); - const scrollViewRef = useRef(null); + const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); + const isAtLeftEdgeValue = useSharedValue(true); const closeGestureRef = useFileExplorerCloseGestureRef(); // Register/unregister scroll offset tracking useEffect(() => { if (!horizontalScroll) return; - // Start at 0 (not scrolled) horizontalScroll.registerScrollOffset(scrollId, 0); return () => { horizontalScroll.unregisterScrollOffset(scrollId); }; }, [horizontalScroll, scrollId]); - const handleScroll = useCallback( - (event: NativeSyntheticEvent) => { - const offsetX = event.nativeEvent.contentOffset.x; - // Track if we're at the left edge (with small threshold for float precision) - setIsAtLeftEdge(offsetX <= 1); + const recordScrollOffset = useCallback( + (offsetX: number) => { if (horizontalScroll) { horizontalScroll.registerScrollOffset(scrollId, offsetX); } }, [horizontalScroll, scrollId], ); + const recordLeftEdge = useCallback((atLeftEdge: boolean) => { + setIsAtLeftEdge(atLeftEdge); + }, []); + + const handleScroll = useAnimatedScrollHandler({ + onScroll: (event) => { + const offsetX = event.contentOffset.x; + offset.value = offsetX; + const atLeftEdge = offsetX <= 1; + if (atLeftEdge !== isAtLeftEdgeValue.value) { + isAtLeftEdgeValue.value = atLeftEdge; + scheduleOnRN(recordLeftEdge, atLeftEdge); + } + scheduleOnRN(recordScrollOffset, offsetX); + }, + }); const handleLayout = useCallback( (e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width), @@ -60,8 +77,7 @@ export function DiffScroll({ ); return ( - {children} - + ); } diff --git a/packages/app/src/components/diff-scroll.web.tsx b/packages/app/src/components/diff-scroll.web.tsx index 4caa3ac40f..22cca08561 100644 --- a/packages/app/src/components/diff-scroll.web.tsx +++ b/packages/app/src/components/diff-scroll.web.tsx @@ -1,12 +1,23 @@ -import { useCallback } from "react"; -import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; +import { useCallback, useId, useLayoutEffect, useState } from "react"; +import { + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { ScrollView } from "react-native-gesture-handler"; +import type { SharedValue } from "react-native-reanimated"; +import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; +import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures"; interface DiffScrollProps { children: React.ReactNode; - scrollViewWidth: number; onScrollViewWidthChange: (width: number) => void; style?: StyleProp; contentContainerStyle?: StyleProp; + offset: SharedValue; + testID?: string; } export function DiffScroll({ @@ -14,9 +25,31 @@ export function DiffScroll({ onScrollViewWidthChange, style, contentContainerStyle, + offset, + testID, }: DiffScrollProps) { + const horizontalScroll = useHorizontalScrollOptional(); + const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); + const scrollId = useId(); + const closeGestureRef = useFileExplorerCloseGestureRef(); + + useLayoutEffect(() => { + horizontalScroll?.registerScrollOffset(scrollId, 0); + return () => horizontalScroll?.unregisterScrollOffset(scrollId); + }, [horizontalScroll, scrollId]); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const offsetX = event.nativeEvent.contentOffset.x; + offset.value = offsetX; + setIsAtLeftEdge(offsetX <= 1); + horizontalScroll?.registerScrollOffset(scrollId, offsetX); + }, + [horizontalScroll, offset, scrollId], + ); + const handleLayout = useCallback( - (e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width), + (event: LayoutChangeEvent) => onScrollViewWidthChange(event.nativeEvent.layout.width), [onScrollViewWidthChange], ); @@ -25,9 +58,14 @@ export function DiffScroll({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator + bounces={false} style={style} contentContainerStyle={contentContainerStyle} + onScroll={handleScroll} + scrollEventThrottle={16} onLayout={handleLayout} + testID={testID} + waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined} > {children} diff --git a/packages/app/src/git/diff-flat-items.test.ts b/packages/app/src/git/diff-flat-items.test.ts deleted file mode 100644 index a3df76492d..0000000000 --- a/packages/app/src/git/diff-flat-items.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "./diff-flat-items"; -import type { ParsedDiffFile } from "@/git/use-diff-query"; - -function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile { - return { path, isNew: false, isDeleted: false, additions, deletions, hunks: [] }; -} - -function summarize(items: DiffFlatItem[]): string[] { - return items.map((item) => { - if (item.type === "folder") { - return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`; - } - const base = item.file.path.split("/").pop(); - return `${" ".repeat(item.depth)}${item.type === "body" ? "body:" : ""}${base}`; - }); -} - -describe("buildDiffFlatItems", () => { - const files = [createFile("src/app/a.ts"), createFile("src/app/nested/b.ts")]; - - it("emits a body and a sticky index for each expanded file", () => { - const { items, stickyHeaderIndices } = buildDiffFlatItems({ - files: [createFile("a.ts"), createFile("b.ts")], - viewMode: "tree", - collapsedFolders: new Set(), - expandedPaths: new Set(["a.ts"]), - }); - // Root-level files (no folder): header, body (for expanded a.ts), header - expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]); - // sticky points at the header (index 0), not the body - expect(stickyHeaderIndices).toEqual([0]); - expect(items[0].type).toBe("header"); - }); - - it("emits the old flat list without folder rows or indentation", () => { - const { items, stickyHeaderIndices } = buildDiffFlatItems({ - files, - viewMode: "flat", - collapsedFolders: new Set(), - expandedPaths: new Set(["src/app/a.ts"]), - }); - - expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]); - expect(stickyHeaderIndices).toEqual([0]); - expect(items.every((item) => item.type !== "folder")).toBe(true); - }); - - it("groups files under compressed folder rows, all expanded by default", () => { - const { items } = buildDiffFlatItems({ - files, - viewMode: "tree", - collapsedFolders: new Set(), - expandedPaths: new Set(), - }); - // dirs sort before files within a level: [nested] precedes a.ts - expect(summarize(items)).toEqual(["[src/app]", " [nested]", " b.ts", " a.ts"]); - }); - - it("collapsing a folder hides its descendants but keeps the row", () => { - const { items } = buildDiffFlatItems({ - files, - viewMode: "tree", - collapsedFolders: new Set(["src/app/nested"]), - expandedPaths: new Set(), - }); - expect(summarize(items)).toEqual(["[src/app]", " [nested] (collapsed)", " a.ts"]); - }); - - it("collapsing an ancestor hides everything below it", () => { - const { items } = buildDiffFlatItems({ - files, - viewMode: "tree", - collapsedFolders: new Set(["src/app"]), - expandedPaths: new Set(), - }); - expect(summarize(items)).toEqual(["[src/app] (collapsed)"]); - }); - - it("derives sticky indices file-headers-only from the post-collapse list", () => { - // dirs-first: [src/app], [nested], b.ts, a.ts. Expanding a.ts puts its - // header at index 3, with the body right after. - const { items, stickyHeaderIndices } = buildDiffFlatItems({ - files, - viewMode: "tree", - collapsedFolders: new Set(), - expandedPaths: new Set(["src/app/a.ts"]), - }); - expect(summarize(items)).toEqual([ - "[src/app]", - " [nested]", - " b.ts", - " a.ts", - " body:a.ts", - ]); - expect(stickyHeaderIndices).toEqual([3]); - for (const idx of stickyHeaderIndices) { - expect(items[idx].type).toBe("header"); - } - }); - - it("maps tree file rows back to their original files array index", () => { - const { items } = buildDiffFlatItems({ - files, - viewMode: "tree", - collapsedFolders: new Set(), - expandedPaths: new Set(), - }); - // Tree order is b.ts (fileIndex 1) then a.ts (fileIndex 0) - const headers = items.filter((i) => i.type === "header"); - expect(headers.map((h) => (h.type === "header" ? h.fileIndex : -1))).toEqual([1, 0]); - }); -}); - -describe("sumHeightsBefore", () => { - const items = buildDiffFlatItems({ - files: [createFile("src/a.ts"), createFile("src/b.ts")], - viewMode: "tree", - collapsedFolders: new Set(), - expandedPaths: new Set(), - }).items; // [folder, a.ts, b.ts] - - const heightFor = (item: DiffFlatItem) => (item.type === "folder" ? 10 : 20); - - it("sums the heights of items before the index, counting folder rows", () => { - expect(sumHeightsBefore(items, 0, heightFor)).toBe(0); - expect(sumHeightsBefore(items, 1, heightFor)).toBe(10); // folder above a.ts - expect(sumHeightsBefore(items, 2, heightFor)).toBe(30); // folder + a.ts above b.ts - }); - - it("clamps an out-of-range index to the list length", () => { - expect(sumHeightsBefore(items, 999, heightFor)).toBe(50); - }); -}); diff --git a/packages/app/src/git/diff-flat-items.ts b/packages/app/src/git/diff-flat-items.ts deleted file mode 100644 index cd60dd97c2..0000000000 --- a/packages/app/src/git/diff-flat-items.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { ParsedDiffFile } from "@/git/use-diff-query"; -import { - buildDiffTree, - compressSingleChildChains, - flattenDiffTree, - type DiffTreeDirNode, -} from "@/git/diff-tree"; - -// The row model for the Changes FlatList. `fileIndex` is the file's index within -// the (path-sorted) `files` array — a stable identity for testIDs/keys that is -// independent of folder rows and collapse state. `depth` drives indentation. -export type DiffFlatItem = - | { - type: "folder"; - dirPath: string; - displayName: string; - depth: number; - collapsed: boolean; - additions: number; - deletions: number; - } - | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean; depth: number } - | { type: "body"; file: ParsedDiffFile; fileIndex: number; depth: number }; - -export interface DiffFlatItemsResult { - items: DiffFlatItem[]; - /** Indices into `items` of expanded file headers — folder rows are never sticky. */ - stickyHeaderIndices: number[]; -} - -export interface BuildDiffFlatItemsInput { - files: ParsedDiffFile[]; - viewMode: "flat" | "tree"; - /** Full uncompressed directory paths that are collapsed (empty = all expanded). */ - collapsedFolders: ReadonlySet; - /** File paths whose diff body is expanded. */ - expandedPaths: ReadonlySet; - /** - * Pre-built compressed tree (from the same `files`). Pass it to avoid rebuilding - * the tree on every collapse toggle; omitted callers (tests) build it internally. - */ - tree?: DiffTreeDirNode; -} - -/** - * Build the FlatList item model plus its sticky-header indices: files grouped - * under directory rows, single-child chains compressed, descendants of collapsed - * folders omitted. - * - * Sticky indices are derived from the FINAL (post-collapse) item list and only - * ever point at expanded FILE headers, so folder rows can't stack or collide in - * the sticky header stack. - */ -export function buildDiffFlatItems({ - files, - viewMode, - collapsedFolders, - expandedPaths, - tree, -}: BuildDiffFlatItemsInput): DiffFlatItemsResult { - const items: DiffFlatItem[] = []; - const stickyHeaderIndices: number[] = []; - - const pushFile = (file: ParsedDiffFile, fileIndex: number, depth: number): void => { - const isExpanded = expandedPaths.has(file.path); - items.push({ type: "header", file, fileIndex, isExpanded, depth }); - if (isExpanded) { - stickyHeaderIndices.push(items.length - 1); - items.push({ type: "body", file, fileIndex, depth }); - } - }; - - if (viewMode === "flat") { - for (const [fileIndex, file] of files.entries()) { - pushFile(file, fileIndex, 0); - } - return { items, stickyHeaderIndices }; - } - - const indexByPath = new Map(files.map((file, index) => [file.path, index])); - const compressedTree = tree ?? compressSingleChildChains(buildDiffTree(files)); - const rows = flattenDiffTree(compressedTree, collapsedFolders); - - for (const row of rows) { - if (row.kind === "folder") { - items.push({ - type: "folder", - dirPath: row.dirPath, - displayName: row.displayName, - depth: row.depth, - collapsed: collapsedFolders.has(row.dirPath), - additions: row.additions, - deletions: row.deletions, - }); - continue; - } - const fileIndex = indexByPath.get(row.file.path); - if (fileIndex === undefined) { - // Should never happen: the tree is built from the same `files` array. - continue; - } - pushFile(row.file, fileIndex, row.depth); - } - - return { items, stickyHeaderIndices }; -} - -/** Cumulative height of every item before `index`. Single source of truth for - * FlatList's getItemLayout AND the collapse scroll-anchor math. */ -export function sumHeightsBefore( - items: DiffFlatItem[], - index: number, - heightFor: (item: DiffFlatItem) => number, -): number { - let offset = 0; - const end = Math.min(index, items.length); - for (let i = 0; i < end; i++) { - offset += heightFor(items[i]); - } - return Math.max(0, offset); -} diff --git a/packages/app/src/git/diff-horizontal-surface.tsx b/packages/app/src/git/diff-horizontal-surface.tsx new file mode 100644 index 0000000000..c16be414ba --- /dev/null +++ b/packages/app/src/git/diff-horizontal-surface.tsx @@ -0,0 +1,80 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { type LayoutChangeEvent, type StyleProp, type ViewStyle, View } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + type SharedValue, +} from "react-native-reanimated"; +import { DiffScroll } from "@/components/diff-scroll"; + +const FILL: ViewStyle = { flex: 1 }; +const CLIP: ViewStyle = { flex: 1, overflow: "hidden" }; + +const DiffHorizontalOffsetContext = createContext | null>(null); + +export function DiffHorizontalSurface({ + children, + contentWidth, + viewportWidth, + onViewportWidthChange, +}: { + children: ReactNode; + contentWidth: number; + viewportWidth: number; + onViewportWidthChange: (width: number) => void; +}) { + const offset = useSharedValue(0); + const pinnedViewportStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: offset.value }], + })); + const contentContainerStyle = useMemo>( + () => [{ flexGrow: 1 }, { minWidth: Math.max(viewportWidth, contentWidth) }], + [contentWidth, viewportWidth], + ); + return ( + + + + {children} + + + + ); +} + +export function DiffHorizontalContent({ + children, + viewportStyle, + contentStyle, + onContentLayout, + testID, +}: { + children: ReactNode; + viewportStyle?: StyleProp; + contentStyle?: StyleProp; + onContentLayout?: (event: LayoutChangeEvent) => void; + testID?: string; +}) { + const offset = useContext(DiffHorizontalOffsetContext); + const translatedContentStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: -(offset?.value ?? 0) }], + })); + + return ( + + + {children} + + + ); +} diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index f0756b34dd..a594faa9c3 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1,4 +1,5 @@ import { + Fragment, useState, useCallback, useEffect, @@ -42,7 +43,6 @@ import { WrapText, } from "lucide-react-native"; import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query"; -import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items"; import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree"; import { DiffFolderRow } from "@/git/diff-folder-row"; import { @@ -59,13 +59,10 @@ import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { CommitsSection } from "@/git/commits-section/commits-section"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useAppSettings } from "@/hooks/use-settings"; -import { DiffScroll } from "@/components/diff-scroll"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll"; import { - buildSplitDiffRows, - buildUnifiedDiffLines, type ReviewableDiffTarget, type SplitDiffDisplayLine, type SplitDiffRow, @@ -107,6 +104,7 @@ import { formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens, + compactHighlightTokens, } from "@/utils/diff-rendering"; import { isWeb, isNative } from "@/constants/platform"; import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; @@ -122,6 +120,15 @@ import { import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; import { DiffTooLargeState } from "@/git/diff-too-large-state"; import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; +import { + buildDiffRowIndex, + createDiffRowCache, + DIFF_VIRTUALIZATION_CONFIG, + type DiffListItem, + type SplitRowGroupItem, + type UnifiedRowGroupItem, +} from "@/git/diff-rows/model"; +import { DiffHorizontalContent, DiffHorizontalSurface } from "@/git/diff-horizontal-surface"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -176,10 +183,6 @@ function useDiffRowMetricsStyle(textMetricsStyle: TextStyle): StyleProp{token.text}
; -} - function HighlightedText({ tokens, textMetricsStyle, @@ -196,15 +199,21 @@ function HighlightedText({ [textMetricsStyle, wrapLines], ); - const keyedTokens = useMemo( - () => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), - [tokens], - ); + const keyedTokens = useMemo(() => { + let characterOffset = 0; + return compactHighlightTokens(tokens).map((token) => { + const keyedToken = { key: `${characterOffset}-${token.style ?? "base"}`, token }; + characterOffset += token.text.length; + return keyedToken; + }); + }, [tokens]); return ( {keyedTokens.map(({ key, token }) => ( - + + {token.text} + ))} ); @@ -299,6 +308,7 @@ function LongPressableLine({ hoverTargetKey, onHoverTargetChange, style, + testID, children, }: { reviewTarget: ReviewableDiffTarget | null | undefined; @@ -307,6 +317,7 @@ function LongPressableLine({ hoverTargetKey?: string | null; onHoverTargetChange?: (key: string | null) => void; style: StyleProp; + testID?: string; children: ReactNode; }) { const onStartComment = reviewActions?.onStartComment; @@ -336,17 +347,26 @@ function LongPressableLine({ if (isWeb && (onHoverChange || onHoverTargetChange)) { return ( - + {children} ); } if (!isNative || !reviewTarget || !onStartComment) { - return {children}; + return ( + + {children} + + ); } return ( - + {children} ); @@ -371,6 +391,7 @@ function DiffGutterCell({ style, textTestID, actionTestID, + rowTestID, }: { lineNumber: number | null; type: DiffLine["type"] | undefined | null; @@ -382,6 +403,7 @@ function DiffGutterCell({ style?: StyleProp; textTestID?: string; actionTestID?: string; + rowTestID?: string; }) { const lineHeight = getNumericLineHeight(textMetricsStyle); const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); @@ -425,6 +447,7 @@ function DiffGutterCell({ onStartComment={onStartComment} style={containerStyle} actionTestID={actionTestID} + testID={rowTestID} > {formatDiffGutterText(lineNumber)} @@ -443,6 +466,7 @@ function DiffTextLine({ hoverTargetKey, onHoverTargetChange, textTestID, + rowTestID, }: { line: DiffLine; wrapLines: boolean; @@ -453,6 +477,7 @@ function DiffTextLine({ hoverTargetKey?: string | null; onHoverTargetChange?: (key: string | null) => void; textTestID?: string; + rowTestID?: string; }) { const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); @@ -483,6 +508,7 @@ function DiffTextLine({ hoverTargetKey={hoverTargetKey} onHoverTargetChange={onHoverTargetChange} style={containerStyle} + testID={rowTestID} > {line.type !== "header" && visibleTokens ? ( (null); const wrapCellStyle = useMemo( @@ -835,16 +868,19 @@ function SplitDiffColumn({ const linesContainerRowStyle = useMemo( () => [ styles.linesContainer, - scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }), + inlineUnistylesStyle({ minWidth: Math.max(textViewportWidth, minContentWidth) }), ], - [scrollWidth], + [minContentWidth, textViewportWidth], ); const headerLineTextStyle = useMemo( () => [styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, styles.headerLineText], [textMetricsStyle], ); - const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]); + const keyedRows = useMemo( + () => rows.map((row, index) => ({ key: `row-${startRowIndex + index}`, row })), + [rows, startRowIndex], + ); if (wrapLines) { return ( @@ -932,50 +968,47 @@ function SplitDiffColumn({ ); })}
- - - {keyedRows.map(({ key, row }) => { - if (row.kind === "header") { - return ( - - {row.content} - - ); - } - const line = side === "left" ? row.left : row.right; - const reviewTargetKey = line?.reviewTarget?.key ?? null; - const reviewRowState = getSplitInlineReviewThreadState({ - left: row.left?.reviewTarget, - right: row.right?.reviewTarget, - reviewActions, - }); + {keyedRows.map(({ key, row }) => { + if (row.kind === "header") { return ( - - - + + {row.content} ); - })} - - + } + const line = side === "left" ? row.left : row.right; + const reviewTargetKey = line?.reviewTarget?.key ?? null; + const reviewRowState = getSplitInlineReviewThreadState({ + left: row.left?.reviewTarget, + right: row.right?.reviewTarget, + reviewActions, + }); + return ( + + + + + ); + })} +
); } @@ -1254,194 +1287,251 @@ const DiffFileHeader = memo(function DiffFileHeader({ ); }); -export function DiffFileBody({ - file, - layout, - wrapLines, - codeFontSize, - textMetricsStyle, - reviewActions, - onBodyHeightChange, - testID, -}: { - file: ParsedDiffFile; - layout: "unified" | "split"; +function maxLineNumber(file: ParsedDiffFile): number { + let maximum = 0; + for (const hunk of file.hunks) { + maximum = Math.max(maximum, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount); + } + return maximum; +} + +function DiffFileStatus({ file }: { file: ParsedDiffFile }) { + const { t } = useTranslation(); + return ( + + + + {file.status === "binary" + ? t("workspace.git.diff.binaryFile") + : t("workspace.git.diff.tooLarge")} + + + + ); +} + +interface DiffRowGroupProps { + item: UnifiedRowGroupItem | SplitRowGroupItem; + viewportWidth: number; wrapLines: boolean; codeFontSize: number; textMetricsStyle: TextStyle; reviewActions?: InlineReviewActions; - onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void; - testID?: string; -}) { - const [scrollViewWidth, setScrollViewWidth] = useState(0); - const [bodyWidth, setBodyWidth] = useState(0); + onHeightChange?: (key: string, height: number) => void; +} + +function DiffRowGroupView({ + item, + viewportWidth, + wrapLines, + codeFontSize, + textMetricsStyle, + reviewActions, + onHeightChange, +}: DiffRowGroupProps) { const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState(null); - const { t } = useTranslation(); const handleLayout = useCallback( (event: LayoutChangeEvent) => { - setBodyWidth(event.nativeEvent.layout.width); - onBodyHeightChange?.(file, event.nativeEvent.layout.height); + if (wrapLines) { + onHeightChange?.(item.key, event.nativeEvent.layout.height); + } }, - [file, onBodyHeightChange], + [item.key, onHeightChange, wrapLines], ); - const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth; + const minimumContentWidth = Math.ceil(item.contentColumns * codeFontSize * 0.75 + SPACING[4]); + const gutterWidth = lineNumberGutterWidth(maxLineNumber(item.file), codeFontSize); + const textViewportWidth = Math.max(0, viewportWidth - gutterWidth); + const minimumTextWidth = Math.max(textViewportWidth, minimumContentWidth); const linesContainerRowStyle = useMemo( - () => [ - styles.linesContainer, - availableWidth > 0 && inlineUnistylesStyle({ minWidth: availableWidth }), - ], - [availableWidth], + () => [styles.linesContainer, inlineUnistylesStyle({ minWidth: minimumTextWidth })], + [minimumTextWidth], ); + const groupStyle = useMemo( + () => [ + styles.fileSectionBodyContainer, + item.isLast && styles.fileSectionBorder, + item.isFirst && styles.diffContent, + styles.diffGroupContent, + ], + [item.isFirst, item.isLast], + ); + + if (item.type === "split_group") { + return ( + + + + + ); + } + + if (wrapLines) { + return ( + + + {item.lines.map(({ line, lineNumber, key, reviewTarget }, localIndex) => { + const index = item.startRowIndex + localIndex; + return ( + + + + + ); + })} + + + ); + } return ( - {(() => { - if (file.status === "too_large" || file.status === "binary") { + + {item.lines.map(({ line, lineNumber, key, reviewTarget }, localIndex) => { + const index = item.startRowIndex + localIndex; + const hasReviewThread = + getInlineReviewThreadState({ reviewTarget, reviewActions }) !== null; return ( - - - {file.status === "binary" - ? t("workspace.git.diff.binaryFile") - : t("workspace.git.diff.tooLarge")} - - - ); - } - - let maxLineNo = 0; - for (const hunk of file.hunks) { - maxLineNo = Math.max( - maxLineNo, - hunk.oldStart + hunk.oldCount, - hunk.newStart + hunk.newCount, - ); - } - const gutterWidth = lineNumberGutterWidth(maxLineNo, codeFontSize); - - if (layout === "split") { - const rows = buildSplitDiffRows(file); - return ( - - + - - + {hasReviewThread ? ( + + ) : null} + ); - } - - const computedLines = buildUnifiedDiffLines(file); - - if (wrapLines) { + })} + + + {item.lines.map(({ line, key, reviewTarget }, localIndex) => { + const index = item.startRowIndex + localIndex; + const hasReviewThread = + getInlineReviewThreadState({ reviewTarget, reviewActions }) !== null; return ( - - - {computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => ( - - - - - ))} - - + + + {hasReviewThread ? ( + + ) : null} + ); - } - - const textViewportWidth = - scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth); - return ( - - - {computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => ( - - - - - ))} - - - - {computedLines.map(({ line, key, reviewTarget }, index) => ( - - - - - ))} - - - - ); - })()} + })} + ); } +function areDiffRowGroupPropsEqual(previous: DiffRowGroupProps, next: DiffRowGroupProps): boolean { + // Row arrays are rebuilt with the index. File identity pins cached row content, while the + // geometry key and compared grouping inputs pin group membership. + return ( + previous.item.key === next.item.key && + previous.item.type === next.item.type && + previous.item.file === next.item.file && + previous.item.fileIndex === next.item.fileIndex && + previous.item.depth === next.item.depth && + previous.item.groupIndex === next.item.groupIndex && + previous.item.startRowIndex === next.item.startRowIndex && + previous.item.isFirst === next.item.isFirst && + previous.item.isLast === next.item.isLast && + previous.item.contentColumns === next.item.contentColumns && + previous.viewportWidth === next.viewportWidth && + previous.wrapLines === next.wrapLines && + previous.codeFontSize === next.codeFontSize && + previous.textMetricsStyle === next.textMetricsStyle && + previous.reviewActions === next.reviewActions && + previous.onHeightChange === next.onHeightChange + ); +} + +const DiffRowGroup = memo(DiffRowGroupView, areDiffRowGroupPropsEqual); + interface GitDiffPaneProps { serverId: string; workspaceId?: string | null; @@ -1824,42 +1914,15 @@ export function DiffOptionsMenu({ const ThemedRotateCw = withUnistyles(RotateCw); -type DiffFlatItemLayoutGetter = NonNullable["getItemLayout"]>; +type DiffListItemLayoutGetter = NonNullable["getItemLayout"]>; + +// COMPAT(react-native-virtualized-list-bounded-data-growth): RN 0.81 carries the old index range +// through middle insertions, synchronously mounting every inserted diff group in that range. +const BOUNDED_DIFF_LIST_DATA_GROWTH_PROPS = { + unstable_boundedRangeOnDataChange: true, +} as const; const EMPTY_PATH_LIST: string[] = []; - -interface DiffFileMetrics { - contentLength: number; - splitLineCount?: number; - unifiedLineCount: number; -} - -const diffFileMetricsCache = new WeakMap(); - -function getDiffFileMetrics(file: ParsedDiffFile): DiffFileMetrics { - const cached = diffFileMetricsCache.get(file); - if (cached) { - return cached; - } - let contentLength = 0; - let unifiedLineCount = 0; - for (const hunk of file.hunks) { - unifiedLineCount += hunk.lines.length; - for (const line of hunk.lines) { - contentLength += line.content.length; - } - } - const metrics = { contentLength, unifiedLineCount }; - diffFileMetricsCache.set(file, metrics); - return metrics; -} - -function getSplitDiffLineCount(file: ParsedDiffFile): number { - const metrics = getDiffFileMetrics(file); - if (metrics.splitLineCount === undefined) { - metrics.splitLineCount = buildSplitDiffRows(file).length; - } - return metrics.splitLineCount; -} +const EMPTY_NUMBER_LIST: number[] = []; function computeEmptyMessage( hideWhitespace: boolean, @@ -1998,6 +2061,9 @@ interface SharedDiffViewProps { }; } +type WorkingTreeDiffMode = Extract; +const EMPTY_WORKING_TREE_DIFF_MODE: Partial = {}; + export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) { const isCompact = useIsCompactFormFactor(); const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences; @@ -2011,7 +2077,22 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ...(trimmedMonoFontFamily ? { fontFamily: trimmedMonoFontFamily } : null), }; }, [codeFontSize, diffBodyLineHeight, monoFontFamily]); - const viewMode = mode.kind === "working_tree" ? mode.viewMode : "flat"; + const workingTreeMode = mode.kind === "working_tree" ? mode : EMPTY_WORKING_TREE_DIFF_MODE; + const { + viewMode = "flat", + collapsedFolders: collapsedFoldersArray = EMPTY_PATH_LIST, + onFilePress, + onOpenFile, + onAddToChat, + workspaceFileDragScope, + onCopyPath, + onCopyRelativePath, + onReveal, + revealTargetName, + onDownload, + onDuplicate, + onRevert, + } = workingTreeMode; const expandedPathsArray = useMemo(() => { if (mode.kind === "working_tree") { return mode.expandedPaths; @@ -2022,26 +2103,12 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi return files.map((file) => file.path); }, [files, mode]); const expandedPaths = useMemo(() => new Set(expandedPathsArray), [expandedPathsArray]); - const collapsedFoldersArray = - mode.kind === "working_tree" ? mode.collapsedFolders : EMPTY_PATH_LIST; const collapsedFolders = useMemo(() => new Set(collapsedFoldersArray), [collapsedFoldersArray]); const stickyHeaders = mode.kind !== "commit"; const interactive = mode.kind !== "commit"; const reviewActions = mode.kind === "commit" ? undefined : mode.reviewActions; - const onFilePress = mode.kind === "working_tree" ? mode.onFilePress : undefined; const focusPath = mode.kind === "working_tab" ? mode.focusPath : undefined; const focusRequestId = mode.kind === "working_tab" ? mode.focusRequestId : undefined; - const onOpenFile = mode.kind === "working_tree" ? mode.onOpenFile : undefined; - const onAddToChat = mode.kind === "working_tree" ? mode.onAddToChat : undefined; - const workspaceFileDragScope = - mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined; - const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined; - const onCopyRelativePath = mode.kind === "working_tree" ? mode.onCopyRelativePath : undefined; - const onReveal = mode.kind === "working_tree" ? mode.onReveal : undefined; - const revealTargetName = mode.kind === "working_tree" ? mode.revealTargetName : undefined; - const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined; - const onDuplicate = mode.kind === "working_tree" ? mode.onDuplicate : undefined; - const onRevert = mode.kind === "working_tree" ? mode.onRevert : undefined; // Keep selection independent from expansion so future keyboard actions (such as R to rename) // can target the current VCS file or folder without changing its open state. const [selectedPath, setSelectedPath] = useState(null); @@ -2062,19 +2129,24 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi () => new Set(Array.from(collapsedFolders).filter((path) => allFolderPathSet.has(path))), [allFolderPathSet, collapsedFolders], ); - const diffListRef = useRef>(null); + const diffListRef = useRef>(null); const scrollbar = useOverlayFlatListScrollbar(diffListRef, { enabled: !isCompact }); const { onLayout: updateScrollbarLayout, onScroll: updateScrollbarOffset } = scrollbar; const consumedFocusRequestRef = useRef(null); const pendingFocusRequestRef = useRef(null); const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); + const pendingMeasuredHeightCorrectionRef = useRef(0); + const [diffListWidth, setDiffListWidth] = useState(800); const headerHeightByPathRef = useRef>({}); - const bodyHeightByKeyRef = useRef>({}); + const measuredGroupHeightsRef = useRef(new Map()); + const rowCacheRef = useRef(createDiffRowCache()); const folderRowHeightRef = useRef(0); const defaultHeaderHeightRef = useRef(44); const [heightVersion, setHeightVersion] = useState(0); + const [geometryVersion, setGeometryVersion] = useState(0); const heightVersionFrameRef = useRef(null); + const geometryVersionFrameRef = useRef(null); const scheduleHeightVersionUpdate = useCallback(() => { if (heightVersionFrameRef.current !== null) { return; @@ -2084,82 +2156,106 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi setHeightVersion((version) => version + 1); }); }, []); + const scheduleGeometryVersionUpdate = useCallback(() => { + if (geometryVersionFrameRef.current !== null) { + return; + } + geometryVersionFrameRef.current = requestAnimationFrame(() => { + geometryVersionFrameRef.current = null; + setGeometryVersion((version) => version + 1); + }); + }, []); useEffect( () => () => { if (heightVersionFrameRef.current !== null) { cancelAnimationFrame(heightVersionFrameRef.current); } + if (geometryVersionFrameRef.current !== null) { + cancelAnimationFrame(geometryVersionFrameRef.current); + } }, [], ); const diffBodyChromeHeight = BORDER_WIDTH[1] * 2; const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight; - const { flatItems, stickyHeaderIndices } = useMemo(() => { - const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({ + const reviewHeightFor = useCallback( + (target: ReviewableDiffTarget | null | undefined): number => + getInlineReviewThreadState({ reviewTarget: target, reviewActions })?.height ?? 0, + [reviewActions], + ); + const geometryKey = `${layout}:${wrapLines ? "wrap" : "scroll"}:${typographyKey}:${Math.round(diffListWidth)}`; + const rowIndex = useMemo(() => { + void heightVersion; + return buildDiffRowIndex({ files, viewMode, tree: compressedTree, collapsedFolders: effectiveCollapsedFolders, expandedPaths, + layout, + wrapLines, + lineHeight: diffBodyLineHeight, + viewportWidth: diffListWidth, + codeFontSize, + headerHeightFor: (file) => + headerHeightByPathRef.current[file.path] ?? defaultHeaderHeightRef.current, + folderHeight: folderRowHeightRef.current || defaultHeaderHeightRef.current, + statusHeight: statusBodyHeightEstimate, + measuredGroupHeights: measuredGroupHeightsRef.current, + geometryKey, + reviewHeightFor, + rowCache: rowCacheRef.current, }); - return { - flatItems: items, - stickyHeaderIndices: stickyHeaders ? stickyIndices : [], - }; - }, [compressedTree, effectiveCollapsedFolders, expandedPaths, files, stickyHeaders, viewMode]); - - const getBodyHeightKey = useCallback( - (file: ParsedDiffFile): string => { - if (file.status === "too_large" || file.status === "binary") { - return `${layout}:${wrapLines ? "wrap" : "scroll"}:${typographyKey}:${file.path}:${file.status}`; - } - - const metrics = getDiffFileMetrics(file); - return [ - layout, - wrapLines ? "wrap" : "scroll", - typographyKey, - file.path, - file.status ?? "ok", - file.additions, - file.deletions, - file.hunks.length, - metrics.unifiedLineCount, - metrics.contentLength, - ].join(":"); - }, - [layout, typographyKey, wrapLines], + }, [ + codeFontSize, + compressedTree, + diffBodyLineHeight, + diffListWidth, + effectiveCollapsedFolders, + expandedPaths, + files, + geometryKey, + heightVersion, + layout, + reviewHeightFor, + statusBodyHeightEstimate, + viewMode, + wrapLines, + ]); + const flatItems = rowIndex.items; + const horizontalContentColumns = useMemo( + () => + flatItems.reduce( + (maximum, item) => + item.type === "unified_group" || item.type === "split_group" + ? Math.max(maximum, item.contentColumns) + : maximum, + 0, + ), + [flatItems], ); - - const estimateBodyHeight = useCallback( - (file: ParsedDiffFile): number => { - if (file.status === "too_large" || file.status === "binary") { - return statusBodyHeightEstimate; - } - - const lineCount = - layout === "split" - ? getSplitDiffLineCount(file) - : getDiffFileMetrics(file).unifiedLineCount; - return diffBodyChromeHeight + lineCount * diffBodyLineHeight; - }, - [diffBodyChromeHeight, diffBodyLineHeight, layout, statusBodyHeightEstimate], + const maximumGutterWidth = useMemo( + () => + files.reduce( + (maximum, file) => + Math.max(maximum, lineNumberGutterWidth(maxLineNumber(file), codeFontSize)), + 0, + ), + [codeFontSize, files], ); - - const getFlatItemHeight = useCallback( - (item: DiffFlatItem): number => { - if (item.type === "folder") { - return folderRowHeightRef.current || defaultHeaderHeightRef.current; - } - if (item.type === "header") { - return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current; - } - const bodyHeightKey = getBodyHeightKey(item.file); - return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file); - }, - [estimateBodyHeight, getBodyHeightKey], + const horizontalCodeWidth = Math.ceil( + horizontalContentColumns * codeFontSize * 0.75 + SPACING[4], ); + const horizontalCodeViewportWidth = Math.max( + 0, + (layout === "split" ? diffListWidth / 2 : diffListWidth) - maximumGutterWidth, + ); + const horizontalSurfaceWidth = + diffListWidth + Math.max(0, horizontalCodeWidth - horizontalCodeViewportWidth); + const rowIndexRef = useRef(rowIndex); + rowIndexRef.current = rowIndex; + const stickyHeaderIndices = stickyHeaders ? rowIndex.stickyHeaderIndices : EMPTY_NUMBER_LIST; const handleFolderRowHeightChange = useCallback( (height: number) => { @@ -2195,28 +2291,48 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [scheduleHeightVersionUpdate], ); - const handleBodyHeightChange = useCallback( - (file: ParsedDiffFile, height: number) => { + const handleGroupHeightChange = useCallback( + (key: string, height: number) => { if (!Number.isFinite(height) || height < 0) { return; } - const heightKey = getBodyHeightKey(file); - const previousHeight = bodyHeightByKeyRef.current[heightKey]; + const previousHeight = measuredGroupHeightsRef.current.get(key); if ( previousHeight !== undefined && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON ) { return; } - bodyHeightByKeyRef.current[heightKey] = height; - scheduleHeightVersionUpdate(); + const currentIndex = rowIndexRef.current; + const update = currentIndex.updateHeight(key, height); + if (!update) return; + if ( + currentIndex.offsetAt(update.index) + update.previousHeight <= + diffListScrollOffsetRef.current + ) { + pendingMeasuredHeightCorrectionRef.current += update.delta; + } + measuredGroupHeightsRef.current.set(key, height); + scheduleGeometryVersionUpdate(); }, - [getBodyHeightKey, scheduleHeightVersionUpdate], + [scheduleGeometryVersionUpdate], ); + useEffect(() => { + const correction = pendingMeasuredHeightCorrectionRef.current; + if (correction === 0) { + return; + } + pendingMeasuredHeightCorrectionRef.current = 0; + const offset = Math.max(0, diffListScrollOffsetRef.current + correction); + diffListScrollOffsetRef.current = offset; + diffListRef.current?.scrollToOffset({ offset, animated: false }); + }, [geometryVersion]); + const handleDiffListScroll = useCallback( (event: NativeSyntheticEvent) => { - diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; + const offset = event.nativeEvent.contentOffset.y; + diffListScrollOffsetRef.current = offset; updateScrollbarOffset(event); }, [updateScrollbarOffset], @@ -2228,6 +2344,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi if (!Number.isFinite(height) || height <= 0) { return; } + setDiffListWidth(event.nativeEvent.layout.width); diffListViewportHeightRef.current = height; updateScrollbarLayout(event); }, @@ -2235,20 +2352,19 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ); const computeItemOffset = useCallback( - (predicate: (item: DiffFlatItem) => boolean): number | null => { + (predicate: (item: DiffListItem) => boolean): number | null => { const index = flatItems.findIndex(predicate); if (index < 0) { return null; } - return sumHeightsBefore(flatItems, index, getFlatItemHeight); + return rowIndex.offsetAt(index); }, - [flatItems, getFlatItemHeight], + [flatItems, rowIndex], ); const computeHeaderOffset = useCallback( - (path: string): number => - computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0, - [computeItemOffset], + (path: string): number => rowIndex.offsetOfFile(path), + [rowIndex], ); useEffect(() => { @@ -2388,7 +2504,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ); const renderFlatItem = useCallback( - ({ item }: { item: DiffFlatItem }) => { + ({ item }: { item: DiffListItem }) => { if (item.type === "folder") { return ( ); } + if (item.type === "status") { + return ; + } return ( - ); }, [ codeFontSize, - handleBodyHeightChange, + diffListWidth, + handleGroupHeightChange, handleFolderRowHeightChange, handleHeaderHeightChange, handleCollapseFolder, handleSelectPath, handleToggleExpanded, handleToggleFolder, - layout, reviewActions, workspaceFileDragScope, textMetricsStyle, @@ -2482,20 +2600,26 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ], ); - const flatKeyExtractor = useCallback( - (item: DiffFlatItem) => - item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`, - [], - ); + const flatKeyExtractor = useCallback((item: DiffListItem) => { + if (item.type === "folder") { + return `folder-${item.dirPath}`; + } + if (item.type === "unified_group" || item.type === "split_group") { + return item.key; + } + return `${item.type}-${item.file.path}`; + }, []); - const getFlatItemLayout = useCallback( + const getFlatItemLayout = useCallback( (_data, index) => { - const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight); - const item = flatItems[index]; - const length = item ? getFlatItemHeight(item) : 0; - return { length, offset, index }; + void geometryVersion; + return { + length: rowIndex.heightAt(index), + offset: rowIndex.offsetAt(index), + index, + }; }, - [flatItems, getFlatItemHeight], + [geometryVersion, rowIndex], ); const flatExtraData = useMemo( @@ -2505,6 +2629,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi layout, typographyKey, heightVersion, + geometryVersion, viewMode, wrapLines, reviewActions, @@ -2514,6 +2639,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi expandedPathsArray, collapsedFoldersArray, heightVersion, + geometryVersion, layout, reviewActions, typographyKey, @@ -2523,29 +2649,45 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ], ); + const diffList = ( + + ); + return ( - + {wrapLines ? ( + diffList + ) : ( + + {diffList} + + )} {scrollbar.overlay} ); @@ -3502,6 +3644,9 @@ const styles = StyleSheet.create((theme) => ({ borderTopColor: theme.colors.border, backgroundColor: theme.colors.surface1, }, + diffGroupContent: { + backgroundColor: theme.colors.surface1, + }, diffContentRow: { flexDirection: "row", alignItems: "stretch", @@ -3548,6 +3693,7 @@ const styles = StyleSheet.create((theme) => ({ }, splitColumnScroll: { flex: 1, + minWidth: 0, }, splitHeaderRow: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/git/diff-rows/model.test.ts b/packages/app/src/git/diff-rows/model.test.ts new file mode 100644 index 0000000000..bd74ffe247 --- /dev/null +++ b/packages/app/src/git/diff-rows/model.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "vitest"; +import type { ParsedDiffFile } from "@/git/use-diff-query"; +import { + buildDiffRowIndex, + createDiffRowCache, + DIFF_VIRTUALIZATION_CONFIG, + type DiffRowCache, +} from "./model"; + +function createFile(path: string, lineCount: number): ParsedDiffFile { + return { + path, + isNew: false, + isDeleted: false, + additions: lineCount, + deletions: 0, + hunks: [ + { + oldStart: 1, + oldCount: 0, + newStart: 1, + newCount: lineCount, + lines: [ + { type: "header", content: `@@ -1,0 +1,${lineCount} @@` }, + ...Array.from({ length: lineCount }, (_, index) => ({ + type: "add" as const, + content: `line ${index + 1}`, + })), + ], + }, + ], + }; +} + +function buildIndex(files: ParsedDiffFile[], expandedPaths: string[]) { + return buildDiffRowIndex({ + files, + viewMode: "flat", + collapsedFolders: new Set(), + expandedPaths: new Set(expandedPaths), + layout: "unified", + wrapLines: false, + lineHeight: 18, + viewportWidth: 400, + codeFontSize: 12, + headerHeightFor: () => 44, + folderHeight: 44, + statusHeight: 64, + measuredGroupHeights: new Map(), + geometryKey: "test", + reviewHeightFor: () => 0, + }); +} + +function containsReviewedLine(group: { + lines: Array<{ reviewTarget?: { lineNumber?: number } | null }>; +}): boolean { + return group.lines.some((line) => line.reviewTarget?.lineNumber === 10); +} + +function summarizeTree(items: ReturnType["items"]): string[] { + return items.map((item) => { + if (item.type === "folder") { + return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`; + } + const base = item.file.path.split("/").pop(); + const isGroup = item.type === "unified_group" || item.type === "split_group"; + return `${" ".repeat(item.depth)}${isGroup ? "group:" : ""}${base}`; + }); +} + +describe("buildDiffRowIndex", () => { + it("turns a large expanded file into bounded native-list cells", () => { + const index = buildIndex([createFile("large.ts", 1_200)], ["large.ts"]); + const groups = index.items.filter((item) => item.type === "unified_group"); + + expect(groups.length).toBeGreaterThan(1); + expect(Math.max(...groups.map((group) => group.lines.length))).toBeLessThanOrEqual( + DIFF_VIRTUALIZATION_CONFIG.groupRowLimit, + ); + expect(groups.reduce((count, group) => count + group.lines.length, 0)).toBe(1_201); + expect(index.stickyHeaderIndices).toEqual([0]); + }); + + it("counts wide glyphs and tabs conservatively for horizontal scroll extent", () => { + for (const [path, content] of [ + ["cjk.ts", "界".repeat(40)], + ["emoji.ts", "😀".repeat(40)], + ["tabs.ts", "\t".repeat(10)], + ]) { + const file = createFile(path, 1); + file.hunks[0].lines[1].content = content; + + const index = buildIndex([file], [file.path]); + const group = index.items.find((item) => item.type === "unified_group"); + + expect(group?.contentColumns).toBe(80); + } + }); + + it("precomputes exact offsets and preserves total file height", () => { + const index = buildIndex( + [createFile("first.ts", 65), createFile("second.ts", 1)], + ["first.ts"], + ); + const firstGroups = index.items.filter( + (item) => item.type === "unified_group" && item.file.path === "first.ts", + ); + const secondHeaderIndex = index.items.findIndex( + (item) => item.type === "header" && item.file.path === "second.ts", + ); + const expectedBodyHeight = 66 * 18 + 2; + + expect(firstGroups).toHaveLength(5); + expect(index.offsetAt(secondHeaderIndex)).toBe(44 + expectedBodyHeight); + expect(index.offsetOfFile("second.ts")).toBe(44 + expectedBodyHeight); + expect(index.totalHeight).toBe(44 + expectedBodyHeight + 44); + }); + + it("keeps a review row whole and shifts every later offset by its height", () => { + const file = createFile("review.ts", 40); + const baseline = buildIndex([file], [file.path]); + const reviewed = buildDiffRowIndex({ + files: [file], + viewMode: "flat", + collapsedFolders: new Set(), + expandedPaths: new Set([file.path]), + layout: "unified", + wrapLines: false, + lineHeight: 18, + viewportWidth: 400, + codeFontSize: 12, + headerHeightFor: () => 44, + folderHeight: 44, + statusHeight: 64, + measuredGroupHeights: new Map(), + geometryKey: "test", + reviewHeightFor: (target) => (target?.lineNumber === 10 ? 132 : 0), + }); + + expect(reviewed.totalHeight - baseline.totalHeight).toBe(132); + const reviewGroup = reviewed.items + .filter((item) => item.type === "unified_group") + .find(containsReviewedLine); + expect(reviewGroup).toBeDefined(); + expect(reviewed.heightAt(reviewed.items.indexOf(reviewGroup!))).toBeGreaterThanOrEqual(150); + }); + + it("preserves compressed tree order, indentation, and collapse state", () => { + const files = [createFile("src/app/a.ts", 1), createFile("src/app/nested/b.ts", 1)]; + const index = buildDiffRowIndex({ + files, + viewMode: "tree", + collapsedFolders: new Set(["src/app/nested"]), + expandedPaths: new Set(["src/app/a.ts"]), + layout: "unified", + wrapLines: false, + lineHeight: 18, + viewportWidth: 400, + codeFontSize: 12, + headerHeightFor: () => 44, + folderHeight: 44, + statusHeight: 64, + measuredGroupHeights: new Map(), + geometryKey: "tree", + reviewHeightFor: () => 0, + }); + + expect(summarizeTree(index.items)).toEqual([ + "[src/app]", + " [nested] (collapsed)", + " a.ts", + " group:a.ts", + ]); + expect(index.stickyHeaderIndices).toEqual([2]); + }); + + it("keeps split groups bounded and sticky indices on headers", () => { + const file = createFile("split.ts", 100); + const index = buildDiffRowIndex({ + files: [file], + viewMode: "flat", + collapsedFolders: new Set(), + expandedPaths: new Set([file.path]), + layout: "split", + wrapLines: false, + lineHeight: 18, + viewportWidth: 800, + codeFontSize: 12, + headerHeightFor: () => 44, + folderHeight: 44, + statusHeight: 64, + measuredGroupHeights: new Map(), + geometryKey: "split", + reviewHeightFor: () => 0, + }); + const groups = index.items.filter((item) => item.type === "split_group"); + + expect(Math.max(...groups.map((group) => group.rows.length))).toBeLessThanOrEqual( + DIFF_VIRTUALIZATION_CONFIG.groupRowLimit, + ); + expect(index.items[index.stickyHeaderIndices[0]].type).toBe("header"); + }); + + it("updates measured group geometry without deriving any file rows again", () => { + const delegate = createDiffRowCache(); + let derivations = 0; + const rowCache: DiffRowCache = { + unifiedFor(file) { + derivations += 1; + return delegate.unifiedFor(file); + }, + splitFor(file) { + derivations += 1; + return delegate.splitFor(file); + }, + }; + const files = [createFile("first.ts", 100), createFile("second.ts", 100)]; + const index = buildDiffRowIndex({ + files, + viewMode: "flat", + collapsedFolders: new Set(), + expandedPaths: new Set(files.map((file) => file.path)), + layout: "unified", + wrapLines: true, + lineHeight: 18, + viewportWidth: 400, + codeFontSize: 12, + headerHeightFor: () => 44, + folderHeight: 44, + statusHeight: 64, + measuredGroupHeights: new Map(), + geometryKey: "wrap", + reviewHeightFor: () => 0, + rowCache, + }); + const group = index.items.find((item) => item.type === "unified_group"); + expect(group).toBeDefined(); + expect(derivations).toBe(2); + + const groupIndex = index.indexOfGroup(group!.key); + const laterOffset = index.offsetAt(groupIndex + 1); + const update = index.updateHeight(group!.key, index.heightAt(groupIndex) + 20); + + expect(update?.delta).toBe(20); + expect(index.offsetAt(groupIndex + 1)).toBe(laterOffset + 20); + expect(derivations).toBe(2); + }); + + it("uses the key's rounded viewport width for wrapped group membership", () => { + const file = createFile("wrapped.ts", 40); + for (const line of file.hunks[0].lines) { + if (line.type !== "header") { + line.content = "x".repeat(88); + } + } + const buildWrapped = (viewportWidth: number) => + buildDiffRowIndex({ + files: [file], + viewMode: "flat", + collapsedFolders: new Set(), + expandedPaths: new Set([file.path]), + layout: "unified", + wrapLines: true, + lineHeight: 18, + viewportWidth, + codeFontSize: 12, + headerHeightFor: () => 44, + folderHeight: 44, + statusHeight: 64, + measuredGroupHeights: new Map(), + geometryKey: `wrap:${Math.round(viewportWidth)}`, + reviewHeightFor: () => 0, + }); + const summarizeGroups = (viewportWidth: number) => + buildWrapped(viewportWidth) + .items.filter((item) => item.type === "unified_group") + .map((group) => ({ + key: group.key, + startRowIndex: group.startRowIndex, + lineCount: group.lines.length, + })); + + expect(summarizeGroups(399.2)).toEqual(summarizeGroups(399.49)); + }); +}); diff --git a/packages/app/src/git/diff-rows/model.ts b/packages/app/src/git/diff-rows/model.ts new file mode 100644 index 0000000000..0206c8aa62 --- /dev/null +++ b/packages/app/src/git/diff-rows/model.ts @@ -0,0 +1,422 @@ +import type { ParsedDiffFile } from "@/git/use-diff-query"; +import { + buildDiffTree, + compressSingleChildChains, + flattenDiffTree, + type DiffTreeDirNode, +} from "@/git/diff-tree"; +import { + buildSplitDiffRows, + buildUnifiedDiffLines, + type ReviewableDiffTarget, + type SplitDiffRow, + type UnifiedDiffDisplayLine, +} from "@/utils/diff-layout"; + +// Tune cell size and the outer list window together. On the 1,200-row fixture, the exact +// three-fling repro filled immediately with both 32/720 + window 21 and this lower-cost pair +// on an iPhone 17 Pro simulator. Simulator frame percentiles are unavailable in xctrace. +export const DIFF_VIRTUALIZATION_CONFIG = { + groupRowLimit: 16, + groupHeightLimit: 360, + initialNumToRender: 8, + maxToRenderPerBatch: 8, + updateCellsBatchingPeriod: 16, + windowSize: 10, +} as const; + +interface FolderItem { + type: "folder"; + dirPath: string; + displayName: string; + depth: number; + collapsed: boolean; + additions: number; + deletions: number; +} + +interface HeaderItem { + type: "header"; + file: ParsedDiffFile; + fileIndex: number; + isExpanded: boolean; + depth: number; +} + +interface StatusItem { + type: "status"; + file: ParsedDiffFile; + fileIndex: number; + depth: number; +} + +interface RowGroupBase { + file: ParsedDiffFile; + fileIndex: number; + depth: number; + groupIndex: number; + startRowIndex: number; + isFirst: boolean; + isLast: boolean; + contentColumns: number; + key: string; +} + +export interface UnifiedRowGroupItem extends RowGroupBase { + type: "unified_group"; + lines: UnifiedDiffDisplayLine[]; +} + +export interface SplitRowGroupItem extends RowGroupBase { + type: "split_group"; + rows: SplitDiffRow[]; +} + +export type DiffListItem = + | FolderItem + | HeaderItem + | StatusItem + | UnifiedRowGroupItem + | SplitRowGroupItem; + +export interface DiffRowIndex { + items: DiffListItem[]; + stickyHeaderIndices: number[]; + heightAt: (index: number) => number; + offsetAt: (index: number) => number; + offsetOfFile: (path: string) => number; + indexOfFile: (path: string) => number; + indexOfGroup: (key: string) => number; + updateHeight: ( + key: string, + height: number, + ) => { delta: number; index: number; previousHeight: number } | null; + totalHeight: number; +} + +export interface DiffRowCache { + unifiedFor: (file: ParsedDiffFile) => UnifiedDiffDisplayLine[]; + splitFor: (file: ParsedDiffFile) => SplitDiffRow[]; +} + +export function createDiffRowCache(): DiffRowCache { + const unified = new WeakMap(); + const split = new WeakMap(); + return { + unifiedFor(file) { + const cached = unified.get(file); + if (cached) return cached; + const lines = buildUnifiedDiffLines(file); + unified.set(file, lines); + return lines; + }, + splitFor(file) { + const cached = split.get(file); + if (cached) return cached; + const rows = buildSplitDiffRows(file); + split.set(file, rows); + return rows; + }, + }; +} + +const defaultRowCache = createDiffRowCache(); + +export interface BuildDiffRowIndexInput { + files: ParsedDiffFile[]; + viewMode: "flat" | "tree"; + collapsedFolders: ReadonlySet; + expandedPaths: ReadonlySet; + layout: "unified" | "split"; + wrapLines: boolean; + lineHeight: number; + viewportWidth: number; + codeFontSize: number; + headerHeightFor: (file: ParsedDiffFile) => number; + folderHeight: number; + statusHeight: number; + measuredGroupHeights: ReadonlyMap; + geometryKey: string; + reviewHeightFor: (target: ReviewableDiffTarget | null | undefined) => number; + rowCache?: DiffRowCache; + tree?: DiffTreeDirNode; +} + +interface PendingGroup { + rows: Row[]; + startRowIndex: number; + height: number; +} + +const FULL_WIDTH_CODE_POINT_RANGES = [ + [0x1100, 0x115f], + [0x2329, 0x232a], + [0x2e80, 0x303e], + [0x3040, 0x3247], + [0x3250, 0x4dbf], + [0x4e00, 0xa4c6], + [0xa960, 0xa97c], + [0xac00, 0xd7a3], + [0xf900, 0xfaff], + [0xfe10, 0xfe19], + [0xfe30, 0xfe6b], + [0xff01, 0xff60], + [0xffe0, 0xffe6], + [0x1b000, 0x1b001], + [0x1f200, 0x1f251], + [0x1f300, 0x1faff], + [0x20000, 0x3fffd], +] as const; + +function isFullWidthCodePoint(codePoint: number): boolean { + return FULL_WIDTH_CODE_POINT_RANGES.some( + ([start, end]) => codePoint >= start && codePoint <= end, + ); +} + +function displayColumns(content: string): number { + let columns = 0; + for (const character of content) { + if (character === "\t") { + columns += 8 - (columns % 8); + continue; + } + columns += isFullWidthCodePoint(character.codePointAt(0) ?? 0) ? 2 : 1; + } + return columns; +} + +function estimatedVisualLines(content: string, columns: number): number { + return Math.max(1, Math.ceil(displayColumns(content) / columns)); +} + +function groupRows(rows: Row[], heightFor: (row: Row) => number): PendingGroup[] { + const groups: PendingGroup[] = []; + let current: PendingGroup = { rows: [], startRowIndex: 0, height: 0 }; + + for (const [rowIndex, row] of rows.entries()) { + const rowHeight = heightFor(row); + const reachedRowLimit = current.rows.length >= DIFF_VIRTUALIZATION_CONFIG.groupRowLimit; + const reachedHeightLimit = + current.rows.length > 0 && + current.height + rowHeight > DIFF_VIRTUALIZATION_CONFIG.groupHeightLimit; + if (reachedRowLimit || reachedHeightLimit) { + groups.push(current); + current = { rows: [], startRowIndex: rowIndex, height: 0 }; + } + current.rows.push(row); + current.height += rowHeight; + } + + if (current.rows.length > 0) { + groups.push(current); + } + return groups; +} + +function contentColumnsForUnified(lines: UnifiedDiffDisplayLine[]): number { + return lines.reduce((longest, line) => Math.max(longest, displayColumns(line.line.content)), 0); +} + +function contentColumnsForSplit(rows: SplitDiffRow[]): number { + return rows.reduce((longest, row) => { + if (row.kind === "header") { + return Math.max(longest, displayColumns(row.content)); + } + return Math.max( + longest, + displayColumns(row.left?.content ?? ""), + displayColumns(row.right?.content ?? ""), + ); + }, 0); +} + +function availableTextColumns(input: BuildDiffRowIndexInput): number { + const viewportWidth = Math.round(input.viewportWidth); + const columnWidth = input.layout === "split" ? viewportWidth / 2 : viewportWidth; + const characterWidth = input.codeFontSize * 0.62; + return Math.max(8, Math.floor((columnWidth - 72) / characterWidth)); +} + +export function buildDiffRowIndex(input: BuildDiffRowIndexInput): DiffRowIndex { + const items: DiffListItem[] = []; + const heights: number[] = []; + const stickyHeaderIndices: number[] = []; + const fileIndexByPath = new Map(); + const groupIndexByKey = new Map(); + const availableColumns = availableTextColumns(input); + const rowCache = input.rowCache ?? defaultRowCache; + + function push(item: DiffListItem, height: number): void { + if (item.type === "unified_group" || item.type === "split_group") { + groupIndexByKey.set(item.key, items.length); + } + items.push(item); + heights.push(height); + } + + function pushUnifiedGroups(file: ParsedDiffFile, fileIndex: number, depth: number): void { + const lines = rowCache.unifiedFor(file); + const contentColumns = contentColumnsForUnified(lines); + const groups = groupRows(lines, (line) => { + const visualLines = input.wrapLines + ? estimatedVisualLines(line.line.content, availableColumns) + : 1; + return visualLines * input.lineHeight + input.reviewHeightFor(line.reviewTarget); + }); + + for (const [groupIndex, group] of groups.entries()) { + const isFirst = groupIndex === 0; + const isLast = groupIndex === groups.length - 1; + const key = `${input.geometryKey}:${file.path}:unified:${group.startRowIndex}`; + const estimatedHeight = group.height + (isFirst ? 1 : 0) + (isLast ? 1 : 0); + push( + { + type: "unified_group", + file, + fileIndex, + depth, + groupIndex, + startRowIndex: group.startRowIndex, + isFirst, + isLast, + contentColumns, + key, + lines: group.rows, + }, + input.measuredGroupHeights.get(key) ?? estimatedHeight, + ); + } + } + + function pushSplitGroups(file: ParsedDiffFile, fileIndex: number, depth: number): void { + const rows = rowCache.splitFor(file); + const contentColumns = contentColumnsForSplit(rows); + const groups = groupRows(rows, (row) => { + if (row.kind === "header") { + const visualLines = input.wrapLines + ? estimatedVisualLines(row.content, availableColumns) + : 1; + return visualLines * input.lineHeight; + } + const visualLines = input.wrapLines + ? Math.max( + estimatedVisualLines(row.left?.content ?? "", availableColumns), + estimatedVisualLines(row.right?.content ?? "", availableColumns), + ) + : 1; + const reviewHeight = Math.max( + input.reviewHeightFor(row.left?.reviewTarget), + input.reviewHeightFor(row.right?.reviewTarget), + ); + return visualLines * input.lineHeight + reviewHeight; + }); + + for (const [groupIndex, group] of groups.entries()) { + const isFirst = groupIndex === 0; + const isLast = groupIndex === groups.length - 1; + const key = `${input.geometryKey}:${file.path}:split:${group.startRowIndex}`; + const estimatedHeight = group.height + (isFirst ? 1 : 0) + (isLast ? 1 : 0); + push( + { + type: "split_group", + file, + fileIndex, + depth, + groupIndex, + startRowIndex: group.startRowIndex, + isFirst, + isLast, + contentColumns, + key, + rows: group.rows, + }, + input.measuredGroupHeights.get(key) ?? estimatedHeight, + ); + } + } + + function pushFile(file: ParsedDiffFile, fileIndex: number, depth: number): void { + const isExpanded = input.expandedPaths.has(file.path); + const headerIndex = items.length; + fileIndexByPath.set(file.path, headerIndex); + push({ type: "header", file, fileIndex, isExpanded, depth }, input.headerHeightFor(file)); + if (!isExpanded) { + return; + } + stickyHeaderIndices.push(headerIndex); + if (file.status === "binary" || file.status === "too_large") { + push({ type: "status", file, fileIndex, depth }, input.statusHeight); + return; + } + if (input.layout === "split") { + pushSplitGroups(file, fileIndex, depth); + return; + } + pushUnifiedGroups(file, fileIndex, depth); + } + + if (input.viewMode === "flat") { + for (const [fileIndex, file] of input.files.entries()) { + pushFile(file, fileIndex, 0); + } + } else { + const indexByPath = new Map(input.files.map((file, index) => [file.path, index])); + const tree = input.tree ?? compressSingleChildChains(buildDiffTree(input.files)); + for (const row of flattenDiffTree(tree, input.collapsedFolders)) { + if (row.kind === "folder") { + push( + { + type: "folder", + dirPath: row.dirPath, + displayName: row.displayName, + depth: row.depth, + collapsed: input.collapsedFolders.has(row.dirPath), + additions: row.additions, + deletions: row.deletions, + }, + input.folderHeight, + ); + continue; + } + const fileIndex = indexByPath.get(row.file.path); + if (fileIndex !== undefined) { + pushFile(row.file, fileIndex, row.depth); + } + } + } + + const offsets = Array.from({ length: items.length + 1 }, () => 0); + for (let index = 0; index < items.length; index += 1) { + offsets[index + 1] = offsets[index] + heights[index]; + } + + return { + items, + stickyHeaderIndices, + heightAt: (index) => heights[index] ?? 0, + offsetAt: (index) => offsets[Math.max(0, Math.min(index, items.length))], + offsetOfFile: (path) => offsets[fileIndexByPath.get(path) ?? 0], + indexOfFile: (path) => fileIndexByPath.get(path) ?? -1, + indexOfGroup: (key) => groupIndexByKey.get(key) ?? -1, + updateHeight: (key, height) => { + const index = groupIndexByKey.get(key); + if (index === undefined || !Number.isFinite(height) || height < 0) { + return null; + } + const previousHeight = heights[index]; + const delta = height - previousHeight; + if (delta === 0) { + return { delta, index, previousHeight }; + } + heights[index] = height; + for (let offsetIndex = index + 1; offsetIndex < offsets.length; offsetIndex += 1) { + offsets[offsetIndex] += delta; + } + return { delta, index, previousHeight }; + }, + get totalHeight() { + return offsets[items.length]; + }, + }; +} diff --git a/packages/app/src/git/diff-rows/virtualized-list-range.test.ts b/packages/app/src/git/diff-rows/virtualized-list-range.test.ts new file mode 100644 index 0000000000..6392b4912d --- /dev/null +++ b/packages/app/src/git/diff-rows/virtualized-list-range.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { boundRetainedRangeOnDataGrowth } from "@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth"; + +describe("bounded VirtualizedList data growth", () => { + it("limits the carried range when middle insertion grows the list", () => { + expect(boundRetainedRangeOnDataGrowth({ first: 0, last: 39 }, 40, 115, 16)).toEqual({ + first: 0, + last: 15, + }); + }); + + it("bounds from the current scrolled range instead of jumping to the start", () => { + expect(boundRetainedRangeOnDataGrowth({ first: 84, last: 123 }, 160, 235, 16)).toEqual({ + first: 84, + last: 99, + }); + }); + + it("does not alter the range when data shrinks or the carried range is already bounded", () => { + const shortRange = { first: 12, last: 18 }; + expect(boundRetainedRangeOnDataGrowth(shortRange, 40, 32, 16)).toBe(shortRange); + expect(boundRetainedRangeOnDataGrowth(shortRange, 40, 115, 16)).toEqual(shortRange); + }); +}); diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index fd79290b5d..7e80fd86cd 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -260,6 +260,7 @@ export function InlineReviewGutterCell({ onStartComment, style, actionTestID, + testID, }: { children: ReactNode; reviewTarget: ReviewableDiffTarget | null | undefined; @@ -270,6 +271,7 @@ export function InlineReviewGutterCell({ onStartComment: (target: ReviewableDiffTarget) => void; style?: StyleProp; actionTestID?: string; + testID?: string; }) { const { t } = useTranslation(); const canComment = Boolean(reviewTarget); @@ -309,7 +311,6 @@ export function InlineReviewGutterCell({ } }, [isInteractionActive]); - const pressableStyle = useCallback((): StyleProp => style, [style]); const lineHeightStyle = useMemo>( () => lineHeight !== undefined @@ -318,13 +319,15 @@ export function InlineReviewGutterCell({ [lineHeight], ); - const labelStyle = useMemo>( - () => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive], - [hasComments, lineHeightStyle], - ); - const innerStyle = useMemo>( - () => [styles.gutterInner, lineHeightStyle], - [lineHeightStyle], + const pressableStyle = useCallback( + (): StyleProp => [ + style, + styles.gutterInner, + styles.gutterLabel, + lineHeightStyle, + hasComments && styles.gutterLabelActive, + ], + [hasComments, lineHeightStyle, style], ); const actionIconStyle = useMemo>( () => [ @@ -346,17 +349,14 @@ export function InlineReviewGutterCell({ onPressIn={handlePressIn} onPressOut={handlePressOut} style={pressableStyle} + testID={testID} > - - - {children} - {showAction ? ( - - - - ) : null} + {children} + {showAction ? ( + + - + ) : null}
); } diff --git a/packages/app/src/types/virtualized-list-internals.d.ts b/packages/app/src/types/virtualized-list-internals.d.ts new file mode 100644 index 0000000000..ae357034ca --- /dev/null +++ b/packages/app/src/types/virtualized-list-internals.d.ts @@ -0,0 +1,8 @@ +declare module "@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth" { + export function boundRetainedRangeOnDataGrowth( + cellsAroundViewport: { first: number; last: number }, + previousItemCount: number, + itemCount: number, + retainedCellLimit: number, + ): { first: number; last: number }; +} diff --git a/packages/app/src/utils/diff-rendering.test.ts b/packages/app/src/utils/diff-rendering.test.ts index 4d6e35e89d..384bd22912 100644 --- a/packages/app/src/utils/diff-rendering.test.ts +++ b/packages/app/src/utils/diff-rendering.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + compactHighlightTokens, formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens, @@ -28,4 +29,23 @@ describe("diff-rendering", () => { expect(hasVisibleDiffTokens([{ text: "" }])).toBe(false); expect(hasVisibleDiffTokens([{ text: "const value = 1;" }])).toBe(true); }); + + it("compacts adjacent syntax tokens without changing their text or styles", () => { + expect( + compactHighlightTokens([ + { text: "const", style: "keyword" }, + { text: " ", style: null }, + { text: "value", style: null }, + { text: " ", style: null }, + { text: "=", style: "operator" }, + { text: "", style: "operator" }, + { text: " 1", style: "number" }, + ]), + ).toEqual([ + { text: "const", style: "keyword" }, + { text: " value ", style: null }, + { text: "=", style: "operator" }, + { text: " 1", style: "number" }, + ]); + }); }); diff --git a/packages/app/src/utils/diff-rendering.ts b/packages/app/src/utils/diff-rendering.ts index a189c56b6a..c3cd9f8bb0 100644 --- a/packages/app/src/utils/diff-rendering.ts +++ b/packages/app/src/utils/diff-rendering.ts @@ -2,6 +2,28 @@ interface HighlightLikeToken { text: string; } +interface StyledHighlightToken extends HighlightLikeToken { + style: string | null; +} + +export function compactHighlightTokens( + tokens: readonly StyledHighlightToken[], +): StyledHighlightToken[] { + const compacted: StyledHighlightToken[] = []; + for (const token of tokens) { + if (token.text.length === 0) { + continue; + } + const previous = compacted.at(-1); + if (previous?.style === token.style) { + previous.text += token.text; + continue; + } + compacted.push({ text: token.text, style: token.style }); + } + return compacted; +} + // Preserve row height when a gutter or diff cell is intentionally blank. // Non-breaking space because the gutter uses numberOfLines={1}, which // collapses a plain ASCII space to zero height on web. diff --git a/patches/@react-native+virtualized-lists+0.81.5.patch b/patches/@react-native+virtualized-lists+0.81.5.patch new file mode 100644 index 0000000000..0ab5e8abcd --- /dev/null +++ b/patches/@react-native+virtualized-lists+0.81.5.patch @@ -0,0 +1,105 @@ +diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js +index c92ac9d..6bdc84f 100644 +--- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js ++++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js +@@ -47,6 +47,7 @@ import { + windowSizeOrDefault, + } from './VirtualizedListProps'; ++import {boundRetainedRangeOnDataGrowth} from './boundRetainedRangeOnDataGrowth'; + import { + computeWindowedRenderLimits, + keyExtractor as defaultKeyExtractor, + } from './VirtualizeUtils'; +@@ -706,7 +706,8 @@ class VirtualizedList extends StateSafePureComponent< + // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make + // sure we're rendering a reasonable range here. + const itemCount = newProps.getItemCount(newProps.data); +- if (itemCount === prevState.renderMask.numCells()) { ++ const previousItemCount = prevState.renderMask.numCells(); ++ if (itemCount === previousItemCount) { + return prevState; + } + +@@ -741,7 +742,7 @@ class VirtualizedList extends StateSafePureComponent< + } + } + +- const constrainedCells = VirtualizedList._constrainToItemCount( ++ let constrainedCells = VirtualizedList._constrainToItemCount( + maintainVisibleContentPositionAdjustment != null + ? { + first: +@@ -756,6 +758,20 @@ class VirtualizedList extends StateSafePureComponent< + newProps, + ); + ++ if ( ++ newProps.unstable_boundedRangeOnDataChange === true && ++ itemCount > previousItemCount ++ ) { ++ const retainedCellLimit = ++ maxToRenderPerBatchOrDefault(newProps.maxToRenderPerBatch) * 2; ++ constrainedCells = boundRetainedRangeOnDataGrowth( ++ constrainedCells, ++ previousItemCount, ++ itemCount, ++ retainedCellLimit, ++ ); ++ } ++ + return { + cellsAroundViewport: constrainedCells, + renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), +diff --git a/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js b/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js +new file mode 100644 +index 0000000..1e5b1f5 +--- /dev/null ++++ b/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js +@@ -0,0 +1,28 @@ ++/** ++ * Copyright (c) Meta Platforms, Inc. and affiliates. ++ * ++ * This source code is licensed under the MIT license found in the ++ * LICENSE file in the root directory of this source tree. ++ * ++ * @format ++ */ ++ ++'use strict'; ++ ++export function boundRetainedRangeOnDataGrowth( ++ cellsAroundViewport, ++ previousItemCount, ++ itemCount, ++ retainedCellLimit, ++) { ++ if (itemCount <= previousItemCount) { ++ return cellsAroundViewport; ++ } ++ return { ++ first: cellsAroundViewport.first, ++ last: Math.min( ++ cellsAroundViewport.last, ++ cellsAroundViewport.first + retainedCellLimit - 1, ++ ), ++ }; ++} +diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js +index 9d6d49e..54324a8 100644 +--- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js ++++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js +@@ -264,8 +264,14 @@ type OptionalVirtualizedListProps = { + * Amount of time between low-pri item render batches, e.g. for rendering items quite a ways off + * screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`. + */ + updateCellsBatchingPeriod?: ?number, ++ ++ /** ++ * Limits the synchronous render range retained when data grows. This is useful for lists that ++ * insert many bounded cells in the middle of an already-rendered range. ++ */ ++ unstable_boundedRangeOnDataChange?: ?boolean, + /** + * See `ViewabilityHelper` for flow type and further documentation. + */ + viewabilityConfig?: ViewabilityConfig, diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 781294b6d4..465a78fc4d 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -8,6 +8,10 @@ import { join, relative } from "node:path"; // workspace root live in their workspace's own node_modules, and patch-package resolves // the patch's node_modules/... paths relative to its working directory. const patchedPackages = [ + { + nodeModulesPath: "node_modules/@react-native/virtualized-lists", + patchPrefix: "@react-native+virtualized-lists+", + }, { nodeModulesPath: "node_modules/react-native-markdown-display", patchPrefix: "react-native-markdown-display+", From 36a13601dd1007bfc4097d605f2fd1327f785eb4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 14:01:00 +0200 Subject: [PATCH 12/73] Let users choose the metadata generation model (#3215) Expose Automatic and Manual metadata model selection in Host Settings, preserve existing configured fallbacks, and keep structured generation resilient through discovered candidates. Includes Playwright coverage, translations, public documentation, and settings-row alignment polish. --- .../metadata-generation-settings.spec.ts | 110 +++++++++++ packages/app/e2e/support/helpers/settings.ts | 1 + .../src/components/schedules/schedule-row.tsx | 4 +- packages/app/src/i18n/resources/ar.ts | 15 ++ packages/app/src/i18n/resources/en.ts | 15 ++ packages/app/src/i18n/resources/es.ts | 15 ++ packages/app/src/i18n/resources/fr.ts | 15 ++ packages/app/src/i18n/resources/ja.ts | 15 ++ packages/app/src/i18n/resources/ko.ts | 15 ++ packages/app/src/i18n/resources/pt-BR.ts | 15 ++ packages/app/src/i18n/resources/ru.ts | 15 ++ packages/app/src/i18n/resources/zh-CN.ts | 14 ++ packages/app/src/screens/settings-screen.tsx | 5 + .../settings/metadata-generation-page.tsx | 181 ++++++++++++++++++ packages/app/src/utils/host-routes.test.ts | 1 + packages/app/src/utils/host-routes.ts | 1 + .../structured-generation-providers.test.ts | 33 +++- .../agent/structured-generation-providers.ts | 52 ----- public-docs/metadata-generation.md | 10 +- 19 files changed, 466 insertions(+), 66 deletions(-) create mode 100644 packages/app/e2e/browser/metadata-generation-settings.spec.ts create mode 100644 packages/app/src/screens/settings/metadata-generation-page.tsx diff --git a/packages/app/e2e/browser/metadata-generation-settings.spec.ts b/packages/app/e2e/browser/metadata-generation-settings.spec.ts new file mode 100644 index 0000000000..5cdc507e8d --- /dev/null +++ b/packages/app/e2e/browser/metadata-generation-settings.spec.ts @@ -0,0 +1,110 @@ +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"; + +async function openMetadataGenerationSettings(page: Page) { + const serverId = getServerId(); + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, serverId); + await openHostSection(page, serverId, "metadata"); + await expectSettingsHeader(page, "Metadata"); +} + +async function openManualMetadataModelPicker(page: Page) { + await page.getByRole("button", { name: "Manual", exact: true }).click(); + await page.getByRole("button", { name: /Select model/ }).click(); +} + +test("chooses a metadata model and can return to automatic selection", async ({ + page, +}, testInfo) => { + await openMetadataGenerationSettings(page); + + await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible(); + await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await page.screenshot({ + path: testInfo.outputPath("metadata-automatic.png"), + fullPage: true, + }); + + await openManualMetadataModelPicker(page); + await page.getByText("Mock Load Test", { exact: true }).click(); + await expect(page.getByText("Ten second stream", { exact: true })).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath("metadata-model-picker.png"), + fullPage: true, + }); + await page.getByText("Ten second stream", { exact: true }).click(); + + await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath("metadata-manual-persisted.png"), + fullPage: true, + }); + + await page.getByRole("button", { name: "Automatic", exact: true }).click(); + await page.reload(); + + await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.getByRole("button", { name: /Ten second stream/ })).toHaveCount(0); +}); + +test("replaces only the first configured metadata model", async ({ page }) => { + const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previousConfig = await client.getDaemonConfig(); + + try { + await client.patchDaemonConfig({ + metadataGeneration: { + providers: [ + { provider: "mock", model: "five-minute-stream" }, + { provider: "mock", model: "thirty-minute-stream" }, + ], + }, + }); + await openMetadataGenerationSettings(page); + + await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await openManualMetadataModelPicker(page); + await page.getByText("Ten second stream", { exact: true }).click(); + + await expect + .poll(async () => (await client.getDaemonConfig()).config.metadataGeneration.providers) + .toEqual([ + { provider: "mock", model: "ten-second-stream" }, + { provider: "mock", model: "thirty-minute-stream" }, + ]); + } finally { + try { + await client.patchDaemonConfig({ + metadataGeneration: { + providers: previousConfig.config.metadataGeneration.providers, + }, + }); + } finally { + await client.close().catch(() => undefined); + } + } +}); diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index 9a3a125124..6f03dfbdcd 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -36,6 +36,7 @@ type HostSection = | "connections" | "pair-device" | "agents" + | "metadata" | "workspaces" | "providers" | "usage" diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx index faf491a35d..8c2e50ba81 100644 --- a/packages/app/src/components/schedules/schedule-row.tsx +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -384,12 +384,12 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minWidth: 0, flexDirection: "row", - alignItems: "center", + alignItems: "flex-start", gap: theme.spacing[3], }, leading: { width: PROVIDER_ICON_SIZE, - height: PROVIDER_ICON_SIZE, + height: 20, alignItems: "center", justifyContent: "center", }, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 810c8d3a10..c5c5b0431c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1762,12 +1762,27 @@ export const ar: TranslationResources = { projects: "المشاريع", connections: "اتصالات", agents: "Agents", + metadata: "البيانات الوصفية", workspaces: "Workspaces", providers: "مقدمي الخدمات", usage: "الاستخدام", terminals: "Terminals", host: "نظرة عامة", }, + metadataGeneration: { + title: "إنشاء البيانات الوصفية", + description: + "اختر النموذج الذي يستخدمه Paseo لعناوين مساحات العمل وأسماء الفروع ورسائل الالتزام ومسودات طلبات السحب", + selection: "اختيار النموذج", + automatic: "تلقائي", + preferred: "يدوي", + automaticHint: "يختار Paseo نموذجًا سريعًا متاحًا", + preferredHint: "اختر النموذج الذي يستخدمه Paseo", + model: "النموذج", + fallbackHint: "إذا لم يكن متاحًا، يستخدم Paseo نموذجًا آخر متاحًا", + docs: "الوثائق", + saveError: "تعذر تحديث إنشاء البيانات الوصفية", + }, general: { title: "عام", browserData: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9caf7292b1..dde57b1cef 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1773,12 +1773,27 @@ export const en = { projects: "Projects", connections: "Connections", agents: "Agents", + metadata: "Metadata", workspaces: "Workspaces", providers: "Providers", usage: "Usage", terminals: "Terminals", host: "Overview", }, + metadataGeneration: { + title: "Metadata generation", + description: + "Choose the model Paseo uses for workspace titles, branch names, commit messages, and pull request drafts", + selection: "Model selection", + automatic: "Automatic", + preferred: "Manual", + automaticHint: "Paseo picks a fast available model", + preferredHint: "Choose the model Paseo uses", + model: "Model", + fallbackHint: "If it is unavailable, Paseo falls back to another available model", + docs: "Docs", + saveError: "Unable to update metadata generation", + }, general: { title: "General", browserData: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index c5d4a2d27d..b286bf6304 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1805,12 +1805,27 @@ export const es: TranslationResources = { projects: "Proyectos", connections: "Conexiones", agents: "Agents", + metadata: "Metadatos", workspaces: "Workspaces", providers: "Proveedores", usage: "Uso", terminals: "Terminals", host: "Resumen", }, + metadataGeneration: { + title: "Generación de metadatos", + description: + "Elige el modelo que Paseo usa para títulos de espacios de trabajo, nombres de ramas, mensajes de commit y borradores de pull requests", + selection: "Selección de modelo", + automatic: "Automática", + preferred: "Manual", + automaticHint: "Paseo elige un modelo rápido disponible", + preferredHint: "Elige el modelo que usa Paseo", + model: "Modelo", + fallbackHint: "Si no está disponible, Paseo usa otro modelo disponible", + docs: "Documentación", + saveError: "No se pudo actualizar la generación de metadatos", + }, general: { title: "General", browserData: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index bc22d3afeb..d7e05cd9c4 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1809,12 +1809,27 @@ export const fr: TranslationResources = { projects: "Projets", connections: "Relations", agents: "Agents", + metadata: "Métadonnées", workspaces: "Workspaces", providers: "Fournisseurs", usage: "Utilisation", terminals: "Terminals", host: "Aperçu", }, + metadataGeneration: { + title: "Génération de métadonnées", + description: + "Choisissez le modèle utilisé par Paseo pour les titres d’espaces de travail, les noms de branches, les messages de commit et les brouillons de pull request", + selection: "Sélection du modèle", + automatic: "Automatique", + preferred: "Manuel", + automaticHint: "Paseo choisit un modèle rapide disponible", + preferredHint: "Choisissez le modèle utilisé par Paseo", + model: "Modèle", + fallbackHint: "S’il est indisponible, Paseo utilise un autre modèle disponible", + docs: "Documentation", + saveError: "Impossible de mettre à jour la génération de métadonnées", + }, general: { title: "Général", browserData: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 07308bb8f1..529d6e157f 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1778,12 +1778,27 @@ export const ja: TranslationResources = { projects: "プロジェクト", connections: "接続", agents: "エージェント", + metadata: "メタデータ", workspaces: "ワークスペース", providers: "プロバイダー", usage: "使用状況", terminals: "ターミナル", host: "概要", }, + metadataGeneration: { + title: "メタデータ生成", + description: + "ワークスペースのタイトル、ブランチ名、コミットメッセージ、プルリクエストの下書きに使用するモデルを選択します", + selection: "モデル選択", + automatic: "自動", + preferred: "手動", + automaticHint: "Paseo が利用可能な高速モデルを選択します", + preferredHint: "Paseo が使用するモデルを選択します", + model: "モデル", + fallbackHint: "利用できない場合、Paseo は別の利用可能なモデルを使用します", + docs: "ドキュメント", + saveError: "メタデータ生成を更新できません", + }, general: { title: "一般", browserData: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 2762f0174d..f242d311a9 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1772,12 +1772,27 @@ export const ko: TranslationResources = { projects: "프로젝트", connections: "연결", agents: "에이전트", + metadata: "메타데이터", workspaces: "워크스페이스", providers: "프로바이더", usage: "사용량", terminals: "터미널", host: "개요", }, + metadataGeneration: { + title: "메타데이터 생성", + description: + "워크스페이스 제목, 브랜치 이름, 커밋 메시지 및 풀 리퀘스트 초안에 사용할 모델을 선택하세요", + selection: "모델 선택", + automatic: "자동", + preferred: "수동", + automaticHint: "Paseo가 사용 가능한 빠른 모델을 선택합니다", + preferredHint: "Paseo에서 사용할 모델을 선택하세요", + model: "모델", + fallbackHint: "사용할 수 없으면 Paseo가 다른 사용 가능한 모델을 사용합니다", + docs: "문서", + saveError: "메타데이터 생성을 업데이트할 수 없습니다", + }, general: { title: "일반", browserData: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 4aeba0209f..f307377b79 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1791,12 +1791,27 @@ export const ptBR: TranslationResources = { projects: "Projetos", connections: "Conexões", agents: "Agentes", + metadata: "Metadados", workspaces: "Workspaces", providers: "Provedores", usage: "Uso", terminals: "Terminais", host: "Visão geral", }, + metadataGeneration: { + title: "Geração de metadados", + description: + "Escolha o modelo usado pelo Paseo para títulos de espaços de trabalho, nomes de branches, mensagens de commit e rascunhos de pull request", + selection: "Seleção de modelo", + automatic: "Automática", + preferred: "Manual", + automaticHint: "O Paseo escolhe um modelo rápido disponível", + preferredHint: "Escolha o modelo que o Paseo usa", + model: "Modelo", + fallbackHint: "Se ele não estiver disponível, o Paseo usa outro modelo disponível", + docs: "Documentação", + saveError: "Não foi possível atualizar a geração de metadados", + }, general: { title: "Geral", browserData: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 37ab6b1cf1..1aacec9748 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1796,12 +1796,27 @@ export const ru: TranslationResources = { projects: "Проекты", connections: "Соединения", agents: "Agents", + metadata: "Метаданные", workspaces: "Workspaces", providers: "Провайдеры", usage: "Использование", terminals: "Terminals", host: "Обзор", }, + metadataGeneration: { + title: "Создание метаданных", + description: + "Выберите модель, которую Paseo использует для названий рабочих пространств, веток, сообщений коммитов и черновиков pull request", + selection: "Выбор модели", + automatic: "Автоматически", + preferred: "Вручную", + automaticHint: "Paseo выбирает быструю доступную модель", + preferredHint: "Выберите модель, которую использует Paseo", + model: "Модель", + fallbackHint: "Если она недоступна, Paseo использует другую доступную модель", + docs: "Документация", + saveError: "Не удалось обновить создание метаданных", + }, general: { title: "Общий", browserData: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 1d2bed9259..1d53b78de6 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1742,12 +1742,26 @@ export const zhCN: TranslationResources = { projects: "项目", connections: "连接", agents: "Agents", + metadata: "元数据", workspaces: "Workspaces", providers: "Providers", usage: "使用情况", terminals: "Terminals", host: "概览", }, + metadataGeneration: { + title: "元数据生成", + description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型", + selection: "模型选择", + automatic: "自动", + preferred: "手动", + automaticHint: "Paseo 会选择一个可用的快速模型", + preferredHint: "选择 Paseo 使用的模型", + model: "模型", + fallbackHint: "如果不可用,Paseo 会改用其他可用模型", + docs: "文档", + saveError: "无法更新元数据生成设置", + }, general: { title: "通用", browserData: { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index af52bb2405..c7e6df8e56 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -36,6 +36,7 @@ import { SquareTerminal, Code2, Smartphone, + Sparkles, } from "lucide-react-native"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; @@ -107,6 +108,7 @@ import { HostWorkspacesPage, HostTerminalsPage, } from "@/screens/settings/host-page"; +import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; @@ -184,6 +186,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "connections", labelKey: "settings.hostSections.connections", icon: Network }, { id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone }, { id: "agents", labelKey: "settings.hostSections.agents", icon: Bot }, + { id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles }, { id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 }, { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, { id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge }, @@ -203,6 +206,8 @@ function renderHostSettingsContent( return ; case "agents": return ; + case "metadata": + return ; case "workspaces": return ; case "providers": diff --git a/packages/app/src/screens/settings/metadata-generation-page.tsx b/packages/app/src/screens/settings/metadata-generation-page.tsx new file mode 100644 index 0000000000..3576c23839 --- /dev/null +++ b/packages/app/src/screens/settings/metadata-generation-page.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { CombinedModelSelector } from "@/components/combined-model-selector"; +import { ExternalLink } from "@/components/ui/external-link"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { buildSelectableProviderSelectorProviders } from "@/provider-selection/provider-selection"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { settingsStyles } from "@/styles/settings"; + +const METADATA_GENERATION_DOCS_URL = "https://paseo.sh/docs/metadata-generation"; +type SelectionMode = "automatic" | "preferred"; + +export function MetadataGenerationPage({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const { config, isLoading: isConfigLoading, patchConfig } = useDaemonConfig(serverId); + const snapshot = useProvidersSnapshot(serverId); + const providers = useMemo( + () => buildSelectableProviderSelectorProviders(snapshot.entries), + [snapshot.entries], + ); + const configuredProviders = config?.metadataGeneration.providers; + const configuredProvider = configuredProviders?.[0] ?? null; + const savedMode: SelectionMode = configuredProvider ? "preferred" : "automatic"; + const [draftMode, setDraftMode] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const mode = draftMode ?? savedMode; + + useEffect(() => { + setDraftMode(null); + }, [configuredProvider?.model, configuredProvider?.provider]); + + const modeOptions = useMemo( + () => [ + { value: "automatic" as const, label: t("settings.metadataGeneration.automatic") }, + { value: "preferred" as const, label: t("settings.metadataGeneration.preferred") }, + ], + [t], + ); + + const saveProviders = useCallback( + async (providersPatch: { provider: string; model?: string }[]) => { + setIsSaving(true); + try { + await patchConfig({ metadataGeneration: { providers: providersPatch } }); + } catch (error) { + setDraftMode(null); + Alert.alert( + t("settings.metadataGeneration.saveError"), + error instanceof Error ? error.message : String(error), + ); + } finally { + setIsSaving(false); + } + }, + [patchConfig, t], + ); + + const handleModeChange = useCallback( + (next: SelectionMode) => { + setDraftMode(next); + if (next === "automatic") { + void saveProviders([]); + } + }, + [saveProviders], + ); + + const handleModelSelect = useCallback( + (provider: AgentProvider, model: string) => { + setDraftMode("preferred"); + void saveProviders([ + { provider, ...(model ? { model } : {}) }, + ...(configuredProviders?.slice(1) ?? []), + ]); + }, + [configuredProviders, saveProviders], + ); + + const handleSelectorOpen = useCallback(() => { + snapshot.refetchIfStale(configuredProvider?.provider); + }, [configuredProvider?.provider, snapshot]); + const handleRetryProvider = useCallback( + (provider: AgentProvider) => snapshot.refresh([provider]), + [snapshot], + ); + const docsLink = useMemo( + () => ( + + ), + [t], + ); + + if (isConfigLoading || !config) { + return ( + + + + ); + } + + return ( + + {t("settings.metadataGeneration.description")} + + + + + {t("settings.metadataGeneration.selection")} + + + {mode === "automatic" + ? t("settings.metadataGeneration.automaticHint") + : t("settings.metadataGeneration.preferredHint")} + + + + + {mode === "preferred" ? ( + + + {t("settings.metadataGeneration.model")} + + {t("settings.metadataGeneration.fallbackHint")} + + + + + ) : null} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + description: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + lineHeight: theme.fontSize.xs * 1.45, + marginHorizontal: theme.spacing[1], + }, + loading: { + alignItems: "center", + justifyContent: "center", + minHeight: 180, + }, + spinnerColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 29ac067c90..644139e92d 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -248,6 +248,7 @@ describe("host settings section slugs", () => { expect(normalizeHostSectionSlug("connections")).toBe("connections"); expect(normalizeHostSectionSlug("pair-device")).toBe("pair-device"); expect(normalizeHostSectionSlug("agents")).toBe("agents"); + expect(normalizeHostSectionSlug("metadata")).toBe("metadata"); expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces"); expect(normalizeHostSectionSlug("projects")).toBe("projects"); expect(normalizeHostSectionSlug("providers")).toBe("providers"); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 8c53ef0b45..87e1517884 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -512,6 +512,7 @@ export const HOST_SECTION_SLUGS = [ "connections", "pair-device", "agents", + "metadata", "workspaces", "providers", "usage", diff --git a/packages/server/src/server/agent/structured-generation-providers.test.ts b/packages/server/src/server/agent/structured-generation-providers.test.ts index 06c8bed6bd..d34aff9640 100644 --- a/packages/server/src/server/agent/structured-generation-providers.test.ts +++ b/packages/server/src/server/agent/structured-generation-providers.test.ts @@ -18,8 +18,17 @@ class ProviderSnapshots { } describe("resolveStructuredGenerationProviders", () => { - test("uses explicit configured provider models without refreshing provider snapshots", async () => { - const snapshots = new ProviderSnapshots([]); + test("tries the configured model before dynamically discovered fallbacks", async () => { + const snapshots = new ProviderSnapshots([ + { + provider: "work-claude", + status: READY, + enabled: true, + models: [ + { provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true }, + ], + }, + ]); const providers = await resolveStructuredGenerationProviders({ cwd: "/tmp/repo", @@ -31,8 +40,11 @@ describe("resolveStructuredGenerationProviders", () => { }, }); - expect(providers).toEqual([{ provider: "mock", model: "ten-second-stream" }]); - expect(snapshots.calls).toEqual([]); + expect(providers).toEqual([ + { provider: "mock", model: "ten-second-stream" }, + { provider: "work-claude", model: "claude-haiku-2026" }, + ]); + expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]); }); test("falls back to dynamic defaults and current selection when no provider is configured", async () => { @@ -155,7 +167,7 @@ describe("resolveStructuredGenerationProviders", () => { ]); }); - test("uses explicit configured provider models as-is instead of waiting to normalize aliases", async () => { + test("normalizes configured nested-provider aliases from the provider snapshot", async () => { const snapshots = new ProviderSnapshots([ { provider: "opencode", @@ -186,8 +198,8 @@ describe("resolveStructuredGenerationProviders", () => { }, }); - expect(providers).toEqual([{ provider: "plexus", model: "small-fast" }]); - expect(snapshots.calls).toEqual([]); + expect(providers).toEqual([{ provider: "opencode", model: "plexus/small-fast" }]); + expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]); }); test("keeps explicit candidates when provider snapshots are in error state", async () => { @@ -215,7 +227,10 @@ describe("resolveStructuredGenerationProviders", () => { }, }); - expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]); - expect(snapshots.calls).toEqual([]); + expect(providers).toEqual([ + { provider: "current-provider", model: "configured-model" }, + { provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" }, + ]); + expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]); }); }); diff --git a/packages/server/src/server/agent/structured-generation-providers.ts b/packages/server/src/server/agent/structured-generation-providers.ts index 89a331b66d..2a09ee1844 100644 --- a/packages/server/src/server/agent/structured-generation-providers.ts +++ b/packages/server/src/server/agent/structured-generation-providers.ts @@ -44,22 +44,6 @@ export async function resolveStructuredGenerationProviders( options: ResolveStructuredGenerationProvidersOptions, ): Promise { const configuredProviders = readConfiguredProviders(options.daemonConfig); - if (configuredProviders.length > 0) { - const explicitProviders = resolveExplicitConfiguredProviders(configuredProviders); - if (explicitProviders.length === configuredProviders.length) { - return dedupeProviders(explicitProviders); - } - - const providerEntries = await options.providerSnapshotManager.listProviders({ - cwd: options.cwd, - wait: false, - }); - const providers = resolveConfiguredProviders(configuredProviders, providerEntries); - if (providers.length > 0) { - return dedupeProviders(providers); - } - } - const providerEntries = await options.providerSnapshotManager.listProviders({ cwd: options.cwd, wait: true, @@ -100,42 +84,6 @@ export async function resolveStructuredGenerationProviders( return dedupeProviders(providers); } -function resolveConfiguredProviders( - configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[], - providerEntries: readonly ProviderSnapshotEntry[], -): StructuredGenerationProvider[] { - const enabledEntries = providerEntries.filter((entry) => entry.enabled); - const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0); - const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry])); - const providers: StructuredGenerationProvider[] = []; - for (const configured of configuredProviders) { - const resolved = resolveConfiguredCandidate(configured, modelEntries, entriesByProvider); - if (resolved) { - providers.push(resolved); - } - } - return providers; -} - -function resolveExplicitConfiguredProviders( - configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[], -): StructuredGenerationProvider[] { - const providers: StructuredGenerationProvider[] = []; - for (const configured of configuredProviders) { - const provider = configured.provider.trim(); - const model = configured.model?.trim(); - if (!provider || !model) { - continue; - } - providers.push({ - provider, - model, - ...(configured.thinkingOptionId ? { thinkingOptionId: configured.thinkingOptionId } : {}), - }); - } - return providers; -} - function resolveCurrentSelection( selection: ResolveStructuredGenerationProvidersOptions["currentSelection"], readyEntries: readonly ProviderSnapshotEntry[], diff --git a/public-docs/metadata-generation.md b/public-docs/metadata-generation.md index f9e7a7c218..1d23ec661d 100644 --- a/public-docs/metadata-generation.md +++ b/public-docs/metadata-generation.md @@ -37,9 +37,13 @@ Each default is a match on the model id or name, so the first enabled provider t The intent of the default order is to prefer small, fast, cheap models for these short tasks before falling back to whatever you have selected. -## Configuring the providers +## Choose a model -To control which models Paseo uses — for example to keep all metadata generation on one provider, or to prefer a local model — set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults. +Open **Settings → Host → Metadata** and select **Automatic** or **Manual**. In Manual mode, Paseo tries the model you choose first, then falls back to the remaining configured models and built-in candidates. Choosing a model replaces the first entry in `agents.metadataGeneration.providers` and preserves the rest of the list. + +## Configure a custom fallback order + +To configure more than one preferred model or control the exact order, set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults. ```json { @@ -60,7 +64,7 @@ Each entry accepts: - `model` (optional) — a specific model id. Omit it to use that provider's default model. - `thinkingOptionId` (optional) — a reasoning/thinking level for models that support one. Falls back to the model's default if the value isn't valid for that model. -Restart the daemon after editing the file. +The Settings screen replaces only the first entry and preserves the rest of a custom list. Restart the daemon after editing the file directly. ## Per-project instructions From 8c003351d0b8ac154511bd5bb408450ad22e931b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 14:09:44 +0200 Subject: [PATCH 13/73] fix(app): keep theme selection consistent (#3214) Theme registration, the appearance picker, persistence, and shortcut cycling drifted as new choices were added. Make one ordered catalog own those paths so Auto and Pure Black participate consistently, while lifting the Pure Black selected-workspace surface above its sidebar. --- .../browser/appearance-theme-picker.spec.ts | 30 +++++ packages/app/src/app/_layout.tsx | 7 +- .../app/src/hooks/use-settings/storage.ts | 6 +- .../appearance/appearance-section.tsx | 56 +++----- .../appearance/apply-appearance.test.ts | 13 +- .../settings/appearance/apply-appearance.ts | 14 +- packages/app/src/styles/theme.test.ts | 24 +++- packages/app/src/styles/theme.ts | 123 +++++++++++++----- packages/app/src/styles/unistyles.ts | 30 +---- 9 files changed, 171 insertions(+), 132 deletions(-) diff --git a/packages/app/e2e/browser/appearance-theme-picker.spec.ts b/packages/app/e2e/browser/appearance-theme-picker.spec.ts index 818cc52baa..0d654f3194 100644 --- a/packages/app/e2e/browser/appearance-theme-picker.spec.ts +++ b/packages/app/e2e/browser/appearance-theme-picker.spec.ts @@ -1,4 +1,7 @@ import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; import { openSettingsSection } from "../support/helpers/settings"; test("shows Pure black in the appearance picker", async ({ page }, testInfo) => { @@ -15,6 +18,33 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) => }); }); +test("keeps the selected workspace visible in Pure black", async ({ page }, testInfo) => { + const workspace = await seedWorkspace({ + repoPrefix: "pure-black-selected-workspace-", + title: "Selected workspace", + }); + + try { + await page.addInitScript(() => { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" })); + }); + await gotoAppShell(page); + + const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); + + await expect(row).toHaveAttribute("aria-selected", "true"); + await expect(row).toHaveCSS("background-color", "rgb(22, 22, 22)"); + await page.screenshot({ + path: testInfo.outputPath("pure-black-selected-workspace.png"), + fullPage: true, + }); + } finally { + await workspace.cleanup(); + } +}); + test("applies the interface font size to settings text", async ({ page }) => { await page.addInitScript(() => { localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 })); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index a9a49e326e..13fd94904d 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -107,7 +107,7 @@ import { getDaemonStartService } from "@/runtime/daemon-start-service"; import { applyAppearance } from "@/screens/settings/appearance/apply-appearance"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { flushDraftPersistStorage } from "@/stores/draft-store"; -import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; +import { getNextThemePreference, THEME_TO_UNISTYLES } from "@/styles/theme"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import type { HostProfile } from "@/types/host-connection"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; @@ -424,7 +424,6 @@ interface AppContainerProps { chromeEnabled?: boolean; } -const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"]; const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12; function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) { @@ -444,9 +443,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon const { width: viewportWidth } = useWindowDimensions(); const cycleTheme = useCallback(() => { - const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName); - const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length; - void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex] }); + void updateSettings({ theme: getNextThemePreference(settings.theme) }); }, [settings.theme, updateSettings]); const isCompactLayout = useIsCompactFormFactor(); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index dbe180cc79..3b78310fa7 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -13,7 +13,7 @@ import { parseSidebarRowItems, type SidebarRowItems, } from "@/components/sidebar/display-preferences/row-items"; -import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; +import { THEME_OPTIONS, type ThemePreference } from "@/styles/theme"; export const APP_SETTINGS_KEY = "@paseo:app-settings"; export const APP_SETTINGS_QUERY_KEY = ["app-settings"]; @@ -27,7 +27,7 @@ export type WorkspaceTitleSource = "title" | "branch"; export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none"; export type ToolCallDetailLevel = "overview" | "detailed"; -const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); +const VALID_THEMES = new Set(THEME_OPTIONS.map((option) => option.name)); const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); const VALID_WORKSPACE_TITLE_SOURCES = new Set(["title", "branch"]); const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set([ @@ -48,7 +48,7 @@ export const MAX_CODE_FONT_SIZE = 22; // line-height 1.5×22=33 stays safe export const MAX_FONT_FAMILY_LENGTH = 200; export interface AppSettings { - theme: ThemeName | "auto"; + theme: ThemePreference; language: AppLanguage; sendBehavior: SendBehavior; serviceUrlBehavior: ServiceUrlBehavior; diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index b6516bf901..8dfda23538 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Text, TextInput, View, type PressableStateCallbackType } from "react-native"; @@ -32,6 +32,7 @@ import { DEFAULT_MONO_FONT_STACK, DEFAULT_UI_FONT_STACK, ICON_SIZE, + THEME_OPTIONS, THEME_SWATCHES, type Theme, } from "@/styles/theme"; @@ -53,28 +54,9 @@ const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); function getThemeLabel(t: TFunction, value: AppSettings["theme"]): string { - const labelKeys: Record = { - light: "settings.appearance.theme.options.light", - dark: "settings.appearance.theme.options.dark", - zinc: "settings.appearance.theme.options.zinc", - midnight: "settings.appearance.theme.options.midnight", - claude: "settings.appearance.theme.options.claude", - ghostty: "settings.appearance.theme.options.ghostty", - pureBlack: "settings.appearance.theme.options.pureBlack", - auto: "settings.appearance.theme.options.auto", - }; - return t(labelKeys[value]); + return t(`settings.appearance.theme.options.${value}`); } -const PRIMARY_THEMES: readonly AppSettings["theme"][] = ["light", "dark", "auto"]; -const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [ - "zinc", - "midnight", - "claude", - "ghostty", - "pureBlack", -]; - // Platform default stacks can be the bare native tokens ("normal"/"monospace"); // those read as a bug, so show a human label in the placeholder instead. const BARE_DEFAULT_STACKS: ReadonlySet = new Set(["normal", "monospace"]); @@ -169,23 +151,21 @@ function ThemeRow({ value, onChange }: ThemeRowProps) { - {PRIMARY_THEMES.map((themeValue) => ( - - ))} - - {DARK_VARIANT_THEMES.map((themeValue) => ( - - ))} + {THEME_OPTIONS.map((option, index) => { + const previousOption = THEME_OPTIONS[index - 1]; + return ( + + {previousOption && previousOption.group !== option.group ? ( + + ) : null} + + + ); + })}
diff --git a/packages/app/src/screens/settings/appearance/apply-appearance.test.ts b/packages/app/src/screens/settings/appearance/apply-appearance.test.ts index 3b6afddbd9..3ce041fbd5 100644 --- a/packages/app/src/screens/settings/appearance/apply-appearance.test.ts +++ b/packages/app/src/screens/settings/appearance/apply-appearance.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { darkHighlightColors, resolveSyntaxColors } from "@getpaseo/highlight"; -import { DEFAULT_UI_FONT_STACK } from "@/styles/theme"; +import { DEFAULT_UI_FONT_STACK, REGISTERED_THEMES } from "@/styles/theme"; import { applyAppearance, type AppearanceInput } from "./apply-appearance"; // Override the global react-native-unistyles mock (vitest.setup.ts) so that @@ -14,16 +14,7 @@ const { runtime, updateTheme } = vi.hoisted(() => { }); vi.mock("react-native-unistyles", () => ({ UnistylesRuntime: runtime })); -// The registered Unistyles theme keys, in the order applyAppearance patches them. -const ALL_THEME_KEYS = [ - "light", - "dark", - "darkZinc", - "darkMidnight", - "darkClaude", - "darkGhostty", - "darkPureBlack", -] as const; +const ALL_THEME_KEYS = Object.keys(REGISTERED_THEMES); // The signature of the updater passed to UnistylesRuntime.updateTheme. type ThemeUpdater = (theme: FakeTheme) => FakeTheme; diff --git a/packages/app/src/screens/settings/appearance/apply-appearance.ts b/packages/app/src/screens/settings/appearance/apply-appearance.ts index 90fe782fdb..9f469d5c8a 100644 --- a/packages/app/src/screens/settings/appearance/apply-appearance.ts +++ b/packages/app/src/screens/settings/appearance/apply-appearance.ts @@ -4,22 +4,12 @@ import { DEFAULT_UI_FONT_STACK, DEFAULT_MONO_FONT_STACK, FONT_SIZE, + REGISTERED_THEMES, type Theme, } from "@/styles/theme"; import { applyRootUiFont } from "./apply-root-font"; -// All registered Unistyles keys — pinned literal (greppable, type-checked). -// The `as const` element types stay assignable to -// `UnistylesRuntime.updateTheme`'s first argument as themes are added. -const ALL_THEME_KEYS = [ - "light", - "dark", - "darkZinc", - "darkMidnight", - "darkClaude", - "darkGhostty", - "darkPureBlack", -] as const; +const ALL_THEME_KEYS = Object.keys(REGISTERED_THEMES) as (keyof typeof REGISTERED_THEMES)[]; // The UI font size at which the FONT_SIZE ramp is authored (1.0 scale factor). const BASE_UI_REFERENCE = FONT_SIZE.base; // 16 diff --git a/packages/app/src/styles/theme.test.ts b/packages/app/src/styles/theme.test.ts index daa6004948..ea2164192c 100644 --- a/packages/app/src/styles/theme.test.ts +++ b/packages/app/src/styles/theme.test.ts @@ -1,5 +1,22 @@ import { describe, expect, it } from "vitest"; -import { darkPureBlackTheme } from "./theme"; +import { darkPureBlackTheme, getNextThemePreference, THEME_OPTIONS } from "./theme"; + +describe("Theme catalog", () => { + it("owns the picker and shortcut order", () => { + expect(THEME_OPTIONS.map((option) => option.name)).toEqual([ + "light", + "dark", + "auto", + "zinc", + "midnight", + "claude", + "ghostty", + "pureBlack", + ]); + expect(getNextThemePreference("dark")).toBe("auto"); + expect(getNextThemePreference("pureBlack")).toBe("light"); + }); +}); describe("Pure black theme", () => { it("uses a pure black application and terminal background", () => { @@ -13,6 +30,11 @@ describe("Pure black theme", () => { expect(darkPureBlackTheme.colors.accentBright).toBe("#7ccba0"); }); + it("keeps selected sidebar rows distinct from the black sidebar", () => { + expect(darkPureBlackTheme.colors.surfaceSidebar).toBe("#000000"); + expect(darkPureBlackTheme.colors.surfaceSidebarHover).toBe("#161616"); + }); + it("keeps ANSI black output readable on its zero-luminance terminal background", () => { expect(darkPureBlackTheme.colors.terminal.black).toBe("#595959"); expect(darkPureBlackTheme.colors.terminal.brightBlack).toBe("#8a8a8a"); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 709061a988..408ed87ae4 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -107,8 +107,6 @@ export const baseColors = { }, } as const; -export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty" | "pureBlack"; - // Diff colors — the +/- inside a diff view, where the color *is* the signal and has to // survive being scanned line by line, so it stays saturated. Light uses muted tones, dark // uses the brighter palette values. @@ -669,7 +667,8 @@ const pureBlackDarkColors = buildDarkSemanticColors({ surface4: "#2d2d2d", surfaceDiffEmpty: "#0c0c0c", surfaceSidebar: "#000000", - surfaceSidebarHover: "#0d0d0d", + // Selected sidebar rows share this surface with hover, so it must remain visible at rest. + surfaceSidebarHover: "#161616", foregroundMuted: "#a1a1aa", foregroundExtraMuted: "#71717a", scrollbarHandle: "#71717a", @@ -717,40 +716,94 @@ export const lightTheme = { // Keep compatibility with existing code export const theme = darkTheme; -export type Theme = - | typeof darkTheme - | typeof darkZincTheme - | typeof darkMidnightTheme - | typeof darkClaudeTheme - | typeof darkGhosttyTheme - | typeof darkPureBlackTheme - | typeof lightTheme; +export const THEME_OPTIONS = [ + { + name: "light", + group: "primary", + unistylesName: "light", + theme: lightTheme, + swatch: "#ffffff", + }, + { + name: "dark", + group: "primary", + unistylesName: "dark", + theme: darkTheme, + swatch: "#2D8B62", + }, + { name: "auto", group: "primary" }, + { + name: "zinc", + group: "variant", + unistylesName: "darkZinc", + theme: darkZincTheme, + swatch: "#808080", + }, + { + name: "midnight", + group: "variant", + unistylesName: "darkMidnight", + theme: darkMidnightTheme, + swatch: "#4A6BA8", + }, + { + name: "claude", + group: "variant", + unistylesName: "darkClaude", + theme: darkClaudeTheme, + swatch: "#D97757", + }, + { + name: "ghostty", + group: "variant", + unistylesName: "darkGhostty", + theme: darkGhosttyTheme, + swatch: "#8caaee", + }, + { + name: "pureBlack", + group: "variant", + unistylesName: "darkPureBlack", + theme: darkPureBlackTheme, + swatch: "#000000", + }, +] as const; -type UnistylesThemeKey = - | "light" - | "dark" - | "darkZinc" - | "darkMidnight" - | "darkClaude" - | "darkGhostty" - | "darkPureBlack"; +export type ThemePreference = (typeof THEME_OPTIONS)[number]["name"]; +export type ThemeName = Exclude; +type ConcreteThemeOption = Exclude<(typeof THEME_OPTIONS)[number], { name: "auto" }>; +export type Theme = ConcreteThemeOption["theme"]; -export const THEME_TO_UNISTYLES: Record = { - light: "light", - dark: "dark", - zinc: "darkZinc", - midnight: "darkMidnight", - claude: "darkClaude", - ghostty: "darkGhostty", - pureBlack: "darkPureBlack", +const CONCRETE_THEME_OPTIONS = THEME_OPTIONS.filter( + (option): option is ConcreteThemeOption => option.name !== "auto", +); + +type ThemeToUnistyles = { + [Name in ThemeName]: Extract["unistylesName"]; }; -export const THEME_SWATCHES: Record = { - light: "#ffffff", - dark: "#2D8B62", - zinc: "#808080", - midnight: "#4A6BA8", - claude: "#D97757", - ghostty: "#8caaee", - pureBlack: "#000000", +type ThemeSwatches = { + [Name in ThemeName]: Extract["swatch"]; }; + +type RegisteredThemes = { + [Option in ConcreteThemeOption as Option["unistylesName"]]: Option["theme"]; +}; + +export const THEME_TO_UNISTYLES = Object.fromEntries( + CONCRETE_THEME_OPTIONS.map((option) => [option.name, option.unistylesName]), +) as ThemeToUnistyles; + +export const THEME_SWATCHES = Object.fromEntries( + CONCRETE_THEME_OPTIONS.map((option) => [option.name, option.swatch]), +) as ThemeSwatches; + +export const REGISTERED_THEMES = Object.fromEntries( + CONCRETE_THEME_OPTIONS.map((option) => [option.unistylesName, option.theme]), +) as RegisteredThemes; + +export function getNextThemePreference(current: ThemePreference): ThemePreference { + const currentIndex = THEME_OPTIONS.findIndex((option) => option.name === current); + const nextIndex = (currentIndex + 1) % THEME_OPTIONS.length; + return THEME_OPTIONS[nextIndex]?.name ?? THEME_OPTIONS[0].name; +} diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index 46124d0530..b679d5e694 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -1,24 +1,8 @@ import { StyleSheet } from "react-native-unistyles"; -import { - lightTheme, - darkTheme, - darkZincTheme, - darkMidnightTheme, - darkClaudeTheme, - darkGhosttyTheme, - darkPureBlackTheme, -} from "./theme"; +import { REGISTERED_THEMES } from "./theme"; StyleSheet.configure({ - themes: { - light: lightTheme, - dark: darkTheme, - darkZinc: darkZincTheme, - darkMidnight: darkMidnightTheme, - darkClaude: darkClaudeTheme, - darkGhostty: darkGhosttyTheme, - darkPureBlack: darkPureBlackTheme, - }, + themes: REGISTERED_THEMES, breakpoints: { xs: 0, sm: 576, @@ -32,15 +16,7 @@ StyleSheet.configure({ }); // Type augmentation for TypeScript -interface AppThemes { - light: typeof lightTheme; - dark: typeof darkTheme; - darkZinc: typeof darkZincTheme; - darkMidnight: typeof darkMidnightTheme; - darkClaude: typeof darkClaudeTheme; - darkGhostty: typeof darkGhosttyTheme; - darkPureBlack: typeof darkPureBlackTheme; -} +type AppThemes = typeof REGISTERED_THEMES; interface AppBreakpoints { xs: number; From 917e39495b9f53662e1eafb674cc1ff7a7492b85 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 14:18:06 +0200 Subject: [PATCH 14/73] Show the source branch on development builds (#3216) Development web and Electron builds now show their source branch in the expanded sidebar, including a branch icon and safe truncation. Dev launchers inject the label at build time; production builds remain unchanged. --- docs/development.md | 4 +++ packages/app/src/components/left-sidebar.tsx | 37 +++++++++++++++++++- packages/desktop/scripts/dev-runner.mjs | 7 +++- packages/desktop/scripts/dev.ps1 | 1 + scripts/dev-app.sh | 2 ++ scripts/dev.ps1 | 1 + 6 files changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/development.md b/docs/development.md index 5db7fb9735..f976549ef6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -23,6 +23,10 @@ Desktop dev launches its desktop-managed daemon with `PASEO_NODE_ENV=development so development-only providers such as Mock Load Test are available. Packaged desktop launches always force the daemon to production mode. +The web and desktop dev launchers pass the current Git branch to Metro as +`EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL`. The expanded desktop sidebar shows it in +the titlebar row. Production builds leave the variable unset and show no label. + `npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state. ## Nix desktop package diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index bfb75a08ee..9c0b2dd182 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -2,6 +2,7 @@ import { router, usePathname } from "expo-router"; import { CalendarClock, FolderPlus, + GitBranch, History, Home, Plus, @@ -77,6 +78,8 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list"; type SidebarTheme = ReturnType["theme"]; +const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null; + interface SidebarSharedProps { theme: SidebarTheme; statusGroups: StatusGroup[]; @@ -853,9 +856,22 @@ function DesktopSidebar({ > - {ownsTopLeft ? ( + {ownsTopLeft || DEV_BUILD_LABEL ? ( + {DEV_BUILD_LABEL ? ( + + + + {DEV_BUILD_LABEL} + + + ) : null} ) : ( @@ -1080,9 +1096,28 @@ const styles = StyleSheet.create((theme) => ({ height: HEADER_INNER_HEIGHT, flexDirection: "row", alignItems: "center", + justifyContent: "flex-end", + paddingHorizontal: theme.spacing[3], borderBottomWidth: theme.borderWidth[1], borderBottomColor: "transparent", }, + devBuildBadge: { + maxWidth: "60%", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: 2, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.accent, + }, + devBuildBadgeText: { + minWidth: 0, + flexShrink: 1, + color: theme.colors.accentForeground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + }, sidebarFooter: { flexDirection: "row", alignItems: "center", diff --git a/packages/desktop/scripts/dev-runner.mjs b/packages/desktop/scripts/dev-runner.mjs index ac96fee5ac..c653f31b39 100644 --- a/packages/desktop/scripts/dev-runner.mjs +++ b/packages/desktop/scripts/dev-runner.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import net from "node:net"; import path from "node:path"; -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs"; @@ -27,6 +27,10 @@ const colorEnv = { FORCE_COLOR: process.env.FORCE_COLOR || "1", npm_config_color: process.env.npm_config_color || "always", }; +const devBuildLabel = execFileSync("git", ["branch", "--show-current"], { + cwd: rootDir, + encoding: "utf8", +}).trim(); const children = new Map(); let stopping = false; @@ -166,6 +170,7 @@ spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { ...colorEnv, BROWSER: "none", APP_VARIANT: "development", + EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL: devBuildLabel, PASEO_WEB_PLATFORM: "electron", }, }); diff --git a/packages/desktop/scripts/dev.ps1 b/packages/desktop/scripts/dev.ps1 index 81f8e4ab6e..edb532f869 100644 --- a/packages/desktop/scripts/dev.ps1 +++ b/packages/desktop/scripts/dev.ps1 @@ -32,6 +32,7 @@ try { # Set EXPO_DEV_URL in the environment so Electron inherits it $env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)" +$env:EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL = (git -C $RootDir branch --show-current).Trim() $env:PASEO_DEV_ROOT = $RootDir $env:PASEO_DEV_RUNTIME_FALLBACK_ROOT = $RootDir diff --git a/scripts/dev-app.sh b/scripts/dev-app.sh index be9857af65..a594f344fd 100755 --- a/scripts/dev-app.sh +++ b/scripts/dev-app.sh @@ -12,6 +12,7 @@ configure_dev_paseo_home EXPO_PORT="${EXPO_PORT:-8081}" DAEMON_ENDPOINT="$(resolve_dev_daemon_endpoint)" +DEV_BUILD_LABEL="$(git -C "$ROOT_DIR" branch --show-current 2>/dev/null || true)" echo "══════════════════════════════════════════════════════" echo " Paseo App Dev" @@ -24,5 +25,6 @@ echo "════════════════════════ exec cross-env \ BROWSER="${BROWSER:-none}" \ APP_VARIANT=development \ + EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL="$DEV_BUILD_LABEL" \ EXPO_PUBLIC_LOCAL_DAEMON="$DAEMON_ENDPOINT" \ npm run start:expo --workspace=@getpaseo/app -- --port "$EXPO_PORT" diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 index 5b390407b3..0e3be90c91 100644 --- a/scripts/dev.ps1 +++ b/scripts/dev.ps1 @@ -50,6 +50,7 @@ $env:PASEO_CORS_ORIGINS = "*" # Configure the app to auto-connect to this daemon on localhost $env:APP_VARIANT = "development" $env:EXPO_PUBLIC_LOCAL_DAEMON = "localhost:6768" +$env:EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL = (git branch --show-current).Trim() $env:PASEO_LISTEN = "127.0.0.1:6768" $env:BROWSER = "none" From b7ca1f7502dedc4091d5801be6f129ee64567e4a Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Tue, 11 Aug 2026 15:53:38 +0200 Subject: [PATCH 15/73] docs: fix typos across contributing, product, and skill docs (#3153) Fix spelling and grammar errors found in a full sweep of the repo's English markdown files. - CONTRIBUTING.md: "contributing to to it", "accpeted", "wil" - .github/PULL_REQUEST_TEMPLATE.md: "value provider to" instead of "value provided to" - docs/product.md: "vendor-lock in" instead of "vendor lock-in" - packages/expo-two-way-audio/CONTRIBUTING.md: "prescreptive" - packages/expo-two-way-audio/README.md: "can bee added" - skills/paseo-committee/SKILL.md: "considered and reject" Co-authored-by: Claude Sonnet 5 --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- CONTRIBUTING.md | 6 +++--- docs/product.md | 2 +- packages/expo-two-way-audio/CONTRIBUTING.md | 2 +- packages/expo-two-way-audio/README.md | 2 +- skills/paseo-committee/SKILL.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d31376ac30..2fbe37e918 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,7 +18,7 @@ Closes # ### Reasoning - + ### Goals diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4035db1a38..8eaaf5c0c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thank you for taking the time to contribute to Paseo. Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md). -Given Paseo's scope, contributing to to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer. +Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer. I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it. @@ -72,8 +72,8 @@ Here is the criteria I use to decide: - PRs that were explicitly approved in a discussion are preferred. - Unsolicited PR can be closed without a detailed review. - Your PR can be narrowed, refactored, or redesigned. -- Your PR might be accpeted but not merged immediately. -- You wil be attributed for your work in the changelog, even if I redesign your PR +- Your PR might be accepted but not merged immediately. +- You will be attributed for your work in the changelog, even if I redesign your PR ## QA evidence diff --git a/docs/product.md b/docs/product.md index e8a7a553b5..eac71c160c 100644 --- a/docs/product.md +++ b/docs/product.md @@ -12,7 +12,7 @@ The development workflow is shifting from manually editing files to orchestratin Freedom and flexibility. Every design decision follows from this: -- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in. +- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor lock-in. - **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal. - **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency. - **Respectful** - No telemetry, no forced cloud, no forced accounts diff --git a/packages/expo-two-way-audio/CONTRIBUTING.md b/packages/expo-two-way-audio/CONTRIBUTING.md index 2720c386e7..cf9701ccca 100644 --- a/packages/expo-two-way-audio/CONTRIBUTING.md +++ b/packages/expo-two-way-audio/CONTRIBUTING.md @@ -26,7 +26,7 @@ npm i ## How to Submit Changes -We try not to be too prescreptive about how people work, but we also believe in helping make things easier by following a couple of basic steps. If you follow these recommendations, it should make the review process simpler and quicker: +We try not to be too prescriptive about how people work, but we also believe in helping make things easier by following a couple of basic steps. If you follow these recommendations, it should make the review process simpler and quicker: 1. If your change is large, consider reaching out to us beforehand and discussing the issue. This could save you a lot of time if there are good reasons why we haven't done something. 2. Fork the repo and work on a branch on your fork. Try to give your branches short, descriptive names in the format {type}/{description} e.g. bugfix/missing-try-catch. diff --git a/packages/expo-two-way-audio/README.md b/packages/expo-two-way-audio/README.md index 5f298c6f3f..293a439f01 100644 --- a/packages/expo-two-way-audio/README.md +++ b/packages/expo-two-way-audio/README.md @@ -88,7 +88,7 @@ npx expo run:ios --device --configuration Release npx expo run:android --device --variant release ``` -For Android, the following permissions are needed: `RECORD_AUDIO`, `MODIFY_AUDIO_SETTINGS`. In Expo apps they can bee added in your `app.json` file: +For Android, the following permissions are needed: `RECORD_AUDIO`, `MODIFY_AUDIO_SETTINGS`. In Expo apps they can be added in your `app.json` file: ```javascript expo.android.permissions: ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"] diff --git a/skills/paseo-committee/SKILL.md b/skills/paseo-committee/SKILL.md index 274cf4e8f1..6ba7dc8b6d 100644 --- a/skills/paseo-committee/SKILL.md +++ b/skills/paseo-committee/SKILL.md @@ -55,7 +55,7 @@ Read both responses. Challenge them — do not accept at face value: - "Why does happen? Symptom or cause?" - Verify any assumption the plan makes about the code. -- "What did you considered and reject?" +- "What did you consider and reject?" Send follow-ups until the plan addresses root cause. From 07044136c8b0ecebac7d5e8309a54453c6049e95 Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Tue, 11 Aug 2026 16:35:10 +0200 Subject: [PATCH 16/73] feat(app): switch sidebar Group by from the command center (#3063) Switching the sidebar between Project and Status grouping takes three clicks today: open the display preferences menu, open Grouping, pick the mode. It is the only sidebar preference with no fast path. Typing "group" in the command center now offers a single entry that switches to the mode you are not in, so it reads "Group by status" while grouped by project and the reverse once you are in status mode. With two modes a "cycle grouping" label would not say where you land, and an entry that reads the same in both states looks like it does nothing. The entry is query-only, so the default Cmd-K list is unchanged. It does not clear the focus restore element, because it navigates nowhere and focus has to return to whatever you were typing in. The contribution is built by a pure function in root-contributions.ts so its branches can be unit tested, mirroring workspace-contributions.ts. The registration subscribes to groupMode with a narrow selector to avoid re-registering every root action when host filters are reconciled. Co-authored-by: Claude Opus 5 --- .../browser/command-center-grouping.spec.ts | 58 ++++++++++++++ .../command-center/root-contributions.test.ts | 76 +++++++++++++++++++ .../src/command-center/root-contributions.ts | 39 ++++++++++ .../src/command-center/root-registration.tsx | 35 +++++++++ packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ja.ts | 2 + packages/app/src/i18n/resources/ko.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 2 + 13 files changed, 226 insertions(+) create mode 100644 packages/app/e2e/browser/command-center-grouping.spec.ts create mode 100644 packages/app/src/command-center/root-contributions.test.ts create mode 100644 packages/app/src/command-center/root-contributions.ts diff --git a/packages/app/e2e/browser/command-center-grouping.spec.ts b/packages/app/e2e/browser/command-center-grouping.spec.ts new file mode 100644 index 0000000000..8bf78177e2 --- /dev/null +++ b/packages/app/e2e/browser/command-center-grouping.spec.ts @@ -0,0 +1,58 @@ +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { openCommandCenter } from "../support/helpers/command-center"; +import { seedWorkspace } from "../support/helpers/seed-client"; + +const GROUP_BY_STATUS = "Group by status"; +const GROUP_BY_PROJECT = "Group by project"; + +// Result rows carry no testID of their own, so the entries are addressed by their visible label. +async function runGroupingEntry(page: Page, label: string, absent: string): Promise { + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill("group"); + + const entry = panel.getByText(label, { exact: true }); + await expect(entry).toBeVisible({ timeout: 30_000 }); + // The entry always names the mode you are not in, so its opposite must never be listed. + await expect(panel.getByText(absent, { exact: true })).toHaveCount(0); + + await entry.click(); + await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 }); +} + +test.describe("Command center sidebar grouping", () => { + test.describe.configure({ timeout: 120_000 }); + + test("flips sidebar grouping and persists the choice across a reload", async ({ page }) => { + const seeded = await seedWorkspace({ repoPrefix: "command-center-grouping-" }); + + try { + await gotoAppShell(page); + const projectList = page.getByTestId("sidebar-project-workspace-list-scroll"); + const statusList = page.getByTestId("sidebar-status-list-scroll"); + await expect(projectList).toBeVisible({ timeout: 30_000 }); + + // Query-only: the grouping entry stays out of the default empty-query list. + const panel = await openCommandCenter(page); + await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0); + await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(panel).not.toBeVisible({ timeout: 30_000 }); + + await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT); + await expect(statusList).toBeVisible({ timeout: 30_000 }); + await expect(projectList).toHaveCount(0); + + // The only assertion that catches a flip which never reached persisted storage. + await page.reload(); + await expect(statusList).toBeVisible({ timeout: 30_000 }); + + // Grouping persists, so the run must leave the sidebar back in project mode. + await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS); + await expect(projectList).toBeVisible({ timeout: 30_000 }); + await expect(statusList).toHaveCount(0); + } finally { + await seeded.cleanup().catch(() => undefined); + } + }); +}); diff --git a/packages/app/src/command-center/root-contributions.test.ts b/packages/app/src/command-center/root-contributions.test.ts new file mode 100644 index 0000000000..9ac7626889 --- /dev/null +++ b/packages/app/src/command-center/root-contributions.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import type { SidebarGroupMode } from "@/stores/sidebar-view-store"; +import type { CommandCenterIconProps } from "./contributions"; +import { buildGroupingContribution, type GroupingCommandCenterSource } from "./root-contributions"; + +function ProjectIcon(_props: CommandCenterIconProps) { + return null; +} + +function StatusIcon(_props: CommandCenterIconProps) { + return null; +} + +function source(groupMode: SidebarGroupMode): { + value: GroupingCommandCenterSource; + applied: SidebarGroupMode[]; +} { + const applied: SidebarGroupMode[] = []; + return { + value: { + groupMode, + labels: { + section: "Actions", + groupByProject: "Group by project", + groupByStatus: "Group by status", + }, + icons: { project: ProjectIcon, status: StatusIcon }, + setGroupMode: (mode) => applied.push(mode), + }, + applied, + }; +} + +describe("grouping command center contribution", () => { + it("offers status while grouped by project", () => { + const fixture = source("project"); + const contribution = buildGroupingContribution(fixture.value); + + expect(contribution.presentation).toMatchObject({ + title: "Group by status", + icon: StatusIcon, + }); + + contribution.run(); + expect(fixture.applied).toEqual(["status"]); + }); + + it("offers project while grouped by status", () => { + const fixture = source("status"); + const contribution = buildGroupingContribution(fixture.value); + + expect(contribution.presentation).toMatchObject({ + title: "Group by project", + icon: ProjectIcon, + }); + + contribution.run(); + expect(fixture.applied).toEqual(["project"]); + }); + + it("keeps a stable id across both modes so the registry tiebreak never moves", () => { + expect(buildGroupingContribution(source("project").value).id).toBe( + buildGroupingContribution(source("status").value).id, + ); + }); + + it("stays out of the default empty-query list", () => { + for (const mode of ["project", "status"] as const) { + const contribution = buildGroupingContribution(source(mode).value); + expect(contribution.visibility).toBe("query"); + expect(contribution.group).toBe("actions"); + // 6 is keyboard-shortcuts and 7 belongs to the workspace actions in #3013. + expect(contribution.rank).toBe(8); + } + }); +}); diff --git a/packages/app/src/command-center/root-contributions.ts b/packages/app/src/command-center/root-contributions.ts new file mode 100644 index 0000000000..f9b6a6d180 --- /dev/null +++ b/packages/app/src/command-center/root-contributions.ts @@ -0,0 +1,39 @@ +import type { SidebarGroupMode } from "@/stores/sidebar-view-store"; +import type { CommandCenterContribution, CommandCenterIcon } from "./contributions"; + +export interface GroupingCommandCenterSource { + groupMode: SidebarGroupMode; + labels: { + section: string; + groupByProject: string; + groupByStatus: string; + }; + icons: { + project?: CommandCenterIcon; + status?: CommandCenterIcon; + }; + setGroupMode(mode: SidebarGroupMode): void; +} + +// One entry that always names the mode you are not in, so it can never read as a no-op. +export function buildGroupingContribution( + source: GroupingCommandCenterSource, +): CommandCenterContribution { + // Collapse into nextGroupMode() from sidebar-view-store once #2504 lands. + const target: SidebarGroupMode = source.groupMode === "project" ? "status" : "project"; + return { + id: "sidebar-grouping", + group: "actions", + groupRank: 0, + rank: 8, + keywords: ["group", "grouping", "sort", "sidebar", "project", "status"], + visibility: "query", + run: () => source.setGroupMode(target), + presentation: { + kind: "action", + title: target === "status" ? source.labels.groupByStatus : source.labels.groupByProject, + sectionTitle: source.labels.section, + icon: target === "status" ? source.icons.status : source.icons.project, + }, + }; +} diff --git a/packages/app/src/command-center/root-registration.tsx b/packages/app/src/command-center/root-registration.tsx index 25d88f20c4..faa89e0fc6 100644 --- a/packages/app/src/command-center/root-registration.tsx +++ b/packages/app/src/command-center/root-registration.tsx @@ -3,6 +3,8 @@ import { router, type Href } from "expo-router"; import { useTranslation } from "react-i18next"; import { CalendarClock, + CircleDashed, + Folder, FolderPlus, History, Home, @@ -18,6 +20,7 @@ import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher" import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { buildOpenProjectRoute, @@ -28,6 +31,7 @@ import { import { getShortcutOs } from "@/utils/shortcut-platform"; import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions"; import { useCommandCenterActions } from "./provider"; +import { buildGroupingContribution } from "./root-contributions"; const ThemedPlus = withUnistyles(Plus, (theme) => ({ color: theme.colors.foregroundMuted })); const ThemedFolderPlus = withUnistyles(FolderPlus, (theme) => ({ @@ -46,6 +50,10 @@ const ThemedSettings = withUnistyles(Settings, (theme) => ({ color: theme.colors.foregroundMuted, })); const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted })); +const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted })); +const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({ + color: theme.colors.foregroundMuted, +})); function PlusIcon({ size }: CommandCenterIconProps) { return ; @@ -75,6 +83,14 @@ function HomeIcon({ size }: CommandCenterIconProps) { return ; } +function FolderIcon({ size }: CommandCenterIconProps) { + return ; +} + +function CircleDashedIcon({ size }: CommandCenterIconProps) { + return ; +} + export function CommandCenterRootActions() { const { t } = useTranslation(); const { overrides } = useKeyboardShortcutOverrides(); @@ -85,6 +101,10 @@ export function CommandCenterRootActions() { const sessionsRoute = useMemo(() => buildSessionsRoute(), []); const schedulesRoute = useMemo(() => buildSchedulesRoute(), []); const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen); + // Narrow selector on purpose: a whole-store subscription would re-register every root action + // each time host filters are reconciled. + const groupMode = useSidebarViewStore((state) => state.groupMode); + const setGroupMode = useSidebarViewStore((state) => state.setGroupMode); const shortcutPlatform = useMemo( () => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }), [], @@ -229,13 +249,28 @@ export function CommandCenterRootActions() { }); } + availableActions.push( + buildGroupingContribution({ + groupMode, + labels: { + section: t("shell.commandCenter.actions"), + groupByProject: t("shell.commandCenter.groupByProject"), + groupByStatus: t("shell.commandCenter.groupByStatus"), + }, + icons: { project: FolderIcon, status: CircleDashedIcon }, + setGroupMode, + }), + ); + return availableActions; }, [ + groupMode, homeRoute, openAddProject, overrides, schedulesRoute, sessionsRoute, + setGroupMode, setShortcutsDialogOpen, settingsRoute, shortcutPlatform, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c5c5b0431c..f96174068c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -65,6 +65,8 @@ export const ar: TranslationResources = { newAgent: "وكيل جديد", addProject: "إضافة مشروع", home: "بيت", + groupByProject: "تجميع حسب المشروع", + groupByStatus: "تجميع حسب الحالة", modelGroupLabel: "النموذج", modelSearchKeywords: "تبديل النموذج تغيير النموذج تعيين النموذج اختيار النموذج", thinkingGroupLabel: "التفكير", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index dde57b1cef..9c18be9d5a 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -63,6 +63,8 @@ export const en = { newAgent: "New agent", addProject: "Add project", home: "Home", + groupByProject: "Group by project", + groupByStatus: "Group by status", modelGroupLabel: "Model", modelSearchKeywords: "switch model change model set model select model", thinkingGroupLabel: "Thinking", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b286bf6304..3f468de94f 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -65,6 +65,8 @@ export const es: TranslationResources = { newAgent: "Nuevo agente", addProject: "Agregar proyecto", home: "Hogar", + groupByProject: "Agrupar por proyecto", + groupByStatus: "Agrupar por estado", modelGroupLabel: "Modelo", modelSearchKeywords: "cambiar modelo modificar modelo establecer modelo seleccionar modelo", thinkingGroupLabel: "Razonamiento", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index d7e05cd9c4..482617e914 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -66,6 +66,8 @@ export const fr: TranslationResources = { newAgent: "Nouvel agent", addProject: "Ajouter un projet", home: "Maison", + groupByProject: "Grouper par projet", + groupByStatus: "Grouper par statut", modelGroupLabel: "Modèle", modelSearchKeywords: "changer de modèle modifier le modèle définir le modèle sélectionner le modèle", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 529d6e157f..787b0458bb 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -65,6 +65,8 @@ export const ja: TranslationResources = { newAgent: "新しいエージェント", addProject: "プロジェクトを追加", home: "ホーム", + groupByProject: "プロジェクトでグループ化", + groupByStatus: "ステータスでグループ化", modelGroupLabel: "モデル", modelSearchKeywords: "モデルを切り替え モデルを変更 モデルを設定 モデルを選択", thinkingGroupLabel: "思考", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index f242d311a9..33e4c9c3a4 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -65,6 +65,8 @@ export const ko: TranslationResources = { newAgent: "새 에이전트", addProject: "프로젝트 추가", home: "홈", + groupByProject: "프로젝트별 그룹화", + groupByStatus: "상태별 그룹화", modelGroupLabel: "모델", modelSearchKeywords: "모델 전환 모델 변경 모델 설정 모델 선택", thinkingGroupLabel: "추론", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f307377b79..2349fea3ce 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -65,6 +65,8 @@ export const ptBR: TranslationResources = { newAgent: "Novo agente", addProject: "Adicionar projeto", home: "Início", + groupByProject: "Agrupar por projeto", + groupByStatus: "Agrupar por status", modelGroupLabel: "Modelo", modelSearchKeywords: "trocar modelo mudar modelo definir modelo selecionar modelo", thinkingGroupLabel: "Raciocínio", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 1aacec9748..9528a85032 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -65,6 +65,8 @@ export const ru: TranslationResources = { newAgent: "Новый агент", addProject: "Добавить проект", home: "Дом", + groupByProject: "Группировать по проекту", + groupByStatus: "Группировать по статусу", modelGroupLabel: "Модель", modelSearchKeywords: "сменить модель изменить модель выбрать модель установить модель", thinkingGroupLabel: "Мышление", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 1d53b78de6..08ada0995a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -65,6 +65,8 @@ export const zhCN: TranslationResources = { newAgent: "新建 Agent", addProject: "添加 project", home: "首页", + groupByProject: "按项目分组", + groupByStatus: "按状态分组", modelGroupLabel: "模型", modelSearchKeywords: "切换模型 更改模型 设置模型 选择模型", thinkingGroupLabel: "思考", From 308e9af84591eb4ebfc023edca1028c5d6c6afe7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 17:42:35 +0200 Subject: [PATCH 17/73] Add reusable agent profiles (#3208) * Search models across all providers from the picker root * Store agent profiles in daemon config and apply agent config in one RPC An agent profile is a named bundle of provider, model, mode, thinking option, feature values and notes, stored host-wide next to terminal profiles. It carries no system prompt: AgentSessionConfig.systemPrompt is creation-only, so a profile holding one would apply when starting an agent and silently do nothing when applied to a running one. Applying a profile through the four single-field RPCs can fail halfway and leave an agent matching no profile, so agent.config.apply takes the whole bundle. Model runs first because a thinking option only means anything against the model that offers it. Old daemons parse their persisted config strictly and would drop an unknown agentProfiles key, making a save appear to succeed and vanish, so both capabilities are advertised on server_info for the client to gate on. list_profiles exposes the profiles to orchestrating agents, which read the notes to pick one and copy its values into create_agent. * Add the agent profiles settings section Profiles live behind one module boundary so removing the feature is a directory plus one mount, which is what favourites never was. The picker and composer will ask for rows and an apply function rather than learning what a profile contains. The edit form follows the form-model pattern instead of the terminal profile modal's per-field state, because provider selection cascades into which models, modes, thinking levels and features exist below it. The section is gated on server_info.features.agentProfiles: an older daemon drops the key on write, and a save that appears to work and then vanishes is worse than an unavailable state. * Document agent profiles and name the four senses of profile The glossary reserved Profile for HostProfile, terminal profiles owned the user-facing word, and custom-providers.md used it for provider aliases. Agent profile is now the term, with all four cross-referenced. data-model.md never documented terminalProfiles either, so both profile lists are backfilled together along with the whole-list patch semantics and why absent and empty differ. * Pin agent profiles to the model picker and delete favourites Applying a profile writes its values into the agent controls and is then forgotten, so rows carry no checkmark and no selected state: after applying, nothing is selected. Chevron still means navigate deeper, so the absence of one is what marks a row as an action. A running agent is its provider's process and agent.config.apply carries no provider, so the live picker only offers profiles matching the agent's provider rather than showing rows that cannot do what they say. The draft path has no such limit and switches provider freely. Draft feature values go through preferences rather than the feature callback alone, because the draft feature hook clears local values whenever the provider changes and a provider-switching profile would lose them. Favourites are deleted rather than migrated: they were device-local AsyncStorage keyed by provider and model, while profiles are host-wide daemon config. ProviderSelectionModelRow.favoriteKey stays as row identity and moves to its real owner; renaming it is its own commit. * Cover agent profiles and cross-provider search with E2E specs Model rows expressed selection only as a checkmark glyph, which assistive tech cannot see and a spec cannot assert. Rows that draw the indicator now carry aria-selected; profile rows deliberately carry none, because a profile is an action rather than a selection. That is what makes "nothing is marked selected after applying a profile" a real assertion instead of a tautology, with the materialized model one level down as the control. The search fixture extends claude rather than mock: provider-config.ts only allows the six shipped providers as extends targets, so a derived provider cannot inherit the dev-only mock provider. * State what applying a config bundle does on partial failure applyBundle is not a transaction and the earlier commit message implied it was. A step that rejects aborts the rest and the steps that already applied stay applied, which a profile omitting modelId can reach: provider setters validate against the agent's live model, so mode can land while a feature that model does not support is rejected. Nothing is hidden by that. Every setter emits agent state before returning, so what applied is streamed to the client next to the rejected response. Rolling back would re-invoke the setters that just failed, usually against a session that is gone, and pre-flight validation would restate each provider's model-dependent checks where they would drift from the setters that own them. What sending the bundle in one request removes is the multi-round-trip failure class: interruption between hops, and interleaving with another client. The test already pinned this behaviour; its name now says so. * feat(agent-profiles): refine profile discovery and appearance Make profiles discoverable from empty and provider-scoped model pickers, keep global search stable and virtualized, and move profile settings under Agents. Replace emoji identity with a shared Lucide icon and host-color picker, while requiring concrete provider selections so profiles materialize predictably. * fix(agent-profiles): defer profile swatch styles Resolving Unistyles proxies at module load can freeze the temporary startup color scheme before the persisted theme is restored. Keep only style-key names at module scope and resolve the selected swatch during render. --- docs/custom-providers.md | 2 + docs/data-model.md | 17 + docs/glossary.md | 4 +- .../e2e/browser/agent-profiles-picker.spec.ts | 126 ++++ .../browser/agent-profiles-settings.spec.ts | 128 ++++ .../model-search-across-providers.spec.ts | 160 +++++ .../app/e2e/support/helpers/agent-profiles.ts | 564 +++++++++++++++ packages/app/e2e/support/helpers/settings.ts | 1 + packages/app/src/agent-profiles/index.ts | 26 + .../internal/agent-profile-glyph.tsx | 128 ++++ .../internal/capabilities.test.ts | 11 + .../agent-profiles/internal/capabilities.ts | 8 + .../internal/materialize-profile.test.ts | 89 +++ .../internal/materialize-profile.ts | 46 ++ .../internal/profile-appearance.ts | 71 ++ .../internal/profile-form-model.test.ts | 431 ++++++++++++ .../internal/profile-form-model.ts | 594 ++++++++++++++++ .../internal/profile-summary.ts | 56 ++ .../internal/use-agent-profile-picker.ts | 180 +++++ .../internal/use-agent-profiles.ts | 34 + .../internal/use-profile-form-inputs.ts | 78 +++ .../internal/use-profile-form-model.ts | 29 + .../agent-profiles/migration/index.test.ts | 199 ++++++ .../app/src/agent-profiles/migration/index.ts | 231 +++++++ .../agent-profile-appearance-field.tsx | 384 +++++++++++ .../settings/agent-profile-edit-modal.tsx | 549 +++++++++++++++ .../settings/agent-profile-row.tsx | 181 +++++ .../settings/agent-profiles-section.tsx | 219 ++++++ packages/app/src/app/_layout.tsx | 27 +- .../src/components/adaptive-modal-sheet.tsx | 6 +- .../components/combined-model-selector.tsx | 33 +- .../src/components/model-browser-view.test.ts | 122 +++- .../app/src/components/model-browser-view.ts | 50 +- packages/app/src/components/model-browser.tsx | 645 +++++++++++------- .../ui/combobox-frame-style.test.ts | 15 + .../src/components/ui/combobox-frame-style.ts | 8 +- packages/app/src/components/ui/combobox.tsx | 32 +- .../app/src/composer/agent-controls/index.tsx | 173 +++-- .../composer/agent-controls/model-sheet.tsx | 29 +- .../preferences.test.ts | 13 + .../create-agent-preferences/preferences.ts | 52 +- .../src/hooks/use-form-preferences.test.ts | 96 +-- .../app/src/hooks/use-form-preferences.ts | 9 +- packages/app/src/i18n/resources.test.ts | 2 +- packages/app/src/i18n/resources/ar.ts | 46 +- packages/app/src/i18n/resources/en.ts | 47 +- packages/app/src/i18n/resources/es.ts | 48 +- packages/app/src/i18n/resources/fr.ts | 50 +- packages/app/src/i18n/resources/ja.ts | 48 +- packages/app/src/i18n/resources/ko.ts | 48 +- packages/app/src/i18n/resources/pt-BR.ts | 48 +- packages/app/src/i18n/resources/ru.ts | 48 +- packages/app/src/i18n/resources/zh-CN.ts | 46 +- .../provider-selection.test.ts | 15 + .../provider-selection/provider-selection.ts | 32 +- .../resolve-agent-form.test.ts | 2 - .../app/src/screens/settings/host-page.tsx | 2 + packages/client/src/daemon-client.ts | 44 +- packages/protocol/src/messages.ts | 74 ++ .../src/server/agent/mcp-parity.e2e.test.ts | 20 +- .../src/server/agent/mcp-server.test.ts | 74 ++ .../src/server/agent/tools/paseo-tools.ts | 28 + packages/server/src/server/bootstrap.ts | 8 +- packages/server/src/server/config.ts | 16 +- .../src/server/daemon-config-store.test.ts | 70 ++ .../server/src/server/daemon-config-store.ts | 1 + .../server/src/server/persisted-config.ts | 3 +- packages/server/src/server/session.ts | 2 + .../agent-config/agent-config-session.test.ts | 125 ++++ .../agent-config/agent-config-session.ts | 76 ++- .../src/server/test-utils/paseo-daemon.ts | 3 + .../server/src/server/websocket-server.ts | 4 + 72 files changed, 6348 insertions(+), 538 deletions(-) create mode 100644 packages/app/e2e/browser/agent-profiles-picker.spec.ts create mode 100644 packages/app/e2e/browser/agent-profiles-settings.spec.ts create mode 100644 packages/app/e2e/browser/model-search-across-providers.spec.ts create mode 100644 packages/app/e2e/support/helpers/agent-profiles.ts create mode 100644 packages/app/src/agent-profiles/index.ts create mode 100644 packages/app/src/agent-profiles/internal/agent-profile-glyph.tsx create mode 100644 packages/app/src/agent-profiles/internal/capabilities.test.ts create mode 100644 packages/app/src/agent-profiles/internal/capabilities.ts create mode 100644 packages/app/src/agent-profiles/internal/materialize-profile.test.ts create mode 100644 packages/app/src/agent-profiles/internal/materialize-profile.ts create mode 100644 packages/app/src/agent-profiles/internal/profile-appearance.ts create mode 100644 packages/app/src/agent-profiles/internal/profile-form-model.test.ts create mode 100644 packages/app/src/agent-profiles/internal/profile-form-model.ts create mode 100644 packages/app/src/agent-profiles/internal/profile-summary.ts create mode 100644 packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts create mode 100644 packages/app/src/agent-profiles/internal/use-agent-profiles.ts create mode 100644 packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts create mode 100644 packages/app/src/agent-profiles/internal/use-profile-form-model.ts create mode 100644 packages/app/src/agent-profiles/migration/index.test.ts create mode 100644 packages/app/src/agent-profiles/migration/index.ts create mode 100644 packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx create mode 100644 packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx create mode 100644 packages/app/src/agent-profiles/settings/agent-profile-row.tsx create mode 100644 packages/app/src/agent-profiles/settings/agent-profiles-section.tsx diff --git a/docs/custom-providers.md b/docs/custom-providers.md index e31fefe068..17bd1ceeb6 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -245,6 +245,8 @@ requires_openai_auth = false You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment. +"Profile" here means a provider alias, and it is not an **Agent profile** — that is a named bundle of provider, model, mode, thinking option and features, stored under `daemon.agentProfiles`. See [glossary.md](glossary.md) for all four senses of the word. + Example: two different Anthropic accounts as separate profiles: ```json diff --git a/docs/data-model.md b/docs/data-model.md index 6802c29f92..3a29076ea3 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -185,6 +185,8 @@ Single file, validated with `PersistedConfigSchema`. mcp: { enabled: boolean, injectIntoAgents: boolean }, git: { maxProcessesPerSecond: number, maxProcessConcurrency: number }, appendSystemPrompt: string, // appended to supported provider system/developer prompts + terminalProfiles: TerminalProfile[], // named shell commands; omitted means DEFAULT_TERMINAL_PROFILES + agentProfiles: AgentProfile[], // named agent launch bundles; omitted means none cors: { allowedOrigins: string[] }, relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, // new homes materialize enabled: false auth: { password: string } // bcrypt hash, optional @@ -231,6 +233,21 @@ Single file, validated with `PersistedConfigSchema`. All fields are optional with sensible defaults. +### Profile lists + +`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the +array, never merges entries, so a client sends the complete next list on every add, edit, reorder +and remove. List order is the display order. + +Absent and empty mean different things for terminal profiles — omitting the key falls back to +`DEFAULT_TERMINAL_PROFILES`, while `[]` means the user removed them all. Agent profiles have no +defaults, so both mean none. + +`PersistedConfigSchema` parses strictly, so a daemon that predates a field drops it on write +rather than storing something it cannot describe. That is why the client gates the agent profiles +UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an +older daemon. + ### Git process limits Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per diff --git a/docs/glossary.md b/docs/glossary.md index ce2e9c952b..4dd156761b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -24,7 +24,9 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`. - **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). - **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log. -- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. +- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy. +- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced. +- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent. - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`). - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). diff --git a/packages/app/e2e/browser/agent-profiles-picker.spec.ts b/packages/app/e2e/browser/agent-profiles-picker.spec.ts new file mode 100644 index 0000000000..87fcffcab9 --- /dev/null +++ b/packages/app/e2e/browser/agent-profiles-picker.spec.ts @@ -0,0 +1,126 @@ +import { test } from "../support/fixtures"; +import { + applyProfileFromPicker, + closeModelPicker, + drillIntoProvider, + expectComposerDoesNotName, + expectAgentProfilesEmptyPrompt, + expectProfileEditTooltip, + expectComposerMode, + expectComposerModel, + expectModelRowSelected, + expectNothingSelectedInPickerRoot, + expectProfileEditIsPencilOnly, + expectProfilePinnedAboveProviders, + expectProfileVisibleForProvider, + openModelPicker, + openAgentProfilesFromEmptyPrompt, + seedAgentProfiles, +} from "../support/helpers/agent-profiles"; +import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; + +const PROFILE = { + id: "agent_profile_e2e_ui_work", + name: "UI work", + icon: "🎨", + provider: "mock", + model: "one-minute-stream", + modeId: "approval-test", + notes: "Use for UI work.", +}; + +const PROFILE_SUMMARY = "Mock Load Test · One minute stream · Approval test"; + +test.describe("Agent profiles in the model picker", () => { + test("an empty host still exposes agent profile settings from the picker", async ({ page }) => { + const seed = await seedAgentProfiles([]); + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "agent-profiles-empty-", + title: "Agent profiles empty", + }); + + try { + await openAgentRoute(page, workspace); + await expectComposerVisible(page); + await openModelPicker(page); + await expectAgentProfilesEmptyPrompt(page); + await openAgentProfilesFromEmptyPrompt(page); + } finally { + await workspace.cleanup(); + await seed.restore(); + } + }); + + test("applying a pinned profile materializes it into the composer and is then forgotten", async ({ + page, + }) => { + const seed = await seedAgentProfiles([PROFILE]); + // A live agent is one provider's process, so the profile has to name that + // same provider or the picker will not offer it at all. + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "agent-profiles-picker-", + title: "Agent profiles picker", + model: "ten-second-stream", + modeId: "load-test", + }); + + try { + await test.step("the agent starts on its seeded model and mode", async () => { + await openAgentRoute(page, workspace); + await expectComposerVisible(page); + await expectComposerModel(page, "Ten second stream"); + await expectComposerMode(page, "Load test"); + }); + + await test.step("the profile is pinned above the provider list", async () => { + await openModelPicker(page); + await expectProfilePinnedAboveProviders(page, { + name: PROFILE.name, + summary: PROFILE_SUMMARY, + }); + await expectProfileEditIsPencilOnly(page); + await expectProfileEditTooltip(page); + }); + + await test.step("applying it writes its model and mode into the composer", async () => { + await applyProfileFromPicker(page, PROFILE.name); + await expectComposerModel(page, "One minute stream"); + await expectComposerMode(page, "Approval test"); + await expectWorkspaceAgentConfiguration(workspace, { + id: workspace.agentId, + provider: "mock", + model: "one-minute-stream", + modeId: "approval-test", + }); + }); + + await test.step("the composer names the model, never the profile", async () => { + await expectComposerDoesNotName(page, PROFILE.name); + }); + + await test.step("reopening the picker shows no profile marked as selected", async () => { + await openModelPicker(page); + await expectProfilePinnedAboveProviders(page, { + name: PROFILE.name, + summary: PROFILE_SUMMARY, + }); + await expectNothingSelectedInPickerRoot(page); + }); + + await test.step("the model it materialized is the one marked selected", async () => { + await drillIntoProvider(page, "mock"); + await expectProfileVisibleForProvider(page, { + name: PROFILE.name, + summary: PROFILE_SUMMARY, + }); + await expectModelRowSelected(page, { provider: "mock", modelId: "one-minute-stream" }); + await closeModelPicker(page); + }); + } finally { + await workspace.cleanup(); + await seed.restore(); + } + }); +}); diff --git a/packages/app/e2e/browser/agent-profiles-settings.spec.ts b/packages/app/e2e/browser/agent-profiles-settings.spec.ts new file mode 100644 index 0000000000..8aa2f3864d --- /dev/null +++ b/packages/app/e2e/browser/agent-profiles-settings.spec.ts @@ -0,0 +1,128 @@ +import { test } from "../support/fixtures"; +import { + createAgentProfile, + createAgentProfileFromEmptyState, + editAgentProfile, + expectAgentProfile, + expectAgentProfileForm, + expectAgentProfileOrder, + expectAgentProfileTagsGone, + expectHostAgentProfiles, + expectNoAgentProfiles, + moveAgentProfileUp, + openAgentProfileSettings, + removeAgentProfile, + seedAgentProfiles, + stageLegacyFavoritesForHostMigration, +} from "../support/helpers/agent-profiles"; + +const MOCK_PROVIDER_LABEL = "Mock Load Test"; + +test.describe("Agent profiles settings", () => { + test("legacy model favourites migrate into provider-and-model-only host profiles", async ({ + page, + }) => { + const seed = await seedAgentProfiles([]); + const migratedProfile = { + id: "legacy_favorite:mock:one-minute-stream", + name: "One minute stream", + provider: "mock", + model: "one-minute-stream", + }; + + try { + await stageLegacyFavoritesForHostMigration(page, [ + { provider: "mock", modelId: "one-minute-stream" }, + ]); + + await expectHostAgentProfiles([migratedProfile]); + await openAgentProfileSettings(page); + await expectAgentProfile(page, { + name: migratedProfile.name, + tags: [MOCK_PROVIDER_LABEL, "One minute stream"], + }); + } finally { + await seed.restore(); + } + }); + + test("host owner creates, edits, reorders and removes agent profiles", async ({ page }) => { + // The journey owns the list, so it starts from an empty one whatever the + // worker ran before, and hands it back untouched. + const seed = await seedAgentProfiles([]); + + try { + await test.step("the section starts empty", async () => { + await openAgentProfileSettings(page); + await expectNoAgentProfiles(page); + }); + + await test.step("create a profile from the modal", async () => { + await createAgentProfileFromEmptyState(page, { + name: "UI work", + provider: MOCK_PROVIDER_LABEL, + model: "Ten second stream", + thinking: "High", + mode: "Approval test", + notes: "Use for UI work — components, layout and design tokens.", + }); + await expectAgentProfile(page, { + name: "UI work", + tags: [MOCK_PROVIDER_LABEL, "Ten second stream", "Approval test", "High"], + notes: "Use for UI work — components, layout and design tokens.", + }); + }); + + await test.step("editing to a model without thinking levels drops the level", async () => { + await editAgentProfile(page, "UI work", { + model: "One minute stream", + notes: "Now for quick checks.", + }); + await expectAgentProfile(page, { + name: "UI work", + tags: [MOCK_PROVIDER_LABEL, "One minute stream", "Approval test"], + notes: "Now for quick checks.", + }); + await expectAgentProfileTagsGone(page, { + name: "UI work", + tags: ["Ten second stream", "High"], + }); + }); + + await test.step("the edit form reopens on what was stored", async () => { + await expectAgentProfileForm(page, "UI work", { + provider: MOCK_PROVIDER_LABEL, + model: "One minute stream", + mode: "Approval test", + notes: "Now for quick checks.", + }); + }); + + await test.step("a second profile lands below the first", async () => { + await createAgentProfile(page, { + name: "Deep review", + provider: MOCK_PROVIDER_LABEL, + model: "Five minute stream", + }); + await expectAgentProfileOrder(page, ["UI work", "Deep review"]); + }); + + await test.step("reorder puts the second profile on top", async () => { + await moveAgentProfileUp(page, "Deep review"); + await expectAgentProfileOrder(page, ["Deep review", "UI work"]); + }); + + await test.step("removing a profile confirms by name", async () => { + await removeAgentProfile(page, "UI work"); + await expectAgentProfileOrder(page, ["Deep review"]); + }); + + await test.step("removing the last profile returns the empty state", async () => { + await removeAgentProfile(page, "Deep review"); + await expectNoAgentProfiles(page); + }); + } finally { + await seed.restore(); + } + }); +}); diff --git a/packages/app/e2e/browser/model-search-across-providers.spec.ts b/packages/app/e2e/browser/model-search-across-providers.spec.ts new file mode 100644 index 0000000000..4a4ab31ea2 --- /dev/null +++ b/packages/app/e2e/browser/model-search-across-providers.spec.ts @@ -0,0 +1,160 @@ +import { test } from "../support/fixtures"; +import { + closeModelPicker, + expectModelSearchEmptyState, + expectModelPickerHeight, + expectModelPickerWidth, + expectModelSearchResult, + expectPinnedProfilesHidden, + openModelPicker, + readModelPickerHeight, + readModelPickerWidth, + searchAllModels, + seedAgentProfiles, + seedModelProvider, + expectSearchResultsVirtualized, +} from "../support/helpers/agent-profiles"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; + +const MOCK_PROVIDER_LABEL = "Mock Load Test"; + +// Its fast model deliberately carries the same label as the mock provider's, so +// the only thing telling the two result rows apart is the provider name. +const STUDIO = { + id: "mock-studio", + label: "Mock Studio", + models: [ + { id: "studio-fast", label: "Ten second stream", description: "Studio quick pass" }, + { id: "studio-deep", label: "Studio deep think", description: "Studio long pass" }, + ], +}; + +// The cross-provider search lives on the picker's root view, and a profile is +// what keeps the picker opening there. +const ROOT_VIEW_PROFILE = { + id: "agent_profile_e2e_search_root", + name: "Search anchor", + provider: "mock", +}; + +const LARGE_CATALOG_SIZE = 200; +const LARGE_CATALOG = { + id: "mock-bulk", + label: "Mock Bulk", + models: Array.from({ length: LARGE_CATALOG_SIZE }, (_, index) => ({ + id: `bulk-${index}`, + label: `Bulk model ${index}`, + description: `Bulk search result ${index}`, + })), +}; + +test.describe("Cross-provider model search", () => { + test("one query over the picker root reaches every provider and names each result's provider", async ({ + page, + }) => { + const provider = await seedModelProvider(STUDIO); + const profile = await seedAgentProfiles([ROOT_VIEW_PROFILE]); + const workspace = await seedWorkspace({ repoPrefix: "model-search-providers-" }); + + try { + await test.step("open a draft composer that can reach every provider", async () => { + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + await expectComposerVisible(page); + await openModelPicker(page); + }); + const restingWidth = await readModelPickerWidth(page); + + await test.step("one query returns the same model label from two providers", async () => { + await searchAllModels(page, "ten second stream"); + await expectModelSearchResult(page, { + provider: "mock", + modelId: "ten-second-stream", + modelLabel: "Ten second stream", + providerLabel: MOCK_PROVIDER_LABEL, + }); + await expectModelSearchResult(page, { + provider: STUDIO.id, + modelId: "studio-fast", + modelLabel: "Ten second stream", + providerLabel: STUDIO.label, + }); + await expectModelPickerWidth(page, restingWidth); + }); + + await test.step("searching by provider name reaches that provider's own models", async () => { + await searchAllModels(page, "studio deep"); + await expectModelSearchResult(page, { + provider: STUDIO.id, + modelId: "studio-deep", + modelLabel: "Studio deep think", + providerLabel: STUDIO.label, + }); + }); + + await test.step("results replace the pinned profiles", async () => { + await expectPinnedProfilesHidden(page); + }); + + // Search falls back to subsequence matching, so "no matches" needs letters + // that cannot be picked out of a model label or description in order. + await test.step("a query with no matches repeats the query back", async () => { + await searchAllModels(page, "zzz"); + await expectModelSearchEmptyState(page, "zzz"); + await closeModelPicker(page); + }); + } finally { + await workspace.cleanup(); + await profile.restore(); + await provider.restore(); + } + }); + + test("desktop search keeps its frame stable and virtualizes a host-wide catalog", async ({ + page, + }) => { + const provider = await seedModelProvider(LARGE_CATALOG); + const profile = await seedAgentProfiles([ROOT_VIEW_PROFILE]); + const workspace = await seedWorkspace({ repoPrefix: "model-search-large-catalog-" }); + + try { + await test.step("open the host-wide model picker", async () => { + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + await expectComposerVisible(page); + await openModelPicker(page); + }); + + const restingHeight = await readModelPickerHeight(page); + const restingWidth = await readModelPickerWidth(page); + + await test.step("a broad query renders only the visible result window", async () => { + await searchAllModels(page, "bulk model"); + await expectSearchResultsVirtualized(page, { + provider: LARGE_CATALOG.id, + total: LARGE_CATALOG_SIZE, + }); + await expectModelPickerHeight(page, restingHeight); + await expectModelPickerWidth(page, restingWidth); + }); + + await test.step("narrowing to one result does not resize the picker", async () => { + await searchAllModels(page, "bulk model 199"); + await expectModelSearchResult(page, { + provider: LARGE_CATALOG.id, + modelId: "bulk-199", + modelLabel: "Bulk model 199", + providerLabel: LARGE_CATALOG.label, + }); + await expectModelPickerHeight(page, restingHeight); + await expectModelPickerWidth(page, restingWidth); + }); + } finally { + await workspace.cleanup(); + await profile.restore(); + await provider.restore(); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/agent-profiles.ts b/packages/app/e2e/support/helpers/agent-profiles.ts new file mode 100644 index 0000000000..1f77369eee --- /dev/null +++ b/packages/app/e2e/support/helpers/agent-profiles.ts @@ -0,0 +1,564 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; +import { gotoAppShell, openSettings } from "./app"; +import { connectDaemonClient } from "./daemon-client-loader"; +import { getServerId } from "./server-id"; +import { expectAppRoute } from "./route-assertions"; +import { openSettingsHost } from "./settings"; + +// ─── Daemon-side seeding ─────────────────────────────────────────────────── + +interface AgentProfilesDaemonClient { + connect(): Promise; + close(): Promise; + getDaemonConfig(): Promise<{ config: { agentProfiles?: AgentProfile[] } }>; + patchDaemonConfig(config: { + agentProfiles?: AgentProfile[]; + providers?: Record>; + removeProviders?: string[]; + }): Promise; +} + +export interface HostSeed { + /** Puts the host's config back the way the test found it and drops the client. */ + restore(): Promise; +} + +async function connectAgentProfilesClient(): Promise { + return connectDaemonClient({ clientIdPrefix: "agent-profiles-e2e" }); +} + +/** + * Writes profiles into host config directly. Journeys that are *about* the + * settings UI create theirs through it; every other journey starts from a host + * that already has them. + */ +export async function seedAgentProfiles(profiles: AgentProfile[]): Promise { + const client = await connectAgentProfilesClient(); + const previous = (await client.getDaemonConfig()).config.agentProfiles ?? []; + await client.patchDaemonConfig({ agentProfiles: profiles }); + return { + async restore() { + await client.patchDaemonConfig({ agentProfiles: previous }).catch(() => undefined); + await client.close().catch(() => undefined); + }, + }; +} + +export async function stageLegacyFavoritesForHostMigration( + page: Page, + favorites: Array<{ provider: string; modelId: string }>, +): Promise { + const serverId = getServerId(); + await gotoAppShell(page); + await page.evaluate( + ({ favorites: storedFavorites, serverId: hostId }) => { + const preferencesKey = "@paseo:create-agent-preferences"; + const raw = localStorage.getItem(preferencesKey); + const preferences = raw ? JSON.parse(raw) : {}; + localStorage.setItem( + preferencesKey, + JSON.stringify({ ...preferences, favoriteModels: storedFavorites }), + ); + localStorage.removeItem(`@paseo:legacy-favorites-to-agent-profiles:v1:${hostId}`); + + // Preserve this upgrade-shaped storage across the fixture's next-page + // default seed; a reload is the startup boundary under test. + const nonce = localStorage.getItem("@paseo:e2e-seed-nonce"); + if (nonce) { + localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce); + } + }, + { favorites, serverId }, + ); + await page.reload(); +} + +export async function expectHostAgentProfiles(expected: AgentProfile[]): Promise { + const client = await connectAgentProfilesClient(); + try { + await expect + .poll(async () => (await client.getDaemonConfig()).config.agentProfiles ?? [], { + timeout: 30_000, + }) + .toEqual(expected); + } finally { + await client.close().catch(() => undefined); + } +} + +export interface SeededProviderModel { + id: string; + label: string; + description: string; +} + +/** + * Registers a second provider that reports a fixed catalog. The E2E daemon has + * exactly one provider with models (`mock`), so nothing cross-provider can be + * proven without one more. + * + * `extends: "mock"` is rejected — the config validator only accepts the six + * shipped providers plus `acp`. Extending `claude` with a replacement `models` + * list is what avoids running anything: replacement models skip catalog + * discovery, and Claude's static modes mean the provider never spawns. The + * command only has to resolve for the availability probe, hence `node`. + */ +export async function seedModelProvider(input: { + id: string; + label: string; + models: SeededProviderModel[]; +}): Promise { + const client = await connectAgentProfilesClient(); + await client.patchDaemonConfig({ + providers: { + [input.id]: { + extends: "claude", + label: input.label, + description: `${input.label} test provider`, + enabled: true, + command: ["node"], + models: input.models, + }, + }, + }); + return { + async restore() { + await client.patchDaemonConfig({ removeProviders: [input.id] }).catch(() => undefined); + await client.close().catch(() => undefined); + }, + }; +} + +// ─── Settings: navigation ────────────────────────────────────────────────── + +/** Reach agent profiles through the same visible Settings path a person uses. */ +export async function openAgentProfileSettings(page: Page): Promise { + const serverId = getServerId(); + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, serverId); + await page.getByRole("button", { name: "Agents", exact: true }).click(); + await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "agents")); + await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 }); +} + +export async function expectNoAgentProfiles(page: Page): Promise { + const card = page.getByTestId("agent-profiles-card"); + await expect(card.getByTestId("agent-profiles-empty")).toBeVisible(); + await expect(card.getByRole("button", { name: "New profile", exact: true })).toBeVisible(); +} + +// ─── Settings: the profile list ──────────────────────────────────────────── + +export function agentProfileRow(page: Page, name: string): Locator { + return page.locator('[data-testid^="agent-profile-row-"]').filter({ hasText: name }); +} + +export async function expectAgentProfile( + page: Page, + expected: { name: string; tags: string[]; notes?: string }, +): Promise { + const row = agentProfileRow(page, expected.name); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByText(expected.tags.join(" · "), { exact: true })).toBeVisible(); + if (expected.notes !== undefined) { + await expect(row.getByText(expected.notes, { exact: true })).toBeVisible(); + } +} + +export async function expectAgentProfileTagsGone( + page: Page, + input: { name: string; tags: string[] }, +): Promise { + const row = agentProfileRow(page, input.name); + for (const tag of input.tags) { + await expect(row.getByText(tag, { exact: true })).toHaveCount(0); + } +} + +/** Order is what the user sees, so it is measured on screen rather than in the DOM. */ +export async function expectAgentProfileOrder(page: Page, names: string[]): Promise { + await expect + .poll( + async () => { + const measured = await Promise.all( + names.map(async (name) => ({ name, top: await boxTop(agentProfileRow(page, name)) })), + ); + return measured.sort((a, b) => a.top - b.top).map((entry) => entry.name); + }, + { timeout: 15_000 }, + ) + .toEqual(names); +} + +export async function moveAgentProfileUp(page: Page, name: string): Promise { + await agentProfileRow(page, name).getByRole("button", { name: "Move up", exact: true }).click(); +} + +export async function moveAgentProfileDown(page: Page, name: string): Promise { + await agentProfileRow(page, name).getByRole("button", { name: "Move down", exact: true }).click(); +} + +/** + * The remove confirmation is `window.confirm` on browser web, so the dialog is + * consumed here — and its text is asserted, since naming the profile is the + * whole point of confirming. + */ +export async function removeAgentProfile(page: Page, name: string): Promise { + const dialogMessage = page.waitForEvent("dialog").then(async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + return message; + }); + await agentProfileRow(page, name).getByRole("button", { name: "Remove", exact: true }).click(); + expect(await dialogMessage).toContain(`Remove "${name}"?`); + await expect(agentProfileRow(page, name)).toHaveCount(0, { timeout: 30_000 }); +} + +// ─── Settings: the edit modal ────────────────────────────────────────────── + +export interface AgentProfileDraft { + name?: string; + provider?: string; + model?: string; + thinking?: string; + mode?: string; + notes?: string; +} + +function editModal(page: Page): Locator { + return page.getByTestId("agent-profile-edit-modal"); +} + +async function chooseSelectFieldOption( + page: Page, + input: { field: "provider" | "model" | "mode" | "thinking"; option: string }, +): Promise { + await editModal(page).getByTestId(`agent-profile-${input.field}-trigger`).click(); + const option = page + .getByTestId("combobox-desktop-container") + .getByRole("button", { name: input.option, exact: true }); + await expect(option).toBeVisible({ timeout: 30_000 }); + await option.click(); + await expect( + page.getByRole("button", { name: `${capitalize(input.field)} (${input.option})` }), + ).toBeVisible({ timeout: 30_000 }); +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promise { + const modal = editModal(page); + if (draft.name !== undefined) { + await modal.getByRole("textbox", { name: "Name", exact: true }).fill(draft.name); + } + // Provider first: it resets every provider-scoped field below it. + if (draft.provider !== undefined) { + await chooseSelectFieldOption(page, { field: "provider", option: draft.provider }); + } + // Thinking after model: thinking levels hang off the model, and switching the + // model drops a level the new one does not offer. + if (draft.model !== undefined) { + await chooseSelectFieldOption(page, { field: "model", option: draft.model }); + } + if (draft.thinking !== undefined) { + await chooseSelectFieldOption(page, { field: "thinking", option: draft.thinking }); + } + if (draft.mode !== undefined) { + await chooseSelectFieldOption(page, { field: "mode", option: draft.mode }); + } + if (draft.notes !== undefined) { + await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes); + } +} + +async function saveAgentProfile(page: Page): Promise { + await editModal(page).getByRole("button", { name: "Save", exact: true }).click(); + await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 }); +} + +export async function createAgentProfile(page: Page, draft: AgentProfileDraft): Promise { + await page.getByRole("button", { name: "Add agent profile", exact: true }).click(); + await expect(editModal(page)).toBeVisible({ timeout: 30_000 }); + await fillAgentProfileForm(page, draft); + await saveAgentProfile(page); +} + +export async function createAgentProfileFromEmptyState( + page: Page, + draft: AgentProfileDraft, +): Promise { + await page + .getByTestId("agent-profiles-card") + .getByRole("button", { name: "New profile", exact: true }) + .click(); + await expect(editModal(page)).toBeVisible({ timeout: 30_000 }); + await fillAgentProfileForm(page, draft); + await saveAgentProfile(page); +} + +export async function editAgentProfile( + page: Page, + name: string, + changes: AgentProfileDraft, +): Promise { + await agentProfileRow(page, name) + .getByRole("button", { name: "Edit profile", exact: true }) + .click(); + await expect(editModal(page)).toBeVisible({ timeout: 30_000 }); + await fillAgentProfileForm(page, changes); + await saveAgentProfile(page); +} + +/** Opens the modal for a profile purely to read back what it stored. */ +export async function expectAgentProfileForm( + page: Page, + name: string, + expected: { provider?: string; model?: string; mode?: string; notes?: string }, +): Promise { + await agentProfileRow(page, name) + .getByRole("button", { name: "Edit profile", exact: true }) + .click(); + const modal = editModal(page); + await expect(modal).toBeVisible({ timeout: 30_000 }); + await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name); + for (const [field, value] of Object.entries(expected)) { + if (field === "notes") { + await expect( + modal.getByRole("textbox", { name: "Notes for agents", exact: true }), + ).toHaveValue(value); + continue; + } + await expect( + modal.getByRole("button", { name: `${capitalize(field)} (${value})` }), + ).toBeVisible({ timeout: 30_000 }); + } + await modal.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 }); +} + +// ─── The model picker ────────────────────────────────────────────────────── + +/** Desktop web renders the model browser inside the combobox popover. */ +function pickerViewport(page: Page): Locator { + return page.getByTestId("combobox-desktop-container"); +} + +export async function openModelPicker(page: Page): Promise { + await page.getByTestId("combined-model-selector").filter({ visible: true }).first().click(); + await expect(pickerViewport(page)).toBeVisible({ timeout: 30_000 }); +} + +export async function closeModelPicker(page: Page): Promise { + await page.keyboard.press("Escape"); + await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 }); +} + +export function profilePickerRow(page: Page, name: string): Locator { + return pickerViewport(page) + .locator('[data-testid^="model-profile-row-"]') + .filter({ hasText: name }); +} + +/** + * Profiles are pinned above the provider list, so "pinned" is an ordering claim: + * the profile row's top edge sits above every provider row's. + */ +export async function expectProfilePinnedAboveProviders( + page: Page, + input: { name: string; summary: string }, +): Promise { + const viewport = pickerViewport(page); + await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 }); + const row = profilePickerRow(page, input.name); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByText(input.summary, { exact: true })).toBeVisible(); + + const profileTop = await boxTop(row); + const providerRows = viewport.locator('[data-testid^="model-provider-"]'); + await expect(providerRows.first()).toBeVisible(); + const providerCount = await providerRows.count(); + for (let index = 0; index < providerCount; index += 1) { + expect(profileTop).toBeLessThan(await boxTop(providerRows.nth(index))); + } +} + +export async function expectProfileVisibleForProvider( + page: Page, + input: { name: string; summary: string }, +): Promise { + const viewport = pickerViewport(page); + await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 }); + const row = profilePickerRow(page, input.name); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByText(input.summary, { exact: true })).toBeVisible(); +} + +export async function expectProfileEditIsPencilOnly(page: Page): Promise { + const viewport = pickerViewport(page); + await expect( + viewport.getByRole("button", { name: "Edit agent profiles", exact: true }), + ).toBeVisible(); + await expect(viewport.getByText("Edit", { exact: true })).toHaveCount(0); +} + +export async function expectAgentProfilesEmptyPrompt(page: Page): Promise { + const viewport = pickerViewport(page); + await expect(viewport.getByText("Profiles", { exact: true })).toHaveCount(0); + await expect( + viewport.getByRole("button", { name: "Edit agent profiles", exact: true }), + ).toHaveCount(0); + await expect(viewport.getByRole("button", { name: "Create profile", exact: true })).toBeVisible({ + timeout: 30_000, + }); +} + +export async function expectProfileEditTooltip(page: Page): Promise { + const edit = pickerViewport(page).getByRole("button", { + name: "Edit agent profiles", + exact: true, + }); + await edit.hover(); + await expect(page.getByText("Edit agent profiles", { exact: true })).toBeVisible(); +} + +export async function openAgentProfilesFromEmptyPrompt(page: Page): Promise { + await pickerViewport(page).getByRole("button", { name: "Create profile", exact: true }).click(); + await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 }); +} + +async function boxTop(locator: Locator): Promise { + const box = await locator.boundingBox(); + if (!box) { + throw new Error("Expected a laid-out element to measure"); + } + return box.y; +} + +export async function applyProfileFromPicker(page: Page, name: string): Promise { + await profilePickerRow(page, name).click(); + await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 }); +} + +/** + * Applying a profile materializes it and forgets it: nothing in the root view + * claims selection. Model rows do carry `aria-selected`, so a count of zero here + * is the absence of a checkmark, not the absence of the attribute everywhere. + */ +export async function expectNothingSelectedInPickerRoot(page: Page): Promise { + await expect(pickerViewport(page).locator("[aria-selected]")).toHaveCount(0); +} + +export async function expectAgentProfilesEditShortcut(page: Page): Promise { + await expect( + pickerViewport(page).getByRole("button", { name: "Edit agent profiles", exact: true }), + ).toBeVisible({ timeout: 30_000 }); +} + +export async function drillIntoProvider(page: Page, providerId: string): Promise { + await pickerViewport(page).getByTestId(`model-provider-${providerId}`).click(); +} + +export async function expectModelRowSelected( + page: Page, + input: { provider: string; modelId: string }, +): Promise { + await expect( + pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`), + ).toHaveAttribute("aria-selected", "true", { timeout: 30_000 }); +} + +// ─── Cross-provider model search ─────────────────────────────────────────── + +export async function searchAllModels(page: Page, query: string): Promise { + const input = page.getByTestId("model-search-all-input"); + await expect(input).toBeVisible({ timeout: 30_000 }); + await input.fill(query); +} + +export async function expectModelSearchResult( + page: Page, + expected: { provider: string; modelId: string; providerLabel: string; modelLabel: string }, +): Promise { + const row = pickerViewport(page).getByTestId( + `model-row-${expected.provider}-${expected.modelId}`, + ); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByText(expected.modelLabel, { exact: true })).toBeVisible(); + await expect( + row.getByText(new RegExp(`^${escapeForRegex(expected.providerLabel)}\\b`)), + ).toBeVisible(); +} + +function escapeForRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Search results replace the whole root view, pinned profiles included. */ +export async function expectPinnedProfilesHidden(page: Page): Promise { + await expect(pickerViewport(page).locator('[data-testid^="model-profile-row-"]')).toHaveCount(0); +} + +export async function expectModelSearchEmptyState(page: Page, query: string): Promise { + const empty = page.getByTestId("model-search-empty"); + await expect(empty).toBeVisible({ timeout: 30_000 }); + await expect(empty.getByText(`No models match "${query}"`, { exact: true })).toBeVisible(); +} + +export async function readModelPickerHeight(page: Page): Promise { + const box = await pickerViewport(page).boundingBox(); + if (!box) { + throw new Error("Expected the model picker to be laid out"); + } + return box.height; +} + +export async function readModelPickerWidth(page: Page): Promise { + const box = await pickerViewport(page).boundingBox(); + if (!box) { + throw new Error("Expected the model picker to be laid out"); + } + return box.width; +} + +export async function expectModelPickerHeight(page: Page, expectedHeight: number): Promise { + await expect.poll(() => readModelPickerHeight(page)).toBe(expectedHeight); +} + +export async function expectModelPickerWidth(page: Page, expectedWidth: number): Promise { + await expect.poll(() => readModelPickerWidth(page)).toBe(expectedWidth); +} + +export async function expectSearchResultsVirtualized( + page: Page, + input: { provider: string; total: number }, +): Promise { + const mountedRows = pickerViewport(page).locator(`[data-testid^="model-row-${input.provider}-"]`); + await expect.poll(() => mountedRows.count()).toBeGreaterThan(0); + expect(await mountedRows.count()).toBeLessThan(input.total); +} + +// ─── Composer agent controls ─────────────────────────────────────────────── + +export async function expectComposerModel(page: Page, modelLabel: string): Promise { + await expect( + page.getByRole("button", { name: `Select model (${modelLabel})`, exact: true }), + ).toBeVisible({ timeout: 30_000 }); +} + +export async function expectComposerMode(page: Page, modeLabel: string): Promise { + await expect( + page.getByRole("button", { name: `Select agent mode (${modeLabel})`, exact: true }).first(), + ).toBeVisible({ timeout: 30_000 }); +} + +/** The composer never shows the profile's name — a profile is not a selection. */ +export async function expectComposerDoesNotName(page: Page, profileName: string): Promise { + await expect( + page.locator('[data-testid="message-input-root"]:visible').getByText(profileName), + ).toHaveCount(0); +} diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index 6f03dfbdcd..d87db35ef9 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -40,6 +40,7 @@ type HostSection = | "workspaces" | "providers" | "usage" + | "terminals" | "host"; export async function openSettingsSection(page: Page, section: SettingsSection): Promise { diff --git a/packages/app/src/agent-profiles/index.ts b/packages/app/src/agent-profiles/index.ts new file mode 100644 index 0000000000..a00ad76173 --- /dev/null +++ b/packages/app/src/agent-profiles/index.ts @@ -0,0 +1,26 @@ +/** + * Agent profiles: named bundles of composer settings (provider, model, mode, + * thinking option, feature values, notes) stored host-wide in daemon config. + * + * Four capabilities leave this module — managing the list in settings, reading + * it, pinning it to the model picker, and drawing a profile's glyph. Everything + * else (the form model, the catalog and feature probes, the materialization + * rules, the icon registry, the row and modal chrome) is internal; import from + * `@/agent-profiles`, never a path inside it. + * + * `useAgentProfilePicker` deliberately hands the picker a flat row view model + * and one `applyProfile(id)` callback rather than the profiles themselves: what + * a profile contains, and how it reaches a live agent versus a draft, stays in + * here. + */ +export type { AgentProfile } from "@getpaseo/protocol/messages"; +export { useAgentProfiles } from "./internal/use-agent-profiles"; +export { + useAgentProfilePicker, + type AgentProfileApplyTarget, + type AgentProfilePicker, + type AgentProfilePickerRow, + type DraftAgentProfileControls, +} from "./internal/use-agent-profile-picker"; +export { AgentProfileGlyph } from "./internal/agent-profile-glyph"; +export { AgentProfilesSection } from "./settings/agent-profiles-section"; diff --git a/packages/app/src/agent-profiles/internal/agent-profile-glyph.tsx b/packages/app/src/agent-profiles/internal/agent-profile-glyph.tsx new file mode 100644 index 0000000000..e9795614e9 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/agent-profile-glyph.tsx @@ -0,0 +1,128 @@ +import { withUnistyles } from "react-native-unistyles"; +import { + BookOpen, + Bot, + Boxes, + Brain, + Bug, + Cloud, + Code, + Compass, + Cpu, + Database, + Eye, + Feather, + FileText, + FlaskConical, + GitBranch, + Globe, + Hammer, + Layers, + Microscope, + Package, + Palette, + Pencil, + Rocket, + Search, + Server, + Shield, + Sparkles, + Terminal, + TestTube, + Wrench, +} from "lucide-react-native"; +import { identityForeground } from "@/styles/identity-colors"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { + AGENT_PROFILE_COLORS, + resolveAgentProfileColor, + resolveAgentProfileIconKey, + type AgentProfileColor, + type AgentProfileIconKey, +} from "./profile-appearance"; + +/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */ +const ThemedDefaultIcon = withUnistyles(Bot); + +/** + * `withUnistyles` has to wrap each icon once at module scope, so the registry + * stores the themed component rather than the raw lucide one. + */ +const THEMED_ICONS: Record = { + code: withUnistyles(Code), + terminal: withUnistyles(Terminal), + bug: withUnistyles(Bug), + wrench: withUnistyles(Wrench), + hammer: withUnistyles(Hammer), + + flask: withUnistyles(FlaskConical), + testTube: withUnistyles(TestTube), + microscope: withUnistyles(Microscope), + search: withUnistyles(Search), + eye: withUnistyles(Eye), + + palette: withUnistyles(Palette), + feather: withUnistyles(Feather), + pencil: withUnistyles(Pencil), + fileText: withUnistyles(FileText), + book: withUnistyles(BookOpen), + + rocket: withUnistyles(Rocket), + package: withUnistyles(Package), + boxes: withUnistyles(Boxes), + server: withUnistyles(Server), + database: withUnistyles(Database), + + cpu: withUnistyles(Cpu), + cloud: withUnistyles(Cloud), + globe: withUnistyles(Globe), + gitBranch: withUnistyles(GitBranch), + layers: withUnistyles(Layers), + + compass: withUnistyles(Compass), + brain: withUnistyles(Brain), + sparkles: withUnistyles(Sparkles), + shield: withUnistyles(Shield), +}; + +const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +/** + * One `uniProps` mapping per colour, built once. Building these inline would + * hand `withUnistyles` a new function every render and defeat its tracking. + */ +const COLOR_MAPPINGS: Record { color: string }> = (() => { + const byColor = {} as Record { color: string }>; + for (const color of AGENT_PROFILE_COLORS) { + byColor[color] = + color === "none" + ? mutedMapping + : (theme: Theme) => ({ color: identityForeground(color, theme.colorScheme) }); + } + return byColor; +})(); + +export function agentProfileColorMapping( + color: AgentProfileColor, +): (theme: Theme) => { color: string } { + return COLOR_MAPPINGS[color]; +} + +/** + * How a profile is drawn everywhere it appears — settings list, model picker, + * and the picker grid's own cells. One component so the three can't drift. + */ +export function AgentProfileGlyph({ + icon, + color, + size = ICON_SIZE.md, +}: { + icon?: string | undefined; + color?: string | undefined; + size?: number; +}) { + const iconKey = resolveAgentProfileIconKey(icon); + const mapping = COLOR_MAPPINGS[resolveAgentProfileColor(color)]; + const Icon = iconKey ? THEMED_ICONS[iconKey] : ThemedDefaultIcon; + return ; +} diff --git a/packages/app/src/agent-profiles/internal/capabilities.test.ts b/packages/app/src/agent-profiles/internal/capabilities.test.ts new file mode 100644 index 0000000000..7708e965a9 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/capabilities.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { supportsAgentProfiles } from "./capabilities"; + +describe("agent profile capabilities", () => { + it("requires storage and live config application support", () => { + expect(supportsAgentProfiles(undefined)).toBe(false); + expect(supportsAgentProfiles({ agentProfiles: true })).toBe(false); + expect(supportsAgentProfiles({ agentConfigApply: true })).toBe(false); + expect(supportsAgentProfiles({ agentProfiles: true, agentConfigApply: true })).toBe(true); + }); +}); diff --git a/packages/app/src/agent-profiles/internal/capabilities.ts b/packages/app/src/agent-profiles/internal/capabilities.ts new file mode 100644 index 0000000000..caf5fb2229 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/capabilities.ts @@ -0,0 +1,8 @@ +interface AgentProfileCapabilities { + agentProfiles?: boolean; + agentConfigApply?: boolean; +} + +export function supportsAgentProfiles(features: AgentProfileCapabilities | undefined): boolean { + return features?.agentProfiles === true && features.agentConfigApply === true; +} diff --git a/packages/app/src/agent-profiles/internal/materialize-profile.test.ts b/packages/app/src/agent-profiles/internal/materialize-profile.test.ts new file mode 100644 index 0000000000..b228c32f37 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/materialize-profile.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile"; + +function profile(overrides: Partial = {}): AgentProfile { + return { + id: "ui-work", + name: "UI work", + provider: "claude", + ...overrides, + }; +} + +describe("materializeAgentProfile", () => { + it("resolves every field a full profile names", () => { + expect( + materializeAgentProfile( + profile({ + model: "claude-opus-5", + modeId: "plan", + thinkingOptionId: "think-hard", + featureValues: { web_search: true }, + }), + ), + ).toEqual({ + provider: "claude", + modelId: "claude-opus-5", + modeId: "plan", + thinkingOptionId: "think-hard", + featureValues: { web_search: true }, + }); + }); + + it("treats omitted and blank fields the same", () => { + expect(materializeAgentProfile(profile({ model: " ", modeId: "" }))).toEqual({ + provider: "claude", + modelId: "", + modeId: "", + thinkingOptionId: "", + featureValues: {}, + }); + }); + + it("trims stored ids", () => { + expect(materializeAgentProfile(profile({ model: " claude-opus-5 " })).modelId).toBe( + "claude-opus-5", + ); + }); +}); + +describe("toAgentConfigApply", () => { + it("sends every value the profile names", () => { + expect( + toAgentConfigApply( + materializeAgentProfile( + profile({ + model: "claude-opus-5", + modeId: "plan", + thinkingOptionId: "think-hard", + featureValues: { web_search: true }, + }), + ), + ), + ).toEqual({ + modelId: "claude-opus-5", + modeId: "plan", + thinkingOptionId: "think-hard", + featureValues: { web_search: true }, + }); + }); + + it("omits what the profile leaves alone rather than clearing it", () => { + expect(toAgentConfigApply(materializeAgentProfile(profile({ modeId: "plan" })))).toEqual({ + modeId: "plan", + }); + }); + + it("omits an empty feature map so the agent keeps its own feature values", () => { + expect( + toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))), + ).not.toHaveProperty("featureValues"); + }); + + it("never carries a provider, which a running agent cannot change", () => { + expect( + toAgentConfigApply(materializeAgentProfile(profile({ model: "claude-opus-5" }))), + ).not.toHaveProperty("provider"); + }); +}); diff --git a/packages/app/src/agent-profiles/internal/materialize-profile.ts b/packages/app/src/agent-profiles/internal/materialize-profile.ts new file mode 100644 index 0000000000..bdedf604a5 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/materialize-profile.ts @@ -0,0 +1,46 @@ +import type { AgentConfigApply, AgentProfile } from "@getpaseo/protocol/messages"; + +/** + * A profile with its blank fields resolved away. Storage keeps every field + * optional and lets the user clear one to an empty string; applying has to know + * the difference between "set this" and "leave whatever is there alone", and + * every consumer would otherwise re-derive the same trim-and-drop rules. + */ +export interface MaterializedAgentProfile { + provider: string; + /** Empty when the profile names no model, meaning "leave the model alone". */ + modelId: string; + modeId: string; + thinkingOptionId: string; + featureValues: Record; +} + +function trimmed(value: string | undefined): string { + return value?.trim() ?? ""; +} + +export function materializeAgentProfile(profile: AgentProfile): MaterializedAgentProfile { + return { + provider: trimmed(profile.provider), + modelId: trimmed(profile.model), + modeId: trimmed(profile.modeId), + thinkingOptionId: trimmed(profile.thinkingOptionId), + featureValues: profile.featureValues ?? {}, + }; +} + +/** + * The payload for a running agent. Omitted fields are left alone by the daemon, + * so a profile that names no mode does not reset the agent's mode. Provider is + * absent because a running agent cannot change the process it is. + */ +export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConfigApply { + return { + ...(profile.modelId ? { modelId: profile.modelId } : {}), + ...(profile.modeId ? { modeId: profile.modeId } : {}), + ...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}), + ...(Object.keys(profile.featureValues).length > 0 + ? { featureValues: profile.featureValues } + : {}), + }; +} diff --git a/packages/app/src/agent-profiles/internal/profile-appearance.ts b/packages/app/src/agent-profiles/internal/profile-appearance.ts new file mode 100644 index 0000000000..3602f7e511 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/profile-appearance.ts @@ -0,0 +1,71 @@ +import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors"; + +/** + * A profile's icon is a key into a fixed registry, not a glyph. The value is + * stored in daemon config and drawn by every client, so it has to survive being + * rendered by an app version that ships a different icon set — an unknown key + * falls back to the default glyph rather than showing the raw string. + * + * Order is what the grid draws, five to a row: related icons sit together so + * scanning the grid is scanning categories, not thirty unrelated shapes. + */ +export const AGENT_PROFILE_ICON_KEYS = [ + "code", + "terminal", + "bug", + "wrench", + "hammer", + + "flask", + "testTube", + "microscope", + "search", + "eye", + + "palette", + "feather", + "pencil", + "fileText", + "book", + + "rocket", + "package", + "boxes", + "server", + "database", + + "cpu", + "cloud", + "globe", + "gitBranch", + "layers", + + "compass", + "brain", + "sparkles", + "shield", +] as const; + +export type AgentProfileIconKey = (typeof AGENT_PROFILE_ICON_KEYS)[number]; + +/** Reuses the host identity palette so a profile and a host name read as one family. */ +export type AgentProfileColor = "none" | IdentityColorName; + +export const AGENT_PROFILE_COLORS: readonly AgentProfileColor[] = ["none", ...IDENTITY_COLOR_NAMES]; + +export function isAgentProfileIconKey(value: string | undefined): value is AgentProfileIconKey { + return AGENT_PROFILE_ICON_KEYS.some((key) => key === value); +} + +export function isAgentProfileColor(value: string | undefined): value is AgentProfileColor { + return AGENT_PROFILE_COLORS.some((color) => color === value); +} + +/** Anything unrecognised means "not chosen": the glyph and colour both default. */ +export function resolveAgentProfileIconKey(value: string | undefined): AgentProfileIconKey | null { + return isAgentProfileIconKey(value) ? value : null; +} + +export function resolveAgentProfileColor(value: string | undefined): AgentProfileColor { + return isAgentProfileColor(value) ? value : "none"; +} diff --git a/packages/app/src/agent-profiles/internal/profile-form-model.test.ts b/packages/app/src/agent-profiles/internal/profile-form-model.test.ts new file mode 100644 index 0000000000..0b5fe6253a --- /dev/null +++ b/packages/app/src/agent-profiles/internal/profile-form-model.test.ts @@ -0,0 +1,431 @@ +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { describe, expect, it } from "vitest"; +import { + buildFeatureRequestKey, + openAgentProfileForm, + type AgentProfileFormModel, +} from "./profile-form-model"; + +const CLAUDE: ProviderSnapshotEntry = { + provider: "claude", + status: "ready", + enabled: true, + label: "Claude Code", + defaultModeId: "plan", + modes: [ + { id: "plan", label: "Plan" }, + { id: "accept-edits", label: "Accept edits" }, + ], + models: [ + { + provider: "claude", + id: "claude-opus-5", + label: "Opus 5", + isDefault: true, + thinkingOptions: [ + { id: "think", label: "Think" }, + { id: "think-hard", label: "Think hard" }, + ], + }, + { + provider: "claude", + id: "claude-haiku-4-5", + label: "Haiku 4.5", + thinkingOptions: [{ id: "think", label: "Think" }], + }, + ], +}; + +const CODEX: ProviderSnapshotEntry = { + provider: "codex", + status: "ready", + enabled: true, + label: "Codex", + modes: [{ id: "read-only", label: "Read only" }], + models: [{ provider: "codex", id: "gpt-5.2-codex", label: "GPT-5.2 Codex", isDefault: true }], +}; + +const DISABLED: ProviderSnapshotEntry = { + provider: "pi", + status: "ready", + enabled: false, + label: "Pi", +}; + +const ENTRIES = [CLAUDE, CODEX, DISABLED]; + +function openWithCatalog( + snapshot: Parameters[0], +): AgentProfileFormModel { + const model = openAgentProfileForm(snapshot); + model.applyProviderCatalog(ENTRIES); + return model; +} + +function selectClaude(model: AgentProfileFormModel): void { + model.setProvider("claude", { label: "Claude Code" }); +} + +function optionValues(options: readonly { value: string }[]): string[] { + return options.map((option) => option.value); +} + +describe("openAgentProfileForm", () => { + it("starts a create form empty and cannot submit", () => { + const model = openWithCatalog({ mode: "create" }); + const state = model.getState(); + + expect(state.name).toBe(""); + expect(state.provider).toBe(""); + expect(state.canSubmit).toBe(false); + expect(state.submitValue).toBeNull(); + expect(state.disclosure.showModelField).toBe(false); + }); + + it("lists only enabled providers", () => { + const model = openWithCatalog({ mode: "create" }); + + expect(optionValues(model.getState().providerOptions)).toEqual(["claude", "codex"]); + }); + + it("requires a name and a provider before it can submit", () => { + const model = openWithCatalog({ mode: "create" }); + + model.setName("UI work"); + expect(model.getState().canSubmit).toBe(false); + + selectClaude(model); + expect(model.getState().canSubmit).toBe(true); + expect(model.getState().submitValue).toMatchObject({ name: "UI work", provider: "claude" }); + }); + + it("omits blank text fields from the submitted value but never a selection", () => { + const model = openWithCatalog({ mode: "create" }); + model.setName(" Cheap grunt "); + model.setProvider("codex", { label: "Codex" }); + model.setNotes(" "); + + // Codex declares no thinking options, so that key is genuinely absent — + // model and mode are seeded, never left for the host to decide. + expect(model.getState().submitValue).toEqual({ + name: "Cheap grunt", + provider: "codex", + model: "gpt-5.2-codex", + modeId: "read-only", + }); + }); + + it("seeds edit mode from the stored profile before the catalog lands", () => { + const model = openAgentProfileForm({ + mode: "edit", + profile: { + id: "p1", + name: "UI work", + icon: "palette", + provider: "claude", + model: "claude-opus-5", + modeId: "plan", + thinkingOptionId: "think-hard", + featureValues: { webSearch: true }, + notes: "Visual work only.", + }, + }); + const state = model.getState(); + + expect(state.name).toBe("UI work"); + expect(state.icon).toBe("palette"); + // Ids double as labels until the catalog arrives. + expect(state.modelDisplay).toEqual({ label: "claude-opus-5" }); + expect(state.thinkingDisplay).toEqual({ label: "think-hard" }); + expect(state.catalogResolution).toBe("idle"); + }); + + it("upgrades seeded displays to catalog labels without touching selections", () => { + const model = openAgentProfileForm({ + mode: "edit", + profile: { + id: "p1", + name: "UI work", + provider: "claude", + model: "claude-opus-5", + modeId: "plan", + thinkingOptionId: "think-hard", + }, + }); + model.applyProviderCatalog(ENTRIES); + const state = model.getState(); + + expect(state.providerDisplay).toEqual({ label: "Claude Code" }); + expect(state.modelDisplay).toEqual({ label: "Opus 5" }); + expect(state.modeDisplay).toEqual({ label: "Plan" }); + expect(state.thinkingDisplay).toEqual({ label: "Think hard" }); + expect(state.modelId).toBe("claude-opus-5"); + expect(state.catalogResolution).toBe("complete"); + }); + + it("keeps a stored value the catalog does not know", () => { + const model = openAgentProfileForm({ + mode: "edit", + profile: { id: "p1", name: "Legacy", provider: "claude", model: "claude-retired-3" }, + }); + model.applyProviderCatalog(ENTRIES); + + expect(model.getState().modelId).toBe("claude-retired-3"); + expect(model.getState().modelDisplay).toEqual({ label: "claude-retired-3" }); + expect(model.getState().disclosure.showModelField).toBe(true); + }); + + describe("provider cascade", () => { + it("reseeds every provider-scoped selection when the provider changes", () => { + const model = openWithCatalog({ mode: "create" }); + model.setName("Test writer"); + selectClaude(model); + model.setModel("claude-opus-5", { label: "Opus 5" }); + model.setThinking("think-hard", { label: "Think hard" }); + model.applyFeatures(model.getState().featureRequestKey ?? "", [ + { type: "toggle", id: "webSearch", label: "Web search", value: false }, + ]); + model.setFeatureValue("webSearch", true); + + model.setProvider("codex", { label: "Codex" }); + const state = model.getState(); + + // Claude's ids mean nothing under Codex, so each field lands on Codex's own default. + expect(state.modelId).toBe("gpt-5.2-codex"); + expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" }); + expect(state.thinkingOptionId).toBe(""); + expect(state.thinkingDisplay).toBeNull(); + expect(state.featureValues).toEqual({}); + expect(state.features).toEqual([]); + expect(state.name).toBe("Test writer"); + }); + + it("seeds each provider's default mode", () => { + const model = openWithCatalog({ mode: "create" }); + selectClaude(model); + + expect(model.getState().modeId).toBe("plan"); + + // Codex declares no defaultModeId, so the first mode it offers stands in. + model.setProvider("codex", { label: "Codex" }); + expect(model.getState().modeId).toBe("read-only"); + }); + + it("re-populates model, mode and thinking options from the new provider", () => { + const model = openWithCatalog({ mode: "create" }); + selectClaude(model); + + expect(optionValues(model.getState().modelOptions)).toEqual([ + "claude-opus-5", + "claude-haiku-4-5", + ]); + expect(optionValues(model.getState().thinkingOptions)).toEqual(["think", "think-hard"]); + + model.setProvider("codex", { label: "Codex" }); + + expect(optionValues(model.getState().modelOptions)).toEqual(["gpt-5.2-codex"]); + expect(optionValues(model.getState().modeOptions)).toEqual(["read-only"]); + // GPT-5.2 Codex declares no thinking options, so the field disappears. + expect(model.getState().thinkingOptions).toEqual([]); + expect(model.getState().disclosure.showThinkingField).toBe(false); + }); + + it("ignores a no-op provider selection", () => { + const model = openWithCatalog({ mode: "create" }); + selectClaude(model); + model.setModel("claude-opus-5", { label: "Opus 5" }); + + selectClaude(model); + + expect(model.getState().modelId).toBe("claude-opus-5"); + }); + }); + + describe("model cascade", () => { + it("reseeds a thinking level the new model does not offer", () => { + const model = openWithCatalog({ mode: "create" }); + selectClaude(model); + model.setModel("claude-opus-5", { label: "Opus 5" }); + model.setThinking("think-hard", { label: "Think hard" }); + + // Haiku offers only "think", so "think-hard" cannot survive the switch. + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + + expect(model.getState().thinkingOptionId).toBe("think"); + expect(model.getState().thinkingDisplay).toEqual({ label: "Think" }); + }); + + it("keeps a thinking level the new model still offers", () => { + const model = openWithCatalog({ mode: "create" }); + selectClaude(model); + model.setModel("claude-opus-5", { label: "Opus 5" }); + model.setThinking("think", { label: "Think" }); + + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + + expect(model.getState().thinkingOptionId).toBe("think"); + }); + + it("seeds the provider's default model and reads thinking options from it", () => { + const model = openWithCatalog({ mode: "create" }); + selectClaude(model); + + expect(model.getState().modelId).toBe("claude-opus-5"); + expect(optionValues(model.getState().thinkingOptions)).toEqual(["think", "think-hard"]); + expect(model.getState().thinkingOptionId).toBe("think"); + }); + + it("offers no way to unset a selection", () => { + const model = openWithCatalog({ mode: "create" }); + selectClaude(model); + const state = model.getState(); + + const everyOption = [...state.modelOptions, ...state.modeOptions, ...state.thinkingOptions]; + + expect(everyOption.length).toBeGreaterThan(0); + expect(optionValues(everyOption)).not.toContain(""); + }); + }); + + describe("features", () => { + it("asks for features per provider/model/mode/thinking combination", () => { + const model = openWithCatalog({ mode: "create" }); + selectClaude(model); + + expect(model.getState().featureRequest).toEqual({ + provider: "claude", + model: "claude-opus-5", + modeId: "plan", + thinkingOptionId: "think", + }); + const firstKey = model.getState().featureRequestKey; + + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + + expect(model.getState().featureRequestKey).not.toBe(firstKey); + expect(model.getState().featureRequest).toEqual({ + provider: "claude", + model: "claude-haiku-4-5", + modeId: "plan", + thinkingOptionId: "think", + }); + }); + + it("moves from pending to complete only for the current request", () => { + const model = openWithCatalog({ mode: "create" }); + selectClaude(model); + const key = model.getState().featureRequestKey ?? ""; + expect(model.getState().featureResolution).toBe("pending"); + + model.applyFeatures("stale-key", [ + { type: "toggle", id: "webSearch", label: "Web search", value: false }, + ]); + expect(model.getState().featureResolution).toBe("pending"); + expect(model.getState().features).toEqual([]); + + model.applyFeatures(key, [ + { type: "toggle", id: "webSearch", label: "Web search", value: false }, + ]); + expect(model.getState().featureResolution).toBe("complete"); + expect(model.getState().disclosure.showFeaturesField).toBe(true); + }); + + it("reflects a set feature value back into the rendered feature", () => { + const model = openWithCatalog({ mode: "create" }); + selectClaude(model); + model.applyFeatures(model.getState().featureRequestKey ?? "", [ + { type: "toggle", id: "webSearch", label: "Web search", value: false }, + ]); + + model.setFeatureValue("webSearch", true); + + expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true }); + expect(model.getState().featureValues).toEqual({ webSearch: true }); + }); + + it("keeps stored feature values while the feature list is still pending", () => { + const model = openAgentProfileForm({ + mode: "edit", + profile: { + id: "p1", + name: "UI work", + provider: "claude", + featureValues: { webSearch: true }, + }, + }); + model.applyProviderCatalog(ENTRIES); + + expect(model.getState().featureResolution).toBe("pending"); + expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true }); + }); + + it("prunes stored values the provider no longer reports", () => { + const model = openAgentProfileForm({ + mode: "edit", + profile: { + id: "p1", + name: "UI work", + provider: "claude", + featureValues: { webSearch: true, retiredFlag: true }, + }, + }); + model.applyProviderCatalog(ENTRIES); + model.applyFeatures(model.getState().featureRequestKey ?? "", [ + { type: "toggle", id: "webSearch", label: "Web search", value: false }, + ]); + + expect(model.getState().featureValues).toEqual({ webSearch: true }); + }); + + it("resolves an unavailable feature listing without showing the field", () => { + const model = openWithCatalog({ mode: "create" }); + selectClaude(model); + + model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? ""); + + expect(model.getState().featureResolution).toBe("complete"); + expect(model.getState().disclosure.showFeaturesField).toBe(false); + }); + }); + + it("blocks submission while a save is in flight", () => { + const model = openWithCatalog({ mode: "create" }); + model.setName("UI work"); + selectClaude(model); + + model.setSubmitting(true); + expect(model.getState().canSubmit).toBe(false); + + model.setSubmitting(false); + expect(model.getState().canSubmit).toBe(true); + }); + + it("stops publishing after close", () => { + const model = openWithCatalog({ mode: "create" }); + let notifications = 0; + model.subscribe(() => { + notifications += 1; + }); + + model.setName("a"); + expect(notifications).toBe(1); + + model.close(); + model.setName("b"); + expect(notifications).toBe(1); + expect(model.getState().name).toBe("a"); + }); +}); + +describe("buildFeatureRequestKey", () => { + it("is null without a provider", () => { + expect(buildFeatureRequestKey(null)).toBeNull(); + }); + + it("separates the fields so a value cannot bleed across positions", () => { + expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||"); + expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe( + buildFeatureRequestKey({ provider: "claude", model: "a" }), + ); + }); +}); diff --git a/packages/app/src/agent-profiles/internal/profile-form-model.ts b/packages/app/src/agent-profiles/internal/profile-form-model.ts new file mode 100644 index 0000000000..ba3a9e5229 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/profile-form-model.ts @@ -0,0 +1,594 @@ +import type { + AgentFeature, + AgentMode, + AgentModelDefinition, + AgentSelectOption, + ProviderSnapshotEntry, +} from "@getpaseo/protocol/agent-types"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels"; +import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences"; +import { filterSelectableModels } from "@/provider-selection/model-catalog"; + +/** + * The persisted profile minus the id; the list owns identity. + * + * `Omit` does not work here. `AgentProfileSchema` is `.passthrough()`, so + * `AgentProfile` carries a `[key: string]: unknown` index signature, and + * `Exclude` stays `string | number` — the named keys + * come back as `unknown` and `name`/`provider` stop being required. Mapping + * `keyof` with an `as` filter drops the index signature and the id together. + */ +export type AgentProfileValue = { + [K in keyof AgentProfile as string extends K + ? never + : K extends "id" + ? never + : K]: AgentProfile[K]; +}; + +export interface AgentProfileFormDisplay { + label: string; + description?: string; +} + +export interface AgentProfileFormOption { + id: string; + value: string; + label: string; + description?: string; + testID: string; +} + +export interface AgentProfileFormSnapshot { + mode: "create" | "edit"; + profile?: AgentProfile; +} + +/** + * The inputs a feature listing needs. Features are provider-scoped and change + * with model/mode/thinking, so every one of those is part of the request. + */ +export interface AgentProfileFeatureRequest { + provider: string; + model?: string; + modeId?: string; + thinkingOptionId?: string; +} + +export type AgentProfileResolutionStatus = "idle" | "pending" | "complete"; + +export interface AgentProfileFormDisclosure { + showModelField: boolean; + showModeField: boolean; + showThinkingField: boolean; + showFeaturesField: boolean; +} + +export interface AgentProfileFormState { + mode: "create" | "edit"; + name: string; + icon: string; + color: string; + notes: string; + provider: string; + modelId: string; + modeId: string; + thinkingOptionId: string; + featureValues: Record; + + providerOptions: AgentProfileFormOption[]; + modelOptions: AgentProfileFormOption[]; + modeOptions: AgentProfileFormOption[]; + thinkingOptions: AgentProfileFormOption[]; + features: AgentFeature[]; + + providerDisplay: AgentProfileFormDisplay | null; + modelDisplay: AgentProfileFormDisplay | null; + modeDisplay: AgentProfileFormDisplay | null; + thinkingDisplay: AgentProfileFormDisplay | null; + + catalogResolution: AgentProfileResolutionStatus; + featureResolution: AgentProfileResolutionStatus; + featureRequest: AgentProfileFeatureRequest | null; + featureRequestKey: string | null; + + disclosure: AgentProfileFormDisclosure; + isSubmitting: boolean; + submitError: string | null; + canSubmit: boolean; + submitValue: AgentProfileValue | null; +} + +export interface AgentProfileFormModel { + getState: () => AgentProfileFormState; + subscribe: (listener: () => void) => () => void; + close: () => void; + /** Late input: the host-scoped provider catalog. Never touches selections. */ + applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void; + /** Late input: the feature list for one request. Stale keys are ignored. */ + applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void; + /** Resolve a request that produced no usable features (provider error). */ + applyFeaturesUnavailable: (requestKey: string) => void; + setName: (value: string) => void; + setAppearance: (value: { icon: string; color: string }) => void; + setNotes: (value: string) => void; + setProvider: (providerId: string, display: AgentProfileFormDisplay) => void; + setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void; + setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void; + setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void; + setFeatureValue: (featureId: string, value: unknown) => void; + setSubmitting: (value: boolean) => void; + setSubmitError: (value: string | null) => void; +} + +function findEntry( + entries: readonly ProviderSnapshotEntry[], + provider: string, +): ProviderSnapshotEntry | null { + if (!provider) { + return null; + } + return entries.find((entry) => entry.provider === provider) ?? null; +} + +function resolveModels( + entries: readonly ProviderSnapshotEntry[], + provider: string, +): AgentModelDefinition[] { + return filterSelectableModels(findEntry(entries, provider)?.models ?? null) ?? []; +} + +function resolveModes(entries: readonly ProviderSnapshotEntry[], provider: string): AgentMode[] { + return findEntry(entries, provider)?.modes ?? []; +} + +/** + * Thinking options hang off a model, so an unset model still has to resolve to + * the model the daemon would pick — otherwise the field disappears whenever the + * user leaves the model on "provider default". + */ +function resolveEffectiveModel( + models: readonly AgentModelDefinition[], + modelId: string, +): AgentModelDefinition | null { + const trimmed = modelId.trim(); + if (trimmed) { + return models.find((model) => model.id === trimmed) ?? null; + } + return models.find((model) => model.isDefault) ?? models[0] ?? null; +} + +function resolveThinkingOptions( + entries: readonly ProviderSnapshotEntry[], + provider: string, + modelId: string, +): AgentSelectOption[] { + return resolveEffectiveModel(resolveModels(entries, provider), modelId)?.thinkingOptions ?? []; +} + +/** Every real option is selected by its own id; only the unset row differs. */ +function formOption(input: { + id: string; + label: string; + description: string | undefined; + testID: string; +}): AgentProfileFormOption { + return { + id: input.id, + value: input.id, + label: input.label, + ...(input.description ? { description: input.description } : {}), + testID: input.testID, + }; +} + +function buildProviderOptions(entries: readonly ProviderSnapshotEntry[]): AgentProfileFormOption[] { + return entries + .filter((entry) => entry.enabled) + .map((entry) => + formOption({ + id: entry.provider, + label: entry.label ?? entry.provider, + description: entry.description, + testID: `agent-profile-provider-option-${entry.provider}`, + }), + ); +} + +function buildModelOptions(models: readonly AgentModelDefinition[]): AgentProfileFormOption[] { + return models.map((model) => + formOption({ + id: model.id, + label: model.label, + // Models are labelled by family, so the id is the only thing that + // distinguishes two entries with the same label. + description: model.description ?? model.id, + testID: `agent-profile-model-option-${model.id}`, + }), + ); +} + +function buildModeOptions(modes: readonly AgentMode[]): AgentProfileFormOption[] { + return modes.map((mode) => + formOption({ + id: mode.id, + label: formatAgentModeLabel(mode), + description: mode.description, + testID: `agent-profile-mode-option-${mode.id}`, + }), + ); +} + +function buildThinkingOptions(options: readonly AgentSelectOption[]): AgentProfileFormOption[] { + return options.map((option) => + formOption({ + id: option.id, + label: formatThinkingOptionLabel(option), + description: option.description, + testID: `agent-profile-thinking-option-${option.id}`, + }), + ); +} + +/** + * Every selection resolves to a concrete id. + * + * Each list used to carry a synthetic "Provider default" row. It read as a value + * you could choose but stored an absent one, so a profile that looked fully + * specified would apply whatever the host preferred at the time — and two hosts + * would materialize the same profile differently. The schedule form never + * offered that row; this form now matches it and seeds the catalog's own default + * instead, so what the form shows is what the profile stores. + */ +function defaultModelId(models: readonly AgentModelDefinition[]): string { + return models.find((model) => model.isDefault)?.id ?? models[0]?.id ?? ""; +} + +function defaultModeId(entry: ProviderSnapshotEntry | null, modes: readonly AgentMode[]): string { + return entry?.defaultModeId ?? modes[0]?.id ?? ""; +} + +function defaultThinkingOptionId(model: AgentModelDefinition | null): string { + const options = model?.thinkingOptions ?? []; + return ( + model?.defaultThinkingOptionId ?? + options.find((option) => option.isDefault)?.id ?? + options[0]?.id ?? + "" + ); +} + +function seedSelections( + next: AgentProfileFormState, + input: { + entry: ProviderSnapshotEntry | null; + models: readonly AgentModelDefinition[]; + modes: readonly AgentMode[]; + }, +): AgentProfileFormState { + const modelId = next.modelId || defaultModelId(input.models); + const modeId = next.modeId || defaultModeId(input.entry, input.modes); + const thinkingOptionId = + next.thinkingOptionId || defaultThinkingOptionId(resolveEffectiveModel(input.models, modelId)); + if ( + modelId === next.modelId && + modeId === next.modeId && + thinkingOptionId === next.thinkingOptionId + ) { + return next; + } + return { ...next, modelId, modeId, thinkingOptionId }; +} + +/** + * Selected labels upgrade from the stored id to the catalog label once the + * catalog lands, and fall back to the id when the catalog does not know the + * value. They never blank out — a profile pointing at a retired model still + * reads as that model. + */ +function refineDisplay( + current: AgentProfileFormDisplay | null, + selectedId: string, + resolvedLabel: string | null, +): AgentProfileFormDisplay | null { + if (!selectedId) { + return null; + } + if (resolvedLabel) { + return { label: resolvedLabel }; + } + return current ?? { label: selectedId }; +} + +function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null { + if (!state.provider) { + return null; + } + return { + provider: state.provider, + ...(state.modelId ? { model: state.modelId } : {}), + ...(state.modeId ? { modeId: state.modeId } : {}), + ...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}), + }; +} + +export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null { + if (!request) { + return null; + } + return [ + request.provider, + request.model ?? "", + request.modeId ?? "", + request.thinkingOptionId ?? "", + ].join("|"); +} + +function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null { + const name = state.name.trim(); + const notes = state.notes.trim(); + if (!name || !state.provider) { + return null; + } + return { + name, + ...(state.icon ? { icon: state.icon } : {}), + ...(state.color ? { color: state.color } : {}), + provider: state.provider, + ...(state.modelId ? { model: state.modelId } : {}), + ...(state.modeId ? { modeId: state.modeId } : {}), + ...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}), + ...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}), + ...(notes ? { notes } : {}), + }; +} + +function resolveFeatureStatus( + featureRequestKey: string | null, + featuresAreCurrent: boolean, +): AgentProfileResolutionStatus { + if (featureRequestKey === null) { + return "idle"; + } + return featuresAreCurrent ? "complete" : "pending"; +} + +const BLANK_PROFILE: AgentProfileValue = { name: "", provider: "" }; + +/** A stored id doubles as its own label until the catalog can upgrade it. */ +function seedDisplay(value: string | undefined): AgentProfileFormDisplay | null { + return value ? { label: value } : null; +} + +/** + * Edit mode seeds every value AND display from the stored profile alone: the + * catalog is not loaded yet, so `applyProviderCatalog` does the upgrading later. + */ +function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileFormState { + const profile = snapshot.profile ?? BLANK_PROFILE; + return { + mode: snapshot.mode, + name: profile.name, + icon: profile.icon ?? "", + color: profile.color ?? "", + notes: profile.notes ?? "", + provider: profile.provider, + modelId: profile.model ?? "", + modeId: profile.modeId ?? "", + thinkingOptionId: profile.thinkingOptionId ?? "", + featureValues: { ...profile.featureValues }, + providerOptions: [], + modelOptions: [], + modeOptions: [], + thinkingOptions: [], + features: [], + providerDisplay: seedDisplay(profile.provider), + modelDisplay: seedDisplay(profile.model), + modeDisplay: seedDisplay(profile.modeId), + thinkingDisplay: seedDisplay(profile.thinkingOptionId), + catalogResolution: "idle", + featureResolution: "idle", + featureRequest: null, + featureRequestKey: null, + disclosure: { + showModelField: false, + showModeField: false, + showThinkingField: false, + showFeaturesField: false, + }, + isSubmitting: false, + submitError: null, + canSubmit: false, + submitValue: null, + }; +} + +export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel { + let entries: readonly ProviderSnapshotEntry[] = []; + let catalogResolution: AgentProfileResolutionStatus = "idle"; + let resolvedFeatureKey: string | null = null; + let resolvedFeatures: AgentFeature[] = []; + let listeners = new Set<() => void>(); + let closed = false; + + function derive(incoming: AgentProfileFormState): AgentProfileFormState { + const models = resolveModels(entries, incoming.provider); + const modes = resolveModes(entries, incoming.provider); + const next = seedSelections(incoming, { + entry: findEntry(entries, incoming.provider), + models, + modes, + }); + const thinking = resolveThinkingOptions(entries, next.provider, next.modelId); + const featureRequest = buildFeatureRequest(next); + const featureRequestKey = buildFeatureRequestKey(featureRequest); + const featuresAreCurrent = + featureRequestKey !== null && featureRequestKey === resolvedFeatureKey; + const features = featuresAreCurrent + ? applyFeatureValues(resolvedFeatures, next.featureValues) + : []; + const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent); + + const withOptions: AgentProfileFormState = { + ...next, + providerOptions: buildProviderOptions(entries), + modelOptions: buildModelOptions(models), + modeOptions: buildModeOptions(modes), + thinkingOptions: buildThinkingOptions(thinking), + features, + providerDisplay: refineDisplay( + next.providerDisplay, + next.provider, + findEntry(entries, next.provider)?.label ?? null, + ), + modelDisplay: refineDisplay( + next.modelDisplay, + next.modelId, + models.find((model) => model.id === next.modelId)?.label ?? null, + ), + modeDisplay: refineDisplay( + next.modeDisplay, + next.modeId, + (() => { + const mode = modes.find((entry) => entry.id === next.modeId); + return mode ? formatAgentModeLabel(mode) : null; + })(), + ), + thinkingDisplay: refineDisplay( + next.thinkingDisplay, + next.thinkingOptionId, + (() => { + const option = thinking.find((entry) => entry.id === next.thinkingOptionId); + return option ? formatThinkingOptionLabel(option) : null; + })(), + ), + catalogResolution, + featureResolution, + featureRequest, + featureRequestKey, + }; + + // A field appears once the catalog can populate it, and stays visible while + // a stored value needs a home even if this host cannot resolve it. + const hasProvider = Boolean(withOptions.provider); + const disclosure: AgentProfileFormDisclosure = { + showModelField: hasProvider && (models.length > 0 || Boolean(withOptions.modelId)), + showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)), + showThinkingField: + hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)), + showFeaturesField: hasProvider && features.length > 0, + }; + const canSubmit = + withOptions.name.trim().length > 0 && + withOptions.provider.length > 0 && + !withOptions.isSubmitting; + const resolved: AgentProfileFormState = { ...withOptions, disclosure, canSubmit }; + return { ...resolved, submitValue: canSubmit ? buildSubmitValue(resolved) : null }; + } + + let state: AgentProfileFormState = derive(buildInitialState(snapshot)); + + function publish(mutate: (current: AgentProfileFormState) => AgentProfileFormState): void { + if (closed) { + return; + } + state = derive(mutate(state)); + for (const listener of listeners) { + listener(); + } + } + + return { + getState: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + close: () => { + closed = true; + listeners = new Set(); + }, + applyProviderCatalog: (nextEntries) => { + entries = [...nextEntries]; + catalogResolution = "complete"; + publish((current) => current); + }, + applyFeatures: (requestKey, features) => { + if (requestKey !== state.featureRequestKey) { + return; + } + resolvedFeatureKey = requestKey; + resolvedFeatures = [...features]; + publish((current) => ({ + ...current, + featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures), + })); + }, + applyFeaturesUnavailable: (requestKey) => { + if (requestKey !== state.featureRequestKey) { + return; + } + resolvedFeatureKey = requestKey; + resolvedFeatures = []; + publish((current) => current); + }, + setName: (value) => publish((current) => ({ ...current, name: value })), + setAppearance: (value) => + publish((current) => ({ ...current, icon: value.icon, color: value.color })), + setNotes: (value) => publish((current) => ({ ...current, notes: value })), + setProvider: (providerId, display) => + publish((current) => { + if (current.provider === providerId) { + return current; + } + // Everything below the provider is provider-scoped: a model id, mode id, + // thinking id or feature id from the old provider means nothing here. + // Clearing them is enough — `derive` seeds the new provider's defaults. + return { + ...current, + provider: providerId, + providerDisplay: display, + modelId: "", + modelDisplay: null, + modeId: "", + modeDisplay: null, + thinkingOptionId: "", + thinkingDisplay: null, + featureValues: {}, + }; + }), + setModel: (modelId, display) => + publish((current) => { + if (current.modelId === modelId) { + return current; + } + // Thinking options are a property of the model, so a level the new model + // does not offer has to go. + const nextThinking = resolveThinkingOptions(entries, current.provider, modelId); + const keepsThinking = + current.thinkingOptionId.length > 0 && + nextThinking.some((option) => option.id === current.thinkingOptionId); + return { + ...current, + modelId, + modelDisplay: display, + thinkingOptionId: keepsThinking ? current.thinkingOptionId : "", + thinkingDisplay: keepsThinking ? current.thinkingDisplay : null, + }; + }), + setMode: (modeId, display) => + publish((current) => ({ ...current, modeId, modeDisplay: display })), + setThinking: (thinkingOptionId, display) => + publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })), + setFeatureValue: (featureId, value) => + publish((current) => ({ + ...current, + featureValues: { ...current.featureValues, [featureId]: value }, + })), + setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })), + setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })), + }; +} diff --git a/packages/app/src/agent-profiles/internal/profile-summary.ts b/packages/app/src/agent-profiles/internal/profile-summary.ts new file mode 100644 index 0000000000..a0507e6b94 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/profile-summary.ts @@ -0,0 +1,56 @@ +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels"; + +export interface AgentProfileTag { + id: string; + label: string; +} + +function findEntry( + entries: readonly ProviderSnapshotEntry[], + provider: string, +): ProviderSnapshotEntry | null { + return entries.find((entry) => entry.provider === provider) ?? null; +} + +/** + * The one-line résumé of a profile in the settings list. Every label falls back + * to the stored id: a row must stay readable on a host whose provider catalog + * is offline, or that no longer ships the model the profile names. + */ +export function buildAgentProfileTags(input: { + profile: AgentProfile; + entries: readonly ProviderSnapshotEntry[] | undefined; + formatFeatureCount: (count: number) => string; +}): AgentProfileTag[] { + const entries = input.entries ?? []; + const entry = findEntry(entries, input.profile.provider); + const tags: AgentProfileTag[] = [ + { id: "provider", label: entry?.label ?? input.profile.provider }, + ]; + + const modelId = input.profile.model?.trim(); + if (modelId) { + const model = entry?.models?.find((candidate) => candidate.id === modelId); + tags.push({ id: "model", label: model?.label ?? modelId }); + } + + const modeId = input.profile.modeId?.trim(); + if (modeId) { + const mode = entry?.modes?.find((candidate) => candidate.id === modeId); + tags.push({ id: "mode", label: mode ? formatAgentModeLabel(mode) : modeId }); + } + + const thinkingOptionId = input.profile.thinkingOptionId?.trim(); + if (thinkingOptionId) { + tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) }); + } + + const featureCount = Object.keys(input.profile.featureValues ?? {}).length; + if (featureCount > 0) { + tags.push({ id: "features", label: input.formatFeatureCount(featureCount) }); + } + + return tags; +} diff --git a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts new file mode 100644 index 0000000000..38799d2a23 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts @@ -0,0 +1,180 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { mergeCreateAgentSelectionPreferences } from "@/create-agent-preferences/preferences"; +import { useFormPreferences } from "@/hooks/use-form-preferences"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { useSessionStore } from "@/stores/session-store"; +import { useToast } from "@/contexts/toast-context"; +import { toErrorMessage } from "@/utils/error-messages"; +import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; +import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile"; +import { buildAgentProfileTags } from "./profile-summary"; +import { useAgentProfiles } from "./use-agent-profiles"; + +/** The draft composer's own setters. Applying a profile drives them like a user would. */ +export interface DraftAgentProfileControls { + selectProvider: (provider: AgentProvider) => void; + selectProviderAndModel: (provider: AgentProvider, modelId: string) => void; + selectMode: (modeId: string) => void; + selectThinkingOption: (thinkingOptionId: string) => void; + setFeature?: (featureId: string, value: unknown) => void; +} + +export type AgentProfileApplyTarget = + | { kind: "agent"; agentId: string } + | { kind: "draft"; controls: DraftAgentProfileControls }; + +/** Everything the model picker renders for one profile. It never sees the profile itself. */ +export interface AgentProfilePickerRow { + id: string; + provider: string; + /** Icon registry key and identity colour; either may be empty for the default glyph. */ + icon: string; + color: string; + name: string; + /** "Claude Code · Opus 5 · Plan · Think hard" */ + summary: string; +} + +export interface AgentProfilePicker { + rows: AgentProfilePickerRow[]; + applyProfile: (profileId: string) => void; +} + +export interface UseAgentProfilePickerInput { + serverId: string | null; + /** + * Providers this composer can actually run; pass a stable reference. A profile + * naming anything else is hidden rather than shown as a row that cannot do + * what it says — a live agent is one provider's process and cannot switch, and + * the draft form ignores a provider the host does not offer. + */ + availableProviders: readonly string[]; + target: AgentProfileApplyTarget; +} + +/** + * The agent-profiles section of the model picker: the rows to draw, and what + * pressing one does. A supported host returns an empty row list when there is + * nothing applicable so the picker can retain its settings shortcut without + * pinning an empty section. Unsupported or still-loading hosts return `null`. + */ +export function useAgentProfilePicker( + input: UseAgentProfilePickerInput, +): AgentProfilePicker | null { + const { serverId, availableProviders, target } = input; + const { t } = useTranslation(); + const { profiles, isSupported } = useAgentProfiles(serverId); + // Profiles are host config, so their labels read from the host-wide catalog + // rather than a workspace's. That is also the key the settings section uses, + // so every composer on a host shares one query instead of adding its own. + const { entries } = useProvidersSnapshot(serverId, { cwd: null }); + const { updatePreferences } = useFormPreferences(); + const client = useSessionStore((state) => state.sessions[serverId ?? ""]?.client ?? null); + const toast = useToast(); + + const applicableProfiles = useMemo(() => { + if (!isSupported || !profiles) { + return []; + } + const available = new Set(availableProviders); + return profiles.filter((profile) => available.has(profile.provider)); + }, [availableProviders, isSupported, profiles]); + + const formatFeatureCount = useCallback( + (count: number) => + count === 1 + ? t("settings.host.agentProfiles.featureCountOne", { count }) + : t("settings.host.agentProfiles.featureCount", { count }), + [t], + ); + + const rows = useMemo( + () => + applicableProfiles.map((profile) => ({ + id: profile.id, + provider: profile.provider, + icon: profile.icon ?? "", + color: profile.color ?? "", + name: profile.name, + summary: buildAgentProfileTags({ profile, entries, formatFeatureCount }) + .map((tag) => tag.label) + .join(" · "), + })), + [applicableProfiles, entries, formatFeatureCount], + ); + + const persistSelection = useCallback( + (profile: AgentProfile) => { + const resolved = materializeAgentProfile(profile); + void updatePreferences((current) => + mergeCreateAgentSelectionPreferences({ + preferences: current, + provider: resolved.provider, + modelId: resolved.modelId, + modeId: resolved.modeId, + thinkingOptionId: resolved.thinkingOptionId, + ...(Object.keys(resolved.featureValues).length > 0 + ? { featureValues: resolved.featureValues } + : {}), + }), + ).catch((error) => { + console.warn("[useAgentProfilePicker] persist profile selection failed", error); + }); + }, + [updatePreferences], + ); + + const applyProfile = useCallback( + (profileId: string) => { + const profile = applicableProfiles.find((entry) => entry.id === profileId); + if (!profile) { + return; + } + const resolved = materializeAgentProfile(profile); + + // Persist on both paths, before driving the controls. The draft form drops + // its local feature values when the provider changes, so preferences are + // the only channel that survives a profile that switches provider. + persistSelection(profile); + + if (target.kind === "draft") { + const { controls } = target; + if (resolved.modelId) { + controls.selectProviderAndModel(resolved.provider, resolved.modelId); + } else { + controls.selectProvider(resolved.provider); + } + if (resolved.modeId) { + controls.selectMode(resolved.modeId); + } + if (resolved.thinkingOptionId) { + controls.selectThinkingOption(resolved.thinkingOptionId); + } + for (const [featureId, value] of Object.entries(resolved.featureValues)) { + controls.setFeature?.(featureId, value); + } + return; + } + + if (!client) { + return; + } + void client + .applyAgentConfig(target.agentId, toAgentConfigApply(resolved)) + .then((notice) => showProviderNoticeToast(toast, notice)) + .catch((error) => { + console.warn("[useAgentProfilePicker] applyAgentConfig failed", error); + toast.error(toErrorMessage(error)); + }); + }, + [applicableProfiles, client, persistSelection, target, toast], + ); + + return useMemo( + () => (isSupported && profiles !== null ? { rows, applyProfile } : null), + [applyProfile, isSupported, profiles, rows], + ); +} diff --git a/packages/app/src/agent-profiles/internal/use-agent-profiles.ts b/packages/app/src/agent-profiles/internal/use-agent-profiles.ts new file mode 100644 index 0000000000..fe5105b18c --- /dev/null +++ b/packages/app/src/agent-profiles/internal/use-agent-profiles.ts @@ -0,0 +1,34 @@ +import { useCallback } from "react"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useSessionStore } from "@/stores/session-store"; +import { supportsAgentProfiles } from "./capabilities"; + +export interface UseAgentProfilesResult { + /** `null` until the daemon config has arrived. */ + profiles: AgentProfile[] | null; + /** False on daemons that predate agent profiles, or while disconnected. */ + isSupported: boolean; + /** Writes the whole list; there is no per-profile RPC. */ + saveProfiles: (next: AgentProfile[]) => Promise; +} + +export function useAgentProfiles(serverId: string | null): UseAgentProfilesResult { + const { config, patchConfig } = useDaemonConfig(serverId); + const isSupported = useSessionStore((state) => { + return supportsAgentProfiles(state.sessions[serverId ?? ""]?.serverInfo?.features); + }); + + const saveProfiles = useCallback( + async (next: AgentProfile[]) => { + await patchConfig({ agentProfiles: next }); + }, + [patchConfig], + ); + + return { + profiles: config ? (config.agentProfiles ?? []) : null, + isSupported, + saveProfiles, + }; +} diff --git a/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts b/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts new file mode 100644 index 0000000000..fb78320ca1 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts @@ -0,0 +1,78 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useFetchQuery } from "@/data/query"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import type { AgentProfileFormModel, AgentProfileFormState } from "./profile-form-model"; + +/** + * Agent profiles are host-wide, so the catalog is probed in the daemon's home + * directory (`cwd: null` for the snapshot, `~` for the feature listing) rather + * than in a workspace. A profile that resolved against one project's directory + * would name models the next project cannot launch. + */ +const HOME_SCOPE_CWD = "~"; + +export function useAgentProfileFormCatalog(input: { + serverId: string; + model: AgentProfileFormModel; +}): void { + const { entries } = useProvidersSnapshot(input.serverId, { cwd: null }); + + useEffect(() => { + if (!entries) { + return; + } + input.model.applyProviderCatalog(entries); + }, [entries, input.model]); +} + +export function useAgentProfileFormFeatures(input: { + serverId: string; + model: AgentProfileFormModel; + state: AgentProfileFormState; +}): void { + const { t } = useTranslation(); + const client = useHostRuntimeClient(input.serverId); + const isConnected = useHostRuntimeIsConnected(input.serverId); + const request = input.state.featureRequest; + const requestKey = input.state.featureRequestKey; + + // `dataShape: "value"` and not `"list"`: a list shape keeps the previous + // page's data while the next request is in flight, which would hand the + // features of one provider/model combination to the key of another. + const featuresQuery = useFetchQuery({ + queryKey: ["agentProfileFeatures", input.serverId, requestKey], + dataShape: "value", + staleTimeMs: 5 * 60 * 1000, + enabled: Boolean(client && isConnected && request), + retry: false, + queryFn: async () => { + if (!client || !request) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD }); + if (payload.error) { + throw new Error(payload.error); + } + return payload.features ?? []; + }, + }); + + const { data, isError } = featuresQuery; + const { model } = input; + + useEffect(() => { + if (!requestKey || !data) { + return; + } + model.applyFeatures(requestKey, data); + }, [data, model, requestKey]); + + useEffect(() => { + if (!requestKey || !isError) { + return; + } + model.applyFeaturesUnavailable(requestKey); + }, [isError, model, requestKey]); +} diff --git a/packages/app/src/agent-profiles/internal/use-profile-form-model.ts b/packages/app/src/agent-profiles/internal/use-profile-form-model.ts new file mode 100644 index 0000000000..f800252be4 --- /dev/null +++ b/packages/app/src/agent-profiles/internal/use-profile-form-model.ts @@ -0,0 +1,29 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { + openAgentProfileForm, + type AgentProfileFormModel, + type AgentProfileFormSnapshot, + type AgentProfileFormState, +} from "./profile-form-model"; + +/** + * One model per mount. The modal keys its body on mode + profile id, so create + * and edit never share an instance and reopening always starts from the record. + */ +export function useAgentProfileFormModel( + snapshot: AgentProfileFormSnapshot, +): AgentProfileFormModel { + const [model] = useState(() => openAgentProfileForm(snapshot)); + + useEffect(() => { + return () => { + model.close(); + }; + }, [model]); + + return model; +} + +export function useAgentProfileFormState(model: AgentProfileFormModel): AgentProfileFormState { + return useSyncExternalStore(model.subscribe, model.getState, model.getState); +} diff --git a/packages/app/src/agent-profiles/migration/index.test.ts b/packages/app/src/agent-profiles/migration/index.test.ts new file mode 100644 index 0000000000..cf40001e70 --- /dev/null +++ b/packages/app/src/agent-profiles/migration/index.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { LegacyFavoriteProfileMigration } from "."; + +class MemoryStorage { + private readonly values = new Map(); + + constructor(initial: Record = {}) { + for (const [key, value] of Object.entries(initial)) { + this.values.set(key, JSON.stringify(value)); + } + } + + async getItem(key: string): Promise { + return this.values.get(key) ?? null; + } + + async setItem(key: string, value: string): Promise { + this.values.set(key, value); + } +} + +class FakeProfileHost { + readonly patches: AgentProfile[][] = []; + config: { agentProfiles?: AgentProfile[] }; + shouldFailPatch = false; + shouldFailCatalog = false; + catalogLoadingReads = 0; + + constructor( + profiles: AgentProfile[] = [], + readonly supported = true, + ) { + this.config = { agentProfiles: profiles }; + } + + getLastServerInfoMessage() { + return { + features: this.supported + ? { agentProfiles: true, agentConfigApply: true } + : { agentProfiles: false, agentConfigApply: false }, + }; + } + + async getDaemonConfig() { + return { requestId: "config", config: this.config }; + } + + async patchDaemonConfig(patch: { agentProfiles?: AgentProfile[] }) { + if (this.shouldFailPatch) { + throw new Error("host write failed"); + } + const profiles = patch.agentProfiles ?? []; + this.patches.push(profiles); + this.config = { ...this.config, agentProfiles: profiles }; + return { requestId: "patch", config: this.config }; + } + + async getProvidersSnapshot() { + if (this.shouldFailCatalog) { + throw new Error("catalog unavailable"); + } + if (this.catalogLoadingReads > 0) { + this.catalogLoadingReads -= 1; + return { + entries: [ + { + provider: "claude" as const, + status: "loading" as const, + enabled: true, + }, + ], + generatedAt: new Date(0).toISOString(), + requestId: "providers-loading", + }; + } + return { + entries: [ + { + provider: "claude" as const, + status: "ready" as const, + enabled: true, + label: "Claude Code", + models: [ + { provider: "claude" as const, id: "opus", label: "Opus" }, + { provider: "claude" as const, id: "sonnet", label: "Sonnet" }, + ], + }, + ], + generatedAt: new Date(0).toISOString(), + requestId: "providers", + }; + } +} + +const PREFERENCES_KEY = "@paseo:create-agent-preferences"; + +function migrationStorage() { + return new MemoryStorage({ + [PREFERENCES_KEY]: { + favoriteModels: [ + { provider: "claude", modelId: "opus" }, + { provider: "claude", modelId: "sonnet" }, + { provider: "claude", modelId: "opus" }, + ], + }, + }); +} + +describe("legacy favourite profile migration", () => { + it("appends provider-and-model-only profiles with useful catalog names", async () => { + const existing: AgentProfile = { + id: "hand-written", + name: "My Opus", + provider: "claude", + model: "opus", + modeId: "plan", + }; + const host = new FakeProfileHost([existing]); + const migration = new LegacyFavoriteProfileMigration(migrationStorage()); + + await migration.migrateHost("host-a", host); + + expect(host.patches).toEqual([ + [ + existing, + { + id: "legacy_favorite:claude:sonnet", + name: "Sonnet", + provider: "claude", + model: "sonnet", + }, + ], + ]); + }); + + it("runs once per host, even if the imported profiles are later deleted", async () => { + const storage = migrationStorage(); + const migration = new LegacyFavoriteProfileMigration(storage); + const firstHost = new FakeProfileHost(); + + await migration.migrateHost("host-a", firstHost); + firstHost.config = { agentProfiles: [] }; + await migration.migrateHost("host-a", firstHost); + + const secondHost = new FakeProfileHost(); + await migration.migrateHost("host-b", secondHost); + + expect(firstHost.patches).toHaveLength(1); + expect(secondHost.patches).toHaveLength(1); + }); + + it("waits for profile capabilities without consuming the host migration", async () => { + const storage = migrationStorage(); + const migration = new LegacyFavoriteProfileMigration(storage); + const oldHost = new FakeProfileHost([], false); + + await migration.migrateHost("host-a", oldHost); + + const upgradedHost = new FakeProfileHost(); + await migration.migrateHost("host-a", upgradedHost); + expect(oldHost.patches).toHaveLength(0); + expect(upgradedHost.patches).toHaveLength(1); + }); + + it("retries after a failed host write", async () => { + const host = new FakeProfileHost(); + const migration = new LegacyFavoriteProfileMigration(migrationStorage()); + host.shouldFailPatch = true; + + await expect(migration.migrateHost("host-a", host)).rejects.toThrow("host write failed"); + host.shouldFailPatch = false; + await migration.migrateHost("host-a", host); + + expect(host.patches).toHaveLength(1); + }); + + it("retries catalog resolution instead of permanently storing raw ids", async () => { + const host = new FakeProfileHost(); + const migration = new LegacyFavoriteProfileMigration(migrationStorage()); + host.shouldFailCatalog = true; + + await expect(migration.migrateHost("host-a", host)).rejects.toThrow("catalog unavailable"); + host.shouldFailCatalog = false; + await migration.migrateHost("host-a", host); + + expect(host.patches[0]?.map((profile) => profile.name)).toEqual(["Opus", "Sonnet"]); + }); + + it("waits for relevant provider models to finish loading", async () => { + const host = new FakeProfileHost(); + const migration = new LegacyFavoriteProfileMigration(migrationStorage()); + host.catalogLoadingReads = 1; + + await migration.migrateHost("host-a", host); + + expect(host.patches[0]?.map((profile) => profile.name)).toEqual(["Opus", "Sonnet"]); + }); +}); diff --git a/packages/app/src/agent-profiles/migration/index.ts b/packages/app/src/agent-profiles/migration/index.ts new file mode 100644 index 0000000000..964ef248fd --- /dev/null +++ b/packages/app/src/agent-profiles/migration/index.ts @@ -0,0 +1,231 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { z } from "zod"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; + +const PREFERENCES_KEY = "@paseo:create-agent-preferences"; +const COMPLETION_KEY_PREFIX = "@paseo:legacy-favorites-to-agent-profiles:v1:"; +const CATALOG_LOADING_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000, 4_000] as const; + +const legacyPreferencesSchema = z.object({ + favoriteModels: z + .array( + z.object({ + provider: z.string().trim().min(1), + modelId: z.string().trim().min(1), + }), + ) + .optional(), +}); + +type LegacyFavorite = NonNullable< + z.infer["favoriteModels"] +>[number]; + +export interface LegacyFavoriteMigrationStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; +} + +export interface LegacyFavoriteMigrationHost { + getLastServerInfoMessage(): { + features?: { agentProfiles?: boolean; agentConfigApply?: boolean }; + } | null; + getDaemonConfig(): Promise<{ config: { agentProfiles?: AgentProfile[] } }>; + patchDaemonConfig(patch: { + agentProfiles: AgentProfile[]; + }): Promise<{ config: { agentProfiles?: AgentProfile[] } }>; + getProvidersSnapshot(): Promise<{ entries: ProviderSnapshotEntry[] }>; +} + +function supportsMigration(host: LegacyFavoriteMigrationHost): boolean { + const features = host.getLastServerInfoMessage()?.features; + return features?.agentProfiles === true && features.agentConfigApply === true; +} + +function completionKey(serverId: string): string { + return `${COMPLETION_KEY_PREFIX}${serverId}`; +} + +function parseStoredPreferences(raw: string | null): LegacyFavorite[] { + if (!raw) { + return []; + } + try { + const result = legacyPreferencesSchema.safeParse(JSON.parse(raw)); + return result.success ? (result.data.favoriteModels ?? []) : []; + } catch { + return []; + } +} + +function favoriteKey(favorite: Pick): string { + return `${favorite.provider}\u0000${favorite.modelId}`; +} + +function migratedProfileId(favorite: LegacyFavorite): string { + return `legacy_favorite:${encodeURIComponent(favorite.provider)}:${encodeURIComponent(favorite.modelId)}`; +} + +function deduplicateFavorites(favorites: readonly LegacyFavorite[]): LegacyFavorite[] { + const seen = new Set(); + return favorites.filter((favorite) => { + const key = favoriteKey(favorite); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +function findCatalogLabels( + favorite: LegacyFavorite, + entries: readonly ProviderSnapshotEntry[], +): { model: string; provider: string } { + const provider = entries.find((entry) => entry.provider === favorite.provider); + const model = provider?.models?.find((entry) => entry.id === favorite.modelId); + return { + model: model?.label?.trim() || favorite.modelId, + provider: provider?.label?.trim() || favorite.provider, + }; +} + +function uniqueProfileName(input: { + favorite: LegacyFavorite; + entries: readonly ProviderSnapshotEntry[]; + usedNames: Set; +}): string { + const labels = findCatalogLabels(input.favorite, input.entries); + const candidates = [ + labels.model, + `${labels.provider} · ${labels.model}`, + `${labels.provider} · ${labels.model} (${input.favorite.modelId})`, + ]; + for (const candidate of candidates) { + if (!input.usedNames.has(candidate)) { + input.usedNames.add(candidate); + return candidate; + } + } + let suffix = 2; + while (input.usedNames.has(`${candidates[2]} ${suffix}`)) { + suffix += 1; + } + const name = `${candidates[2]} ${suffix}`; + input.usedNames.add(name); + return name; +} + +function buildMigratedProfiles(input: { + favorites: readonly LegacyFavorite[]; + existing: readonly AgentProfile[]; + entries: readonly ProviderSnapshotEntry[]; +}): AgentProfile[] { + const existingSelections = new Set( + input.existing.map((profile) => + favoriteKey({ provider: profile.provider, modelId: profile.model?.trim() ?? "" }), + ), + ); + const existingIds = new Set(input.existing.map((profile) => profile.id)); + const usedNames = new Set(input.existing.map((profile) => profile.name)); + + return deduplicateFavorites(input.favorites).flatMap((favorite) => { + const id = migratedProfileId(favorite); + if (existingSelections.has(favoriteKey(favorite)) || existingIds.has(id)) { + return []; + } + return [ + { + id, + name: uniqueProfileName({ favorite, entries: input.entries, usedNames }), + provider: favorite.provider, + model: favorite.modelId, + }, + ]; + }); +} + +function catalogIsLoading( + entries: readonly ProviderSnapshotEntry[], + favorites: readonly LegacyFavorite[], +): boolean { + const providers = new Set(favorites.map((favorite) => favorite.provider)); + return entries.some((entry) => providers.has(entry.provider) && entry.status === "loading"); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function readProviderCatalog( + host: LegacyFavoriteMigrationHost, + favorites: readonly LegacyFavorite[], +): Promise { + for (const retryDelay of CATALOG_LOADING_RETRY_DELAYS_MS) { + const entries = (await host.getProvidersSnapshot()).entries; + if (!catalogIsLoading(entries, favorites)) { + return entries; + } + await delay(retryDelay); + } + const entries = (await host.getProvidersSnapshot()).entries; + if (catalogIsLoading(entries, favorites)) { + throw new Error("Provider catalog remained loading during legacy favourite migration"); + } + return entries; +} + +/** + * One-time bridge from device-local model favourites to host-wide profiles. + * The module owns every migration invariant; callers only announce a connected + * host and may safely call again after reconnects or React remounts. + */ +export class LegacyFavoriteProfileMigration { + private readonly inFlight = new Map>(); + + constructor(private readonly storage: LegacyFavoriteMigrationStorage) {} + + migrateHost(serverId: string, host: LegacyFavoriteMigrationHost): Promise { + const active = this.inFlight.get(serverId); + if (active) { + return active; + } + const migration = this.run(serverId, host).finally(() => { + if (this.inFlight.get(serverId) === migration) { + this.inFlight.delete(serverId); + } + }); + this.inFlight.set(serverId, migration); + return migration; + } + + private async run(serverId: string, host: LegacyFavoriteMigrationHost): Promise { + if (!supportsMigration(host)) { + return; + } + const markerKey = completionKey(serverId); + if ((await this.storage.getItem(markerKey)) === "1") { + return; + } + + const favorites = parseStoredPreferences(await this.storage.getItem(PREFERENCES_KEY)); + if (favorites.length === 0) { + await this.storage.setItem(markerKey, "1"); + return; + } + + const [{ config }, entries] = await Promise.all([ + host.getDaemonConfig(), + readProviderCatalog(host, favorites), + ]); + const existing = config.agentProfiles ?? []; + const migrated = buildMigratedProfiles({ favorites, existing, entries }); + if (migrated.length > 0) { + await host.patchDaemonConfig({ agentProfiles: [...existing, ...migrated] }); + } + await this.storage.setItem(markerKey, "1"); + } +} + +export const legacyFavoriteProfileMigration = new LegacyFavoriteProfileMigration(AsyncStorage); diff --git a/packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx b/packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx new file mode 100644 index 0000000000..9d958daffe --- /dev/null +++ b/packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx @@ -0,0 +1,384 @@ +import { useCallback, useMemo, useRef, useState, type ReactElement } from "react"; +import { Pressable, View, type PressableStateCallbackType } from "react-native"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Check, ChevronDown } from "lucide-react-native"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { + createControlGeometry, + resolveControlInteractionStyles, + type FieldControlSize, +} from "@/components/ui/control-geometry"; +import { Field } from "@/components/ui/form-field"; +import { identityForeground } from "@/styles/identity-colors"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { AgentProfileGlyph } from "../internal/agent-profile-glyph"; +import { + AGENT_PROFILE_COLORS, + AGENT_PROFILE_ICON_KEYS, + resolveAgentProfileColor, + resolveAgentProfileIconKey, + type AgentProfileColor, + type AgentProfileIconKey, +} from "../internal/profile-appearance"; + +const CELL_SIZE = 40; +const CELL_GAP = 4; +const GRID_COLUMNS = 5; +const CELL_ROW_WIDTH = GRID_COLUMNS * CELL_SIZE + (GRID_COLUMNS - 1) * CELL_GAP; +/** Matches `styles.body.padding`; both derive from `theme.spacing[3]`. */ +const CELL_GRID_PADDING = 12; +/** Swatches are dots, not targets the eye has to land on precisely, so they pack tighter. */ +const COLOR_CELL_SIZE = 32; +const SWATCH_SIZE = 20; + +const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = []; +const ThemedChevronDown = withUnistyles(ChevronDown); +const ThemedCheck = withUnistyles(Check); + +const foregroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const onIdentityMapping = (theme: Theme) => ({ color: theme.colors.background }); + +/** + * Locked, not a floor: an unlocked popover stretches to its 400px maximum and + * the wrapping grid follows it to eight columns. Two spare pixels so a frame + * border can never push the fifth cell onto its own row. + */ +const DESKTOP_WIDTH = CELL_ROW_WIDTH + CELL_GRID_PADDING * 2 + 2; + +/** Colours are named in host appearance; profiles reuse both the palette and its labels. */ +function colorLabel(t: TFunction, color: AgentProfileColor): string { + return t(`settings.host.appearance.color.options.${color}`); +} + +function IconCell({ + iconKey, + selected, + color, + onSelect, +}: { + iconKey: AgentProfileIconKey | null; + selected: boolean; + color: AgentProfileColor; + onSelect: (iconKey: AgentProfileIconKey | null) => void; +}) { + const handlePress = useCallback(() => onSelect(iconKey), [iconKey, onSelect]); + const accessibilityState = useMemo(() => ({ selected }), [selected]); + const cellStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.cell, + Boolean(hovered) && styles.cellHovered, + pressed && styles.cellPressed, + selected && styles.cellSelected, + ], + [selected], + ); + return ( + + + + ); +} + +function ColorCell({ + color, + selected, + onSelect, +}: { + color: AgentProfileColor; + selected: boolean; + onSelect: (color: AgentProfileColor) => void; +}) { + const { t } = useTranslation(); + const handlePress = useCallback(() => onSelect(color), [color, onSelect]); + const accessibilityState = useMemo(() => ({ selected }), [selected]); + const swatchStyle = useMemo( + () => [styles.swatch, color === "none" ? styles.swatchNone : identitySwatch(color)], + [color], + ); + const cellStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [styles.colorCell, pressed && styles.cellPressed], + [], + ); + return ( + + + {selected ? ( + + ) : null} + + + ); +} + +/** + * Icon and colour in one popover rather than two fields: the pair is a single + * visual decision, and picking them apart means never seeing the result until + * both are closed. The grid redraws in the chosen colour as you switch colours, + * so the swatch row sits above it. + */ +export function AgentProfileAppearanceField({ + label, + icon, + color, + onChange, + size = "md", + disabled = false, + testID, + triggerTestID, +}: { + label: string; + icon: string; + color: string; + onChange: (next: { icon: string; color: string }) => void; + size?: FieldControlSize; + disabled?: boolean; + testID?: string; + triggerTestID?: string; +}): ReactElement { + const { t } = useTranslation(); + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + const selectedIcon = resolveAgentProfileIconKey(icon); + const selectedColor = resolveAgentProfileColor(color); + + const handleTriggerPress = useCallback(() => { + if (disabled) { + return; + } + setOpen((current) => !current); + }, [disabled]); + + // Colour keeps the popover open — you are still choosing. An icon is the last + // decision, so it commits and closes. + const handleColorSelect = useCallback( + (next: AgentProfileColor) => onChange({ icon, color: next === "none" ? "" : next }), + [icon, onChange], + ); + const handleIconSelect = useCallback( + (next: AgentProfileIconKey | null) => { + onChange({ icon: next ?? "", color }); + setOpen(false); + }, + [color, onChange], + ); + + const triggerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + styles.trigger, + size === "sm" ? styles.triggerSm : styles.triggerMd, + styles.triggerIconOnly, + resolveControlInteractionStyles( + { + controlRest: styles.controlRest, + controlHover: styles.controlHover, + controlActive: styles.controlActive, + controlDisabled: styles.controlDisabled, + }, + { hovered: Boolean(hovered), active: pressed || open, disabled }, + ), + ], + [disabled, open, size], + ); + + const header = useMemo(() => ({ title: label }), [label]); + + return ( + + + + + + + + + + + {AGENT_PROFILE_COLORS.map((option) => ( + + ))} + + + + {AGENT_PROFILE_ICON_KEYS.map((key) => ( + + ))} + + + + + ); +} + +function noop() {} + +/** + * One named rule per hue. Unistyles cannot resolve a colour computed at render, + * so the palette has to be spelled out as static styles. + */ +function identitySwatch(color: Exclude) { + return styles[IDENTITY_SWATCH_STYLE_KEYS[color]]; +} + +const styles = StyleSheet.create((theme) => { + const geometry = createControlGeometry(theme); + + return { + trigger: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[1], + backgroundColor: theme.colors.surface2, + }, + triggerSm: { ...geometry.fieldControlSm }, + triggerMd: { ...geometry.fieldControlMd }, + // The field geometry pads for a line of text. This trigger holds a glyph and + // a chevron, so it keeps the shared height and drops the text-sized inset. + triggerIconOnly: { + paddingHorizontal: theme.spacing[2], + }, + controlRest: { ...geometry.controlRest }, + controlHover: { ...geometry.controlHover }, + controlActive: { ...geometry.controlActive }, + controlDisabled: { ...geometry.controlDisabled }, + + body: { + padding: CELL_GRID_PADDING, + gap: theme.spacing[3], + }, + colorRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: CELL_GAP, + paddingBottom: theme.spacing[3], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + colorCell: { + width: COLOR_CELL_SIZE, + height: COLOR_CELL_SIZE, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.md, + }, + grid: { + flexDirection: "row", + flexWrap: "wrap", + gap: CELL_GAP, + }, + cell: { + width: CELL_SIZE, + height: CELL_SIZE, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: "transparent", + }, + cellHovered: { + backgroundColor: theme.colors.surface2, + }, + cellPressed: { + opacity: 0.7, + }, + cellSelected: { + backgroundColor: theme.colors.surface2, + borderColor: theme.colors.border, + }, + swatch: { + width: SWATCH_SIZE, + height: SWATCH_SIZE, + borderRadius: SWATCH_SIZE / 2, + alignItems: "center", + justifyContent: "center", + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + }, + swatchNone: { + backgroundColor: theme.colors.surface2, + }, + swatchViolet: { backgroundColor: identityForeground("violet", theme.colorScheme) }, + swatchSky: { backgroundColor: identityForeground("sky", theme.colorScheme) }, + swatchEmerald: { backgroundColor: identityForeground("emerald", theme.colorScheme) }, + swatchOrange: { backgroundColor: identityForeground("orange", theme.colorScheme) }, + swatchPink: { backgroundColor: identityForeground("pink", theme.colorScheme) }, + swatchIndigo: { backgroundColor: identityForeground("indigo", theme.colorScheme) }, + swatchTeal: { backgroundColor: identityForeground("teal", theme.colorScheme) }, + swatchRed: { backgroundColor: identityForeground("red", theme.colorScheme) }, + swatchAmber: { backgroundColor: identityForeground("amber", theme.colorScheme) }, + swatchBlue: { backgroundColor: identityForeground("blue", theme.colorScheme) }, + }; +}); + +const IDENTITY_SWATCH_STYLE_KEYS = { + violet: "swatchViolet", + sky: "swatchSky", + emerald: "swatchEmerald", + orange: "swatchOrange", + pink: "swatchPink", + indigo: "swatchIndigo", + teal: "swatchTeal", + red: "swatchRed", + amber: "swatchAmber", + blue: "swatchBlue", +} as const; diff --git a/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx b/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx new file mode 100644 index 0000000000..c4e63a847c --- /dev/null +++ b/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx @@ -0,0 +1,549 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import type { AgentFeature } from "@getpaseo/protocol/agent-types"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { type FieldControlSize } from "@/components/ui/control-geometry"; +import { Field, FormTextInput } from "@/components/ui/form-field"; +import { + SelectField, + type SelectFieldDisplay, + type SelectFieldOption, +} from "@/components/ui/select-field"; +import { Switch } from "@/components/ui/switch"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { toErrorMessage } from "@/utils/error-messages"; +import { AgentProfileAppearanceField } from "./agent-profile-appearance-field"; +import type { + AgentProfileFormModel, + AgentProfileFormOption, + AgentProfileValue, +} from "../internal/profile-form-model"; +import { + useAgentProfileFormCatalog, + useAgentProfileFormFeatures, +} from "../internal/use-profile-form-inputs"; +import { + useAgentProfileFormModel, + useAgentProfileFormState, +} from "../internal/use-profile-form-model"; + +export interface AgentProfileEditModalProps { + serverId: string; + visible: boolean; + mode: "create" | "edit"; + profile?: AgentProfile; + onClose: () => void; + onSave: (value: AgentProfileValue) => Promise; +} + +/** + * Only reachable before the catalog lands. Every select is seeded to a concrete + * id as soon as one exists, and none of them offer an "unset" row. + */ +const UNSET_VALUE = ""; + +function openKey(props: AgentProfileEditModalProps): string { + return props.mode === "edit" ? `edit:${props.profile?.id ?? ""}` : "create"; +} + +/** + * Create and edit never share a mounted instance: the body is keyed on mode plus + * profile id, so every open constructs a form model seeded from that record. + */ +export function AgentProfileEditModal(props: AgentProfileEditModalProps): ReactElement | null { + const [renderedProps, setRenderedProps] = useState(() => + props.visible ? props : null, + ); + const [sheetVisible, setSheetVisible] = useState(props.visible); + const livePropsRef = useRef(props); + const closeRequestedRef = useRef(false); + livePropsRef.current = props; + + useEffect(() => { + if (props.visible) { + if (closeRequestedRef.current) { + return; + } + setRenderedProps(props); + setSheetVisible(true); + return; + } + if (renderedProps) { + setSheetVisible(false); + } + }, [props, renderedProps]); + + const requestClose = useCallback(() => { + closeRequestedRef.current = true; + setSheetVisible(false); + }, []); + + const handleDismiss = useCallback(() => { + const dismissedProps = livePropsRef.current; + closeRequestedRef.current = false; + setRenderedProps(null); + setSheetVisible(false); + if (dismissedProps.visible) { + dismissedProps.onClose(); + } + }, []); + + if (!renderedProps) { + return null; + } + + return ( + + ); +} + +function toSelectOptions(options: AgentProfileFormOption[]): SelectFieldOption[] { + return options.map((option) => ({ + id: option.id, + value: option.value, + label: option.label, + ...(option.description ? { description: option.description } : {}), + testID: option.testID, + })); +} + +function OpenAgentProfileEditModal({ + serverId, + visible, + mode, + profile, + onClose, + onDismiss, + onSave, +}: AgentProfileEditModalProps & { onDismiss: () => void }): ReactElement { + const { t } = useTranslation(); + const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm"; + const snapshot = useMemo(() => ({ mode, ...(profile ? { profile } : {}) }), [mode, profile]); + const model = useAgentProfileFormModel(snapshot); + const state = useAgentProfileFormState(model); + useAgentProfileFormCatalog({ serverId, model }); + useAgentProfileFormFeatures({ serverId, model, state }); + + const sheetHeader = useMemo( + () => ({ + title: + mode === "edit" + ? t("settings.host.agentProfiles.editProfileTitle") + : t("settings.host.agentProfiles.addProfileTitle"), + }), + [mode, t], + ); + + const providerOptions = useMemo( + () => toSelectOptions(state.providerOptions), + [state.providerOptions], + ); + const modelOptions = useMemo(() => toSelectOptions(state.modelOptions), [state.modelOptions]); + const modeOptions = useMemo(() => toSelectOptions(state.modeOptions), [state.modeOptions]); + const thinkingOptions = useMemo( + () => toSelectOptions(state.thinkingOptions), + [state.thinkingOptions], + ); + + const handleAppearanceChange = useCallback( + (value: { icon: string; color: string }) => model.setAppearance(value), + [model], + ); + const handleProviderChange = useCallback( + (value: string, display: SelectFieldDisplay) => model.setProvider(value, display), + [model], + ); + const handleModelChange = useCallback( + (value: string, display: SelectFieldDisplay) => model.setModel(value, value ? display : null), + [model], + ); + const handleModeChange = useCallback( + (value: string, display: SelectFieldDisplay) => model.setMode(value, value ? display : null), + [model], + ); + const handleThinkingChange = useCallback( + (value: string, display: SelectFieldDisplay) => + model.setThinking(value, value ? display : null), + [model], + ); + + const handleSave = useCallback(async () => { + const value = model.getState().submitValue; + if (!value) { + return; + } + model.setSubmitError(null); + model.setSubmitting(true); + try { + await onSave(value); + onClose(); + } catch (error) { + model.setSubmitError(toErrorMessage(error)); + } finally { + model.setSubmitting(false); + } + }, [model, onClose, onSave]); + + const handleSavePress = useCallback(() => { + void handleSave(); + }, [handleSave]); + + const handleCancel = useCallback(() => { + if (state.isSubmitting) { + return; + } + onClose(); + }, [onClose, state.isSubmitting]); + + return ( + + + + + + + + + + + + + + 6} + title={t("settings.host.agentProfiles.providerLabel")} + size={controlSize} + testID="agent-profile-provider-field" + triggerTestID="agent-profile-provider-trigger" + /> + + {state.disclosure.showModelField ? ( + 6} + title={t("settings.host.agentProfiles.modelLabel")} + size={controlSize} + testID="agent-profile-model-field" + triggerTestID="agent-profile-model-trigger" + /> + ) : null} + + {state.disclosure.showModeField ? ( + 6} + title={t("settings.host.agentProfiles.modeLabel")} + size={controlSize} + testID="agent-profile-mode-field" + triggerTestID="agent-profile-mode-trigger" + /> + ) : null} + + {state.disclosure.showThinkingField ? ( + 6} + title={t("settings.host.agentProfiles.thinkingLabel")} + size={controlSize} + testID="agent-profile-thinking-field" + triggerTestID="agent-profile-thinking-trigger" + /> + ) : null} + + {state.disclosure.showFeaturesField ? ( + + + {state.features.map((feature, index) => ( + + ))} + + + ) : null} + + + + + + {state.submitError ? ( + + {state.submitError} + + ) : null} + + + + + + + + ); +} + +function AgentProfileFeatureRow({ + feature, + isFirst, + disabled, + size, + model, +}: { + feature: AgentFeature; + isFirst: boolean; + disabled: boolean; + size: FieldControlSize; + model: AgentProfileFormModel; +}): ReactElement { + const rowStyle = useMemo( + () => [styles.featureRow, isFirst ? null : styles.featureRowBorder], + [isFirst], + ); + const handleToggle = useCallback( + (value: boolean) => model.setFeatureValue(feature.id, value), + [feature.id, model], + ); + const handleSelect = useCallback( + (value: string) => model.setFeatureValue(feature.id, value), + [feature.id, model], + ); + const selectOptions = useMemo[]>( + () => + feature.type === "select" + ? feature.options.map((option) => ({ + id: option.id, + value: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + testID: `agent-profile-feature-option-${feature.id}-${option.id}`, + })) + : [], + [feature], + ); + const selectedDisplay = useMemo(() => { + if (feature.type !== "select" || !feature.value) { + return null; + } + const option = feature.options.find((candidate) => candidate.id === feature.value); + return { label: option?.label ?? feature.value }; + }, [feature]); + + if (feature.type === "select") { + return ( + + + + ); + } + + return ( + + + + {feature.label} + + {feature.description ? ( + + {feature.description} + + ) : null} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + body: { + gap: theme.spacing[4], + paddingBottom: theme.spacing[2], + }, + nameRow: { + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[2], + }, + // No width: the trigger is a glyph and a chevron, so it sizes to them. + iconField: { + flexShrink: 0, + }, + nameField: { + flex: 1, + }, + featureCard: { + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + paddingHorizontal: theme.spacing[3], + }, + featureRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[3], + paddingVertical: theme.spacing[3], + }, + featureRowBorder: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + featureMeta: { + flex: 1, + gap: theme.spacing[1], + }, + featureLabel: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + featureDescription: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + notesInput: { + minHeight: 88, + textAlignVertical: "top", + }, + submitError: { + color: theme.colors.statusDanger, + fontSize: theme.fontSize.sm, + }, + actions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + marginTop: theme.spacing[2], + }, + actionButton: { + flex: 1, + }, +})); diff --git a/packages/app/src/agent-profiles/settings/agent-profile-row.tsx b/packages/app/src/agent-profiles/settings/agent-profile-row.tsx new file mode 100644 index 0000000000..4a766ec0fd --- /dev/null +++ b/packages/app/src/agent-profiles/settings/agent-profile-row.tsx @@ -0,0 +1,181 @@ +import { useCallback, useMemo, type ReactElement } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { ArrowDown, ArrowUp, FileText, Pencil, Trash2 } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { Button } from "@/components/ui/button"; +import { settingsStyles } from "@/styles/settings"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { AgentProfileGlyph } from "../internal/agent-profile-glyph"; +import { buildAgentProfileTags } from "../internal/profile-summary"; + +const ThemedArrowUp = withUnistyles(ArrowUp); +const ThemedArrowDown = withUnistyles(ArrowDown); +const ThemedPencil = withUnistyles(Pencil); +const ThemedTrash2 = withUnistyles(Trash2); +const ThemedFileText = withUnistyles(FileText); + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); + +const moveUpIcon = ; +const moveDownIcon = ; +const editIcon = ; +const removeIcon = ; + +export interface AgentProfileRowProps { + profile: AgentProfile; + entries: readonly ProviderSnapshotEntry[] | undefined; + isFirst: boolean; + isLast: boolean; + onEdit: (id: string) => void; + onRemove: (id: string) => void; + onMoveUp: (id: string) => void; + onMoveDown: (id: string) => void; +} + +export function AgentProfileRow({ + profile, + entries, + isFirst, + isLast, + onEdit, + onRemove, + onMoveUp, + onMoveDown, +}: AgentProfileRowProps): ReactElement { + const { t } = useTranslation(); + + const handleEdit = useCallback(() => onEdit(profile.id), [onEdit, profile.id]); + const handleRemove = useCallback(() => onRemove(profile.id), [onRemove, profile.id]); + const handleMoveUp = useCallback(() => onMoveUp(profile.id), [onMoveUp, profile.id]); + const handleMoveDown = useCallback(() => onMoveDown(profile.id), [onMoveDown, profile.id]); + + const formatFeatureCount = useCallback( + (count: number) => + count === 1 + ? t("settings.host.agentProfiles.featureCountOne", { count }) + : t("settings.host.agentProfiles.featureCount", { count }), + [t], + ); + const tags = useMemo( + () => buildAgentProfileTags({ profile, entries, formatFeatureCount }), + [entries, formatFeatureCount, profile], + ); + const summary = useMemo(() => tags.map((tag) => tag.label).join(" · "), [tags]); + + const rowStyle = useMemo( + () => [settingsStyles.row, isFirst ? null : settingsStyles.rowBorder, styles.row], + [isFirst], + ); + + return ( + + + + + + + + {profile.name} + + + {summary} + + + {profile.notes ? ( + + + + {profile.notes} + + + ) : null} + + + + + )} + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + emptyCard: { + paddingVertical: theme.spacing[6], + paddingHorizontal: theme.spacing[4], + alignItems: "center", + gap: theme.spacing[3], + }, + emptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, +})); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 13fd94904d..431d1cc66d 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -72,6 +72,7 @@ import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-rout import { ThemedStack } from "@/navigation/themed-stack"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { AgentNavigationListener } from "@/desktop/agent-navigation"; +import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration"; import { listenToDesktopEvent } from "@/desktop/electron/events"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; import { getDesktopHost } from "@/desktop/host"; @@ -101,6 +102,7 @@ import { useHostRegistryLoaded, useHostMutations, useHostRuntimeClient, + useHostRuntimeIsConnected, useHosts, } from "@/runtime/host-runtime"; import { getDaemonStartService } from "@/runtime/daemon-start-service"; @@ -108,6 +110,7 @@ import { applyAppearance } from "@/screens/settings/appearance/apply-appearance" import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { flushDraftPersistStorage } from "@/stores/draft-store"; import { getNextThemePreference, THEME_TO_UNISTYLES } from "@/styles/theme"; +import { useSessionStore } from "@/stores/session-store"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import type { HostProfile } from "@/types/host-connection"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; @@ -266,11 +269,33 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { return ( - {null} + ); } +function LegacyFavoriteProfileMigrationBootstrap({ + serverId, + client, +}: { + serverId: string; + client: NonNullable>; +}) { + const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null); + const isConnected = useHostRuntimeIsConnected(serverId); + + useEffect(() => { + if (!serverInfo || !isConnected) { + return; + } + void legacyFavoriteProfileMigration.migrateHost(serverId, client).catch((error) => { + console.warn("[AgentProfiles] Failed to migrate legacy favourites", error); + }); + }, [client, isConnected, serverId, serverInfo]); + + return null; +} + function HostSessionManager() { const hosts = useHosts(); diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index b689bdc6a4..94191f4eb9 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -128,9 +128,13 @@ const styles = StyleSheet.create((theme) => ({ // Horizontal padding matches the model picker's row indent: the picker uses // children mode (desktopChildrenScrollContent, no scroll padding), so the // row content starts at item.paddingHorizontal = spacing[3]. + // The search row below owns the gap under the title: the input already + // carries its own vertical padding, so a paddingBottom here would stack on + // top of two more and push the title far off the field. inlineHeaderRow: { paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], + paddingTop: theme.spacing[2], + paddingBottom: 0, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 179e531324..5053e138d7 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -3,6 +3,7 @@ import { Pressable, Text, View, type PressableStateCallbackType } from "react-na import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { AgentProfilePicker } from "@/agent-profiles"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox"; @@ -12,7 +13,6 @@ import type { ProviderSelectorProvider } from "@/provider-selection/provider-sel import { ICON_SIZE, type Theme } from "@/styles/theme"; const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = []; -const EMPTY_FAVORITE_KEYS = new Set(); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedMapping = (theme: Theme) => ({ @@ -27,8 +27,9 @@ interface CombinedModelSelectorProps { selectedModel: string; onSelect: (provider: AgentProvider, modelId: string) => void; isLoading: boolean; - favoriteKeys?: Set; - onToggleFavorite?: (provider: string, modelId: string) => void; + profiles?: AgentProfilePicker | null; + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; renderTrigger?: (input: { selectedModelLabel: string; onPress: () => void; @@ -66,8 +67,9 @@ export function CombinedModelSelector({ selectedModel, onSelect, isLoading, - favoriteKeys = EMPTY_FAVORITE_KEYS, - onToggleFavorite, + profiles = null, + onApplyProfile, + onEditProfiles, renderTrigger, onOpen, onClose, @@ -89,7 +91,7 @@ export function CombinedModelSelector({ selectedProvider, selectedModel, isLoading, - favoriteKeys, + profiles, serverId, }); const { prepareToOpen, reset } = browser; @@ -153,13 +155,28 @@ export function CombinedModelSelector({ [disabled, isOpen, renderTrigger, triggerFill], ); + const handleApplyProfile = useCallback( + (profileId: string) => { + onApplyProfile?.(profileId); + handleOpenChange(false); + }, + [handleOpenChange, onApplyProfile], + ); + + const handleEditProfiles = useCallback(() => { + handleOpenChange(false); + onEditProfiles?.(); + }, [handleOpenChange, onEditProfiles]); + const selectorBody = isContentReady ? ( ) : ( @@ -230,7 +247,9 @@ export function CombinedModelSelector({ anchorRef={anchorRef} desktopPlacement={desktopPlacement} desktopMinWidth={desktopMinWidth} + desktopLockWidth desktopFixedHeight={browser.desktopFixedHeight} + desktopChildrenScrollEnabled={false} header={browser.header} mobileChildrenScrollEnabled={!browser.isProviderView || !isNative} mobileChildrenContentContainerStyle={styles.mobileBrowserContent} diff --git a/packages/app/src/components/model-browser-view.test.ts b/packages/app/src/components/model-browser-view.test.ts index 719832b095..edb499a4a1 100644 --- a/packages/app/src/components/model-browser-view.test.ts +++ b/packages/app/src/components/model-browser-view.test.ts @@ -1,12 +1,35 @@ import { describe, expect, it } from "vitest"; -import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; -import { resolveInitialModelBrowserView } from "./model-browser-view"; +import type { + ProviderSelectionModelRow, + ProviderSelectorProvider, +} from "@/provider-selection/provider-selection"; +import { resolveInitialModelBrowserView, resolveModelBrowserAllView } from "./model-browser-view"; -function provider(id: string, label: string): ProviderSelectorProvider { +function provider( + id: string, + label: string, + rows: ProviderSelectionModelRow[] = [], +): ProviderSelectorProvider { return { id, label, - modelSelection: { kind: "models", rows: [] }, + modelSelection: { kind: "models", rows }, + }; +} + +function modelRow( + providerId: string, + providerLabel: string, + modelId: string, + modelLabel: string, +): ProviderSelectionModelRow { + return { + favoriteKey: `${providerId}:${modelId}`, + provider: providerId, + providerLabel, + modelId, + modelLabel, + description: modelId, }; } @@ -20,30 +43,111 @@ describe("model browser initial view", () => { providers: [pi], selectedProvider: "", selectedModel: "", - favoriteKeys: new Set(), + hasProfiles: false, }), ).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" }); }); - it("opens the selected provider when its model is not a favorite", () => { + it("opens the selected provider when there is one", () => { expect( resolveInitialModelBrowserView({ providers: [codex, pi], selectedProvider: "pi", selectedModel: "pi-pro", - favoriteKeys: new Set(), + hasProfiles: false, }), ).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" }); }); - it("opens the provider overview when the selected model is a favorite", () => { + it("opens the root so pinned profiles are reachable", () => { expect( resolveInitialModelBrowserView({ providers: [codex, pi], selectedProvider: "pi", selectedModel: "pi-pro", - favoriteKeys: new Set(["pi:pi-pro"]), + hasProfiles: true, + }), + ).toEqual({ kind: "all" }); + }); + + it("keeps the root even when a single provider would otherwise be skipped", () => { + expect( + resolveInitialModelBrowserView({ + providers: [pi], + selectedProvider: "pi", + selectedModel: "pi-pro", + hasProfiles: true, + }), + ).toEqual({ kind: "all" }); + }); + + it("falls back to the root when the selected provider is gone", () => { + expect( + resolveInitialModelBrowserView({ + providers: [codex, pi], + selectedProvider: "gemini", + selectedModel: "gemini-3", + hasProfiles: false, }), ).toEqual({ kind: "all" }); }); }); + +describe("model browser all view", () => { + const claude = provider("claude", "Claude Code", [ + modelRow("claude", "Claude Code", "opus-5", "Opus 5"), + modelRow("claude", "Claude Code", "sonnet-4.6", "Sonnet 4.6"), + ]); + const copilot = provider("copilot", "Copilot", [ + modelRow("copilot", "Copilot", "claude-opus-5", "Opus 5"), + ]); + const codex = provider("codex", "Codex", [modelRow("codex", "Codex", "gpt-5.4", "GPT-5.4")]); + const providers = [claude, copilot, codex]; + + it("browses providers while the query is empty", () => { + expect(resolveModelBrowserAllView({ providers, normalizedQuery: "" })).toEqual({ + kind: "browse", + }); + }); + + it("ranks the same model label across every provider that offers it", () => { + const view = resolveModelBrowserAllView({ providers, normalizedQuery: "opus" }); + + expect(view.kind).toBe("searchResults"); + expect(view.kind === "searchResults" ? view.rows.map((row) => row.favoriteKey) : []).toEqual([ + "claude:opus-5", + "copilot:claude-opus-5", + ]); + }); + + it("matches models by their provider label", () => { + const view = resolveModelBrowserAllView({ providers, normalizedQuery: "codex" }); + + expect(view.kind === "searchResults" ? view.rows.map((row) => row.modelId) : []).toEqual([ + "gpt-5.4", + ]); + }); + + it("reports no matches instead of falling back to the provider list", () => { + expect(resolveModelBrowserAllView({ providers, normalizedQuery: "zzzz" })).toEqual({ + kind: "noSearchMatches", + }); + }); + + it("ignores providers that are still loading or errored", () => { + const loading: ProviderSelectorProvider = { + id: "opencode", + label: "OpenCode", + modelSelection: { kind: "loading" }, + }; + const failed: ProviderSelectorProvider = { + id: "pi", + label: "Pi", + modelSelection: { kind: "error", message: "unavailable" }, + }; + + expect( + resolveModelBrowserAllView({ providers: [loading, failed], normalizedQuery: "opus" }), + ).toEqual({ kind: "noSearchMatches" }); + }); +}); diff --git a/packages/app/src/components/model-browser-view.ts b/packages/app/src/components/model-browser-view.ts index 9452b956d6..acdde18b04 100644 --- a/packages/app/src/components/model-browser-view.ts +++ b/packages/app/src/components/model-browser-view.ts @@ -1,20 +1,57 @@ -import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; +import { + filterAndRankModelRows, + getAllProviderModelRows, + type ProviderSelectionModelRow, + type ProviderSelectorProvider, +} from "@/provider-selection/provider-selection"; export type ModelBrowserView = | { kind: "all" } | { kind: "provider"; providerId: string; providerLabel: string }; +/** What the root view shows: the provider drill-down, or ranked cross-provider results. */ +export type ModelBrowserAllView = + | { kind: "browse" } + | { kind: "searchResults"; rows: ProviderSelectionModelRow[] } + | { kind: "noSearchMatches" }; + +export function resolveModelBrowserAllView({ + providers, + normalizedQuery, +}: { + providers: ProviderSelectorProvider[]; + normalizedQuery: string; +}): ModelBrowserAllView { + if (!normalizedQuery) { + return { kind: "browse" }; + } + const rows = filterAndRankModelRows(getAllProviderModelRows(providers), normalizedQuery); + if (rows.length === 0) { + return { kind: "noSearchMatches" }; + } + return { kind: "searchResults", rows }; +} + +/** + * Where the picker lands when it opens. Agent profiles live on the root view, so + * a host that has any profile always opens there — including the single-provider + * case, which would otherwise skip the root entirely and hide them. + */ export function resolveInitialModelBrowserView({ providers, selectedProvider, selectedModel, - favoriteKeys, + hasProfiles, }: { providers: ProviderSelectorProvider[]; selectedProvider: string; selectedModel: string; - favoriteKeys: Set; + hasProfiles: boolean; }): ModelBrowserView { + if (hasProfiles) { + return { kind: "all" }; + } + const singleProvider = providers.length === 1 ? providers[0] : undefined; if (singleProvider) { return { @@ -24,12 +61,7 @@ export function resolveInitialModelBrowserView({ }; } - const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`; - const shouldOpenSelectedProvider = - selectedProvider.length > 0 && - selectedModel.length > 0 && - !favoriteKeys.has(selectedFavoriteKey); - if (shouldOpenSelectedProvider) { + if (selectedProvider.length > 0 && selectedModel.length > 0) { const provider = providers.find((entry) => entry.id === selectedProvider); if (provider) { return { kind: "provider", providerId: provider.id, providerLabel: provider.label }; diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index e9c34b0ec8..e00a0328b6 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -16,15 +16,30 @@ import { import { BottomSheetFlatList } from "@gorhom/bottom-sheet"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { AlertTriangle, Check, ChevronRight, Search, Settings, Star } from "lucide-react-native"; +import { + AlertTriangle, + Check, + ChevronRight, + Pencil, + Plus, + Search, + Settings, +} from "lucide-react-native"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import { + AgentProfileGlyph, + type AgentProfilePicker, + type AgentProfilePickerRow as AgentProfilePickerRowModel, +} from "@/agent-profiles"; import type { SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getProviderIcon } from "@/components/provider-icons"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { + buildProviderQualifiedDescription, buildSelectedTriggerLabel, filterAndRankModelRows, getAllProviderModelRows, @@ -38,6 +53,7 @@ import { useCurrentOverlayLayer } from "@/lib/overlay-root"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { resolveInitialModelBrowserView, + resolveModelBrowserAllView, type ModelBrowserView, } from "@/components/model-browser-view"; @@ -50,9 +66,10 @@ const ThemedAlertTriangle = withUnistyles(AlertTriangle); const ThemedCheck = withUnistyles(Check); const ThemedChevronRight = withUnistyles(ChevronRight); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const ThemedPencil = withUnistyles(Pencil); +const ThemedPlus = withUnistyles(Plus); const ThemedSearch = withUnistyles(Search); const ThemedSettings = withUnistyles(Settings); -const ThemedStar = withUnistyles(Star); function ProviderSettingsAction({ accessibilityLabel, @@ -84,6 +101,29 @@ function ProviderSettingsAction({ ); } +function AgentProfilesEditAction({ onPress }: { onPress: () => void }) { + const { t } = useTranslation(); + return ( + + + + + + + + {t("modelSelector.editProfilesLabel")} + + + ); +} + const IndependentScrollGestureContext = createContext | null>( null, ); @@ -92,29 +132,21 @@ const foregroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); +const foregroundExtraMutedMapping = (theme: Theme) => ({ + color: theme.colors.foregroundExtraMuted, +}); + const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({ color: disabled ? theme.colors.border : theme.colors.foregroundMuted, }); -const favoriteStarMapping = - (isFavorite: boolean, hovered: boolean) => - (theme: Theme): { color: string; fill: string } => { - const favoriteColor = theme.colors.palette.amber[500]; - if (isFavorite) { - return { color: favoriteColor, fill: favoriteColor }; - } - return { - color: hovered ? theme.colors.foregroundMuted : theme.colors.border, - fill: "transparent", - }; - }; - interface ModelBrowserInput { providers: ProviderSelectorProvider[]; selectedProvider: string; selectedModel: string; isLoading: boolean; - favoriteKeys: Set; + /** Pinned above the provider list on the root view. `null` hides the section. */ + profiles?: AgentProfilePicker | null; serverId?: string | null; } @@ -122,7 +154,7 @@ export interface ModelBrowserState { providers: ProviderSelectorProvider[]; selectedProvider: string; selectedModel: string; - favoriteKeys: Set; + profiles: AgentProfilePicker | null; view: ModelBrowserView; searchQuery: string; header: SheetHeader; @@ -138,7 +170,9 @@ export interface ModelBrowserState { interface ModelBrowserProps { state: ModelBrowserState; onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; + /** Applying a profile resolves the pick and dismisses, exactly like a model row. */ + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; onRetryProvider?: (provider: AgentProvider) => void; isRetryingProvider?: boolean; scrolling?: "sheet" | "independent"; @@ -150,7 +184,7 @@ interface ModelBrowserContentProps extends Omit; + profiles: AgentProfilePicker | null; onDrillDown: (providerId: string, providerLabel: string) => void; scrolling: "sheet" | "independent"; } @@ -177,22 +211,6 @@ function HeaderSettingsIcon({ disabled }: { disabled: boolean }) { return ; } -function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) { - const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]); - return ; -} - -function favoriteButtonStyle({ - hovered, - pressed, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [ - styles.favoriteButton, - Boolean(hovered) && styles.favoriteButtonHovered, - pressed && styles.favoriteButtonPressed, - ]; -} - function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) { return [ styles.rowIconButton, @@ -201,18 +219,19 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho ]; } +function countModelsInView(view: ModelBrowserView, providers: ProviderSelectorProvider[]): number { + if (view.kind === "all") { + return getAllProviderModelRows(providers).length; + } + const provider = providers.find((entry) => entry.id === view.providerId); + return provider?.modelSelection.kind === "models" ? getProviderModelRows(provider).length : 0; +} + function resolveDesktopFixedHeight( view: ModelBrowserView, providers: ProviderSelectorProvider[], -): number | undefined { - if (view.kind !== "provider") { - return undefined; - } - const provider = providers.find((entry) => entry.id === view.providerId); - if (!provider || provider.modelSelection.kind !== "models") { - return DESKTOP_PROVIDER_VIEW_MIN_HEIGHT; - } - const modelCount = getProviderModelRows(provider).length; +): number { + const modelCount = countModelsInView(view, providers); return Math.min( Math.max( DESKTOP_PROVIDER_VIEW_MIN_HEIGHT, @@ -227,13 +246,14 @@ export function useModelBrowser({ selectedProvider, selectedModel, isLoading, - favoriteKeys, + profiles = null, serverId = null, }: ModelBrowserInput): ModelBrowserState { const { t } = useTranslation(); const [view, setView] = useState({ kind: "all" }); const [searchQuery, setSearchQuery] = useState(""); const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0); + const hasProfiles = (profiles?.rows.length ?? 0) > 0; const initialView = useMemo( () => @@ -241,9 +261,9 @@ export function useModelBrowser({ providers, selectedProvider, selectedModel, - favoriteKeys, + hasProfiles, }), - [favoriteKeys, providers, selectedModel, selectedProvider], + [hasProfiles, providers, selectedModel, selectedProvider], ); const prepareToOpen = useCallback(() => { @@ -260,18 +280,33 @@ export function useModelBrowser({ reset(); }, [reset]); - const drillDown = useCallback((providerId: string, providerLabel: string) => { - setView({ kind: "provider", providerId, providerLabel }); - }, []); + const drillDown = useCallback( + (providerId: string, providerLabel: string) => { + setView({ kind: "provider", providerId, providerLabel }); + reset(); + }, + [reset], + ); const handleSearchQueryChange = useCallback((value: string) => { setSearchQuery(value); }, []); - const singleProviderView = providers.length === 1; + // A pinned profiles section makes the root worth returning to, so the back + // affordance stays even when the drill-down was the only provider. + const singleProviderView = providers.length === 1 && !hasProfiles; const header = useMemo(() => { if (view.kind === "all") { - return { title: t("modelSelector.title") }; + return { + title: t("modelSelector.title"), + search: { + onChange: handleSearchQueryChange, + resetKey: `all:${searchResetKey}`, + placeholder: t("modelSelector.searchAllPlaceholder"), + autoFocus: isWeb, + testID: "model-search-all-input", + }, + }; } return { title: view.providerLabel, @@ -280,13 +315,15 @@ export function useModelBrowser({ ), back: singleProviderView ? undefined : { onPress: handleBackToAll }, actions: ( - + + + ), search: { onChange: handleSearchQueryChange, @@ -333,7 +370,7 @@ export function useModelBrowser({ providers, selectedProvider, selectedModel, - favoriteKeys, + profiles, view, searchQuery, header, @@ -351,22 +388,6 @@ function normalizeSearchQuery(value: string): string { return value.trim().toLowerCase(); } -function sortFavoritesFirst( - rows: ProviderSelectionModelRow[], - favoriteKeys: Set, -): ProviderSelectionModelRow[] { - const favorites: ProviderSelectionModelRow[] = []; - const rest: ProviderSelectionModelRow[] = []; - for (const row of rows) { - if (favoriteKeys.has(row.favoriteKey)) { - favorites.push(row); - } else { - rest.push(row); - } - } - return [...favorites, ...rest]; -} - interface ModelBrowserPressableProps { children: React.ReactNode | ((state: PressableStateCallbackType) => React.ReactNode); style?: @@ -375,6 +396,8 @@ interface ModelBrowserPressableProps { onPress: () => void; hitSlop?: number; accessibilityLabel?: string; + /** Only rows that can express selection pass this; the rest stay unannotated. */ + accessibilitySelected?: boolean; testID?: string; } @@ -384,6 +407,7 @@ function ModelBrowserPressable({ onPress, hitSlop, accessibilityLabel, + accessibilitySelected, testID, }: ModelBrowserPressableProps) { const independentScrollGesture = useContext(IndependentScrollGestureContext); @@ -420,6 +444,10 @@ function ModelBrowserPressable({ }, [onPress], ); + const accessibilityState = useMemo( + () => (accessibilitySelected === undefined ? undefined : { selected: accessibilitySelected }), + [accessibilitySelected], + ); if (!independentScrollGesture) { return ( @@ -429,6 +457,8 @@ function ModelBrowserPressable({ style={style} accessibilityRole="button" accessibilityLabel={accessibilityLabel} + accessibilityState={accessibilityState} + aria-selected={accessibilitySelected} testID={testID} > {children} @@ -445,6 +475,8 @@ function ModelBrowserPressable({ accessible accessibilityRole="button" accessibilityLabel={accessibilityLabel} + accessibilityState={accessibilityState} + aria-selected={accessibilitySelected} accessibilityActions={[{ name: "activate" }]} onAccessibilityAction={handleAccessibilityAction} style={resolvedStyle} @@ -466,6 +498,7 @@ function ModelBrowserRow({ selected = false, selectionIndicator = false, tone = "default", + labelMuted = false, spacing = "model", onPress, testID, @@ -477,6 +510,8 @@ function ModelBrowserRow({ selected?: boolean; selectionIndicator?: boolean; tone?: ModelBrowserRowTone; + /** For rows that offer an action rather than name a thing you can pick. */ + labelMuted?: boolean; spacing?: "model" | "provider"; onPress: () => void; testID?: string; @@ -498,11 +533,21 @@ function ModelBrowserRow({ const hasTrailing = selected || trailingSlot; return ( - + {leadingSlot} - + {label} {description ? ( @@ -531,55 +576,30 @@ function ModelBrowserRow({ function ModelRow({ row, isSelected, - isFavorite, - elevated = false, + showProviderLabel = false, onPress, - onToggleFavorite, }: { row: ProviderSelectionModelRow; isSelected: boolean; - isFavorite: boolean; - elevated?: boolean; + showProviderLabel?: boolean; onPress: () => void; - onToggleFavorite?: (provider: string, modelId: string) => void; }) { - const { t } = useTranslation(); - const handleToggleFavorite = useCallback( - () => onToggleFavorite?.(row.provider, row.modelId), - [onToggleFavorite, row.modelId, row.provider], - ); const leadingSlot = useMemo( () => , [row.provider], ); - const trailingSlot = useMemo( - () => - onToggleFavorite ? ( - - {({ hovered }) => } - - ) : null, - [handleToggleFavorite, isFavorite, onToggleFavorite, row.modelId, row.provider, t], - ); + + const description = showProviderLabel ? buildProviderQualifiedDescription(row) : row.description; return ( ); } @@ -587,17 +607,13 @@ function ModelRow({ function SelectableModelRow({ row, isSelected, - isFavorite, - elevated, + showProviderLabel, onSelect, - onToggleFavorite, }: { row: ProviderSelectionModelRow; isSelected: boolean; - isFavorite: boolean; - elevated?: boolean; + showProviderLabel?: boolean; onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; }) { const handlePress = useCallback(() => { onSelect(row.provider, row.modelId); @@ -606,51 +622,110 @@ function SelectableModelRow({ ); } -function FavoritesSection({ - favoriteRows, - selectedProvider, - selectedModel, - favoriteKeys, - onSelect, - onToggleFavorite, +function AgentProfilePickerRowView({ + row, + onApply, }: { - favoriteRows: ProviderSelectionModelRow[]; - selectedProvider: string; - selectedModel: string; - favoriteKeys: Set; - onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; + row: AgentProfilePickerRowModel; + onApply: (profileId: string) => void; +}) { + const handlePress = useCallback(() => onApply(row.id), [onApply, row.id]); + const leadingSlot = useMemo( + () => , + [row.color, row.icon], + ); + return ( + + ); +} + +/** + * Pinned above the provider list. Rows are actions, not selections: applying a + * profile writes its values into the composer and nothing stays bound to it, so + * there is no checkmark and no active row to show. + */ +function AgentProfilesPickerSection({ + rows, + onApplyProfile, + onEditProfiles, +}: { + rows: AgentProfilePickerRowModel[]; + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; }) { const { t } = useTranslation(); - if (favoriteRows.length === 0) return null; + const handleApply = useCallback( + (profileId: string) => onApplyProfile?.(profileId), + [onApplyProfile], + ); return ( - + - {t("modelSelector.favorites")} + {t("modelSelector.profiles")} + {onEditProfiles ? : null} - {favoriteRows.map((row) => ( - + {rows.map((row) => ( + ))} ); } +function CreateAgentProfileRow({ onPress }: { onPress: () => void }) { + const { t } = useTranslation(); + const leadingSlot = useMemo( + () => ( + + + + ), + [], + ); + return ( + + ); +} + +function AgentProfilesPickerContent({ + rows, + onApplyProfile, + onEditProfiles, +}: { + rows: AgentProfilePickerRowModel[]; + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; +}) { + if (rows.length === 0) { + return onEditProfiles ? : null; + } + return ( + + ); +} + function GroupProviderButton({ provider, onDrillDown, @@ -764,15 +839,18 @@ function IndependentScrollBoundary({ children }: { children: React.ReactElement function IndependentModelList({ rows, renderItem, + header, }: { rows: ProviderSelectionModelRow[]; renderItem: ({ item }: { item: ProviderSelectionModelRow }) => React.ReactElement; + header?: React.ReactElement; }) { return ( ; onSelect: (provider: string, modelId: string) => void; - onToggleFavorite?: (provider: string, modelId: string) => void; - normalizedQuery: string; + showProviderLabel?: boolean; + header?: React.ReactElement; scrolling: "sheet" | "independent"; }) { const isCompact = useIsCompactFormFactor(); - const displayRows = useMemo( - () => (normalizedQuery ? rows : sortFavoritesFirst(rows, favoriteKeys)), - [favoriteKeys, normalizedQuery, rows], - ); const renderItem = useCallback( ({ item }: { item: ProviderSelectionModelRow }) => ( ), - [favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider], + [onSelect, selectedModel, selectedProvider, showProviderLabel], ); const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []); if (scrolling === "independent") { - return ; + return ; } if (isCompact && isNative) { return ( - {displayRows.map((row) => ( + {header} + {rows.map((row) => ( {renderItem({ item: row })} ))} @@ -902,15 +975,111 @@ function ProviderErrorEmptyState({ ); } +function ModelSearchEmptyState() { + const { t } = useTranslation(); + return ( + + + {t("modelSelector.noMatches")} + + ); +} + +function ProviderModelBrowserContent({ + view, + provider, + profiles, + selectedProvider, + selectedModel, + normalizedQuery, + onSelect, + onApplyProfile, + onEditProfiles, + onRetryProvider, + isRetryingProvider, + scrolling, +}: { + view: Extract; + provider: ProviderSelectorProvider | null; + profiles: AgentProfilePicker | null; + selectedProvider: string; + selectedModel: string; + normalizedQuery: string; + onSelect: (provider: string, modelId: string) => void; + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; + onRetryProvider?: (provider: AgentProvider) => void; + isRetryingProvider: boolean; + scrolling: "sheet" | "independent"; +}) { + const { t } = useTranslation(); + const visibleRows = useMemo( + () => (provider ? filterAndRankModelRows(getProviderModelRows(provider), normalizedQuery) : []), + [normalizedQuery, provider], + ); + const providerProfileRows = useMemo( + () => profiles?.rows.filter((row) => row.provider === view.providerId) ?? [], + [profiles, view.providerId], + ); + const profileHeader = useMemo( + () => + normalizedQuery.length === 0 && profiles ? ( + + ) : undefined, + [normalizedQuery, onApplyProfile, onEditProfiles, profiles, providerProfileRows], + ); + + if (!provider) return ; + const selection = provider.modelSelection; + if (selection.kind === "loading") { + return ( + + + + + {t("modelSelector.loadingShort")} + + ); + } + if (selection.kind === "error") { + return ( + + ); + } + if (visibleRows.length === 0) { + return profileHeader ?? ; + } + return ( + + ); +} + function ModelBrowserContent({ view, providers, selectedProvider, selectedModel, searchQuery, - favoriteKeys, + profiles, onSelect, - onToggleFavorite, + onApplyProfile, + onEditProfiles, onDrillDown, onRetryProvider, isRetryingProvider = false, @@ -921,62 +1090,54 @@ function ModelBrowserContent({ const selectedViewProvider = useMemo( () => view.kind === "provider" - ? providers.find((provider) => provider.id === view.providerId) + ? (providers.find((provider) => provider.id === view.providerId) ?? null) : null, [providers, view], ); - const visibleRows = useMemo( - () => - selectedViewProvider - ? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery) - : [], - [normalizedQuery, selectedViewProvider], - ); - const favoriteRows = useMemo( - () => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)), - [favoriteKeys, providers], - ); - const hasResults = favoriteRows.length > 0 || providers.length > 0; - const emptyState = ( - - - {t("modelSelector.noMatches")} - + const allView = useMemo( + () => resolveModelBrowserAllView({ providers, normalizedQuery }), + [normalizedQuery, providers], ); + const hasResults = profiles !== null || providers.length > 0; if (view.kind === "provider") { - if (!selectedViewProvider) return emptyState; - const selection = selectedViewProvider.modelSelection; - if (selection.kind === "loading") { - return ( - - - - - {t("modelSelector.loadingShort")} - - ); - } - if (selection.kind === "error") { - return ( - - ); - } - if (visibleRows.length === 0) return emptyState; return ( - + ); + } + + if (allView.kind === "noSearchMatches") { + return ( + + + + {t("modelSelector.noMatchesForQuery", { query: searchQuery.trim() })} + + + ); + } + + if (allView.kind === "searchResults") { + return ( + ); @@ -984,18 +1145,24 @@ function ModelBrowserContent({ const allProvidersContent = ( - - {providers.length > 0 ? ( - + {profiles ? ( + ) : null} - {!hasResults ? emptyState : null} + {providers.length > 0 ? ( + + {profiles ? ( + + {t("modelSelector.providers")} + + ) : null} + + + ) : null} + {!hasResults ? : null} ); @@ -1009,7 +1176,8 @@ function ModelBrowserContent({ export function ModelBrowser({ state, onSelect, - onToggleFavorite, + onApplyProfile, + onEditProfiles, onRetryProvider, isRetryingProvider = false, scrolling = "sheet", @@ -1021,9 +1189,10 @@ export function ModelBrowser({ selectedProvider={state.selectedProvider} selectedModel={state.selectedModel} searchQuery={state.searchQuery} - favoriteKeys={state.favoriteKeys} + profiles={state.profiles} onSelect={onSelect} - onToggleFavorite={onToggleFavorite} + onApplyProfile={onApplyProfile} + onEditProfiles={onEditProfiles} onDrillDown={state.drillDown} onRetryProvider={onRetryProvider} isRetryingProvider={isRetryingProvider} @@ -1033,7 +1202,12 @@ export function ModelBrowser({ } const styles = StyleSheet.create((theme) => ({ - favoritesContainer: { + headerActionRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + profilesContainer: { backgroundColor: theme.colors.surface1, borderBottomWidth: 1, borderBottomColor: theme.colors.border, @@ -1051,6 +1225,7 @@ const styles = StyleSheet.create((theme) => ({ paddingBottom: theme.spacing[1], }, sectionHeadingText: { + flex: 1, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, @@ -1099,6 +1274,11 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, flexShrink: 0, }, + browserRowLabelMuted: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + flexShrink: 0, + }, browserRowDescription: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, @@ -1156,6 +1336,10 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, + tooltipText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + }, virtualizedModelList: { flex: 1, }, @@ -1166,19 +1350,6 @@ const styles = StyleSheet.create((theme) => ({ virtualizedProviderListContent: { paddingTop: 0, }, - favoriteButton: { - width: 24, - height: 24, - borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", - }, - favoriteButtonHovered: { - backgroundColor: theme.colors.surface2, - }, - favoriteButtonPressed: { - backgroundColor: theme.colors.surface1, - }, providerIconMuted: { color: theme.colors.foregroundMuted, }, diff --git a/packages/app/src/components/ui/combobox-frame-style.test.ts b/packages/app/src/components/ui/combobox-frame-style.test.ts index 84f884c4e7..69026b141a 100644 --- a/packages/app/src/components/ui/combobox-frame-style.test.ts +++ b/packages/app/src/components/ui/combobox-frame-style.test.ts @@ -9,6 +9,7 @@ function buildWidthStyle(input: { }): Pick { const [frameStyle] = buildDesktopFrameStyle({ desktopMinWidth: input.desktopMinWidth, + desktopLockWidth: false, referenceWidth: input.referenceWidth, desktopFixedHeight: undefined, desktopPositionStyle: { left: 0, top: 0 }, @@ -55,4 +56,18 @@ describe("buildDesktopFrameStyle", () => { maxWidth: 400, }); }); + + it("locks the frame to its opening floor when content must not resize it", () => { + const [frameStyle] = buildDesktopFrameStyle({ + desktopMinWidth: 360, + desktopLockWidth: true, + referenceWidth: 120, + desktopFixedHeight: undefined, + desktopPositionStyle: { left: 0, top: 0 }, + shouldHideDesktopContent: false, + availableHeight: undefined, + }) as ViewStyle[]; + + expect(frameStyle).toMatchObject({ width: 360, minWidth: 360, maxWidth: 360 }); + }); }); diff --git a/packages/app/src/components/ui/combobox-frame-style.ts b/packages/app/src/components/ui/combobox-frame-style.ts index 0e825abaf6..8c1a495c61 100644 --- a/packages/app/src/components/ui/combobox-frame-style.ts +++ b/packages/app/src/components/ui/combobox-frame-style.ts @@ -2,6 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native"; export interface DesktopFrameStyleInput { desktopMinWidth: number | undefined; + desktopLockWidth: boolean; referenceWidth: number | null; desktopFixedHeight: number | undefined; desktopPositionStyle: StyleProp; @@ -12,6 +13,7 @@ export interface DesktopFrameStyleInput { export function buildDesktopFrameStyle(input: DesktopFrameStyleInput): StyleProp { const { desktopMinWidth, + desktopLockWidth, referenceWidth, desktopFixedHeight, desktopPositionStyle, @@ -28,11 +30,13 @@ export function buildDesktopFrameStyle(input: DesktopFrameStyleInput): StyleProp ? { maxHeight: Math.min(availableHeight, desktopFixedHeight ?? 400) } : null; const floor = Math.max(desktopMinWidth ?? 0, referenceWidth ?? 200); + const widthStyle = desktopLockWidth + ? { width: floor, minWidth: floor, maxWidth: floor } + : { minWidth: floor, maxWidth: Math.max(400, floor) }; return [ { position: "absolute" as const, - minWidth: floor, - maxWidth: Math.max(400, floor), + ...widthStyle, }, fixedHeightStyle, desktopPositionStyle, diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index b756ecc9c2..da9dc2e192 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -108,6 +108,8 @@ export interface ComboboxProps { */ header?: SheetHeader; mobileChildrenScrollEnabled?: boolean; + /** Let custom desktop children own scrolling, for example a virtualized list. */ + desktopChildrenScrollEnabled?: boolean; /** Overrides the mobile scroll container spacing for custom child content. */ mobileChildrenContentContainerStyle?: StyleProp; presentation?: "push" | "replace"; @@ -122,6 +124,8 @@ export interface ComboboxProps { desktopPreventInitialFlash?: boolean; /** Minimum width for the desktop popover (overrides trigger-based width). */ desktopMinWidth?: number; + /** Keep the desktop popover at its opening width as its content changes. */ + desktopLockWidth?: boolean; /** Fixed height for the desktop popover (overrides default 400px max). */ desktopFixedHeight?: number; /** Content rendered above the scroll area on desktop (sticky header). */ @@ -1080,17 +1084,28 @@ interface DesktopBodyProps { handleSelect: (id: string) => void; renderOption: RenderOptionFn | undefined; hasChildren: boolean; + childrenScrollEnabled: boolean; children: ReactNode; } function DesktopComboboxChildrenBody(props: { header: SheetHeader | undefined; stickyHeader: ReactNode; + scrollEnabled: boolean; children: ReactNode; }): ReactElement { + const header = props.header ? : props.stickyHeader; + if (!props.scrollEnabled) { + return ( + <> + {header} + {props.children} + + ); + } return ( <> - {props.header ? : props.stickyHeader} + {header} {props.hasChildren ? ( - + {props.children} ) : ( @@ -1284,6 +1303,7 @@ export function Combobox({ title, header, mobileChildrenScrollEnabled = true, + desktopChildrenScrollEnabled = true, mobileChildrenContentContainerStyle, presentation, open, @@ -1291,6 +1311,7 @@ export function Combobox({ desktopPlacement = "bottom-start", desktopPreventInitialFlash = true, desktopMinWidth, + desktopLockWidth, desktopFixedHeight, stickyHeader, footer, @@ -1538,6 +1559,7 @@ export function Combobox({ () => buildDesktopFrameStyle({ desktopMinWidth, + desktopLockWidth: Boolean(desktopLockWidth), referenceWidth, desktopFixedHeight, desktopPositionStyle, @@ -1546,6 +1568,7 @@ export function Combobox({ }), [ desktopMinWidth, + desktopLockWidth, referenceWidth, desktopFixedHeight, desktopPositionStyle, @@ -1630,6 +1653,7 @@ export function Combobox({ handleSelect={handleSelect} renderOption={renderOption} hasChildren={hasChildren} + childrenScrollEnabled={desktopChildrenScrollEnabled} > {children} @@ -1786,6 +1810,10 @@ const styles = StyleSheet.create((theme) => ({ desktopChildrenScrollContent: { // No padding — custom children (e.g. model selector) control their own spacing }, + desktopChildrenBody: { + flex: 1, + minHeight: 0, + }, desktopScrollContentAboveSearch: { flexGrow: 1, justifyContent: "flex-end", diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 642f080469..a659b46d1d 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -9,6 +9,7 @@ import { type RefObject, } from "react"; import { useTranslation } from "react-i18next"; +import { router } from "expo-router"; import { View, Text, @@ -36,12 +37,7 @@ import { filterSelectableModels } from "@/provider-selection/model-catalog"; import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; -import { - buildFavoriteModelKey, - mergeProviderPreferences, - toggleFavoriteModel, - useFormPreferences, -} from "@/hooks/use-form-preferences"; +import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { AgentModeControl, @@ -81,6 +77,12 @@ import { ComposerControlLayoutProvider } from "@/composer/agent-controls/layout- import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; import { AgentControlTrigger } from "@/composer/agent-controls/control"; import { CompactModelSheet } from "@/composer/agent-controls/model-sheet"; +import { + useAgentProfilePicker, + type AgentProfileApplyTarget, + type AgentProfilePicker, +} from "@/agent-profiles"; +import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; interface AgentControlOption { id: string; @@ -106,8 +108,9 @@ interface ControlledAgentControlsProps { disabled?: boolean; isModelLoading?: boolean; modelSelectorProviders?: ProviderSelectorProvider[]; - favoriteKeys?: Set; - onToggleFavoriteModel?: (provider: string, modelId: string) => void; + agentProfiles?: AgentProfilePicker | null; + onApplyAgentProfile?: (profileId: string) => void; + onEditAgentProfiles?: () => void; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; onDropdownClose?: () => void; @@ -241,6 +244,21 @@ function toThinkingControlOptions(options: AgentControlOption[] | undefined): Ag })); } +/** + * The picker's edit shortcut. Agent profiles are host config, so it lands on the + * host settings section that owns the list. + */ +function useEditAgentProfilesNavigation( + serverId: string | null, + isSupported: boolean, +): (() => void) | undefined { + const handleEdit = useCallback(() => { + if (!serverId) return; + router.push(buildSettingsHostSectionRoute(serverId, "agents")); + }, [serverId]); + return serverId && isSupported ? handleEdit : undefined; +} + function buildFallbackModelSelectorProviders( provider: string, modelOptions: AgentControlOption[] | undefined, @@ -255,7 +273,7 @@ function buildFallbackModelSelectorProviders( modelSelection: { kind: "models", rows: modelOptions.map((option) => ({ - favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }), + favoriteKey: `${provider}:${option.id}`, provider, providerLabel: provider, modelId: option.id, @@ -416,8 +434,9 @@ function ControlledAgentControls({ disabled = false, isModelLoading = false, modelSelectorProviders, - favoriteKeys = new Set(), - onToggleFavoriteModel, + agentProfiles = null, + onApplyAgentProfile, + onEditAgentProfiles, features, onSetFeature, onDropdownClose, @@ -670,12 +689,13 @@ function ControlledAgentControls({ selectedThinkingOptionId={selectedThinkingOptionId} features={features} onSetFeature={onSetFeature} - onToggleFavoriteModel={onToggleFavoriteModel} + onApplyAgentProfile={onApplyAgentProfile} + onEditAgentProfiles={onEditAgentProfiles} onDropdownClose={onDropdownClose} onModelSelectorOpen={onModelSelectorOpen} onRetryModelProvider={onRetryModelProvider} isRetryingModelProvider={isRetryingModelProvider} - favoriteKeys={favoriteKeys} + agentProfiles={agentProfiles} disabled={disabled} isModelLoading={isModelLoading} canSelectProvider={canSelectProvider} @@ -716,12 +736,13 @@ function ControlledAgentControls({ selectedThinkingOptionId={selectedThinkingOptionId} features={features} onSetFeature={onSetFeature} - onToggleFavoriteModel={onToggleFavoriteModel} + onApplyAgentProfile={onApplyAgentProfile} + onEditAgentProfiles={onEditAgentProfiles} onDropdownClose={onDropdownClose} onModelSelectorOpen={onModelSelectorOpen} onRetryModelProvider={onRetryModelProvider} isRetryingModelProvider={isRetryingModelProvider} - favoriteKeys={favoriteKeys} + agentProfiles={agentProfiles} disabled={disabled} isModelLoading={isModelLoading} canSelectModel={canSelectModel} @@ -758,12 +779,13 @@ interface DesktopAgentControlsContentProps { selectedThinkingOptionId?: string; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; - onToggleFavoriteModel?: (provider: string, modelId: string) => void; + onApplyAgentProfile?: (profileId: string) => void; + onEditAgentProfiles?: () => void; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; onRetryModelProvider?: (provider: AgentProvider) => void; isRetryingModelProvider: boolean; - favoriteKeys: Set; + agentProfiles: AgentProfilePicker | null; disabled: boolean; isModelLoading: boolean; canSelectProvider: boolean; @@ -817,12 +839,13 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { selectedThinkingOptionId, features, onSetFeature, - onToggleFavoriteModel, + onApplyAgentProfile, + onEditAgentProfiles, onDropdownClose, onModelSelectorOpen, onRetryModelProvider, isRetryingModelProvider, - favoriteKeys, + agentProfiles, disabled, isModelLoading, canSelectProvider, @@ -903,8 +926,9 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { selectedProvider={provider} selectedModel={selectedModelId ?? ""} onSelect={handleDesktopModelSelect} - favoriteKeys={favoriteKeys} - onToggleFavorite={onToggleFavoriteModel} + profiles={agentProfiles} + onApplyProfile={onApplyAgentProfile} + onEditProfiles={onEditAgentProfiles} isLoading={isModelLoading} disabled={modelDisabled} onOpen={onModelSelectorOpen} @@ -1021,12 +1045,13 @@ interface SheetAgentControlsContentProps { selectedThinkingOptionId?: string; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; - onToggleFavoriteModel?: (provider: string, modelId: string) => void; + onApplyAgentProfile?: (profileId: string) => void; + onEditAgentProfiles?: () => void; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; onRetryModelProvider?: (provider: AgentProvider) => void; isRetryingModelProvider: boolean; - favoriteKeys: Set; + agentProfiles: AgentProfilePicker | null; disabled: boolean; isModelLoading: boolean; canSelectModel: boolean; @@ -1061,12 +1086,13 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { selectedThinkingOptionId, features, onSetFeature, - onToggleFavoriteModel, + onApplyAgentProfile, + onEditAgentProfiles, onDropdownClose, onModelSelectorOpen, onRetryModelProvider, isRetryingModelProvider, - favoriteKeys, + agentProfiles, disabled, isModelLoading, canSelectModel, @@ -1158,8 +1184,9 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { selectedProvider={provider} selectedModel={selectedModelId ?? ""} onSelect={handleSheetModelSelect} - favoriteKeys={favoriteKeys} - onToggleFavorite={onToggleFavoriteModel} + profiles={agentProfiles} + onApplyProfile={onApplyAgentProfile} + onEditProfiles={onEditAgentProfiles} isLoading={isModelLoading} disabled={modelDisabled} onOpen={onModelSelectorOpen} @@ -1428,7 +1455,7 @@ export const AgentControls = memo(function AgentControls({ onDropdownClose, isCompactLayout, }: AgentControlsProps) { - const { preferences, updatePreferences } = useFormPreferences(); + const { updatePreferences } = useFormPreferences(); const agent = useSessionStore( useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)), ); @@ -1483,13 +1510,6 @@ export const AgentControls = memo(function AgentControls({ const modelOptions = useMemo(() => { return (models ?? []).map((model) => ({ id: model.id, label: model.label })); }, [models]); - const favoriteKeys = useMemo( - () => - new Set( - (preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)), - ), - [preferences.favoriteModels], - ); const thinkingOptions = useMemo(() => { return (modelSelection.thinkingOptions ?? []).map((option) => ({ @@ -1527,16 +1547,19 @@ export const AgentControls = memo(function AgentControls({ [handleSelectModel], ); - const handleToggleFavoriteModel = useCallback( - (provider: string, modelId: string) => { - void updatePreferences((current) => - toggleFavoriteModel({ preferences: current, provider, modelId }), - ).catch((error) => { - console.warn("[AgentControls] toggle favorite model failed", error); - }); - }, - [updatePreferences], + // A running agent is one provider's process, so only that provider's profiles + // can apply to it. + const profileProviders = useMemo(() => (agentProvider ? [agentProvider] : []), [agentProvider]); + const profileTarget = useMemo( + () => ({ kind: "agent", agentId }), + [agentId], ); + const agentProfiles = useAgentProfilePicker({ + serverId, + availableProviders: profileProviders, + target: profileTarget, + }); + const handleEditAgentProfiles = useEditAgentProfilesNavigation(serverId, agentProfiles !== null); const handleSelectThinkingOption = useCallback( (thinkingOptionId: string) => { @@ -1645,8 +1668,9 @@ export const AgentControls = memo(function AgentControls({ modelOptions={modelOptions} selectedModelId={modelSelection.activeModelId ?? undefined} onSelectModel={handleSelectModel} - favoriteKeys={favoriteKeys} - onToggleFavoriteModel={handleToggleFavoriteModel} + agentProfiles={agentProfiles} + onApplyAgentProfile={agentProfiles?.applyProfile} + onEditAgentProfiles={handleEditAgentProfiles} thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} onSelectThinkingOption={handleSelectThinkingOption} @@ -1668,7 +1692,7 @@ export const AgentControls = memo(function AgentControls({ export function DraftAgentControls({ providerDefinitions, selectedProvider, - onSelectProvider: _onSelectProvider, + onSelectProvider, modeOptions, selectedMode, onSelectMode, @@ -1692,17 +1716,9 @@ export function DraftAgentControls({ modelSelectorServerId = null, isCompactLayout, }: DraftAgentControlsProps) { - const { preferences, updatePreferences } = useFormPreferences(); const mappedThinkingOptions = useMemo(() => { return toThinkingControlOptions(thinkingOptions); }, [thinkingOptions]); - const favoriteKeys = useMemo( - () => - new Set( - (preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)), - ), - [preferences.favoriteModels], - ); const effectiveSelectedThinkingOption = selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; @@ -1716,15 +1732,39 @@ export function DraftAgentControls({ [models], ); - const handleToggleFavorite = useCallback( - (provider: string, modelId: string) => { - void updatePreferences((current) => - toggleFavoriteModel({ preferences: current, provider, modelId }), - ).catch((error) => { - console.warn("[DraftAgentControls] toggle favorite model failed", error); - }); - }, - [updatePreferences], + // The draft form is the one surface that can switch provider, so every profile + // the host can actually run is offered here. + const profileProviders = useMemo( + () => modelSelectorProviders.map((entry) => entry.id), + [modelSelectorProviders], + ); + const profileTarget = useMemo( + () => ({ + kind: "draft", + controls: { + selectProvider: onSelectProvider, + selectProviderAndModel: onSelectProviderAndModel, + selectMode: onSelectMode, + selectThinkingOption: onSelectThinkingOption, + setFeature: onSetFeature, + }, + }), + [ + onSelectMode, + onSelectProvider, + onSelectProviderAndModel, + onSelectThinkingOption, + onSetFeature, + ], + ); + const agentProfiles = useAgentProfilePicker({ + serverId: modelSelectorServerId, + availableProviders: profileProviders, + target: profileTarget, + }); + const handleEditAgentProfiles = useEditAgentProfilesNavigation( + modelSelectorServerId, + agentProfiles !== null, ); const modeControl = useMemo( @@ -1751,8 +1791,9 @@ export function DraftAgentControls({ onSelectModel={onSelectModel} onSelectProviderAndModel={onSelectProviderAndModel} isModelLoading={isAllModelsLoading} - favoriteKeys={favoriteKeys} - onToggleFavoriteModel={handleToggleFavorite} + agentProfiles={agentProfiles} + onApplyAgentProfile={agentProfiles?.applyProfile} + onEditAgentProfiles={handleEditAgentProfiles} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} selectedThinkingOptionId={effectiveSelectedThinkingOption} onSelectThinkingOption={onSelectThinkingOption} diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx index a3ea5e022c..b49813dd42 100644 --- a/packages/app/src/composer/agent-controls/model-sheet.tsx +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { AgentProfilePicker } from "@/agent-profiles"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { getProviderIcon } from "@/components/provider-icons"; @@ -24,8 +25,9 @@ interface CompactModelSheetProps { selectedModel: string; onSelect: (provider: string, modelId: string) => void; isLoading: boolean; - favoriteKeys: Set; - onToggleFavorite?: (provider: string, modelId: string) => void; + profiles?: AgentProfilePicker | null; + onApplyProfile?: (profileId: string) => void; + onEditProfiles?: () => void; onOpen?: () => void; onClose?: () => void; onRetryProvider?: (provider: AgentProvider) => void; @@ -47,8 +49,9 @@ export function CompactModelSheet({ selectedModel, onSelect, isLoading, - favoriteKeys, - onToggleFavorite, + profiles = null, + onApplyProfile, + onEditProfiles, onOpen, onClose, onRetryProvider, @@ -66,7 +69,7 @@ export function CompactModelSheet({ selectedProvider, selectedModel, isLoading, - favoriteKeys, + profiles, serverId, }); const { prepareToOpen, reset } = browser; @@ -104,6 +107,19 @@ export function CompactModelSheet({ [close, onSelect], ); + const handleApplyProfile = useCallback( + (profileId: string) => { + onApplyProfile?.(profileId); + close(); + }, + [close, onApplyProfile], + ); + + const handleEditProfiles = useCallback(() => { + close(); + onEditProfiles?.(); + }, [close, onEditProfiles]); + const toggle = useCallback(() => { if (isOpen) { close(); @@ -168,7 +184,8 @@ export function CompactModelSheet({ { }); }); + it("preserves legacy favourites across preference writes until host migration", async () => { + const favoriteModels = [{ provider: "claude", modelId: "opus" }]; + const storage = new FakeCreateAgentPreferenceStorage({ stored: { favoriteModels } }); + const preferences = new CreateAgentPreferencesService(storage); + + const save = preferences.update({ isolation: "worktree" }); + await storage.nextWrite(); + storage.finishOldestWrite(); + await save; + + expect(storage.savedPreferences()).toEqual({ favoriteModels, isolation: "worktree" }); + }); + it("treats stored preferences without an isolation choice as undefined", () => { expect(parseFormPreferences({ provider: "codex" }).isolation).toBeUndefined(); }); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index 2c8cfd2bee..7153fa8b4f 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -1,20 +1,6 @@ import { z } from "zod"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -export interface FavoriteModelPreference { - provider: string; - modelId: string; -} - -export interface FavoriteModelRow { - favoriteKey: string; - provider: string; - providerLabel: string; - modelId: string; - modelLabel: string; - description?: string; -} - const providerPreferencesSchema = z.object({ model: z.string().optional(), mode: z.string().optional(), @@ -30,6 +16,9 @@ const launchTargetSchema = z.discriminatedUnion("kind", [ const formPreferencesSchema = z.object({ provider: z.string().optional(), providerPreferences: z.record(z.string(), providerPreferencesSchema).optional(), + // COMPAT(agentProfileFavoriteMigration): favourites were removed in v0.3.2. + // Keep the legacy payload alive until every capable host has had a chance to + // import it; ordinary preference writes must not erase it first. favoriteModels: z .array( z.object({ @@ -138,38 +127,3 @@ export function mergeCreateAgentSelectionPreferences(args: { }, }); } - -export function buildFavoriteModelKey(input: FavoriteModelPreference): string { - return `${input.provider}:${input.modelId}`; -} - -export function isFavoriteModel(args: { - preferences: FormPreferences; - provider: string; - modelId: string; -}): boolean { - const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId }); - return (args.preferences.favoriteModels ?? []).some( - (favorite) => buildFavoriteModelKey(favorite) === favoriteKey, - ); -} - -export function toggleFavoriteModel(args: { - preferences: FormPreferences; - provider: string; - modelId: string; -}): FormPreferences { - const favorite = { provider: args.provider, modelId: args.modelId }; - const favoriteKey = buildFavoriteModelKey(favorite); - const existingFavorites = args.preferences.favoriteModels ?? []; - const hasFavorite = existingFavorites.some( - (entry) => buildFavoriteModelKey(entry) === favoriteKey, - ); - - return { - ...args.preferences, - favoriteModels: hasFavorite - ? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey) - : [...existingFavorites, favorite], - }; -} diff --git a/packages/app/src/hooks/use-form-preferences.test.ts b/packages/app/src/hooks/use-form-preferences.test.ts index 4dd2774203..be0653fbc1 100644 --- a/packages/app/src/hooks/use-form-preferences.test.ts +++ b/packages/app/src/hooks/use-form-preferences.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - buildFavoriteModelKey, - isFavoriteModel, - mergeProviderPreferences, - toggleFavoriteModel, -} from "./use-form-preferences"; +import { mergeProviderPreferences } from "./use-form-preferences"; describe("mergeProviderPreferences", () => { it("stores the selected model for a provider", () => { @@ -95,92 +90,3 @@ describe("mergeProviderPreferences", () => { }); }); }); - -describe("favorite model preferences", () => { - it("builds a stable favorite key from provider and model", () => { - expect(buildFavoriteModelKey({ provider: "claude", modelId: "sonnet-4.6" })).toBe( - "claude:sonnet-4.6", - ); - }); - - it("adds a model to favorites without dropping other preferences", () => { - expect( - toggleFavoriteModel({ - preferences: { - provider: "claude", - providerPreferences: { - claude: { - model: "claude-sonnet-4-6", - }, - }, - }, - provider: "codex", - modelId: "gpt-5.4", - }), - ).toEqual({ - provider: "claude", - providerPreferences: { - claude: { - model: "claude-sonnet-4-6", - }, - }, - favoriteModels: [ - { - provider: "codex", - modelId: "gpt-5.4", - }, - ], - }); - }); - - it("removes a model from favorites when toggled again", () => { - expect( - toggleFavoriteModel({ - preferences: { - favoriteModels: [ - { - provider: "codex", - modelId: "gpt-5.4", - }, - ], - }, - provider: "codex", - modelId: "gpt-5.4", - }), - ).toEqual({ - favoriteModels: [], - }); - }); - - it("reports whether a model is favorited", () => { - expect( - isFavoriteModel({ - preferences: { - favoriteModels: [ - { - provider: "codex", - modelId: "gpt-5.4", - }, - ], - }, - provider: "codex", - modelId: "gpt-5.4", - }), - ).toBe(true); - - expect( - isFavoriteModel({ - preferences: { - favoriteModels: [ - { - provider: "codex", - modelId: "gpt-5.4", - }, - ], - }, - provider: "claude", - modelId: "sonnet-4.6", - }), - ).toBe(false); - }); -}); diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts index ee69498b6b..72006faa51 100644 --- a/packages/app/src/hooks/use-form-preferences.ts +++ b/packages/app/src/hooks/use-form-preferences.ts @@ -1,13 +1,8 @@ import { useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { - buildFavoriteModelKey, DEFAULT_FORM_PREFERENCES, - isFavoriteModel, mergeProviderPreferences, - toggleFavoriteModel, - type FavoriteModelPreference, - type FavoriteModelRow, type FormPreferences, type ProviderPreferences, } from "@/create-agent-preferences/preferences"; @@ -18,9 +13,9 @@ import { const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"]; -export type { FavoriteModelPreference, FavoriteModelRow, FormPreferences, ProviderPreferences }; +export type { FormPreferences, ProviderPreferences }; -export { buildFavoriteModelKey, isFavoriteModel, mergeProviderPreferences, toggleFavoriteModel }; +export { mergeProviderPreferences }; async function loadFormPreferences(): Promise { return createAgentPreferencesService.load(); diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index ca2c42f736..55b5995ee9 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -318,7 +318,7 @@ describe("translation resources", () => { it("includes provider selector and pairing keys for the Batch 4D migration", () => { expect(en.modelSelector.title).toBe("Select provider"); - expect(en.modelSelector.favorites).toBe("Favorites"); + expect(en.modelSelector.profiles).toBe("Profiles"); expect(en.providerCatalog.title).toBe("Add provider"); expect(en.providerCatalog.actions.installInstructions).toBe("Install instructions"); expect(en.pairing.link.title).toBe("Paste pairing link"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f96174068c..a3baaf06c7 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1396,14 +1396,18 @@ export const ar: TranslationResources = { loadingSelector: "جارٍ تحميل محدد النموذج...", error: "خطأ", defaultModel: "تقصير", - favorites: "المفضلة", - favoriteModel: "النموذج المفضل", - unfavoriteModel: "نموذج غير مفضل", + profiles: "الملفات الشخصية", + providers: "المزودون", + editProfiles: "تحرير", + editProfilesLabel: "تحرير ملفات الوكيل", + createProfile: "إنشاء ملف شخصي", modelCount: "{{count}} نموذج", modelCountPlural: "{{count}} نماذج", retry: "أعد المحاولة", retrying: "جارٍ إعادة المحاولة...", noMatches: "لا توجد نماذج تطابق بحثك", + noMatchesForQuery: 'لا توجد نماذج تطابق "{{query}}"', + searchAllPlaceholder: "ابحث في كل النماذج...", searchPlaceholder: "نماذج البحث...", openProviderSettings: "افتح إعدادات{{provider}}", }, @@ -2190,6 +2194,42 @@ export const ar: TranslationResources = { save: "Save", emptyState: "No profiles yet. Add one to launch terminals with a specific command.", }, + agentProfiles: { + sectionTitle: "ملفات تعريف الوكيل", + unavailable: "اتصل بهذا المضيف لإدارة ملفات تعريف الوكيل", + unsupported: "يشغّل هذا المضيف نسخة Daemon لا تدعم ملفات تعريف الوكيل بعد", + emptyState: "لا توجد ملفات تعريف بعد. أضف واحدًا لبدء الوكلاء من مزود ونموذج محفوظين.", + addProfileTitle: "إضافة ملف تعريف وكيل", + newProfile: "ملف تعريف جديد", + editProfile: "تعديل الملف", + editProfileTitle: "تعديل ملف تعريف الوكيل", + nameLabel: "الاسم", + namePlaceholder: "عمل الواجهة", + iconLabel: "الأيقونة", + noIcon: "بلا", + providerLabel: "المزود", + providerPlaceholder: "اختر مزودًا", + noProviders: "لا يوجد مزودون متاحون على هذا المضيف", + modelLabel: "النموذج", + noModels: "لا توجد نماذج يمكن الاختيار من بينها لهذا المزود", + modeLabel: "الوضع", + noModes: "لا توجد أوضاع يمكن الاختيار من بينها لهذا المزود", + thinkingLabel: "التفكير", + noThinkingOptions: "لا توجد مستويات تفكير لهذا النموذج", + featuresLabel: "السمات", + featureCount: "{{count}} سمة", + featureCountOne: "{{count}} سمة", + notesLabel: "ملاحظات للوكلاء", + notesPlaceholder: "استخدمه لعمل الواجهة — المكونات والتخطيط ورموز التصميم.", + notesHint: 'تُعاد بواسطة أداة MCP "list_profiles". اكتبها كتعليمة لوكيل آخر.', + save: "حفظ", + saving: "جارٍ الحفظ...", + remove: "إزالة", + removeConfirmTitle: "إزالة الملف؟", + removeConfirmMessage: 'إزالة "{{name}}"؟', + moveUp: "نقل لأعلى", + moveDown: "نقل لأسفل", + }, daemon: { rename: { editLabel: "تحرير التسمية", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9c18be9d5a..520a431992 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1407,14 +1407,18 @@ export const en = { loadingSelector: "Loading model selector...", error: "Error", defaultModel: "Default", - favorites: "Favorites", - favoriteModel: "Favorite model", - unfavoriteModel: "Unfavorite model", + profiles: "Profiles", + providers: "Providers", + editProfiles: "Edit", + editProfilesLabel: "Edit agent profiles", + createProfile: "Create profile", modelCount: "{{count}} model", modelCountPlural: "{{count}} models", retry: "Retry", retrying: "Retrying...", noMatches: "No models match your search", + noMatchesForQuery: 'No models match "{{query}}"', + searchAllPlaceholder: "Search all models...", searchPlaceholder: "Search models...", openProviderSettings: "Open {{provider}} settings", }, @@ -2201,6 +2205,43 @@ export const en = { save: "Save", emptyState: "No profiles yet. Add one to launch terminals with a specific command.", }, + agentProfiles: { + sectionTitle: "Agent profiles", + unavailable: "Connect to this host to manage agent profiles", + unsupported: "This host runs a daemon that does not support agent profiles yet", + emptyState: "No profiles yet. Add one to start agents from a saved provider and model.", + addProfileTitle: "Add agent profile", + newProfile: "New profile", + editProfile: "Edit profile", + editProfileTitle: "Edit agent profile", + nameLabel: "Name", + namePlaceholder: "UI work", + iconLabel: "Icon", + noIcon: "None", + providerLabel: "Provider", + providerPlaceholder: "Select a provider", + noProviders: "No providers are available on this host", + modelLabel: "Model", + noModels: "This provider has no models to choose from", + modeLabel: "Mode", + noModes: "This provider has no modes to choose from", + thinkingLabel: "Thinking", + noThinkingOptions: "This model has no thinking levels", + featuresLabel: "Features", + featureCount: "{{count}} features", + featureCountOne: "{{count}} feature", + notesLabel: "Notes for agents", + notesPlaceholder: "Use for UI work — components, layout and design tokens.", + notesHint: + "Returned by the list_profiles MCP tool. Write it as an instruction to another agent.", + save: "Save", + saving: "Saving...", + remove: "Remove", + removeConfirmTitle: "Remove profile?", + removeConfirmMessage: 'Remove "{{name}}"?', + moveUp: "Move up", + moveDown: "Move down", + }, daemon: { rename: { editLabel: "Edit label", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 3f468de94f..c811ca446e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1437,14 +1437,18 @@ export const es: TranslationResources = { loadingSelector: "Cargando selector de modelo...", error: "Error", defaultModel: "Por defecto", - favorites: "Favoritos", - favoriteModel: "modelo favorito", - unfavoriteModel: "Modelo no favorito", + profiles: "Perfiles", + providers: "Proveedores", + editProfiles: "Editar", + editProfilesLabel: "Editar perfiles de agente", + createProfile: "Crear perfil", modelCount: "{{count}} modelo", modelCountPlural: "{{count}} modelos", retry: "Rever", retrying: "Reintentando...", noMatches: "Ningún modelo coincide con tu búsqueda", + noMatchesForQuery: "Ningún modelo coincide con «{{query}}»", + searchAllPlaceholder: "Buscar en todos los modelos...", searchPlaceholder: "Buscar modelos...", openProviderSettings: "Abrir configuración de{{provider}}", }, @@ -2240,6 +2244,44 @@ export const es: TranslationResources = { save: "Save", emptyState: "No profiles yet. Add one to launch terminals with a specific command.", }, + agentProfiles: { + sectionTitle: "Perfiles de agente", + unavailable: "Conéctese a este host para administrar perfiles de agente", + unsupported: "Este host ejecuta un daemon que aún no admite perfiles de agente", + emptyState: + "Aún no hay perfiles. Agregue uno para iniciar agentes con un proveedor y un modelo guardados.", + addProfileTitle: "Agregar perfil de agente", + newProfile: "Nuevo perfil", + editProfile: "Editar perfil", + editProfileTitle: "Editar perfil de agente", + nameLabel: "Nombre", + namePlaceholder: "Trabajo de UI", + iconLabel: "Icono", + noIcon: "Ninguno", + providerLabel: "Proveedor", + providerPlaceholder: "Seleccione un proveedor", + noProviders: "No hay proveedores disponibles en este host", + modelLabel: "Modelo", + noModels: "Este proveedor no tiene modelos para elegir", + modeLabel: "Modo", + noModes: "Este proveedor no tiene modos para elegir", + thinkingLabel: "Pensamiento", + noThinkingOptions: "Este modelo no tiene niveles de pensamiento", + featuresLabel: "Características", + featureCount: "{{count}} características", + featureCountOne: "{{count}} característica", + notesLabel: "Notas para los agentes", + notesPlaceholder: "Úselo para trabajo de UI: componentes, diseño y tokens de diseño.", + notesHint: + 'Devuelto por la herramienta MCP "list_profiles". Escríbalo como una instrucción para otro agente.', + save: "Guardar", + saving: "Guardando...", + remove: "Eliminar", + removeConfirmTitle: "¿Eliminar perfil?", + removeConfirmMessage: '¿Eliminar "{{name}}"?', + moveUp: "Mover hacia arriba", + moveDown: "Mover hacia abajo", + }, daemon: { rename: { editLabel: "Editar etiqueta", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 482617e914..22d4bec50a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1441,14 +1441,18 @@ export const fr: TranslationResources = { loadingSelector: "Chargement du sélecteur de modèle...", error: "Erreur", defaultModel: "Défaut", - favorites: "Favoris", - favoriteModel: "Modèle préféré", - unfavoriteModel: "Modèle défavorisé", + profiles: "Profils", + providers: "Fournisseurs", + editProfiles: "Modifier", + editProfilesLabel: "Modifier les profils d'agent", + createProfile: "Créer un profil", modelCount: "{{count}} modèle", modelCountPlural: "{{count}} modèles", retry: "Réessayer", retrying: "Nouvelle tentative...", noMatches: "Aucun modèle ne correspond à votre recherche", + noMatchesForQuery: "Aucun modèle ne correspond à « {{query}} »", + searchAllPlaceholder: "Rechercher dans tous les modèles...", searchPlaceholder: "Rechercher des modèles...", openProviderSettings: "Ouvrir les paramètres{{provider}}", }, @@ -2244,6 +2248,46 @@ export const fr: TranslationResources = { save: "Save", emptyState: "No profiles yet. Add one to launch terminals with a specific command.", }, + agentProfiles: { + sectionTitle: "Profils d'agent", + unavailable: "Connectez-vous à cet hôte pour gérer les profils d'agent", + unsupported: + "Cet hôte exécute un daemon qui ne prend pas encore en charge les profils d'agent", + emptyState: + "Aucun profil pour le moment. Ajoutez-en un pour démarrer des agents à partir d'un fournisseur et d'un modèle enregistrés.", + addProfileTitle: "Ajouter un profil d'agent", + newProfile: "Nouveau profil", + editProfile: "Modifier le profil", + editProfileTitle: "Modifier le profil d'agent", + nameLabel: "Nom", + namePlaceholder: "Travail sur l'UI", + iconLabel: "Icône", + noIcon: "Aucune", + providerLabel: "Fournisseur", + providerPlaceholder: "Sélectionner un fournisseur", + noProviders: "Aucun fournisseur n'est disponible sur cet hôte", + modelLabel: "Modèle", + noModels: "Ce fournisseur n'a aucun modèle à choisir", + modeLabel: "Mode", + noModes: "Ce fournisseur n'a aucun mode à choisir", + thinkingLabel: "Pensée", + noThinkingOptions: "Ce modèle n'a aucun niveau de pensée", + featuresLabel: "Caractéristiques", + featureCount: "{{count}} caractéristiques", + featureCountOne: "{{count}} caractéristique", + notesLabel: "Notes pour les agents", + notesPlaceholder: + "À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.", + notesHint: + "Renvoyé par l'outil MCP « list_profiles ». Rédigez-le comme une instruction destinée à un autre agent.", + save: "Enregistrer", + saving: "Enregistrement...", + remove: "Supprimer", + removeConfirmTitle: "Supprimer le profil ?", + removeConfirmMessage: "Supprimer « {{name}} » ?", + moveUp: "Déplacer vers le haut", + moveDown: "Déplacer vers le bas", + }, daemon: { rename: { editLabel: "Modifier l'étiquette", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 787b0458bb..e6dd25e266 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1410,14 +1410,18 @@ export const ja: TranslationResources = { loadingSelector: "モデルセレクターを読み込み中...", error: "エラー", defaultModel: "デフォルト", - favorites: "お気に入り", - favoriteModel: "お気に入りモデル", - unfavoriteModel: "お気に入りを解除", + profiles: "プロファイル", + providers: "プロバイダー", + editProfiles: "編集", + editProfilesLabel: "エージェントプロファイルを編集", + createProfile: "プロファイルを作成", modelCount: "{{count}}つのモデル", modelCountPlural: "{{count}}つのモデル", retry: "再試行", retrying: "再試行中...", noMatches: "検索に一致するモデルがありません", + noMatchesForQuery: "「{{query}}」に一致するモデルがありません", + searchAllPlaceholder: "すべてのモデルを検索...", searchPlaceholder: "モデルを検索...", openProviderSettings: "{{provider}}の設定を開く", }, @@ -2210,6 +2214,44 @@ export const ja: TranslationResources = { emptyState: "プロファイルがまだありません。特定のコマンドでターミナルを起動するために追加してください。", }, + agentProfiles: { + sectionTitle: "エージェントプロファイル", + unavailable: "エージェントプロファイルを管理するにはこのホストに接続してください", + unsupported: "このホストで動作するデーモンはまだエージェントプロファイルに対応していません", + emptyState: + "プロファイルがまだありません。保存したプロバイダーとモデルでエージェントを起動するために追加してください。", + addProfileTitle: "エージェントプロファイルを追加", + newProfile: "新しいプロファイル", + editProfile: "プロファイルを編集", + editProfileTitle: "エージェントプロファイルを編集", + nameLabel: "名前", + namePlaceholder: "UI作業", + iconLabel: "アイコン", + noIcon: "なし", + providerLabel: "プロバイダー", + providerPlaceholder: "プロバイダーを選択", + noProviders: "このホストで利用できるプロバイダーがありません", + modelLabel: "モデル", + noModels: "このプロバイダーには選択できるモデルがありません", + modeLabel: "モード", + noModes: "このプロバイダーには選択できるモードがありません", + thinkingLabel: "思考", + noThinkingOptions: "このモデルには思考レベルがありません", + featuresLabel: "機能", + featureCount: "{{count}}個の機能", + featureCountOne: "{{count}}個の機能", + notesLabel: "エージェントへのメモ", + notesPlaceholder: "UI作業に使用 — コンポーネント、レイアウト、デザイントークンなど。", + notesHint: + "list_profiles MCPツールによって返されます。他のエージェントへの指示として記述してください。", + save: "保存", + saving: "保存中...", + remove: "削除", + removeConfirmTitle: "プロファイルを削除しますか?", + removeConfirmMessage: '"{{name}}"を削除しますか?', + moveUp: "上に移動", + moveDown: "下に移動", + }, daemon: { rename: { editLabel: "ラベルを編集", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 33e4c9c3a4..e6491869b1 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1406,14 +1406,18 @@ export const ko: TranslationResources = { loadingSelector: "모델 선택기 불러오는 중...", error: "오류", defaultModel: "기본값", - favorites: "즐겨찾기", - favoriteModel: "모델 즐겨찾기", - unfavoriteModel: "모델 즐겨찾기 해제", + profiles: "프로필", + providers: "제공자", + editProfiles: "편집", + editProfilesLabel: "에이전트 프로필 편집", + createProfile: "프로필 만들기", modelCount: "모델 {{count}}개", modelCountPlural: "모델 {{count}}개", retry: "다시 시도", retrying: "다시 시도 중...", noMatches: "검색과 일치하는 모델이 없습니다", + noMatchesForQuery: '"{{query}}"과(와) 일치하는 모델이 없습니다', + searchAllPlaceholder: "모든 모델 검색...", searchPlaceholder: "모델 검색...", openProviderSettings: "{{provider}} 설정 열기", }, @@ -2202,6 +2206,44 @@ export const ko: TranslationResources = { save: "저장", emptyState: "아직 프로필이 없습니다. 특정 명령으로 터미널을 실행하려면 하나 추가하세요.", }, + agentProfiles: { + sectionTitle: "에이전트 프로필", + unavailable: "에이전트 프로필을 관리하려면 이 호스트에 연결하세요", + unsupported: "이 호스트에서 실행 중인 데몬은 아직 에이전트 프로필을 지원하지 않습니다", + emptyState: + "아직 프로필이 없습니다. 저장된 프로바이더와 모델로 에이전트를 시작하려면 하나 추가하세요.", + addProfileTitle: "에이전트 프로필 추가", + newProfile: "새 프로필", + editProfile: "프로필 편집", + editProfileTitle: "에이전트 프로필 편집", + nameLabel: "이름", + namePlaceholder: "UI 작업", + iconLabel: "아이콘", + noIcon: "없음", + providerLabel: "프로바이더", + providerPlaceholder: "프로바이더 선택", + noProviders: "이 호스트에서 사용 가능한 프로바이더가 없습니다", + modelLabel: "모델", + noModels: "이 프로바이더에는 선택할 수 있는 모델이 없습니다", + modeLabel: "모드", + noModes: "이 프로바이더에는 선택할 수 있는 모드가 없습니다", + thinkingLabel: "사고", + noThinkingOptions: "이 모델에는 사고 수준이 없습니다", + featuresLabel: "기능", + featureCount: "기능 {{count}}개", + featureCountOne: "기능 {{count}}개", + notesLabel: "에이전트를 위한 메모", + notesPlaceholder: "UI 작업에 사용 — 컴포넌트, 레이아웃, 디자인 토큰.", + notesHint: + "list_profiles MCP 도구가 반환합니다. 다른 에이전트에게 전달할 지침으로 작성하세요.", + save: "저장", + saving: "저장하는 중...", + remove: "제거", + removeConfirmTitle: "프로필을 제거할까요?", + removeConfirmMessage: '"{{name}}"을(를) 제거할까요?', + moveUp: "위로 이동", + moveDown: "아래로 이동", + }, daemon: { rename: { editLabel: "레이블 편집", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 2349fea3ce..21b4091ea6 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1423,14 +1423,18 @@ export const ptBR: TranslationResources = { loadingSelector: "Carregando seletor de modelos...", error: "Erro", defaultModel: "Padrão", - favorites: "Favoritos", - favoriteModel: "Favoritar modelo", - unfavoriteModel: "Remover modelo dos favoritos", + profiles: "Perfis", + providers: "Provedores", + editProfiles: "Editar", + editProfilesLabel: "Editar perfis de agente", + createProfile: "Criar perfil", modelCount: "{{count}} modelo", modelCountPlural: "{{count}} modelos", retry: "Tentar novamente", retrying: "Tentando novamente...", noMatches: "Nenhum modelo corresponde à sua busca", + noMatchesForQuery: "Nenhum modelo corresponde a «{{query}}»", + searchAllPlaceholder: "Buscar em todos os modelos...", searchPlaceholder: "Buscar modelos...", openProviderSettings: "Abrir configurações de {{provider}}", }, @@ -2225,6 +2229,44 @@ export const ptBR: TranslationResources = { emptyState: "Nenhum perfil ainda. Adicione um para abrir terminais com um comando específico.", }, + agentProfiles: { + sectionTitle: "Perfis de agente", + unavailable: "Conecte-se a este host para gerenciar perfis de agente", + unsupported: "Este host executa um daemon que ainda não é compatível com perfis de agente", + emptyState: + "Nenhum perfil ainda. Adicione um para iniciar agentes a partir de um provedor e modelo salvos.", + addProfileTitle: "Adicionar perfil de agente", + newProfile: "Novo perfil", + editProfile: "Editar perfil", + editProfileTitle: "Editar perfil de agente", + nameLabel: "Nome", + namePlaceholder: "Trabalho de UI", + iconLabel: "Ícone", + noIcon: "Nenhum", + providerLabel: "Provedor", + providerPlaceholder: "Selecione um provedor", + noProviders: "Nenhum provedor está disponível neste host", + modelLabel: "Modelo", + noModels: "Este provedor não tem modelos para escolher", + modeLabel: "Modo", + noModes: "Este provedor não tem modos para escolher", + thinkingLabel: "Raciocínio", + noThinkingOptions: "Este modelo não tem níveis de raciocínio", + featuresLabel: "Recursos", + featureCount: "{{count}} recursos", + featureCountOne: "{{count}} recurso", + notesLabel: "Notas para os agentes", + notesPlaceholder: "Use para trabalho de UI — componentes, layout e tokens de design.", + notesHint: + 'Retornado pela ferramenta MCP "list_profiles". Escreva como uma instrução para outro agente.', + save: "Salvar", + saving: "Salvando...", + remove: "Remover", + removeConfirmTitle: "Remover perfil?", + removeConfirmMessage: 'Remover "{{name}}"?', + moveUp: "Mover para cima", + moveDown: "Mover para baixo", + }, daemon: { rename: { editLabel: "Editar rótulo", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9528a85032..7ac5033346 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1428,14 +1428,18 @@ export const ru: TranslationResources = { loadingSelector: "Загрузка выбора модели...", error: "Ошибка", defaultModel: "По умолчанию", - favorites: "Избранное", - favoriteModel: "Любимая модель", - unfavoriteModel: "Нелюбимая модель", + profiles: "Профили", + providers: "Провайдеры", + editProfiles: "Изменить", + editProfilesLabel: "Изменить профили агентов", + createProfile: "Создать профиль", modelCount: "{{count}} модель", modelCountPlural: "{{count}} моделей", retry: "Повторить попытку", retrying: "Повторная попытка...", noMatches: "Ни одна модель не соответствует вашему запросу", + noMatchesForQuery: "Нет моделей, соответствующих «{{query}}»", + searchAllPlaceholder: "Поиск по всем моделям...", searchPlaceholder: "Поиск моделей...", openProviderSettings: "Открыть настройки{{provider}}", }, @@ -2229,6 +2233,44 @@ export const ru: TranslationResources = { save: "Save", emptyState: "No profiles yet. Add one to launch terminals with a specific command.", }, + agentProfiles: { + sectionTitle: "Профили агента", + unavailable: "Подключитесь к этому хосту, чтобы управлять профилями агента", + unsupported: "На этом хосте работает daemon, который пока не поддерживает профили агента", + emptyState: + "Пока нет профилей. Добавьте один, чтобы запускать агентов с сохранённым провайдером и моделью.", + addProfileTitle: "Добавить профиль агента", + newProfile: "Новый профиль", + editProfile: "Изменить профиль", + editProfileTitle: "Изменить профиль агента", + nameLabel: "Имя", + namePlaceholder: "Работа над UI", + iconLabel: "Значок", + noIcon: "Нет", + providerLabel: "Провайдер", + providerPlaceholder: "Выберите провайдера", + noProviders: "На этом хосте нет доступных провайдеров", + modelLabel: "Модель", + noModels: "У этого провайдера нет моделей для выбора", + modeLabel: "Режим", + noModes: "У этого провайдера нет режимов для выбора", + thinkingLabel: "Мышление", + noThinkingOptions: "У этой модели нет уровней мышления", + featuresLabel: "Функции", + featureCount: "{{count}} функций", + featureCountOne: "{{count}} функция", + notesLabel: "Заметки для агентов", + notesPlaceholder: "Используйте для работы над UI — компоненты, макет и токены дизайна.", + notesHint: + "Возвращается инструментом MCP «list_profiles». Напишите это как инструкцию для другого агента.", + save: "Сохранить", + saving: "Сохранение...", + remove: "Удалить", + removeConfirmTitle: "Удалить профиль?", + removeConfirmMessage: "Удалить «{{name}}»?", + moveUp: "Переместить вверх", + moveDown: "Переместить вниз", + }, daemon: { rename: { editLabel: "Изменить ярлык", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 08ada0995a..e44f6c1039 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1379,14 +1379,18 @@ export const zhCN: TranslationResources = { loadingSelector: "正在加载模型选择器...", error: "错误", defaultModel: "默认", - favorites: "收藏", - favoriteModel: "收藏模型", - unfavoriteModel: "取消收藏模型", + profiles: "配置文件", + providers: "提供方", + editProfiles: "编辑", + editProfilesLabel: "编辑智能体配置文件", + createProfile: "创建配置文件", modelCount: "{{count}} 个模型", modelCountPlural: "{{count}} 个模型", retry: "重试", retrying: "正在重试...", noMatches: "没有匹配的模型", + noMatchesForQuery: "没有与“{{query}}”匹配的模型", + searchAllPlaceholder: "搜索所有模型...", searchPlaceholder: "搜索模型...", openProviderSettings: "打开 {{provider}} 设置", }, @@ -2167,6 +2171,42 @@ export const zhCN: TranslationResources = { save: "Save", emptyState: "No profiles yet. Add one to launch terminals with a specific command.", }, + agentProfiles: { + sectionTitle: "Agent 配置", + unavailable: "连接到这个 Host 以管理 Agent 配置", + unsupported: "此 Host 运行的 Daemon 尚不支持 Agent 配置", + emptyState: "还没有配置。添加一个以便使用保存的 Provider 和 Model 启动 Agent。", + addProfileTitle: "添加 Agent 配置", + newProfile: "新建配置", + editProfile: "编辑配置", + editProfileTitle: "编辑 Agent 配置", + nameLabel: "名称", + namePlaceholder: "UI 工作", + iconLabel: "图标", + noIcon: "无", + providerLabel: "Provider", + providerPlaceholder: "选择一个 Provider", + noProviders: "此 Host 上没有可用的 Provider", + modelLabel: "Model", + noModels: "此 Provider 没有可选择的 Model", + modeLabel: "Mode", + noModes: "此 Provider 没有可选择的 Mode", + thinkingLabel: "Thinking", + noThinkingOptions: "此 Model 没有 Thinking 级别", + featuresLabel: "Features", + featureCount: "{{count}} 个 Feature", + featureCountOne: "{{count}} 个 Feature", + notesLabel: "给 Agent 的说明", + notesPlaceholder: "用于 UI 工作 — 组件、布局和设计 token。", + notesHint: "由 list_profiles MCP 工具返回。请将其写成给另一个 Agent 的指令。", + save: "保存", + saving: "保存中...", + remove: "移除", + removeConfirmTitle: "移除配置?", + removeConfirmMessage: "移除「{{name}}」?", + moveUp: "上移", + moveDown: "下移", + }, daemon: { rename: { editLabel: "编辑标签", diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts index 4c40fe61b2..690bd439af 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -3,6 +3,7 @@ import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/prot import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { i18n } from "@/i18n/i18next"; import { + buildProviderQualifiedDescription, buildProviderSelectorProviders, buildSelectableProviderSelectorProviders, buildSelectedTriggerLabel, @@ -246,6 +247,20 @@ describe("combined model selector data", () => { expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4"); }); + it("names the provider first when a model row is shown outside its provider", () => { + const row = { + favoriteKey: "copilot:claude-opus-5", + provider: "copilot", + providerLabel: "Copilot", + modelId: "claude-opus-5", + modelLabel: "Opus 5", + description: "claude-opus-5", + }; + + expect(buildProviderQualifiedDescription(row)).toBe("Copilot · claude-opus-5"); + expect(buildProviderQualifiedDescription({ ...row, description: undefined })).toBe("Copilot"); + }); + it("resolves selected labels from explicit provider model-selection state", () => { const providers = buildSelectableProviderSelectorProviders([ snapshotEntry({ diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index 6e3a48de34..ca8be17b85 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -6,12 +6,28 @@ import type { } from "@getpaseo/protocol/agent-types"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; -import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences"; import { i18n } from "@/i18n/i18next"; import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match"; import { filterSelectableModels } from "./model-catalog"; -export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean }; +export interface ProviderSelectionModelRow { + /** + * Row identity — `provider:modelId`. Used as the React key and as the row's + * stable handle across list rebuilds. The name is historical; it has nothing + * to do with the removed favourites feature. + */ + favoriteKey: string; + provider: string; + providerLabel: string; + modelId: string; + modelLabel: string; + description?: string; + isDefault?: boolean; +} + +function buildModelRowKey(provider: string, modelId: string): string { + return `${provider}:${modelId}`; +} export type ProviderModelSelection = | { kind: "models"; rows: ProviderSelectionModelRow[] } @@ -44,7 +60,7 @@ function buildModelRows( models: AgentModelDefinition[], ): ProviderSelectionModelRow[] { return models.map((model) => ({ - favoriteKey: buildFavoriteModelKey({ provider, modelId: model.id }), + favoriteKey: buildModelRowKey(provider, model.id), provider, providerLabel, modelId: model.id, @@ -59,7 +75,7 @@ function buildSyntheticDefaultRow( providerLabel: string, ): ProviderSelectionModelRow { return { - favoriteKey: buildFavoriteModelKey({ provider, modelId: "" }), + favoriteKey: buildModelRowKey(provider, ""), provider, providerLabel, modelId: "", @@ -198,6 +214,14 @@ export function buildSelectedTriggerLabel(modelLabel: string): string { return modelLabel; } +/** + * Cross-provider result rows need the provider named up front: the same model + * label ships on several providers at once. + */ +export function buildProviderQualifiedDescription(row: ProviderSelectionModelRow): string { + return row.description ? `${row.providerLabel} · ${row.description}` : row.providerLabel; +} + export function matchesModelSearch( row: ProviderSelectionModelRow, normalizedQuery: string, diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index 82d0290464..f6170394c1 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -310,7 +310,6 @@ describe("mergeSelectedComposerPreferences", () => { }, claude: { model: "claude-sonnet-4-6" }, }, - favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }], }, provider: "codex", updates: { model: "gpt-5.4" }, @@ -326,7 +325,6 @@ describe("mergeSelectedComposerPreferences", () => { }, claude: { model: "claude-sonnet-4-6" }, }, - favoriteModels: [{ provider: "codex", modelId: "gpt-5.4-mini" }], }); }); diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 9a2a413345..85c0ac25df 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -21,6 +21,7 @@ import { getTerminalProfileIcon, DEFAULT_TERMINAL_PROFILES, } from "@getpaseo/protocol/terminal-profiles"; +import { AgentProfilesSection } from "@/agent-profiles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { Alert as InlineAlert } from "@/components/ui/alert"; @@ -288,6 +289,7 @@ export function HostAgentsPage({ serverId }: { serverId: string }) { {t("settings.host.agents.unavailable")} )} + ); } diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index d078d84739..6df09fb275 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -110,7 +110,11 @@ import type { AgentProvider, AgentSessionConfig, } from "@getpaseo/protocol/agent-types"; -import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages"; +import type { + AgentConfigApply, + MutableDaemonConfig, + MutableDaemonConfigPatch, +} from "@getpaseo/protocol/messages"; import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; import { @@ -3131,6 +3135,44 @@ export class DaemonClient { return payload.notice ?? null; } + /** + * Applies a whole agent-config bundle in one request. Use this instead of + * chaining the single-field setters when the values belong together so client + * interruption and other mutations cannot interleave between steps. A + * provider rejection can still leave earlier steps applied. + * Gated on `server_info.features.agentConfigApply`. + */ + async applyAgentConfig( + agentId: string, + config: AgentConfigApply, + ): Promise { + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "agent.config.apply.request", + agentId, + config, + requestId, + }); + const payload = await this.sendRequest({ + requestId, + message, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "agent.config.apply.response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + }); + if (!payload.accepted) { + throw new Error(payload.error ?? "applyAgentConfig rejected"); + } + return payload.notice ?? null; + } + async restartServer(reason?: string, requestId?: string): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 5cf5f50990..bb95c66a30 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -134,6 +134,35 @@ export const TerminalProfileSchema = z export type TerminalProfile = z.infer; +/** + * A named launch bundle: a provider plus the agent-config values a client would + * otherwise set one control at a time. Field names mirror `AgentSessionConfig` + * so applying a profile is a copy rather than a translation table. + * + * There is deliberately no system prompt here. `AgentSessionConfig.systemPrompt` + * is creation-only, so a profile carrying one would apply when starting a new + * agent and silently do nothing when applied to a running one. + */ +export const AgentProfileSchema = z + .object({ + id: z.string(), + name: z.string(), + /** A key into the client's icon registry, not a glyph. Unknown keys draw the default. */ + icon: z.string().optional(), + /** An identity colour name shared with host badges. Unknown values draw unthemed. */ + color: z.string().optional(), + provider: z.string(), + model: z.string().optional(), + modeId: z.string().optional(), + thinkingOptionId: z.string().optional(), + featureValues: z.record(z.string(), z.unknown()).optional(), + /** Free text, surfaced to orchestrating agents by the `list_profiles` MCP tool. */ + notes: z.string().optional(), + }) + .passthrough(); + +export type AgentProfile = z.infer; + const MutableBrowserToolsConfigSchema = z .object({ enabled: z.boolean().default(false), @@ -160,6 +189,7 @@ export const MutableDaemonConfigSchema = z enableTerminalAgentHooks: z.boolean().default(false), appendSystemPrompt: z.string().default(""), terminalProfiles: z.array(TerminalProfileSchema).optional(), + agentProfiles: z.array(AgentProfileSchema).optional(), }) .passthrough(); @@ -177,6 +207,7 @@ export const MutableDaemonConfigPatchSchema = z enableTerminalAgentHooks: z.boolean().optional(), appendSystemPrompt: z.string().optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(), + agentProfiles: z.array(AgentProfileSchema).optional(), }) .partial() .passthrough(); @@ -1544,6 +1575,38 @@ export const SetAgentFeatureResponseMessageSchema = z.object({ payload: AgentActionResponsePayloadSchema, }); +/** + * Every agent-config value a client can change in one shot. Each field is + * optional and an omitted field is left alone; `null` on model and thinking + * clears them, matching the single-field RPCs above. + */ +export const AgentConfigApplySchema = z.object({ + modelId: z.string().nullable().optional(), + modeId: z.string().optional(), + thinkingOptionId: z.string().nullable().optional(), + featureValues: z.record(z.string(), z.unknown()).optional(), +}); + +export type AgentConfigApply = z.infer; + +/** + * Applies a whole config bundle to one agent. The four single-field RPCs above + * stay for individual control edits. One request prevents client interruption + * and other mutations from interleaving between bundle steps; provider-level + * rejection can still leave earlier steps applied. + */ +export const AgentConfigApplyRequestMessageSchema = z.object({ + type: z.literal("agent.config.apply.request"), + agentId: z.string(), + config: AgentConfigApplySchema, + requestId: z.string(), +}); + +export const AgentConfigApplyResponseMessageSchema = z.object({ + type: z.literal("agent.config.apply.response"), + payload: AgentActionResponsePayloadSchema, +}); + export const AgentDetachRequestMessageSchema = z.object({ type: z.literal("agent.detach.request"), agentId: z.string(), @@ -2702,6 +2765,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetAgentModelRequestMessageSchema, SetAgentThinkingRequestMessageSchema, SetAgentFeatureRequestMessageSchema, + AgentConfigApplyRequestMessageSchema, AgentDetachRequestMessageSchema, AgentRewindRequestMessageSchema, AgentPermissionResponseMessageSchema, @@ -3071,6 +3135,13 @@ export const ServerInfoStatusPayloadSchema = z fsEntryDuplicate: z.boolean().optional(), // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. checkoutDiscardChanges: z.boolean().optional(), + // COMPAT(agentProfiles): added in v0.3.2, remove gate after 2027-02-11. + // An older daemon parses its persisted config strictly, so writing + // agentProfiles to one is silently dropped. The client hides the feature + // rather than letting a save appear to succeed. + agentProfiles: z.boolean().optional(), + // COMPAT(agentConfigApply): added in v0.3.2, remove gate after 2027-02-11. + agentConfigApply: z.boolean().optional(), }) .optional(), }) @@ -5648,6 +5719,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ SetAgentModelResponseMessageSchema, SetAgentThinkingResponseMessageSchema, SetAgentFeatureResponseMessageSchema, + AgentConfigApplyResponseMessageSchema, AgentDetachResponseMessageSchema, AgentRewindResponseMessageSchema, UpdateAgentResponseMessageSchema, @@ -5842,6 +5914,7 @@ export type SetAgentModeResponseMessage = z.infer; export type SetAgentThinkingResponseMessage = z.infer; export type SetAgentFeatureResponseMessage = z.infer; +export type AgentConfigApplyResponseMessage = z.infer; export type AgentDetachResponseMessage = z.infer; export type AgentRewindResponseMessage = z.infer; export type UpdateAgentResponseMessage = z.infer; @@ -6005,6 +6078,7 @@ export type SetAgentModeRequestMessage = z.infer; export type SetAgentThinkingRequestMessage = z.infer; export type SetAgentFeatureRequestMessage = z.infer; +export type AgentConfigApplyRequestMessage = z.infer; export type AgentDetachRequestMessage = z.infer; export type AgentPermissionResponseMessage = z.infer; export type CheckoutStatusRequest = z.infer; diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 209befc21f..50867f10d9 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -146,6 +146,15 @@ let parentAgentCwd: string; let worktreeRepoCwd: string; let launchConfigsByProvider: Record; +const seededAgentProfile = { + id: "ui-profile", + name: "UI work", + provider: "claude", + model: "claude-test-model", + modeId: "bypassPermissions", + notes: "Use for UI work: components, layout, design tokens. Not for backend.", +}; + function createRecordingAgentClients(): Record { const baseClients = createTestAgentClients(); launchConfigsByProvider = {}; @@ -285,7 +294,10 @@ beforeAll(async () => { parentAgentCwd = await makeCwd("parent-agent-cwd"); worktreeRepoCwd = await makeCwd("worktree-repo"); - daemonHandle = await createTestPaseoDaemon({ agentClients: createRecordingAgentClients() }); + daemonHandle = await createTestPaseoDaemon({ + agentClients: createRecordingAgentClients(), + agentProfiles: [seededAgentProfile], + }); topLevelClient = await createMcpClient(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`); const parentPayload = await callToolStructured(topLevelClient, "create_agent", { @@ -825,6 +837,12 @@ describe("Suite D: Provider Tools", () => { expect(payload.provider).toBe("claude"); expect(Array.isArray(payload.models)).toBe(true); }); + + test("list_profiles returns configured agent profiles, including notes", async () => { + const payload = await callToolStructured(topLevelClient, "list_profiles"); + const profiles = recordArr(payload.profiles); + expect(profiles).toEqual([seededAgentProfile]); + }); }); describe("Suite E: Worktree Tools", () => { diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ce5367663a..349eb15c53 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -52,6 +52,8 @@ import type { ForgeService } from "../../services/forge-service.js"; import { areEquivalentPaths } from "../../utils/path.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages"; +import type { DaemonConfigStore } from "../daemon-config-store.js"; import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js"; import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js"; import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js"; @@ -4901,6 +4903,78 @@ describe("provider listing MCP tool", () => { }); }); +function daemonConfigStoreStub(agentProfiles?: AgentProfile[]): Pick { + const config = MutableDaemonConfigSchema.parse({ + relay: { enabled: true }, + mcp: { injectIntoAgents: true }, + ...(agentProfiles !== undefined ? { agentProfiles } : {}), + }); + return { get: () => config }; +} + +describe("agent profile listing MCP tool", () => { + const logger = createTestLogger(); + + it("returns configured profiles, including notes", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const profiles: AgentProfile[] = [ + { + id: "ui-profile", + name: "UI work", + provider: "claude", + model: "claude-test-model", + modeId: "bypassPermissions", + thinkingOptionId: "high", + featureValues: { fast_mode: true }, + notes: "Use for UI work: components, layout, design tokens. Not for backend.", + }, + ]; + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + daemonConfigStore: daemonConfigStoreStub(profiles), + logger, + }); + const tool = registeredTool(server, "list_profiles"); + + const response = await tool.handler({}); + + expect(response.structuredContent).toEqual({ profiles }); + }); + + it("returns an empty array when no profiles are configured", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + daemonConfigStore: daemonConfigStoreStub(), + logger, + }); + const tool = registeredTool(server, "list_profiles"); + + const response = await tool.handler({}); + + expect(response.structuredContent).toEqual({ profiles: [] }); + }); + + it("returns an empty array when no daemon config store is provided", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "list_profiles"); + + const response = await tool.handler({}); + + expect(response.structuredContent).toEqual({ profiles: [] }); + }); +}); + describe("provider MCP tools", () => { const logger = createTestLogger(); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 2aa69906d0..fcc314ac11 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -4,6 +4,8 @@ import type { Logger } from "pino"; import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentManager } from "../agent-manager.js"; +import { AgentProfileSchema } from "@getpaseo/protocol/messages"; +import type { DaemonConfigStore } from "../../daemon-config-store.js"; import { AgentFeatureSchema, AgentPermissionRequestPayloadSchema, @@ -98,6 +100,7 @@ export interface PaseoToolHostDependencies { getDaemonTcpPort?: () => number | null; scheduleService?: ScheduleService | null; providerSnapshotManager: ProviderSnapshotManager; + daemonConfigStore?: Pick; github?: ForgeService; workspaceGitService?: Pick< WorkspaceGitService, @@ -543,6 +546,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase workspaceScripts, scheduleService, providerSnapshotManager, + daemonConfigStore, callerAgentId, resolveSpeakHandler, resolveCallerContext, @@ -2919,6 +2923,30 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); + registerTool( + "list_profiles", + { + title: "List agent profiles", + description: + "List agent profiles: named provider/model/mode bundles a human configured for specific " + + "kinds of work. Read each profile's `notes` to pick the one that fits the task you're " + + "delegating, then copy its `provider`, `model`, `modeId`, `thinkingOptionId`, and " + + "`featureValues` into create_agent (there is no `profile` parameter). Returns an empty " + + "list if none are configured.", + inputSchema: {}, + outputSchema: { + profiles: z.array(AgentProfileSchema), + }, + }, + async () => { + const profiles = daemonConfigStore?.get().agentProfiles ?? []; + return { + content: [], + structuredContent: ensureValidJson({ profiles }), + }; + }, + ); + registerTool( "inspect_provider", { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e5f12972c0..fad665b102 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -168,7 +168,7 @@ import type { PushNotificationSender } from "./push/index.js"; import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js"; -import type { FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages"; +import type { AgentProfile, FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages"; import type { AgentProviderRuntimeSettingsMap, ProviderOverride, @@ -397,6 +397,7 @@ export interface PaseoDaemonConfig { enableTerminalAgentHooks?: boolean; appendSystemPrompt?: string; terminalProfiles?: TerminalProfile[]; + agentProfiles?: AgentProfile[]; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -536,6 +537,10 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae initialConfig.terminalProfiles = config.terminalProfiles; } + if (config.agentProfiles !== undefined) { + initialConfig.agentProfiles = config.agentProfiles; + } + return initialConfig; } @@ -1235,6 +1240,7 @@ export async function createPaseoDaemon( getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), scheduleService, providerSnapshotManager, + daemonConfigStore, github, workspaceGitService, findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 93ab9409a1..2930d1f0b2 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -438,6 +438,18 @@ function resolveBrowserToolsEnabled(persisted: ReturnType) { + return { + terminalProfiles: persisted.daemon?.terminalProfiles, + agentProfiles: persisted.daemon?.agentProfiles, + }; +} + function resolveStaticLoadConfigSettings( env: NodeJS.ProcessEnv, cli: CliConfigOverrides | undefined, @@ -450,7 +462,7 @@ function resolveStaticLoadConfigSettings( browserToolsEnabled: resolveBrowserToolsEnabled(persisted), autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false, appendSystemPrompt: resolveAppendSystemPrompt(persisted), - terminalProfiles: persisted.daemon?.terminalProfiles, + ...resolveProfileLists(persisted), hostnames: mergeHostnames([ persisted.daemon?.hostnames, parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS), @@ -479,6 +491,7 @@ export function loadConfig( autoArchiveAfterMerge, appendSystemPrompt, terminalProfiles, + agentProfiles, hostnames, trustedProxies, appBaseUrl, @@ -520,6 +533,7 @@ export function loadConfig( enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false, appendSystemPrompt, terminalProfiles, + agentProfiles, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index e73150fec2..1d9bcb1e8f 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -85,6 +85,76 @@ describe("DaemonConfigStore", () => { expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true); }); + test("patch round-trips agent profiles through the strictly-parsed persisted config", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore(paseoHome, { + relay: { enabled: false }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }); + + store.patch({ + agentProfiles: [ + { + id: "profile_ui", + name: "UI work", + icon: "🎨", + provider: "claude", + model: "claude-opus-5", + modeId: "plan", + thinkingOptionId: "think-hard", + featureValues: { webSearch: true }, + notes: "Use for components, layout and design tokens.", + }, + ], + }); + + expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([ + { + id: "profile_ui", + name: "UI work", + icon: "🎨", + provider: "claude", + model: "claude-opus-5", + modeId: "plan", + thinkingOptionId: "think-hard", + featureValues: { webSearch: true }, + notes: "Use for components, layout and design tokens.", + }, + ]); + expect(store.get().agentProfiles).toHaveLength(1); + }); + + test("patch replaces the whole agent profile list rather than merging entries", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore(paseoHome, { + relay: { enabled: false }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + agentProfiles: [ + { id: "a", name: "Keep", provider: "claude" }, + { id: "b", name: "Drop", provider: "codex" }, + ], + }); + + store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] }); + + expect(store.get().agentProfiles).toEqual([{ id: "a", name: "Keep", provider: "claude" }]); + expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toHaveLength(1); + }); + test("rolls back config when a field transition fails", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index f77fccc06e..9ed029e088 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -359,6 +359,7 @@ function mergeMutableConfigIntoPersistedConfig(params: { ...(mutable.terminalProfiles !== undefined ? { terminalProfiles: mutable.terminalProfiles } : {}), + ...(mutable.agentProfiles !== undefined ? { agentProfiles: mutable.agentProfiles } : {}), }, agents: nextAgents, } as PersistedConfig; diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index e864450682..9a4dbd11b0 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -9,7 +9,7 @@ import { } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; -import { TerminalProfileSchema } from "@getpaseo/protocol/messages"; +import { AgentProfileSchema, TerminalProfileSchema } from "@getpaseo/protocol/messages"; import { PaseoServicePortAllocationSchema } from "@getpaseo/protocol/paseo-config-schema"; export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); @@ -259,6 +259,7 @@ export const PersistedConfigSchema = z enableTerminalAgentHooks: z.boolean().optional(), appendSystemPrompt: z.string().optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(), + agentProfiles: z.array(AgentProfileSchema).optional(), cors: z .object({ allowedOrigins: z.array(z.string()).optional(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3f47aeb3b6..1fb8228a8f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2010,6 +2010,8 @@ export class Session { return this.agentConfigSession.handleSetAgentFeatureRequest(msg); case "set_agent_thinking_request": return this.agentConfigSession.handleSetAgentThinkingRequest(msg); + case "agent.config.apply.request": + return this.agentConfigSession.handleAgentConfigApplyRequest(msg); case "get_daemon_config_request": this.emit({ type: "get_daemon_config_response", diff --git a/packages/server/src/server/session/agent-config/agent-config-session.test.ts b/packages/server/src/server/session/agent-config/agent-config-session.test.ts index f6d35a1f3d..1592c5bb3d 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.test.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.test.ts @@ -14,6 +14,8 @@ class FakeAgentConfigOperations implements AgentConfigOperations { readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = []; readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = []; readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = []; + /** Cross-operation ordering, which the per-operation arrays above cannot show. */ + readonly callLog: string[] = []; modeNotice: AgentProviderNotice | null = null; thinkingNotice: AgentProviderNotice | null = null; loadFailure: Error | null = null; @@ -26,17 +28,20 @@ class FakeAgentConfigOperations implements AgentConfigOperations { async setMode(agentId: string, modeId: string): Promise { this.modeCalls.push({ agentId, modeId }); + this.callLog.push("mode"); if (this.failWith) throw this.failWith; return this.modeNotice; } async setModel(agentId: string, modelId: string | null): Promise { this.modelCalls.push({ agentId, modelId }); + this.callLog.push("model"); if (this.failWith) throw this.failWith; } async setFeature(agentId: string, featureId: string, value: unknown): Promise { this.featureCalls.push({ agentId, featureId, value }); + this.callLog.push(`feature:${featureId}`); if (this.failWith) throw this.failWith; } @@ -45,6 +50,7 @@ class FakeAgentConfigOperations implements AgentConfigOperations { thinkingOptionId: string | null, ): Promise { this.thinkingCalls.push({ agentId, thinkingOptionId }); + this.callLog.push("thinking"); if (this.failWith) throw this.failWith; return this.thinkingNotice; } @@ -302,4 +308,123 @@ describe("AgentConfigSession", () => { payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "thinking boom" }, }); }); + + test("apply: sets model before thinking and features last, in one accepted response", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + + await subsystem.handleAgentConfigApplyRequest({ + type: "agent.config.apply.request", + agentId: "agent-1", + config: { + modelId: "opus-5", + modeId: "plan", + thinkingOptionId: "high", + featureValues: { webSearch: true }, + }, + requestId: "req-1", + }); + + expect(operations.callLog).toEqual(["model", "mode", "thinking", "feature:webSearch"]); + expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "opus-5" }]); + expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]); + expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]); + expect(operations.featureCalls).toEqual([ + { agentId: "agent-1", featureId: "webSearch", value: true }, + ]); + expect(operations.loadedAgentIds).toEqual(["agent-1"]); + expect(emitted).toEqual([ + { + type: "agent.config.apply.response", + payload: { + requestId: "req-1", + agentId: "agent-1", + accepted: true, + error: null, + notice: null, + }, + }, + ]); + }); + + test("apply: leaves omitted fields alone", async () => { + const { subsystem, operations } = makeSubsystem(); + + await subsystem.handleAgentConfigApplyRequest({ + type: "agent.config.apply.request", + agentId: "agent-1", + config: { modelId: "haiku-4.5" }, + requestId: "req-1", + }); + + expect(operations.callLog).toEqual(["model"]); + }); + + test("apply: clears model and thinking when they are explicitly null", async () => { + const { subsystem, operations } = makeSubsystem(); + + await subsystem.handleAgentConfigApplyRequest({ + type: "agent.config.apply.request", + agentId: "agent-1", + config: { modelId: null, thinkingOptionId: null }, + requestId: "req-1", + }); + + expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: null }]); + expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: null }]); + }); + + test("apply: reports the first notice and still runs the rest of the bundle", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.modeNotice = { type: "info", message: "Switched to plan mode" }; + operations.thinkingNotice = { type: "warning", message: "Thinking budget reduced" }; + + await subsystem.handleAgentConfigApplyRequest({ + type: "agent.config.apply.request", + agentId: "agent-1", + config: { modeId: "plan", thinkingOptionId: "high", featureValues: { webSearch: true } }, + requestId: "req-1", + }); + + expect(operations.callLog).toEqual(["mode", "thinking", "feature:webSearch"]); + expect(emitted).toEqual([ + { + type: "agent.config.apply.response", + payload: { + requestId: "req-1", + agentId: "agent-1", + accepted: true, + error: null, + notice: { type: "info", message: "Switched to plan mode" }, + }, + }, + ]); + }); + + test("apply: a mid-bundle failure rejects and leaves the earlier steps applied", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.failWith = new Error("apply boom"); + + await subsystem.handleAgentConfigApplyRequest({ + type: "agent.config.apply.request", + agentId: "agent-1", + config: { modelId: "opus-5", modeId: "plan" }, + requestId: "req-1", + }); + + expect(operations.callLog).toEqual(["model"]); + expect(emitted.map((m) => m.type)).toEqual(["activity_log", "agent.config.apply.response"]); + expect(emitted[0]).toEqual({ + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: "Failed to apply agent config: apply boom", + }, + }); + expect(emitted[1]).toEqual({ + type: "agent.config.apply.response", + payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "apply boom" }, + }); + }); }); diff --git a/packages/server/src/server/session/agent-config/agent-config-session.ts b/packages/server/src/server/session/agent-config/agent-config-session.ts index dab811b980..8cf2394192 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.ts @@ -1,12 +1,13 @@ import type pino from "pino"; import { v4 as uuidv4 } from "uuid"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; +import type { AgentConfigApply } from "@getpaseo/protocol/messages"; import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; /** - * The four agent-config response messages share one payload shape; deriving the - * type keeps it pinned to the protocol schema instead of restating it. + * The agent-config response messages share one payload shape; deriving the type + * keeps it pinned to the protocol schema instead of restating it. */ type AgentActionResponsePayload = Extract< SessionOutboundMessage, @@ -51,11 +52,11 @@ interface ConfigChange { } /** - * A client's per-agent config surface: set mode, model, feature, and thinking - * option. Each request shares one envelope — log, run the mutation, then emit the - * accepted response, or on failure emit an activity_log error frame followed by - * the rejected response. Reaches no state beyond the injected operations and the - * outbound channel. + * A client's per-agent config surface: set mode, model, feature, or thinking + * option one at a time, or apply all of them together. Each request shares one + * envelope — log, run the mutation, then emit the accepted response, or on + * failure emit an activity_log error frame followed by the rejected response. + * Reaches no state beyond the injected operations and the outbound channel. */ export class AgentConfigSession { private readonly host: AgentConfigSessionHost; @@ -134,6 +135,67 @@ export class AgentConfigSession { }); } + handleAgentConfigApplyRequest( + msg: Extract, + ): Promise { + const { agentId, config, requestId } = msg; + return this.applyConfigChange({ + agentId, + requestId, + logLabel: "agent.config.apply.request", + logFields: { agentId, requestId, config }, + failureText: "Failed to apply agent config", + run: () => this.applyBundle(agentId, config), + emitResponse: (payload) => this.host.emit({ type: "agent.config.apply.response", payload }), + }); + } + + /** + * Model first, because a thinking option only means anything against the model + * that offers it; features last so they settle on top of the rest. An omitted + * field is left alone. The first notice produced is the one the client sees — + * later steps still run, so a notice never truncates the bundle. + * + * This is not a transaction. A step that rejects aborts the rest, and steps + * that already applied stay applied — provider setters validate against the + * agent's live model, so a bundle that omits `modelId` can have its mode land + * and its feature rejected. Nothing is hidden by that: every AgentManager + * setter emits agent state before returning, so what applied is already + * streamed to the client alongside the rejected response and the error frame. + * + * Rolling back would re-invoke the setters that just failed, most often + * against a session that is already gone. Pre-flight validation would restate + * each provider's model-dependent checks somewhere they would drift from the + * setters that own them. What sending the bundle in one request does remove is + * the multi-round-trip failure class: interruption between hops, and + * interleaving with another client's mutation. + */ + private async applyBundle( + agentId: string, + config: AgentConfigApply, + ): Promise { + let notice: AgentProviderNotice | null = null; + + if (config.modelId !== undefined) { + await this.operations.setModel(agentId, config.modelId); + } + if (config.modeId !== undefined) { + // Await first, assign second: `notice ??= await ...` would skip the call + // entirely once an earlier step produced a notice. + const modeNotice = await this.operations.setMode(agentId, config.modeId); + notice ??= modeNotice; + } + if (config.thinkingOptionId !== undefined) { + const thinkingNotice = await this.operations.setThinking(agentId, config.thinkingOptionId); + notice ??= thinkingNotice; + } + for (const [featureId, value] of Object.entries(config.featureValues ?? {})) { + await this.operations.setFeature(agentId, featureId, value); + } + + return notice; + } + private async applyConfigChange(change: ConfigChange): Promise { const { agentId, requestId, logLabel, logFields, failureText, run, emitResponse } = change; this.logger.info(logFields, `session: ${logLabel}`); diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 8481c4876d..3891d90ea3 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -12,6 +12,7 @@ import { import type { AgentClient, AgentProvider } from "../agent/agent-sdk-types.js"; import { createTestAgentClients } from "./fake-agent-client.js"; import type { PushNotificationSender } from "../push/index.js"; +import type { AgentProfile } from "@getpaseo/protocol/messages"; interface TestPaseoDaemonOptions { daemonVersion?: string; @@ -45,6 +46,7 @@ interface TestPaseoDaemonOptions { serviceProxy?: PaseoDaemonConfig["serviceProxy"]; webUi?: PaseoDaemonConfig["webUi"]; trustedProxies?: PaseoDaemonConfig["trustedProxies"]; + agentProfiles?: AgentProfile[]; } export interface TestPaseoDaemon { @@ -194,6 +196,7 @@ async function prepareTestDaemonConfig( voiceLlmModel: options.voiceLlmModel ?? null, dictationFinalTimeoutMs: options.dictationFinalTimeoutMs, downloadTokenTtlMs: options.downloadTokenTtlMs, + agentProfiles: options.agentProfiles, }; return { config, paseoHomeRoot, paseoHome, staticDir }; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index e3d643d484..d14681b531 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1629,6 +1629,10 @@ export class VoiceAssistantWebSocketServer { fsEntryDuplicate: true, // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. checkoutDiscardChanges: true, + // COMPAT(agentProfiles): added in v0.3.2, remove gate after 2027-02-11. + agentProfiles: true, + // COMPAT(agentConfigApply): added in v0.3.2, remove gate after 2027-02-11. + agentConfigApply: true, }, }; } From b2ff359b60e6a75d73c08103c2fb49890d9ce5ce Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 17:54:59 +0200 Subject: [PATCH 18/73] Always create a fresh worktree (#3224) * fix(server): always create requested worktrees A slug is a preferred name, not workspace identity. Let the worktree creator suffix occupied paths and branches so duplicate and detached-slug requests still create fresh worktrees. * fix(server): suffix occupied checkout branches Fresh creation also applies when checkout targets are already in use. Create a suffixed branch from the requested branch instead of rejecting the request. * test(server): align occupied branch coverage * test(server): remove stale worktree reuse expectations --- .../hub/execution-session.websocket.test.ts | 7 +- .../src/server/paseo-worktree-service.test.ts | 35 +++-- ...rkspace-create-worktree-source.e2e.test.ts | 129 ++++++++++++++++++ .../src/server/worktree-core.posix.test.ts | 16 +-- packages/server/src/server/worktree-core.ts | 11 -- .../server/src/utils/worktree.posix.test.ts | 26 ++-- packages/server/src/utils/worktree.ts | 46 +------ 7 files changed, 175 insertions(+), 95 deletions(-) diff --git a/packages/server/src/server/hub/execution-session.websocket.test.ts b/packages/server/src/server/hub/execution-session.websocket.test.ts index 589b82794b..6a14cbae2b 100644 --- a/packages/server/src/server/hub/execution-session.websocket.test.ts +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -246,7 +246,7 @@ test("a sibling workspace keeps an archived execution's worktree directory alive expect(await hub.ownedAgentArchivedAt(created.payload.agentId!)).toEqual(expect.any(String)); }); -test("archiving an execution in a reused worktree leaves the existing workspace intact", async () => { +test("archiving a second same-slug execution leaves the first worktree intact", async () => { const hub = await launchRelationship(); const worktree = { mode: "branch-off" as const, @@ -267,6 +267,7 @@ test("archiving an execution in a reused worktree leaves the existing workspace prompt: "sleep 30", }); const reused = await hub.ownedCreateResult("reused-worktree-create"); + const secondWorktreeCwd = hub.latestCreatedCwd()!; const reusedWorkspaceId = reused.payload.agent?.workspaceId; await hub.ownedRunningUpdate(reused.payload.agentId!); @@ -278,8 +279,10 @@ test("archiving an execution in a reused worktree leaves the existing workspace expect(response).toMatchObject({ success: true, error: null }); expect(reusedWorkspaceId).toEqual(expect.any(String)); expect(reusedWorkspaceId).not.toBe(originalWorkspaceId); - expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, worktreeCwd)).toBe(true); + expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, worktreeCwd)).toBe(false); + expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, secondWorktreeCwd)).toBe(true); expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: true, listed: true }); + expect(await hub.worktreeState(secondWorktreeCwd)).toEqual({ exists: false, listed: false }); expect(await hub.agentRemainsAvailable(original.payload.agentId!)).toBe(true); expect(await hub.ownedAgentArchivedAt(reused.payload.agentId!)).toEqual(expect.any(String)); expect(await hub.ownedWorkspaceArchivedAt(reused.payload.agentId!)).toEqual(expect.any(String)); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 5fec6bad05..614820dd4f 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -458,9 +458,8 @@ test("an explicit project FK remains unchanged when its worktree comes from anot }); }); -// POSIX-only: Windows git worktree paths need separate canonicalization coverage. test.skipIf(isPlatform("win32"))( - "reuses an existing worktree and still upserts the workspace", + "creates a suffixed worktree when the preferred slug is occupied", async () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -492,11 +491,10 @@ test.skipIf(isPlatform("win32"))( deps, ); - expect(second.created).toBe(false); - expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath); + expect(second.created).toBe(true); + expect(second.worktree.worktreePath).not.toBe(first.worktree.worktreePath); + expect(path.basename(second.worktree.worktreePath)).toBe("reuse-me-1"); expect(events).toContain(`workspace:${second.workspace.workspaceId}`); - // Creation never dedupes by directory: the same worktree path yields a - // distinct workspace record on the second call. expect(second.workspace.workspaceId).not.toBe(first.workspace.workspaceId); }, ); @@ -932,7 +930,7 @@ test.skipIf(isPlatform("win32"))( expect(worktreeList).toContain(created.worktreePath); // Recreating without pruning fails with the stale registration pinning the - // branch — this is the case restore must heal. + // path — this is the case restore must heal. await expect( createWorktree({ cwd: repoDir, @@ -941,7 +939,7 @@ test.skipIf(isPlatform("win32"))( runSetup: false, paseoHome, }), - ).rejects.toMatchObject({ name: "BranchAlreadyCheckedOutError" }); + ).rejects.toThrow("missing but already registered worktree"); // The restore-side prune frees the stale registration; recreate then succeeds. execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" }); @@ -986,7 +984,7 @@ test.skipIf(isPlatform("win32"))( ); test.skipIf(isPlatform("win32"))( - "rejects with BranchAlreadyCheckedOutError when the kept branch is checked out elsewhere", + "creates a suffixed branch when the requested branch is checked out elsewhere", async () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -1002,15 +1000,16 @@ test.skipIf(isPlatform("win32"))( }); expect(existsSync(first.worktreePath)).toBe(true); - await expect( - createWorktree({ - cwd: repoDir, - worktreeSlug: "busy-branch-again", - source: { kind: "checkout-branch", branchName: "busy-branch" }, - runSetup: false, - paseoHome, - }), - ).rejects.toMatchObject({ name: "BranchAlreadyCheckedOutError" }); + const second = await createWorktree({ + cwd: repoDir, + worktreeSlug: "busy-branch-again", + source: { kind: "checkout-branch", branchName: "busy-branch" }, + runSetup: false, + paseoHome, + }); + + expect(second.branchName).toBe("busy-branch-1"); + expect(existsSync(second.worktreePath)).toBe(true); }, ); diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index 2fbdc930cc..0ae15e526c 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -95,3 +95,132 @@ test("workspace.create keeps a branch-off name separate from its worktree slug", rmSync(tempRoot, { recursive: true, force: true }); } }, 180000); + +test("workspace.create always creates a new worktree when the slug is already occupied", async () => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + + const first = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "branch-off", + worktreeSlug: "same-slug", + baseBranch: "main", + }, + }); + const second = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "branch-off", + worktreeSlug: "same-slug", + baseBranch: "main", + }, + }); + + expect(first.error).toBeNull(); + expect(second.error).toBeNull(); + expect(path.basename(first.workspace?.workspaceDirectory ?? "")).toBe("same-slug"); + expect(path.basename(second.workspace?.workspaceDirectory ?? "")).toBe("same-slug-1"); + expect(second.workspace?.id).not.toBe(first.workspace?.id); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(tempRoot, { recursive: true, force: true }); + } +}, 180000); + +test("workspace.create suffixes past an occupied detached worktree", async () => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + + const first = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "branch-off", + worktreeSlug: "detached-slug", + baseBranch: "main", + }, + }); + expect(first.error).toBeNull(); + expect(first.workspace?.workspaceDirectory).toBeTypeOf("string"); + const firstPath = first.workspace?.workspaceDirectory as string; + execFileSync("git", ["checkout", "--detach"], { cwd: firstPath, stdio: "pipe" }); + + const second = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "branch-off", + worktreeSlug: "detached-slug", + baseBranch: "main", + }, + }); + + expect(second.error).toBeNull(); + expect(path.basename(second.workspace?.workspaceDirectory ?? "")).toBe("detached-slug-1"); + expect(second.workspace?.gitRuntime?.currentBranch).toBe("detached-slug-1"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(tempRoot, { recursive: true, force: true }); + } +}, 180000); + +test("workspace.create suffixes an occupied checkout branch", async () => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + await client.connect(); + + const first = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "checkout", + refName: "feature/existing-branch", + worktreeSlug: "existing-branch", + }, + }); + const second = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "checkout", + refName: "feature/existing-branch", + worktreeSlug: "existing-branch", + }, + }); + + expect(first.error).toBeNull(); + expect(first.workspace?.gitRuntime?.currentBranch).toBe("feature/existing-branch"); + expect(second.error).toBeNull(); + expect(path.basename(second.workspace?.workspaceDirectory ?? "")).toBe("existing-branch-1"); + expect(second.workspace?.gitRuntime?.currentBranch).toBe("feature/existing-branch-1"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(tempRoot, { recursive: true, force: true }); + } +}, 180000); diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index 40bd983c7b..94f654de12 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -1438,8 +1438,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { expect(result.worktree.branchName).toBe("agent-worktree"); }); - // POSIX-only: Windows git worktree paths need separate canonicalization coverage. - test("reuses an existing branch-off worktree for the same slug", async () => { + test("creates a suffixed branch-off worktree for the same slug", async () => { const { tempDir, repoDir, paseoHome } = createGitRepo(); cleanupPaths.push(tempDir); const deps = createCoreDeps(); @@ -1454,12 +1453,12 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { ); expect(first.created).toBe(true); - expect(second.created).toBe(false); - expect(second.worktree).toEqual(first.worktree); + expect(second.created).toBe(true); + expect(path.basename(second.worktree.worktreePath)).toBe("reused-worktree-1"); + expect(second.worktree.branchName).toBe("reused-worktree-1"); }); - // POSIX-only: Windows git worktree paths need separate canonicalization coverage. - test("reuses an existing GitHub PR worktree for the resolved slug", async () => { + test("creates a suffixed GitHub PR worktree for the resolved slug", async () => { const { tempDir, repoDir, paseoHome } = createGitHubPrRemoteRepo(); cleanupPaths.push(tempDir); const deps = createCoreDeps(); @@ -1475,8 +1474,9 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { const second = await createCoreWorktree(input, deps); expect(first.created).toBe(true); - expect(second.created).toBe(false); - expect(second.worktree).toEqual(first.worktree); + expect(second.created).toBe(true); + expect(path.basename(second.worktree.worktreePath)).toBe("feature-review-pr-1"); + expect(second.worktree.branchName).toBe("feature/review-pr-1"); }); test("uses an injectable ForgeService dependency for missing PR head refs", async () => { diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index 98ffa777da..7189d75c28 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -3,7 +3,6 @@ import { createNameId } from "mnemonic-id"; import type { ForgeService } from "../services/forge-service.js"; import { createWorktree, - resolveExistingWorktreeForSlug, slugify, validateBranchSlug, type WorktreeConfig, @@ -118,16 +117,6 @@ async function createWorktreeCoreWithPriority( } } - const existingWorktree = await resolveExistingWorktreeForSlug({ - slug: normalizedSlug, - repoRoot, - paseoHome: input.paseoHome, - worktreesRoot: input.worktreesRoot, - }); - if (existingWorktree) { - return { worktree: existingWorktree, intent, repoRoot, created: false }; - } - return { worktree: await createWorktree({ cwd: repoRoot, diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 74e2fcf479..2d45b0fe5c 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -2,7 +2,6 @@ /* eslint-disable max-nested-callbacks */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { - BranchAlreadyCheckedOutError, createWorktree as createWorktreePrimitive, deriveWorktreeProjectHash, deletePaseoWorktree, @@ -299,22 +298,17 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(currentBranch).toBe("release/1.1.15"); }); - it("throws a typed error when checking out a branch already checked out in the main repo", async () => { - let caughtError: unknown; - try { - await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "dev-worktree", - source: { kind: "checkout-branch", branchName: "main" }, - runSetup: true, - paseoHome, - }); - } catch (error) { - caughtError = error; - } + it("creates a suffixed branch when checking out a branch already checked out in the main repo", async () => { + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "dev-worktree", + source: { kind: "checkout-branch", branchName: "main" }, + runSetup: true, + paseoHome, + }); - expect(caughtError).toBeInstanceOf(BranchAlreadyCheckedOutError); - expect((caughtError as BranchAlreadyCheckedOutError).branchName).toBe("main"); + expect(result.branchName).toBe("main-1"); + expect(existsSync(result.worktreePath)).toBe(true); }); it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 983823adc2..1403ef7ce8 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -207,13 +207,6 @@ export interface CreateWorktreeOptions { worktreesRoot?: string; } -interface ResolveExistingWorktreeForSlugOptions { - slug: string; - repoRoot: string; - paseoHome?: string; - worktreesRoot?: string; -} - export class BranchAlreadyCheckedOutError extends Error { readonly branchName: string; @@ -1063,38 +1056,6 @@ export async function listPaseoWorktrees({ ); } -export async function resolveExistingWorktreeForSlug({ - slug, - repoRoot, - paseoHome, - worktreesRoot, -}: ResolveExistingWorktreeForSlugOptions): Promise { - const worktrees = await listPaseoWorktrees({ - cwd: repoRoot, - paseoHome, - worktreesRoot, - }); - const slugSuffix = `${sep}${slug}`; - const existingWorktree = worktrees.find((worktree) => worktree.path.endsWith(slugSuffix)); - if (!existingWorktree) { - return null; - } - - const { stdout } = await runGitCommand(["branch", "--show-current"], { - cwd: existingWorktree.path, - envOverlay: READ_ONLY_GIT_ENV, - }); - const branchName = stdout.trim(); - if (!branchName) { - throw new Error(`Unable to resolve branch for existing worktree: ${existingWorktree.path}`); - } - - return { - branchName, - worktreePath: existingWorktree.path, - }; -} - export interface DeletePaseoWorktreeOptions { cwd: string | null; worktreePath?: string; @@ -1375,7 +1336,12 @@ async function resolveWorktreeSourcePlan({ } } if (await isBranchCheckedOut(cwd, source.branchName)) { - throw new BranchAlreadyCheckedOutError(source.branchName); + const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName); + return { + branchName, + metadataBaseRefName: source.branchName, + addArguments: ["-b", branchName, "--no-track", source.branchName], + }; } return { From db6520bf368c2f0a0ec3163dc83a7968cbbe74fe Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 18:23:30 +0200 Subject: [PATCH 19/73] Show live task progress while agents work (#3227) * feat(agents): surface live task progress Keep current task state above the composer and turn task snapshots into semantic timeline activity. Normalize provider task protocols while preserving genuine plan flows. * fix(agents): admit tasks through stream sequencing * test(agents): expect normalized task status --- .../browser/provider-task-list.real.spec.ts | 116 ++++++++++ packages/app/e2e/support/helpers/task-list.ts | 24 +++ packages/app/src/agent-stream/view.tsx | 2 +- packages/app/src/components/message.tsx | 62 +++++- packages/app/src/composer/task-list/index.tsx | 163 ++++++++++++++ packages/app/src/i18n/resources.test.ts | 8 + packages/app/src/i18n/resources/ar.ts | 9 + packages/app/src/i18n/resources/en.ts | 9 + packages/app/src/i18n/resources/es.ts | 9 + packages/app/src/i18n/resources/fr.ts | 9 + packages/app/src/i18n/resources/ja.ts | 9 + packages/app/src/i18n/resources/ko.ts | 9 + packages/app/src/i18n/resources/pt-BR.ts | 9 + packages/app/src/i18n/resources/ru.ts | 9 + packages/app/src/i18n/resources/zh-CN.ts | 9 + packages/app/src/panels/agent-panel.tsx | 2 + packages/app/src/stores/session-store.test.ts | 56 +++++ packages/app/src/stores/session-store.ts | 38 +++- .../timeline/session-stream-reducers.test.ts | 53 +++++ .../src/timeline/session-stream-reducers.ts | 16 +- packages/app/src/types/stream.test.ts | 107 +++++++++- packages/app/src/types/stream.ts | 124 +++++++++-- packages/protocol/src/agent-types.ts | 10 +- packages/protocol/src/messages.ts | 3 + .../protocol/src/messages.wire-compat.test.ts | 34 +++ .../src/server/agent/agent-sdk-types.ts | 5 +- .../agent/providers/claude/agent.test.ts | 47 ++++ .../server/agent/providers/claude/agent.ts | 14 +- .../agent/providers/claude/task-state.test.ts | 127 +++++++++++ .../agent/providers/claude/task-state.ts | 202 ++++++++++++++++++ .../providers/codex-app-server-agent.test.ts | 20 ++ .../agent/providers/codex-app-server-agent.ts | 38 +++- .../agent/providers/omp/todo-mapper.test.ts | 24 +-- .../server/agent/providers/omp/todo-mapper.ts | 7 + .../agent/providers/opencode-agent.test.ts | 8 +- .../server/agent/providers/opencode-agent.ts | 7 + .../opencode/event-translator.test.ts | 4 +- 37 files changed, 1342 insertions(+), 60 deletions(-) create mode 100644 packages/app/e2e/browser/provider-task-list.real.spec.ts create mode 100644 packages/app/e2e/support/helpers/task-list.ts create mode 100644 packages/app/src/composer/task-list/index.tsx create mode 100644 packages/server/src/server/agent/providers/claude/task-state.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/task-state.ts diff --git a/packages/app/e2e/browser/provider-task-list.real.spec.ts b/packages/app/e2e/browser/provider-task-list.real.spec.ts new file mode 100644 index 0000000000..b388b7d2c7 --- /dev/null +++ b/packages/app/e2e/browser/provider-task-list.real.spec.ts @@ -0,0 +1,116 @@ +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "../support/fixtures"; +import { + cleanupRewindFlow, + launchAgent, + sendMessage, + type AgentHandle, + type RewindFlowProvider, +} from "../support/helpers/rewind-flow"; +import { + expectCompletedTaskActivity, + expectTaskListEntries, + openCompletedTaskList, +} from "../support/helpers/task-list"; +import { submitMessage } from "../support/helpers/composer"; + +interface TaskScenario { + provider: RewindFlowProvider; + entries: [string, string]; + prompt: string; + providerConfig?: Parameters[0]["providerConfig"]; +} + +const scenarios: TaskScenario[] = [ + { + provider: "claude", + entries: ["Claude inventory alpha", "Claude inventory beta"], + providerConfig: { model: "opus" }, + prompt: + "Use TaskCreate to create exactly two tasks named Claude inventory alpha and Claude inventory beta. Use TaskUpdate to mark each in_progress and then completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.", + }, + { + provider: "codex", + entries: ["Codex inventory alpha", "Codex inventory beta"], + prompt: + "Use update_plan to track exactly two steps named Codex inventory alpha and Codex inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.", + }, + { + provider: "opencode", + entries: ["OpenCode inventory alpha", "OpenCode inventory beta"], + prompt: + "Use TodoWrite to track exactly two tasks named OpenCode inventory alpha and OpenCode inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.", + }, +]; + +test.describe("real provider composer task lists", () => { + test.setTimeout(600_000); + + for (const scenario of scenarios) { + test(`${scenario.provider} shows native task progress above the composer`, async ({ + page, + }, testInfo) => { + const cwd = realpathSync( + mkdtempSync(path.join(tmpdir(), `paseo-task-list-${scenario.provider}-`)), + ); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ + page, + provider: scenario.provider, + cwd, + mode: "full-access", + providerConfig: scenario.providerConfig, + }); + await sendMessage(handle, scenario.prompt); + await openCompletedTaskList(page, scenario.entries.length); + await expectTaskListEntries(page, scenario.entries); + await expectCompletedTaskActivity(page, scenario.entries); + await expect(page.getByText("TaskUpdate", { exact: true })).toHaveCount(0); + await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0); + + const screenshot = testInfo.outputPath(`${scenario.provider}-composer-tasks.png`); + await page.screenshot({ path: screenshot }); + await testInfo.attach(`${scenario.provider} composer tasks`, { + path: screenshot, + contentType: "image/png", + }); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); + } + + test("Codex genuine Plan mode keeps the existing plan card", async ({ page }, testInfo) => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-genuine-codex-plan-"))); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ + page, + provider: "codex", + cwd, + mode: "full-access", + providerConfig: { featureValues: { plan_mode: true } }, + }); + await submitMessage( + page, + "Produce a concise implementation plan with exactly these steps: Inspect genuine plan boundary; Implement genuine plan boundary; Verify genuine plan boundary. Do not implement it.", + ); + + const planCard = page.getByTestId("permission-plan-card"); + await expect(planCard).toBeVisible({ timeout: 120_000 }); + await expect(planCard).toContainText("Inspect genuine plan boundary"); + await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0); + + const screenshot = testInfo.outputPath("codex-genuine-plan.png"); + await page.screenshot({ path: screenshot }); + await testInfo.attach("Codex genuine plan", { path: screenshot, contentType: "image/png" }); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/task-list.ts b/packages/app/e2e/support/helpers/task-list.ts new file mode 100644 index 0000000000..368e279fcd --- /dev/null +++ b/packages/app/e2e/support/helpers/task-list.ts @@ -0,0 +1,24 @@ +import { expect, type Page } from "@playwright/test"; + +export async function openCompletedTaskList(page: Page, count: number): Promise { + const taskButton = page.getByRole("button", { name: `${count}/${count} tasks` }); + await expect(taskButton).toBeVisible({ timeout: 60_000 }); + await taskButton.click(); +} + +export async function expectTaskListEntries(page: Page, entries: readonly string[]): Promise { + for (const entry of entries) { + await expect(page.getByLabel(entry, { exact: true })).toBeVisible(); + } +} + +export async function expectCompletedTaskActivity( + page: Page, + entries: readonly string[], +): Promise { + for (const entry of entries) { + await expect( + page.getByRole("button", { name: `Completed ${entry}`, exact: true }), + ).toBeVisible(); + } +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 7203ca380a..58174eae19 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -799,7 +799,7 @@ const AgentStreamViewComponent = forwardRef; + return ; case "compaction": return ( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 1b19994c7d..b6a0162fdf 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -41,7 +41,10 @@ import { ChevronDown, Check, CheckSquare, + CircleDot, Copy, + Plus, + RotateCcw, TriangleAlertIcon, Scissors, MicVocal, @@ -62,7 +65,7 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer"; -import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream"; +import type { TaskActivity, TodoEntry, UserMessageImageAttachment } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { buildToolCallPresentation } from "@/tool-calls/presentation"; @@ -2240,15 +2243,32 @@ export const CompactionMarker = memo(function CompactionMarker({ interface TodoListCardProps { items: TodoEntry[]; + activity: TaskActivity; disableOuterSpacing?: boolean; } interface TodoListItemRowProps { text: string; completed: boolean; + status?: TodoEntry["status"]; } -function TodoListItemRow({ text, completed }: TodoListItemRowProps) { +function taskActivityIcon(activity: TaskActivity) { + switch (activity.type) { + case "added": + return Plus; + case "started": + return CircleDot; + case "completed": + return Check; + case "reopened": + return RotateCcw; + default: + return CheckSquare; + } +} + +function TodoListItemRow({ text, completed, status }: TodoListItemRowProps) { const badgeStyle = useMemo( () => [ todoListCardStylesheet.radioBadge, @@ -2264,7 +2284,9 @@ function TodoListItemRow({ text, completed }: TodoListItemRowProps) { ); return ( - + {completed ? ( ) : null} @@ -2300,6 +2322,12 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({ radioBadgeComplete: { opacity: 0.95, }, + radioBadgeActive: { + borderWidth: 2, + borderColor: theme.colors.primary, + backgroundColor: "transparent", + opacity: 1, + }, itemText: { flex: 1, color: theme.colors.foreground, @@ -2317,12 +2345,23 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({ export const TodoListCard = memo(function TodoListCard({ items, + activity, disableOuterSpacing, }: TodoListCardProps) { const { t } = useTranslation(); const [isExpanded, setIsExpanded] = useState(false); - - const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]); + const activityDisplay = useMemo(() => { + if (activity.type === "created") { + return { + label: t("message.todo.activity.created", { count: activity.count }), + secondaryLabel: undefined, + }; + } + return { + label: t(`message.todo.activity.${activity.type}`), + secondaryLabel: activity.task, + }; + }, [activity, t]); const handleToggle = useCallback(() => { setIsExpanded((prev) => !prev); @@ -2336,7 +2375,12 @@ export const TodoListCard = memo(function TodoListCard({ {t("message.todo.empty")} ) : ( items.map((item) => ( - + )) )} @@ -2346,9 +2390,9 @@ export const TodoListCard = memo(function TodoListCard({ return ( ({ color: theme.colors.foregroundMuted }); +const activeIcon = (theme: Theme) => ({ color: theme.colors.statusWarning }); +const completedIcon = (theme: Theme) => ({ color: theme.colors.statusSuccess }); + +export const AgentTaskList = memo(function AgentTaskList({ + serverId, + agentId, +}: { + serverId: string; + agentId: string; +}) { + const tasks = useSessionStore((state) => state.sessions[serverId]?.agentTasks.get(agentId)); + if (!tasks?.length) return null; + return ; +}); + +function TaskStatusIcon({ task }: { task: TodoEntry }) { + if (task.completed || task.status === "completed") { + return ; + } + if (task.status === "in_progress") { + return ; + } + return ; +} + +const TaskListCard = memo(function TaskListCard({ tasks }: { tasks: TodoEntry[] }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const completed = useMemo( + () => tasks.filter((task) => task.completed || task.status === "completed").length, + [tasks], + ); + const currentTask = useMemo( + () => + tasks.find((task) => task.status === "in_progress") ?? + tasks.find((task) => !task.completed && task.status !== "completed"), + [tasks], + ); + const currentTaskText = + currentTask?.status === "in_progress" && currentTask.activeForm + ? currentTask.activeForm + : currentTask?.text; + const label = currentTaskText + ? t("message.todo.tasksProgressCurrent", { + completed, + total: tasks.length, + task: currentTaskText, + }) + : t("message.todo.tasksProgress", { completed, total: tasks.length }); + const toggle = useCallback(() => setExpanded((current) => !current), []); + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + + return ( + + + + + {expanded ? ( + + {tasks.map((task, index) => { + const isActive = task.status === "in_progress"; + const text = isActive && task.activeForm ? task.activeForm : task.text; + return ( + + + + {text} + + + ); + })} + + ) : null} + + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + alignItems: "center", + paddingHorizontal: theme.spacing[4], + }, + track: { + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + marginBottom: -theme.spacing[4], + }, + card: { + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderBottomWidth: 0, + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], + overflow: "hidden", + paddingBottom: theme.spacing[4], + }, + header: { + width: "100%", + justifyContent: "flex-start", + borderRadius: 0, + paddingHorizontal: theme.spacing[3], + }, + headerText: { + color: theme.colors.foregroundMuted, + }, + list: { + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + paddingTop: theme.spacing[1], + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + }, + taskText: { + flex: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + completedText: { + color: theme.colors.foregroundMuted, + textDecorationLine: "line-through", + }, +})); diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 55b5995ee9..20de9d0469 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -393,6 +393,14 @@ describe("translation resources", () => { expect(en.message.question.otherPlaceholder).toBe("Other..."); expect(en.message.todo.title).toBe("Tasks"); expect(en.message.todo.empty).toBe("No tasks yet."); + expect(en.message.todo.tasksProgressCurrent).toBe("{{completed}}/{{total}} tasks · {{task}}"); + expect(en.message.todo.activity).toEqual({ + created: "Created {{count}} tasks", + added: "Added", + started: "Started", + completed: "Completed", + reopened: "Reopened", + }); }); it("includes workspace tab toast keys for the Batch 4J migration", () => { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index a3baaf06c7..1f0d16ec0e 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -333,6 +333,15 @@ export const ar: TranslationResources = { todo: { title: "المهام", empty: "لا توجد مهام حتى الآن.", + tasksProgress: "{{completed}}/{{total}} مهام", + tasksProgressCurrent: "{{completed}}/{{total}} مهام · {{task}}", + activity: { + created: "تم إنشاء {{count}} مهام", + added: "أُضيفت", + started: "بدأت", + completed: "اكتملت", + reopened: "أُعيد فتحها", + }, }, compaction: { loading: "الضغط...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 520a431992..477051f2ea 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -332,6 +332,15 @@ export const en = { todo: { title: "Tasks", empty: "No tasks yet.", + tasksProgress: "{{completed}}/{{total}} tasks", + tasksProgressCurrent: "{{completed}}/{{total}} tasks · {{task}}", + activity: { + created: "Created {{count}} tasks", + added: "Added", + started: "Started", + completed: "Completed", + reopened: "Reopened", + }, }, compaction: { loading: "Compacting...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index c811ca446e..1b32460d33 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -336,6 +336,15 @@ export const es: TranslationResources = { todo: { title: "Tareas", empty: "Aún no hay tareas.", + tasksProgress: "{{completed}}/{{total}} tareas", + tasksProgressCurrent: "{{completed}}/{{total}} tareas · {{task}}", + activity: { + created: "Se crearon {{count}} tareas", + added: "Añadida", + started: "Iniciada", + completed: "Completada", + reopened: "Reabierta", + }, }, compaction: { loading: "Compactando...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 22d4bec50a..b6da767993 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -337,6 +337,15 @@ export const fr: TranslationResources = { todo: { title: "Tâches", empty: "Aucune tâche pour l'instant.", + tasksProgress: "{{completed}}/{{total}} tâches", + tasksProgressCurrent: "{{completed}}/{{total}} tâches · {{task}}", + activity: { + created: "{{count}} tâches créées", + added: "Ajoutée", + started: "Commencée", + completed: "Terminée", + reopened: "Rouverte", + }, }, compaction: { loading: "Compactage...", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index e6dd25e266..f10037ef72 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -336,6 +336,15 @@ export const ja: TranslationResources = { todo: { title: "タスク", empty: "タスクがまだありません。", + tasksProgress: "{{completed}}/{{total}}タスク", + tasksProgressCurrent: "{{completed}}/{{total}}タスク · {{task}}", + activity: { + created: "{{count}}件のタスクを作成", + added: "追加", + started: "開始", + completed: "完了", + reopened: "再開", + }, }, compaction: { loading: "コンテキストを圧縮中...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index e6491869b1..66dc58c16d 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -334,6 +334,15 @@ export const ko: TranslationResources = { todo: { title: "작업", empty: "아직 작업이 없습니다.", + tasksProgress: "작업 {{completed}}/{{total}}개", + tasksProgressCurrent: "작업 {{completed}}/{{total}}개 · {{task}}", + activity: { + created: "작업 {{count}}개 생성", + added: "추가됨", + started: "시작됨", + completed: "완료됨", + reopened: "다시 열림", + }, }, compaction: { loading: "압축하는 중...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 21b4091ea6..c87eed9a10 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -336,6 +336,15 @@ export const ptBR: TranslationResources = { todo: { title: "Tarefas", empty: "Nenhuma tarefa ainda.", + tasksProgress: "{{completed}}/{{total}} tarefas", + tasksProgressCurrent: "{{completed}}/{{total}} tarefas · {{task}}", + activity: { + created: "{{count}} tarefas criadas", + added: "Adicionada", + started: "Iniciada", + completed: "Concluída", + reopened: "Reaberta", + }, }, compaction: { loading: "Compactando...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 7ac5033346..33d54be3da 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -335,6 +335,15 @@ export const ru: TranslationResources = { todo: { title: "Задачи", empty: "Заданий пока нет.", + tasksProgress: "{{completed}}/{{total}} задач", + tasksProgressCurrent: "{{completed}}/{{total}} задач · {{task}}", + activity: { + created: "Создано задач: {{count}}", + added: "Добавлена", + started: "Начата", + completed: "Завершена", + reopened: "Возобновлена", + }, }, compaction: { loading: "Уплотнение...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e44f6c1039..2283dad923 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -333,6 +333,15 @@ export const zhCN: TranslationResources = { todo: { title: "任务", empty: "还没有任务。", + tasksProgress: "{{completed}}/{{total}} 项任务", + tasksProgressCurrent: "{{completed}}/{{total}} 项任务 · {{task}}", + activity: { + created: "已创建 {{count}} 项任务", + added: "已添加", + started: "已开始", + completed: "已完成", + reopened: "已重新打开", + }, }, compaction: { loading: "正在压缩...", diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index ae58e89237..24a85b3070 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -74,6 +74,7 @@ import { deriveRouteBottomAnchorRequest, } from "@/screens/agent/agent-ready-screen-bottom-anchor"; import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab"; +import { AgentTaskList } from "@/composer/task-list"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; @@ -1612,6 +1613,7 @@ function ActiveAgentComposer({ return ( + (values: readonly T[]): T[][] { ); } +function taskTexts(tasks: ReadonlyArray<{ text: string }>): string[] { + return tasks.map((task) => task.text); +} + +describe("agent task state", () => { + it("notifies the task subscriber only when its task snapshot changes", () => { + initializeTestSession(); + const snapshots: string[][] = []; + const unsubscribe = useSessionStore.subscribe( + (state) => state.sessions["test-server"]?.agentTasks.get("agent-1") ?? [], + (tasks) => snapshots.push(taskTexts(tasks)), + ); + + const tasks = [{ id: "1", text: "Inspect", status: "pending" as const, completed: false }]; + useSessionStore + .getState() + .setAgentStreamState("test-server", "agent-1", { taskSnapshot: tasks }); + useSessionStore.getState().setMessages("test-server", []); + useSessionStore + .getState() + .setAgentStreamState("test-server", "agent-1", { taskSnapshot: [...tasks] }); + useSessionStore.getState().setAgentStreamState("test-server", "agent-1", { + taskSnapshot: [{ ...tasks[0], status: "completed", completed: true }], + }); + unsubscribe(); + + expect(snapshots).toEqual([["Inspect"], ["Inspect"]]); + }); + + it("restores the latest task snapshot from an authoritative timeline", () => { + initializeTestSession(); + const todo: StreamItem = { + kind: "todo_list", + id: "todo-1", + provider: "codex", + timestamp: new Date("2026-08-11T10:00:00.000Z"), + activity: { type: "started", task: "Verify" }, + items: [{ text: "Verify", status: "in_progress", completed: false }], + }; + + useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", { + items: [todo], + head: [], + range: null, + older: "none", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); + + expect(useSessionStore.getState().sessions["test-server"]?.agentTasks.get("agent-1")).toEqual( + todo.items, + ); + }); +}); + describe("agent timeline state", () => { it("commits canonical items, range, and older availability as one synced state", () => { initializeTestSession(); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 544507dfd7..c8ca04cb20 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -9,6 +9,7 @@ import { handoffCreatedAgentUserMessageToStream, removeSubmittedUserMessage, type StreamItem, + type TodoEntry, type UserMessageItem, } from "@/types/stream"; import { @@ -394,6 +395,26 @@ export function selectAgentTurnPresentation( ); } +function latestTasksFromStream(items: readonly StreamItem[]): TodoEntry[] { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item?.kind === "todo_list") return item.items; + } + return []; +} + +function updateAgentTasks( + current: Map, + agentId: string, + taskSnapshot: TodoEntry[] | undefined, +): Map { + if (taskSnapshot === undefined || equal(current.get(agentId) ?? [], taskSnapshot)) return current; + const next = new Map(current); + if (taskSnapshot.length > 0) next.set(agentId, taskSnapshot); + else next.delete(agentId); + return next; +} + export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade"; // Per-session state @@ -426,6 +447,7 @@ export interface SessionState { // Stream state (head/tail model) agentStreamTail: Map; agentStreamHead: Map; + agentTasks: Map; agentTurnLiveness: Map; messageSubmissions: Map; agentTimelineCursor: Map; @@ -523,6 +545,7 @@ interface SessionStoreActions { tail?: StreamItem[]; head?: StreamItem[]; acknowledgedClientMessageIds?: readonly string[]; + taskSnapshot?: TodoEntry[]; }, ) => void; applyAgentTurnLiveness: ( @@ -692,6 +715,7 @@ function createInitialSessionState( currentAssistantMessage: "", agentStreamTail: new Map(), agentStreamHead: new Map(), + agentTasks: new Map(), agentTurnLiveness: new Map(), messageSubmissions: new Map(), agentTimelineCursor: new Map(), @@ -812,8 +836,11 @@ export const useSessionStore = create()( const session = createInitialSessionState(serverId, null); const timeline = replica.timeline; const agentStreamTail = new Map(); + const agentTasks = new Map(); if (timeline) { agentStreamTail.set(timeline.agentId, timeline.items); + const tasks = latestTasksFromStream(timeline.items); + if (tasks.length > 0) agentTasks.set(timeline.agentId, tasks); } const agentLastActivity = new Map(prev.agentLastActivity); for (const agent of replica.agents.values()) { @@ -830,6 +857,7 @@ export const useSessionStore = create()( workspaces: replica.workspaces, projects: replica.projects, agentStreamTail, + agentTasks, }, }, agentLastActivity, @@ -1149,8 +1177,10 @@ export const useSessionStore = create()( state.acknowledgedClientMessageIds ?? [], ); const changedSubmissions = observedSubmissions !== currentSubmissions; + const agentTasks = updateAgentTasks(session.agentTasks, agentId, state.taskSnapshot); + const changedTasks = agentTasks !== session.agentTasks; - if (!changedTail && !changedHead && !changedSubmissions) { + if (!changedTail && !changedHead && !changedSubmissions && !changedTasks) { return prev; } @@ -1172,6 +1202,7 @@ export const useSessionStore = create()( ...session, agentStreamTail: nextTail, agentStreamHead: nextHead, + agentTasks, messageSubmissions, }, }, @@ -1601,6 +1632,10 @@ export const useSessionStore = create()( nextAuthoritative.set(agentId, true); nextSyncGeneration.set(agentId, session.historySyncGeneration); } + const tasks = latestTasksFromStream([...state.items, ...state.head]); + const agentTasks = new Map(session.agentTasks); + if (tasks.length > 0) agentTasks.set(agentId, tasks); + else agentTasks.delete(agentId); return { ...prev, @@ -1610,6 +1645,7 @@ export const useSessionStore = create()( ...session, agentStreamTail: nextTail, agentStreamHead: nextHead, + agentTasks, agentTimelineCursor: nextCursor, agentTimelineHasOlder: nextHasOlder, agentTimelineHasNewer: nextHasNewer, diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index c89855105b..a0daa3db1d 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -3557,6 +3557,59 @@ describe("processAgentStreamEvent", () => { expect(result.sideEffects).toEqual([]); }); + it("publishes task snapshots only from accepted timeline events", () => { + const tasks = [ + { id: "inspect", text: "Inspect", status: "in_progress" as const, completed: false }, + ]; + const event: AgentStreamEventPayload = { + type: "timeline", + provider: "codex", + item: { + type: "todo", + items: tasks, + }, + }; + const currentCursor: TimelineCursor = { + epoch: "epoch-1", + startSeq: 1, + endSeq: 4, + }; + + const accepted = processAgentStreamEvent({ + ...baseStreamInput, + event, + seq: 5, + epoch: "epoch-1", + currentCursor, + }); + const stale = processAgentStreamEvent({ + ...baseStreamInput, + event, + seq: 4, + epoch: "epoch-1", + currentCursor, + }); + const wrongEpoch = processAgentStreamEvent({ + ...baseStreamInput, + event, + seq: 5, + epoch: "epoch-2", + currentCursor, + }); + const gapped = processAgentStreamEvent({ + ...baseStreamInput, + event, + seq: 8, + epoch: "epoch-1", + currentCursor, + }); + + expect(accepted.taskSnapshot).toEqual(tasks); + expect(stale.taskSnapshot).toBeUndefined(); + expect(wrongEpoch.taskSnapshot).toBeUndefined(); + expect(gapped.taskSnapshot).toBeUndefined(); + }); + it("detects gap and emits catch-up side effect", () => { const existingCursor: TimelineCursor = { epoch: "epoch-1", diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 158a8ead85..4d6515ada2 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1,6 +1,6 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; -import type { AssistantMessageItem, StreamItem } from "@/types/stream"; +import type { AssistantMessageItem, StreamItem, TodoEntry } from "@/types/stream"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { applyStreamEvent, @@ -1436,6 +1436,7 @@ export interface ProcessAgentStreamEventOutput { cursor: TimelineCursor | null; cursorChanged: boolean; acknowledgedClientMessageIds: string[]; + taskSnapshot?: TodoEntry[]; sideEffects: AgentStreamReducerSideEffect[]; } @@ -1642,6 +1643,9 @@ export function processAgentStreamEvent( cursor: sequencing.nextTimelineCursor, cursorChanged: sequencing.cursorChanged, acknowledgedClientMessageIds: streamResult.acknowledgedClientMessageIds ?? [], + ...(sequencing.shouldApplyStreamEvent && event.type === "timeline" && event.item.type === "todo" + ? { taskSnapshot: event.item.items } + : {}), sideEffects: sequencing.sideEffects, }; } @@ -1667,6 +1671,7 @@ export function processAgentStreamEvents( let changedTail = false; let changedHead = false; let cursorChanged = false; + let taskSnapshot: TodoEntry[] | undefined; const acknowledgedClientMessageIds = new Set(); const sideEffects: AgentStreamReducerSideEffect[] = []; @@ -1690,6 +1695,9 @@ export function processAgentStreamEvents( for (const clientMessageId of result.acknowledgedClientMessageIds) { acknowledgedClientMessageIds.add(clientMessageId); } + if (result.taskSnapshot !== undefined) { + taskSnapshot = result.taskSnapshot; + } if (result.cursorChanged) { cursor = result.cursor ?? undefined; @@ -1705,6 +1713,7 @@ export function processAgentStreamEvents( cursor: cursor ?? null, cursorChanged, acknowledgedClientMessageIds: [...acknowledgedClientMessageIds], + ...(taskSnapshot !== undefined ? { taskSnapshot } : {}), sideEffects, }; } @@ -1788,6 +1797,7 @@ interface StreamStatePatch { tail?: StreamItem[]; head?: StreamItem[]; acknowledgedClientMessageIds?: readonly string[]; + taskSnapshot?: TodoEntry[]; } export function deriveAgentStreamTurnLiveness( @@ -1850,7 +1860,8 @@ export function createSessionAgentStreamReducerQueue( if ( result.changedTail || result.changedHead || - result.acknowledgedClientMessageIds.length > 0 + result.acknowledgedClientMessageIds.length > 0 || + result.taskSnapshot !== undefined ) { setAgentStreamState(serverId, agentId, { ...(result.changedTail ? { tail: result.tail } : {}), @@ -1858,6 +1869,7 @@ export function createSessionAgentStreamReducerQueue( ...(result.acknowledgedClientMessageIds.length > 0 ? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds } : {}), + ...(result.taskSnapshot !== undefined ? { taskSnapshot: result.taskSnapshot } : {}), }); } if (result.cursorChanged && result.cursor) { diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index c12d452539..3b0229e2ab 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -192,10 +192,19 @@ function canonicalToolTimeline(params: { }; } -function todoTimeline(items: { text: string; completed: boolean }[]): AgentStreamEventPayload { +function todoTimeline( + items: Array<{ + id?: string; + text: string; + completed: boolean; + status?: "pending" | "in_progress" | "completed"; + activeForm?: string; + }>, + provider: AgentProvider = "codex", +): AgentStreamEventPayload { return { type: "timeline", - provider: "codex", + provider, item: { type: "todo", items, @@ -970,6 +979,80 @@ describe("stream reducer canonical tool calls", () => { assert.ok(todos); assert.strictEqual(todos.items.length, 2); assert.strictEqual(todos.items[1]?.completed, true); + assert.deepStrictEqual(todos.activity, { type: "created", count: 2 }); + }); + + it("turns task snapshots into semantic timeline activity", () => { + const state = hydrateStreamState([ + { + event: todoTimeline([ + { id: "a", text: "Inspect provider", completed: false, status: "pending" }, + { id: "b", text: "Ship fix", completed: false, status: "pending" }, + ]), + timestamp: new Date("2025-01-01T10:50:00Z"), + }, + { + event: todoTimeline([ + { id: "a", text: "Inspect provider", completed: false, status: "in_progress" }, + { id: "b", text: "Ship fix", completed: false, status: "pending" }, + ]), + timestamp: new Date("2025-01-01T10:50:01Z"), + }, + { + event: todoTimeline([ + { id: "a", text: "Inspect provider", completed: true, status: "completed" }, + { id: "b", text: "Ship fix", completed: false, status: "in_progress" }, + ]), + timestamp: new Date("2025-01-01T10:50:02Z"), + }, + { + event: todoTimeline([ + { id: "a", text: "Inspect provider", completed: true, status: "completed" }, + { id: "b", text: "Ship fix", completed: true, status: "completed" }, + ]), + timestamp: new Date("2025-01-01T10:50:03Z"), + }, + ]); + + expect(state.flatMap((item) => (item.kind === "todo_list" ? [item.activity] : []))).toEqual([ + { type: "created", count: 2 }, + { type: "started", task: "Inspect provider" }, + { type: "completed", task: "Inspect provider" }, + { type: "started", task: "Ship fix" }, + { type: "completed", task: "Ship fix" }, + ]); + }); + + it("groups consecutive initial Claude TaskCreate snapshots", () => { + const state = hydrateStreamState([ + { + event: todoTimeline( + [{ id: "a", text: "Inspect provider", completed: false, status: "pending" }], + "claude", + ), + timestamp: new Date("2025-01-01T10:50:00Z"), + }, + { + event: todoTimeline( + [ + { id: "a", text: "Inspect provider", completed: false, status: "pending" }, + { id: "b", text: "Ship fix", completed: false, status: "pending" }, + ], + "claude", + ), + timestamp: new Date("2025-01-01T10:50:01Z"), + }, + ]); + + expect(state.filter((item) => item.kind === "todo_list")).toEqual([ + expect.objectContaining({ + activity: { type: "created", count: 2 }, + items: expect.arrayContaining([ + expect.objectContaining({ text: "Inspect provider" }), + expect.objectContaining({ text: "Ship fix" }), + ]), + }), + ]); }); it("preserves compaction trigger when completed update replaces loading marker", () => { @@ -1022,6 +1105,26 @@ describe("stream reducer canonical tool calls", () => { assert.strictEqual(todos.items[0]?.text, "Task 1"); }); + it.each(["TaskCreate", "TaskUpdate", "TaskList"])( + "suppresses Claude %s bookkeeping tool calls", + (name) => { + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId: name, + name, + status: "completed", + input: { taskId: "1", status: "completed" }, + }), + timestamp: new Date("2025-01-01T11:00:00Z"), + }, + ]); + + expect(state.filter(isAgentToolCallItem)).toEqual([]); + }, + ); + it("preserves submitted user message images when authoritative user message arrives", () => { const messageId = "msg-user-images"; const submittedTimestamp = new Date("2025-01-01T11:10:00Z"); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 2a2b41f5a9..5572797677 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -766,8 +766,15 @@ export interface CompactionItem { export interface TodoEntry { text: string; completed: boolean; + id?: string; + status?: "pending" | "in_progress" | "completed"; + activeForm?: string; } +export type TaskActivity = + | { type: "created"; count: number } + | { type: "added" | "started" | "completed" | "reopened"; task: string }; + export interface TodoListItem { kind: "todo_list"; id: string; @@ -775,6 +782,7 @@ export interface TodoListItem { timestamp: Date; provider: AgentProvider; items: TodoEntry[]; + activity: TaskActivity; } export type StreamUpdateSource = "live" | "canonical"; @@ -1178,34 +1186,103 @@ function appendTodoList( const normalizedItems = items.map((item) => ({ text: item.text, completed: item.completed, + ...(item.id ? { id: item.id } : {}), + ...(item.status ? { status: item.status } : {}), + ...(item.activeForm ? { activeForm: item.activeForm } : {}), })); - const lastItem = state[state.length - 1]; - if (lastItem && lastItem.kind === "todo_list" && lastItem.provider === provider) { + const previousIndex = state.findLastIndex( + (item) => item.kind === "todo_list" && item.provider === provider, + ); + const previous = state[previousIndex]; + const previousItems = previous?.kind === "todo_list" ? previous.items : []; + const activities = deriveTaskActivities(previousItems, normalizedItems); + + if (activities.length === 0) { + if (!previous || previous.kind !== "todo_list") return state; const next = [...state]; - const updated: TodoListItem = { - ...lastItem, + next[previousIndex] = { + ...previous, ...(timelineCursor ? { timelineCursor } : {}), items: normalizedItems, timestamp, }; - next[next.length - 1] = updated; return next; } - const idSeed = `${provider}:${JSON.stringify(normalizedItems)}`; - const entryId = createUniqueTimelineId(state, "todo", idSeed, timestamp); + const lastItem = state[state.length - 1]; + if ( + activities.length === 1 && + activities[0]?.type === "added" && + lastItem?.kind === "todo_list" && + lastItem.provider === provider && + lastItem.activity.type === "created" && + normalizedItems.every((item) => taskStatus(item) === "pending") + ) { + const next = [...state]; + next[next.length - 1] = { + ...lastItem, + ...(timelineCursor ? { timelineCursor } : {}), + items: normalizedItems, + activity: { type: "created", count: normalizedItems.length }, + timestamp, + }; + return next; + } - const entry: TodoListItem = { - kind: "todo_list", - id: entryId, - ...(timelineCursor ? { timelineCursor } : {}), - timestamp, - provider, - items: normalizedItems, - }; + const next = [...state]; + for (const activity of activities) { + const idSeed = `${provider}:${JSON.stringify(activity)}:${JSON.stringify(normalizedItems)}`; + next.push({ + kind: "todo_list", + id: createUniqueTimelineId(next, "todo", idSeed, timestamp), + ...(timelineCursor ? { timelineCursor } : {}), + timestamp, + provider, + items: normalizedItems, + activity, + }); + } + return next; +} - return [...state, entry]; +function taskStatus(task: TodoEntry): NonNullable { + if (task.completed || task.status === "completed") return "completed"; + return task.status === "in_progress" ? "in_progress" : "pending"; +} + +function taskKey(task: TodoEntry, index: number): string { + return task.id ?? `${index}:${task.text}`; +} + +function deriveTaskActivities( + previous: readonly TodoEntry[], + current: readonly TodoEntry[], +): TaskActivity[] { + if (previous.length === 0) { + return current.length > 0 ? [{ type: "created", count: current.length }] : []; + } + + const previousByKey = new Map(previous.map((task, index) => [taskKey(task, index), task])); + const activities: TaskActivity[] = []; + for (const [index, task] of current.entries()) { + const prior = previousByKey.get(taskKey(task, index)); + if (!prior) { + activities.push({ type: "added", task: task.text }); + continue; + } + const before = taskStatus(prior); + const after = taskStatus(task); + if (before === after) continue; + if (after === "completed") { + activities.push({ type: "completed", task: task.text }); + } else if (before === "completed") { + activities.push({ type: "reopened", task: task.text }); + } else if (after === "in_progress") { + activities.push({ type: "started", task: task.text }); + } + } + return activities; } function reduceTimelineToolCall( @@ -1243,6 +1320,15 @@ function reduceTimelineToolCall( ); } + if ( + event.provider === "claude" && + (normalizedToolName === "taskcreate" || + normalizedToolName === "taskupdate" || + normalizedToolName === "tasklist") + ) { + return state; + } + const tasks = extractTaskEntriesFromToolCall(item.name, inputFromUnknownDetail(item.detail)); if (tasks) { return appendTodoList( @@ -1349,12 +1435,12 @@ function reduceTimelineEvent( reduceTimelineToolCall(state, event, item, timestamp, timelineCursor), ); case "todo": { - if (event.provider === "claude") { - return finalizeActiveThoughts(state); - } const items: TodoEntry[] = (item.items ?? []).map((todo) => ({ text: todo.text, completed: todo.completed, + id: todo.id, + status: todo.status, + activeForm: todo.activeForm, })); return finalizeActiveThoughts( appendTodoList(state, event.provider, items, timestamp, timelineCursor), diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 9d2dffd2e3..06d7fa72a7 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -339,12 +339,20 @@ export interface CompactionTimelineItem { preTokens?: number; } +export interface AgentTaskItem { + text: string; + completed: boolean; + id?: string; + status?: "pending" | "in_progress" | "completed"; + activeForm?: string; +} + export type AgentTimelineItem = | { type: "user_message"; text: string; messageId?: string; clientMessageId?: string } | { type: "assistant_message"; text: string; messageId?: string } | { type: "reasoning"; text: string } | ToolCallTimelineItem - | { type: "todo"; items: { text: string; completed: boolean }[] } + | { type: "todo"; items: AgentTaskItem[] } | { type: "error"; message: string } | CompactionTimelineItem; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index bb95c66a30..7ec0a93ff9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -659,6 +659,9 @@ export const AgentTimelineItemPayloadSchema: z.ZodType { }); }); + test("task progress fields are optional on the wire", () => { + expect( + AgentTimelineItemPayloadSchema.parse({ + type: "todo", + items: [{ text: "Legacy task", completed: false }], + }), + ).toEqual({ type: "todo", items: [{ text: "Legacy task", completed: false }] }); + expect( + AgentTimelineItemPayloadSchema.parse({ + type: "todo", + items: [ + { + id: "task-1", + text: "Current task", + activeForm: "Working on current task", + status: "in_progress", + completed: false, + }, + ], + }), + ).toEqual({ + type: "todo", + items: [ + { + id: "task-1", + text: "Current task", + activeForm: "Working on current task", + status: "in_progress", + completed: false, + }, + ], + }); + }); + test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => { const parsed = LegacySubAgentToolCallSchema.parse({ type: "tool_call", diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index c2c5855255..cea5a9f1bd 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -1,12 +1,13 @@ import type { AgentProviderNotice, + AgentTaskItem, ProviderOptions, ToolPolicy, } from "@getpaseo/protocol/agent-types"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { PaseoToolCatalog } from "./tools/types.js"; -export type { AgentProviderNotice }; +export type { AgentProviderNotice, AgentTaskItem }; export type AgentProvider = string; @@ -383,7 +384,7 @@ export type AgentTimelineItem = | { type: "assistant_message"; text: string; messageId?: string } | { type: "reasoning"; text: string } | ToolCallTimelineItem - | { type: "todo"; items: { text: string; completed: boolean }[] } + | { type: "todo"; items: AgentTaskItem[] } | { type: "error"; message: string } | CompactionTimelineItem; diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 2abba3a6eb..72652bee4a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -21,6 +21,7 @@ import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../ag interface TestClaudeSession { translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; + close(): Promise; } afterEach(() => { @@ -1388,6 +1389,52 @@ describe("ClaudeAgentSession context window usage", () => { }); } + test("emits canonical task snapshots from Claude TaskCreate results", async () => { + const session = await createSessionForTest(); + try { + session.translateMessageToEvents({ + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "task-create-1", + name: "TaskCreate", + input: { subject: "Inspect provider", activeForm: "Inspecting provider" }, + }, + ], + }, + } as unknown as SDKMessage); + + const events = session.translateMessageToEvents({ + type: "user", + message: { + content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }], + }, + toolUseResult: { task: { id: "1", subject: "Inspect provider" } }, + } as unknown as SDKMessage); + + expect(events).toContainEqual({ + type: "timeline", + provider: "claude", + item: { + type: "todo", + items: [ + { + id: "1", + text: "Inspect provider", + activeForm: "Inspecting provider", + status: "pending", + completed: false, + }, + ], + }, + }); + } finally { + await session.close(); + } + }); + async function collectStreamEvents(session: AgentSession, prompt = "turn") { const events: AgentStreamEvent[] = []; for await (const event of streamSession(session, prompt)) { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 96fa62aa89..fcd6b46946 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -44,6 +44,7 @@ import { } from "./model-manifest.js"; import { parsePartialJsonObject } from "./partial-json.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; +import { ClaudeTaskState } from "./task-state.js"; import { ClaudeTaskProtocolSource, type ClaudeHookObservationInput, @@ -2025,6 +2026,7 @@ class ClaudeAgentSession implements AgentSession { private autonomousTurn: AutonomousTurnState | null = null; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly timelineAssembler = new TimelineAssembler(); + private readonly taskState = new ClaudeTaskState(); private readonly taskProtocolSource = new ClaudeTaskProtocolSource({ getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, readWorkflowResult: readClaudeWorkflowResultFile, @@ -2794,6 +2796,7 @@ class ClaudeAgentSession implements AgentSession { this.userMessageIds = []; this.emittedUserMessageIds.clear(); this.rewindTurnAnchors.length = 0; + this.taskState.reset(); this.loadPersistedHistory(sessionId); if (oldSessionId && oldSessionId !== sessionId) { this.dispatchEvents([ @@ -2824,6 +2827,7 @@ class ClaudeAgentSession implements AgentSession { this.userMessageIds = []; this.emittedUserMessageIds.clear(); this.rewindTurnAnchors.length = 0; + this.taskState.reset(); } private rememberUserMessageId(messageId: string | null | undefined): void { @@ -3837,6 +3841,7 @@ class ClaudeAgentSession implements AgentSession { } const events: AgentStreamEvent[] = []; + this.appendTaskStateEvent(message, events); // Subagent identity and lifecycle are announced by Claude Code's task protocol, so they are // read rather than inferred from sidechain frames. `task_started` precedes the child's first @@ -3901,6 +3906,11 @@ class ClaudeAgentSession implements AgentSession { return events; } + private appendTaskStateEvent(message: SDKMessage, events: AgentStreamEvent[]): void { + const item = this.taskState.observe(message); + if (item) events.push({ type: "timeline", provider: "claude", item }); + } + /** * A frame from inside a subagent, routed to that child rather than the parent's transcript. */ @@ -4559,6 +4569,7 @@ class ClaudeAgentSession implements AgentSession { private loadPersistedHistory(sessionId: string): void { try { + this.taskState.reset(); const historyPath = this.resolveHistoryPath(sessionId); if (!historyPath || !fs.existsSync(historyPath)) { return; @@ -4683,7 +4694,8 @@ class ClaudeAgentSession implements AgentSession { } const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp); - const items = this.convertHistoryEntry(entry); + const taskSnapshot = this.taskState.observe(entry); + const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)]; const isVisibleUserEntry = entry.type === "user" && typeof entry.uuid === "string" && diff --git a/packages/server/src/server/agent/providers/claude/task-state.test.ts b/packages/server/src/server/agent/providers/claude/task-state.test.ts new file mode 100644 index 0000000000..b175cc58ea --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/task-state.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "vitest"; +import { ClaudeTaskState } from "./task-state.js"; + +function toolUse(id: string, name: string, input: Record) { + return { type: "assistant", message: { content: [{ type: "tool_use", id, name, input }] } }; +} + +function toolResult(id: string, result: Record) { + return { + type: "user", + message: { content: [{ type: "tool_result", tool_use_id: id, content: "ok" }] }, + toolUseResult: result, + }; +} + +describe("ClaudeTaskState", () => { + test("accumulates TaskCreate and TaskUpdate mutations by task id", () => { + const state = new ClaudeTaskState(); + state.observe( + toolUse("create-1", "TaskCreate", { subject: "Alpha", activeForm: "Doing alpha" }), + ); + expect(state.observe(toolResult("create-1", { task: { id: "1", subject: "Alpha" } }))).toEqual({ + type: "todo", + items: [ + { id: "1", text: "Alpha", activeForm: "Doing alpha", status: "pending", completed: false }, + ], + }); + + state.observe(toolUse("update-1", "TaskUpdate", { taskId: "1", status: "in_progress" })); + expect(state.observe(toolResult("update-1", { success: true, taskId: "1" }))).toEqual({ + type: "todo", + items: [ + { + id: "1", + text: "Alpha", + activeForm: "Doing alpha", + status: "in_progress", + completed: false, + }, + ], + }); + }); + + test("removes deleted tasks and ignores replayed results", () => { + const state = new ClaudeTaskState(); + state.observe(toolUse("create", "TaskCreate", { subject: "Disposable" })); + state.observe(toolResult("create", { task: { id: "1", subject: "Disposable" } })); + state.observe(toolUse("delete", "TaskUpdate", { taskId: "1", status: "deleted" })); + const result = toolResult("delete", { success: true, taskId: "1" }); + expect(state.observe(result)).toEqual({ type: "todo", items: [] }); + expect(state.observe(result)).toBeNull(); + }); + + test("preserves status when TaskUpdate changes only descriptive fields", () => { + const state = new ClaudeTaskState(); + state.observe(toolUse("create", "TaskCreate", { subject: "Original" })); + state.observe(toolResult("create", { task: { id: "1", subject: "Original" } })); + state.observe(toolUse("complete", "TaskUpdate", { taskId: "1", status: "completed" })); + state.observe(toolResult("complete", { success: true, taskId: "1" })); + state.observe(toolUse("rename", "TaskUpdate", { taskId: "1", subject: "Renamed" })); + + expect(state.observe(toolResult("rename", { success: true, taskId: "1" }))).toEqual({ + type: "todo", + items: [{ id: "1", text: "Renamed", status: "completed", completed: true }], + }); + }); + + test("replaces state from TodoWrite and TaskList snapshots", () => { + const state = new ClaudeTaskState(); + expect( + state.observe( + toolUse("legacy", "TodoWrite", { + todos: [{ content: "Legacy", status: "in_progress", activeForm: "Working" }], + }), + ), + ).toEqual({ + type: "todo", + items: [ + { + id: "legacy:0", + text: "Legacy", + activeForm: "Working", + status: "in_progress", + completed: false, + }, + ], + }); + state.observe(toolUse("list", "TaskList", {})); + expect( + state.observe( + toolResult("list", { + tasks: [{ id: "7", subject: "Current", status: "completed", activeForm: "Finishing" }], + }), + ), + ).toEqual({ + type: "todo", + items: [ + { id: "7", text: "Current", activeForm: "Finishing", status: "completed", completed: true }, + ], + }); + }); + + test("keeps synthetic TodoWrite task ids stable across snapshots", () => { + const state = new ClaudeTaskState(); + state.observe( + toolUse("first", "TodoWrite", { + todos: [{ content: "Stable", status: "pending" }], + }), + ); + + expect( + state.observe( + toolUse("second", "TodoWrite", { + todos: [{ content: "Stable", status: "completed" }], + }), + ), + ).toEqual({ + type: "todo", + items: [{ id: "legacy:0", text: "Stable", status: "completed", completed: true }], + }); + }); + + test("does not confuse Claude's subagent Task tool with task tracking", () => { + const state = new ClaudeTaskState(); + expect(state.observe(toolUse("subagent", "Task", { description: "delegate" }))).toBeNull(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/task-state.ts b/packages/server/src/server/agent/providers/claude/task-state.ts new file mode 100644 index 0000000000..bcb4df26bc --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/task-state.ts @@ -0,0 +1,202 @@ +import type { AgentTaskItem, AgentTimelineItem } from "../../agent-sdk-types.js"; + +type TaskToolName = "TodoWrite" | "TaskCreate" | "TaskUpdate" | "TaskList"; + +interface PendingTaskTool { + name: TaskToolName; + input: Record; +} + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function string(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function taskStatus(value: unknown): AgentTaskItem["status"] | "deleted" { + if (value === "completed" || value === "deleted" || value === "in_progress") return value; + return "pending"; +} + +function retainedTaskStatus(task: AgentTaskItem): AgentTaskItem["status"] { + if (task.status) return task.status; + return task.completed ? "completed" : "pending"; +} + +function toTaskItem(value: unknown): AgentTaskItem | null { + const task = record(value); + if (!task) return null; + const text = string(task.subject) ?? string(task.content) ?? string(task.text); + if (!text) return null; + const status = taskStatus(task.status); + if (status === "deleted") return null; + const id = string(task.id) ?? string(task.taskId); + const activeForm = string(task.activeForm) ?? string(task.active_form); + return { + ...(id ? { id } : {}), + text, + status, + completed: status === "completed", + ...(activeForm ? { activeForm } : {}), + }; +} + +function toolUses(message: Record): Array> { + const content = record(message.message)?.content; + return Array.isArray(content) + ? content.flatMap((block) => { + const candidate = record(block); + return candidate?.type === "tool_use" ? [candidate] : []; + }) + : []; +} + +function toolResultId(message: Record): string | undefined { + const content = record(message.message)?.content; + if (!Array.isArray(content)) return undefined; + for (const block of content) { + const candidate = record(block); + if (candidate?.type === "tool_result") return string(candidate.tool_use_id); + } + return undefined; +} + +function structuredResult(message: Record): Record | null { + return record(message.toolUseResult) ?? record(message.tool_use_result); +} + +/** Accumulates Claude's snapshot and ID-based task tools into canonical todo snapshots. */ +export class ClaudeTaskState { + private readonly tasks = new Map(); + private readonly calls = new Map(); + private readonly appliedResults = new Set(); + + observe(value: unknown): Extract | null { + const message = record(value); + if (!message) return null; + + let snapshot: Extract | null = null; + for (const block of toolUses(message)) { + const id = string(block.id); + const name = string(block.name); + if (!id || !isTaskToolName(name)) continue; + const input = record(block.input) ?? {}; + this.calls.set(id, { name, input }); + if (name === "TodoWrite") snapshot = this.replaceLegacyTodos(input.todos); + } + + const resultId = toolResultId(message); + if (!resultId || this.appliedResults.has(resultId)) return snapshot; + const call = this.calls.get(resultId); + if (!call) return snapshot; + this.appliedResults.add(resultId); + this.calls.delete(resultId); + return this.applyResult(call, structuredResult(message)) ?? snapshot; + } + + reset(): void { + this.tasks.clear(); + this.calls.clear(); + this.appliedResults.clear(); + } + + private replaceLegacyTodos(value: unknown): Extract { + this.tasks.clear(); + if (Array.isArray(value)) { + for (const [index, taskValue] of value.entries()) { + const item = toTaskItem(taskValue); + if (!item) continue; + const id = item.id ?? `legacy:${index}`; + this.tasks.set(id, { ...item, id }); + } + } + return this.snapshot(); + } + + private applyResult( + call: PendingTaskTool, + result: Record | null, + ): Extract | null { + if (result?.success === false) return null; + if (call.name === "TaskCreate") return this.applyCreate(call.input, result); + if (call.name === "TaskUpdate") return this.applyUpdate(call.input, result); + if (call.name === "TaskList") return this.applyList(result); + return null; + } + + private applyCreate( + input: Record, + result: Record | null, + ): Extract | null { + const resultTask = record(result?.task); + const id = string(resultTask?.id) ?? string(result?.taskId); + const text = string(resultTask?.subject) ?? string(input.subject); + if (!id || !text) return null; + const activeForm = string(input.activeForm); + this.tasks.set(id, { + id, + text, + status: "pending", + completed: false, + ...(activeForm ? { activeForm } : {}), + }); + return this.snapshot(); + } + + private applyUpdate( + input: Record, + result: Record | null, + ): Extract | null { + const id = string(input.taskId) ?? string(result?.taskId); + if (!id) return null; + const current = this.tasks.get(id); + if (!current) return null; + const statusValue = input.status ?? record(result?.statusChange)?.to; + const status = + statusValue === undefined ? retainedTaskStatus(current) : taskStatus(statusValue); + if (status === "deleted") { + this.tasks.delete(id); + return this.snapshot(); + } + const text = string(input.subject); + const activeForm = string(input.activeForm); + this.tasks.set(id, { + ...current, + ...(text ? { text } : {}), + ...(activeForm ? { activeForm } : {}), + status, + completed: status === "completed", + }); + return this.snapshot(); + } + + private applyList( + result: Record | null, + ): Extract | null { + const tasks = result?.tasks; + if (!Array.isArray(tasks)) return null; + this.tasks.clear(); + for (const taskValue of tasks) { + const item = toTaskItem(taskValue); + if (item?.id) this.tasks.set(item.id, item); + } + return this.snapshot(); + } + + private snapshot(): Extract { + return { type: "todo", items: [...this.tasks.values()] }; + } +} + +function isTaskToolName(value: string | undefined): value is TaskToolName { + return ( + value === "TodoWrite" || + value === "TaskCreate" || + value === "TaskUpdate" || + value === "TaskList" + ); +} diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index be08a231ce..c0d16c70c2 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -22,10 +22,30 @@ import { codexAppServerTurnInputFromPrompt, listCodexSkills, mapCodexPatchNotificationToToolCall, + mapCodexPlanUpdateToTodo, mapCodexPlanToToolCall, normalizeCodexOutputSchema, toAgentUsage, } from "./codex-app-server-agent.js"; + +describe("mapCodexPlanUpdateToTodo", () => { + test("preserves checklist progress without creating a plan card", () => { + expect( + mapCodexPlanUpdateToTodo([ + { step: "Inspect", status: "completed" }, + { step: "Implement", status: "inProgress" }, + { step: "Verify", status: "pending" }, + ]), + ).toEqual({ + type: "todo", + items: [ + { id: "0", text: "Inspect", status: "completed", completed: true }, + { id: "1", text: "Implement", status: "in_progress", completed: false }, + { id: "2", text: "Verify", status: "pending", completed: false }, + ], + }); + }); +}); import { CodexAppServerClient } from "./codex/app-server-transport.js"; import { createFakeCodexAppServer, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 98f5aa8430..ae119ef894 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -939,6 +939,26 @@ export function planStepsToMarkdown(steps: Array<{ step: string; status: string return normalizePlanMarkdown(lines.join("\n")); } +export function mapCodexPlanUpdateToTodo( + steps: Array<{ step?: string | null; status?: string | null }>, +): Extract { + return { + type: "todo", + items: steps.flatMap((entry, index) => { + const text = entry.step?.trim(); + if (!text) return []; + const status = normalizeCodexTaskStatus(entry.status); + return [{ id: String(index), text, status, completed: status === "completed" }]; + }), + }; +} + +function normalizeCodexTaskStatus(status: string | null | undefined) { + if (status === "completed") return "completed" as const; + if (status === "inProgress" || status === "in_progress") return "in_progress" as const; + return "pending" as const; +} + export function mapCodexPlanToToolCall(params: { callId: string; text: string; @@ -5597,6 +5617,14 @@ export class CodexAppServerAgentSession implements AgentSession { private handlePlanUpdatedNotification( parsed: Extract, ): void { + if (!this.planModeEnabled) { + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: mapCodexPlanUpdateToTodo(parsed.plan), + }); + return; + } const timelineItem = mapCodexPlanToToolCall({ callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`, text: planStepsToMarkdown( @@ -5608,13 +5636,9 @@ export class CodexAppServerAgentSession implements AgentSession { }); if (timelineItem) { this.rememberPlanResult(timelineItem); - // In plan mode, the same plan is rendered through the synthetic approval - // permission. Keep the remembered text for that card, but do not also - // emit a static timeline plan panel. - if (this.planModeEnabled) { - return; - } - this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + // Older Codex app-server builds reported Plan-mode proposals through + // turn/plan/updated. Retain that compatibility path only while Plan mode is active. + return; } } diff --git a/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts b/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts index b427e5ba65..e04ddee2bc 100644 --- a/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts @@ -15,7 +15,7 @@ const TODO_PHASES = [ ] as const; describe("OMP todo mapper", () => { - test("maps todo tool results and collapses statuses to completed booleans", () => { + test("maps todo tool results without losing progress status", () => { expect( mapOmpTodoToolResult( parseToolResult({ @@ -37,9 +37,9 @@ describe("OMP todo mapper", () => { ).toEqual({ type: "todo", items: [ - { text: "alpha task", completed: false }, - { text: "beta task", completed: false }, - { text: "gamma task", completed: false }, + { text: "alpha task", status: "in_progress", completed: false }, + { text: "beta task", status: "pending", completed: false }, + { text: "gamma task", status: "pending", completed: false }, ], }); @@ -48,9 +48,9 @@ describe("OMP todo mapper", () => { ).toEqual({ type: "todo", items: [ - { text: "alpha task", completed: true }, - { text: "beta task", completed: false }, - { text: "gamma task", completed: false }, + { text: "alpha task", status: "completed", completed: true }, + { text: "beta task", status: "in_progress", completed: false }, + { text: "gamma task", status: "pending", completed: false }, ], }); }); @@ -67,8 +67,8 @@ describe("OMP todo mapper", () => { ).toEqual({ type: "todo", items: [ - { text: "beta task", completed: false }, - { text: "gamma task", completed: false }, + { text: "beta task", status: "in_progress", completed: false }, + { text: "gamma task", status: "pending", completed: false }, ], }); }); @@ -89,9 +89,9 @@ describe("OMP todo mapper", () => { { type: "todo", items: [ - { text: "alpha task", completed: true }, - { text: "beta task", completed: false }, - { text: "gamma task", completed: false }, + { text: "alpha task", status: "completed", completed: true }, + { text: "beta task", status: "in_progress", completed: false }, + { text: "gamma task", status: "pending", completed: false }, ], }, ]); diff --git a/packages/server/src/server/agent/providers/omp/todo-mapper.ts b/packages/server/src/server/agent/providers/omp/todo-mapper.ts index 1d8af90834..0b41d5a43f 100644 --- a/packages/server/src/server/agent/providers/omp/todo-mapper.ts +++ b/packages/server/src/server/agent/providers/omp/todo-mapper.ts @@ -41,11 +41,18 @@ function mapOmpTodoItems(items: readonly OmpTodoItem[]): AgentTimelineItem | nul type: "todo", items: items.map((item) => ({ text: item.content, + status: normalizeOmpTodoStatus(item.status), completed: item.status === "completed", })), }; } +function normalizeOmpTodoStatus(status: OmpTodoItem["status"]) { + if (status === "completed") return "completed" as const; + if (status === "in_progress") return "in_progress" as const; + return "pending" as const; +} + function resultDetails(result: OmpToolResult): Record | null { if (typeof result === "string" || result === null) { return null; diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 2c31762e37..3a480f7b9a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1878,7 +1878,13 @@ describe("OpenCode adapter startTurn error handling", () => { provider: "opencode", item: { type: "todo", - items: [{ text: "Inspect current directory and existing files", completed: true }], + items: [ + { + text: "Inspect current directory and existing files", + status: "completed", + completed: true, + }, + ], }, }, ]); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 6711f556aa..3ea0baab7a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1905,6 +1905,7 @@ function mapOpenCodeTodosToTimelineItems( return [ { text, + status: normalizeOpenCodeTodoStatus(todo.status), completed: todo.status === "completed", }, ]; @@ -1912,6 +1913,12 @@ function mapOpenCodeTodosToTimelineItems( }; } +function normalizeOpenCodeTodoStatus(status?: string | null) { + if (status === "completed") return "completed" as const; + if (status === "in_progress" || status === "inProgress") return "in_progress" as const; + return "pending" as const; +} + function createCompactionTimelineItem( status: Extract["status"], trigger?: Extract["trigger"], diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index e8f7f5d94a..2c5d6792cc 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -680,8 +680,8 @@ describe("translateOpenCodeEvent", () => { item: { type: "todo", items: [ - { text: "Outline", completed: false }, - { text: "Ship", completed: true }, + { text: "Outline", status: "pending", completed: false }, + { text: "Ship", status: "completed", completed: true }, ], }, }, From a39959219fc5f4e7f06d4f13f57f9fc55641bdb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Hanz=C3=ADk?= Date: Tue, 11 Aug 2026 18:36:58 +0200 Subject: [PATCH 20/73] feat: Implement workspace rename cli (#3209) * feat(cli): add workspace rename command `paseo workspace rename ` sets a workspace's user-visible title through the existing workspace.title.set RPC, and `--reset` clears the override so the name reverts to the branch or directory. The command reads the descriptor back so the output shows what a reset reverted to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(cli): harden workspace rename against read-back and arg failures Review findings on the rename command: - A failed workspace list sweep after a successful title change reported the whole command as failed. The read-back is display-only, so it now runs outside the mutation's error path and degrades to a null name. - Only --reset needs the descriptor read back; a set title is already the resolved display name, so the common path no longer pages the list. - Dropped the `"code" in error` rethrow. Nothing in that block throws a CommandError, but DaemonRpcError always carries a `code` property even when it is undefined, so the guard let those through to render as UNKNOWN_ERROR with a stack instead of WORKSPACE_RENAME_FAILED. - Commander 12 allows excess positionals, so an unquoted multi-word title renamed to its first word and dropped the rest. The command now rejects excess arguments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(cli): report the rename RPC response instead of reading back Reading the descriptor back to show what --reset reverted to was the only reason rename needed a paged workspace sweep, and that sweep dragged in everything around it: a failure path that could report an applied rename as failed, a nullable name, and a bespoke warning sink to tell the two null cases apart. Sibling commands don't do this. `workspace archive`, `agent mode`, and `schedule update` all report what the RPC returned and stop; the one command that reads back is `agent update`, which has the same "applied but reported as failed" bug. So rename now mirrors `workspace archive`. `--reset` reports `title: null` rather than naming what it reverted to; `paseo workspace ls` shows the resolved name. This also reverts the `collectWorkspaces` extraction, whose only second caller was rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> --- docs/architecture.md | 2 +- docs/development.md | 2 +- packages/cli/src/commands/workspace/index.ts | 13 +++ .../cli/src/commands/workspace/rename.test.ts | 55 +++++++++++++ packages/cli/src/commands/workspace/rename.ts | 80 +++++++++++++++++++ .../cli/tests/38-workspace-rename.test.ts | 58 ++++++++++++++ public-docs/cli.md | 4 +- public-docs/worktrees.md | 1 + 8 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/workspace/rename.test.ts create mode 100644 packages/cli/src/commands/workspace/rename.ts create mode 100644 packages/cli/tests/38-workspace-rename.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index b4bf1a56c9..4dcf7a676a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,7 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex - `paseo script ls/start/stop` - `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` - `paseo heartbeat create/update/delete` -- `paseo workspace create/ls/archive` +- `paseo workspace create/ls/rename/archive` - `paseo permit allow/deny/ls` - `paseo provider ls/models` - hidden legacy `paseo worktree create/ls/archive` compatibility alias diff --git a/docs/development.md b/docs/development.md index f976549ef6..22548cb1ee 100644 --- a/docs/development.md +++ b/docs/development.md @@ -460,7 +460,7 @@ install. Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing. -Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases. +Canonical automation uses `paseo workspace create/ls/rename/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases. ```bash npm run cli -- ls -a -g # List all agents globally diff --git a/packages/cli/src/commands/workspace/index.ts b/packages/cli/src/commands/workspace/index.ts index 27d7131dce..bf143fa137 100644 --- a/packages/cli/src/commands/workspace/index.ts +++ b/packages/cli/src/commands/workspace/index.ts @@ -4,6 +4,7 @@ import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { runArchiveCommand } from "./archive.js"; import { runCreateCommand } from "./create.js"; import { runLsCommand } from "./ls.js"; +import { runRenameCommand } from "./rename.js"; export function createWorkspaceCommand(): Command { const workspace = new Command("workspace").description("Manage workspaces"); @@ -32,6 +33,18 @@ export function createWorkspaceCommand(): Command { withOutput(runLsCommand), ); + addJsonAndDaemonHostOptions( + workspace + .command("rename") + .description("Set a workspace's user-visible title") + .argument("<workspace-id>", "Workspace id") + .argument("[title]", "New workspace title") + .option("--reset", "Clear the title and revert to the branch or directory name") + // Commander 12 accepts excess positionals by default, which would rename to + // the first word of an unquoted multi-word title and silently drop the rest. + .allowExcessArguments(false), + ).action(withOutput(runRenameCommand)); + addJsonAndDaemonHostOptions( workspace .command("archive") diff --git a/packages/cli/src/commands/workspace/rename.test.ts b/packages/cli/src/commands/workspace/rename.test.ts new file mode 100644 index 0000000000..587a4810bd --- /dev/null +++ b/packages/cli/src/commands/workspace/rename.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { createWorkspaceCommand } from "./index.js"; +import { resolveWorkspaceTitle } from "./rename.js"; + +function catchError(run: () => unknown): unknown { + try { + run(); + return null; + } catch (error) { + return error; + } +} + +describe("workspace rename title", () => { + it("trims the requested title", () => { + expect(resolveWorkspaceTitle({ title: " Fix login " })).toBe("Fix login"); + }); + + it("clears the title when resetting to the derived name", () => { + expect(resolveWorkspaceTitle({ reset: true })).toBeNull(); + }); + + it("rejects a reset that also carries a title", () => { + expect( + catchError(() => resolveWorkspaceTitle({ title: "Fix login", reset: true })), + ).toMatchObject({ code: "INVALID_OPTIONS" }); + // An explicit empty title is still a title the caller passed, not a reset. + expect(catchError(() => resolveWorkspaceTitle({ title: "", reset: true }))).toMatchObject({ + code: "INVALID_OPTIONS", + }); + }); + + it("requires a non-empty title when not resetting", () => { + expect(catchError(() => resolveWorkspaceTitle({}))).toMatchObject({ code: "MISSING_TITLE" }); + expect(catchError(() => resolveWorkspaceTitle({ title: " " }))).toMatchObject({ + code: "MISSING_TITLE", + }); + }); +}); + +describe("workspace rename arguments", () => { + function parseRename(argv: string[]): unknown { + const workspace = createWorkspaceCommand() + .exitOverride() + .configureOutput({ writeErr: () => undefined }); + workspace.commands.find((command) => command.name() === "rename")?.exitOverride(); + return catchError(() => workspace.parse(argv, { from: "user" })); + } + + it("rejects an unquoted multi-word title instead of dropping words", () => { + expect(parseRename(["rename", "ws-1", "Auth", "rework"])).toMatchObject({ + code: "commander.excessArguments", + }); + }); +}); diff --git a/packages/cli/src/commands/workspace/rename.ts b/packages/cli/src/commands/workspace/rename.ts new file mode 100644 index 0000000000..fd5857682b --- /dev/null +++ b/packages/cli/src/commands/workspace/rename.ts @@ -0,0 +1,80 @@ +import type { Command } from "commander"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js"; + +interface WorkspaceRenameResult { + workspaceId: string; + /** Raw title override; null once it has been reset to the derived name. */ + title: string | null; +} + +const workspaceRenameSchema: OutputSchema<WorkspaceRenameResult> = { + idField: "workspaceId", + columns: [ + { header: "WORKSPACE ID", field: "workspaceId", width: 20 }, + { header: "TITLE", field: "title", width: 30 }, + ], +}; + +export interface WorkspaceRenameOptions { + host?: string; + reset?: boolean; +} + +/** + * Resolve the title to send to the daemon. Null clears the override and + * reverts the workspace to its branch- or directory-derived name. + */ +export function resolveWorkspaceTitle(input: { title?: string; reset?: boolean }): string | null { + const title = input.title?.trim() ?? ""; + if (input.reset) { + if (input.title !== undefined) { + throw { + code: "INVALID_OPTIONS", + message: "--reset cannot be combined with a title", + details: "Pass a title to rename the workspace, or --reset to revert to the derived name", + } satisfies CommandError; + } + return null; + } + if (title.length === 0) { + throw { + code: "MISSING_TITLE", + message: "Title cannot be empty", + details: "Usage: paseo workspace rename <workspace-id> <title> | --reset", + } satisfies CommandError; + } + return title; +} + +export async function runRenameCommand( + workspaceId: string, + titleArg: string | undefined, + options: WorkspaceRenameOptions, + _command: Command, +): Promise<SingleResult<WorkspaceRenameResult>> { + const title = resolveWorkspaceTitle({ title: titleArg, reset: options.reset }); + + const host = getDaemonHost({ host: options.host }); + const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${host}: ${message}`, + details: "Start the daemon with: paseo daemon start", + } satisfies CommandError; + }); + try { + const applied = await client.setWorkspaceTitle(workspaceId, title); + return { + type: "single", + data: { workspaceId, title: applied.title }, + schema: workspaceRenameSchema, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw { code: "WORKSPACE_RENAME_FAILED", message } satisfies CommandError; + } finally { + await client.close().catch(() => undefined); + } +} diff --git a/packages/cli/tests/38-workspace-rename.test.ts b/packages/cli/tests/38-workspace-rename.test.ts new file mode 100644 index 0000000000..50d3b4ce82 --- /dev/null +++ b/packages/cli/tests/38-workspace-rename.test.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert"; +import { basename } from "node:path"; +import { createE2ETestContext } from "./helpers/test-daemon.ts"; + +console.log("=== Workspace Rename Command ===\n"); + +const ctx = await createE2ETestContext({ timeout: 30000 }); + +try { + const created = await ctx.paseo([ + "workspace", + "create", + "--isolation", + "local", + "--path", + ctx.workDir, + "--title", + "Original title", + "--json", + ]); + assert.strictEqual(created.exitCode, 0, created.stderr); + const workspaceId = JSON.parse(created.stdout).workspaceId as string; + + const renamed = await ctx.paseo(["workspace", "rename", workspaceId, "Auth rework", "--json"]); + assert.strictEqual(renamed.exitCode, 0, renamed.stderr); + assert.deepStrictEqual(JSON.parse(renamed.stdout), { + workspaceId, + title: "Auth rework", + }); + + const renamedList = await ctx.paseo(["workspace", "ls", "--json"]); + assert.strictEqual(renamedList.exitCode, 0, renamedList.stderr); + assert.strictEqual( + JSON.parse(renamedList.stdout).find( + (workspace: { workspaceId: string }) => workspace.workspaceId === workspaceId, + )?.name, + "Auth rework", + ); + + const reset = await ctx.paseo(["workspace", "rename", workspaceId, "--reset", "--json"]); + assert.strictEqual(reset.exitCode, 0, reset.stderr); + assert.deepStrictEqual(JSON.parse(reset.stdout), { workspaceId, title: null }); + + const resetList = await ctx.paseo(["workspace", "ls", "--json"]); + assert.strictEqual(resetList.exitCode, 0, resetList.stderr); + assert.strictEqual( + JSON.parse(resetList.stdout).find( + (workspace: { workspaceId: string }) => workspace.workspaceId === workspaceId, + )?.name, + basename(ctx.workDir), + ); +} finally { + await ctx.stop(); +} + +console.log("=== Workspace Rename Command Tests Passed ==="); diff --git a/public-docs/cli.md b/public-docs/cli.md index c4880ba2e5..9fb60c6e77 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -76,11 +76,13 @@ paseo workspace create \ --pr-number 2186 ``` -Then list, use, or archive it: +Then list, use, rename, or archive it: ```bash paseo workspace ls paseo run --workspace <workspace-id> "implement authentication" +paseo workspace rename <workspace-id> "Auth rework" +paseo workspace rename <workspace-id> --reset # back to the branch or directory name paseo workspace archive <workspace-id> ``` diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index db43e21c11..3302233851 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -257,6 +257,7 @@ Services additionally get: ```bash paseo workspace ls paseo run --workspace <workspace-id> "implement auth" +paseo workspace rename <workspace-id> "Auth rework" paseo workspace archive <workspace-id> ``` From f24664b8826658885db7d0c644296d4b1823be11 Mon Sep 17 00:00:00 2001 From: Edi Hasaj <34984294+edihasaj@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:02:17 +0200 Subject: [PATCH 21/73] fix: terminalize Codex compaction on turn end (#3211) * fix: terminalize Codex compaction on turn end * fix: ignore stale Codex compaction completions --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> --- docs/providers.md | 2 + packages/app/src/types/stream.test.ts | 10 +- .../providers/codex-app-server-agent.test.ts | 133 ++++++++++++++++++ .../agent/providers/codex-app-server-agent.ts | 78 +++++++++- .../codex/test-utils/fake-app-server.ts | 18 ++- 5 files changed, 237 insertions(+), 4 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index a8e05895f8..b76aa3d89d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -85,6 +85,8 @@ Rewind accepts the canonical wire `messageId` and resolves it to the provider id Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID. +Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading. + Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state. Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready. diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 3b0229e2ab..c3594b9d75 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -1055,7 +1055,7 @@ describe("stream reducer canonical tool calls", () => { ]); }); - it("preserves compaction trigger when completed update replaces loading marker", () => { + it("terminalizes the loading compaction before a completed turn", () => { const state = hydrateStreamState([ { event: compactionTimeline("loading", "auto"), @@ -1065,6 +1065,10 @@ describe("stream reducer canonical tool calls", () => { event: compactionTimeline("completed"), timestamp: new Date("2025-01-01T10:50:01Z"), }, + { + event: { type: "turn_completed", provider: "codex" }, + timestamp: new Date("2025-01-01T10:50:02Z"), + }, ]); const compactions = state.filter( @@ -1074,6 +1078,10 @@ describe("stream reducer canonical tool calls", () => { assert.strictEqual(compactions.length, 1); assert.strictEqual(compactions[0].status, "completed"); assert.strictEqual(compactions[0].trigger, "auto"); + assert.strictEqual( + state.some((item) => item.kind === "compaction" && item.status === "loading"), + false, + ); }); it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index c0d16c70c2..1c62182dd1 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -90,6 +90,11 @@ type CodexTestSession = AgentSession & { client: CodexClientLike | null; }; +type TurnTerminalEvent = Extract< + AgentStreamEvent, + { type: "turn_completed" | "turn_failed" | "turn_canceled" } +>; + const ONE_BY_ONE_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII="; const CODEX_PROVIDER = "codex"; @@ -139,6 +144,41 @@ function createProviderWithFakeAppServer(appServer: FakeCodexAppServer): CodexAp return provider; } +async function startCompactionTurnTest(): Promise<{ + appServer: FakeCodexAppServer; + session: AgentSession; + events: AgentStreamEvent[]; + terminalEvent: Promise<TurnTerminalEvent>; +}> { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + const terminalEvent = new Promise<TurnTerminalEvent>((resolve) => { + session.subscribe((event) => { + const isCompaction = event.type === "timeline" && event.item.type === "compaction"; + const isTerminal = + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled"; + if (isCompaction || isTerminal) { + events.push(event); + } + if (isTerminal) { + resolve(event); + } + }); + }); + + await session.startTurn("exercise compaction lifecycle"); + appServer.startsTurn({ threadId: "thread-1", turnId: "codex-turn-1" }); + return { appServer, session, events, terminalEvent }; +} + function archivedThreadHandle() { return { sessionId: "archived-thread-id", @@ -3843,6 +3883,99 @@ describe("Codex app-server provider", () => { ]); }); + test("completes a pending Codex compaction when its turn ends", async () => { + const { appServer, session, events, terminalEvent } = await startCompactionTurnTest(); + + try { + appServer.startsCompaction({ + threadId: "thread-1", + itemId: "compact-without-completion", + }); + appServer.completeTurn(); + await terminalEvent; + + expect( + events.map((event) => + event.type === "timeline" ? `${event.item.type}:${event.item.status}` : event.type, + ), + ).toEqual(["compaction:loading", "compaction:completed", "turn_completed"]); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("does not complete a Codex compaction twice when its item finishes before the turn", async () => { + const { appServer, session, events, terminalEvent } = await startCompactionTurnTest(); + + try { + appServer.startsCompaction({ + threadId: "thread-1", + itemId: "compact-completed-normally", + }); + appServer.completesCompaction({ + threadId: "thread-1", + itemId: "compact-completed-normally", + }); + appServer.completeTurn(); + await terminalEvent; + + expect( + events.map((event) => + event.type === "timeline" ? `${event.item.type}:${event.item.status}` : event.type, + ), + ).toEqual(["compaction:loading", "compaction:completed", "turn_completed"]); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("does not let a late compaction completion consume the current pending item", async () => { + const { appServer, session, events, terminalEvent } = await startCompactionTurnTest(); + + try { + appServer.startsCompaction({ threadId: "thread-1", itemId: "current-compaction" }); + appServer.completesCompaction({ threadId: "thread-1", itemId: "older-compaction" }); + appServer.completeTurn(); + await terminalEvent; + + expect( + events.map((event) => + event.type === "timeline" ? `${event.item.type}:${event.item.status}` : event.type, + ), + ).toEqual(["compaction:loading", "compaction:completed", "turn_completed"]); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test.each([ + { status: "failed", terminalType: "turn_failed" }, + { status: "interrupted", terminalType: "turn_canceled" }, + ])("completes a pending compaction before a $status turn", async ({ status, terminalType }) => { + const { appServer, session, events, terminalEvent } = await startCompactionTurnTest(); + + try { + appServer.startsCompaction({ threadId: "thread-1", itemId: `compact-${status}` }); + appServer.completeTurn({ + status, + error: status === "failed" ? { message: "Compaction failed" } : null, + }); + await terminalEvent; + + expect( + events.map((event) => + event.type === "timeline" ? `${event.item.type}:${event.item.status}` : event.type, + ), + ).toEqual(["compaction:loading", "compaction:completed", terminalType]); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("emits and dedupes Codex thread/compacted notifications", () => { const session = createSession(); session.activeForegroundTurnId = null; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index ae119ef894..0a628498a6 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3166,6 +3166,10 @@ interface CodexPendingPermissionHandler { planText?: string; } +interface ConsumedRootCompaction { + itemId?: string; +} + export class CodexAppServerAgentSession implements AgentSession { readonly provider = CODEX_PROVIDER; readonly capabilities = CODEX_APP_SERVER_CAPABILITIES; @@ -3231,6 +3235,8 @@ export class CodexAppServerAgentSession implements AgentSession { private readonly userMessageTurnIds: string[] = []; private pendingManualCompactionStarts = 0; private compactionTriggerByItemId = new Map<string, "auto" | "manual">(); + private pendingRootCompactionItemIds = new Set<string>(); + private pendingAnonymousRootCompactions = 0; // Codex can report one completed compaction through both channels: // `thread/compacted` and a completed `contextCompaction` item. private unpairedCompactionNotificationCompletions = 0; @@ -5183,6 +5189,16 @@ export class CodexAppServerAgentSession implements AgentSession { if (!this.isContextCompactionItem(item)) { return false; } + const consumedPendingCompaction = this.consumePendingRootCompaction(item.id); + const hasDifferentPendingCompaction = + this.pendingRootCompactionItemIds.size > 0 || this.pendingAnonymousRootCompactions > 0; + const isLateCompletionForOlderItem = + item.id !== undefined && + consumedPendingCompaction === undefined && + hasDifferentPendingCompaction; + if (isLateCompletionForOlderItem) { + return true; + } if (this.unpairedCompactionNotificationCompletions > 0) { this.unpairedCompactionNotificationCompletions -= 1; return true; @@ -5570,6 +5586,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitSubAgentActivityUpdate(subAgentCallId, status); return; } + this.completePendingRootCompactions(); if (parsed.status === "failed") { this.emitEvent({ type: "turn_failed", @@ -5610,6 +5627,8 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingFileChangeOutputDeltas.clear(); this.pendingAssistantMessageBoundary = false; this.warnedIncompleteEditToolCallIds.clear(); + this.pendingRootCompactionItemIds.clear(); + this.pendingAnonymousRootCompactions = 0; this.unpairedCompactionNotificationCompletions = 0; this.unpairedCompactionItemCompletions = 0; } @@ -5669,6 +5688,61 @@ export class CodexAppServerAgentSession implements AgentSession { return undefined; } + private trackPendingRootCompaction(itemId?: string): void { + if (itemId) { + this.pendingRootCompactionItemIds.add(itemId); + return; + } + this.pendingAnonymousRootCompactions += 1; + } + + private consumePendingRootCompaction(itemId?: string): ConsumedRootCompaction | undefined { + if (itemId) { + if (this.pendingRootCompactionItemIds.delete(itemId)) { + return { itemId }; + } + if ( + this.pendingRootCompactionItemIds.size === 0 && + this.pendingAnonymousRootCompactions > 0 + ) { + this.pendingAnonymousRootCompactions -= 1; + return {}; + } + return undefined; + } + const pendingItemId = this.pendingRootCompactionItemIds.values().next().value; + if (typeof pendingItemId === "string") { + this.pendingRootCompactionItemIds.delete(pendingItemId); + return { itemId: pendingItemId }; + } + if (this.pendingAnonymousRootCompactions > 0) { + this.pendingAnonymousRootCompactions -= 1; + return {}; + } + return undefined; + } + + private completePendingRootCompactions(): void { + // Some Codex builds end a turn without completing the contextCompaction + // item. Close every loading timeline row before emitting the terminal turn. + for (const itemId of this.pendingRootCompactionItemIds) { + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: this.createContextCompactionTimelineItem("completed", itemId), + }); + } + for (let index = 0; index < this.pendingAnonymousRootCompactions; index += 1) { + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: this.createContextCompactionTimelineItem("completed"), + }); + } + this.pendingRootCompactionItemIds.clear(); + this.pendingAnonymousRootCompactions = 0; + } + private createContextCompactionTimelineItem( status: "loading" | "completed", itemId?: string, @@ -5718,11 +5792,12 @@ export class CodexAppServerAgentSession implements AgentSession { this.unpairedCompactionItemCompletions -= 1; return; } + const pendingItemId = this.consumePendingRootCompaction()?.itemId; this.unpairedCompactionNotificationCompletions += 1; this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, - item: this.createContextCompactionTimelineItem("completed"), + item: this.createContextCompactionTimelineItem("completed", pendingItemId), ...(parsed.turnId ? { turnId: parsed.turnId } : {}), }); } @@ -6047,6 +6122,7 @@ export class CodexAppServerAgentSession implements AgentSession { return; } if (this.isContextCompactionItem(parsed.item)) { + this.trackPendingRootCompaction(parsed.item.id); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index dc04a31f09..fc0031818c 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -51,8 +51,13 @@ export interface FakeCodexAppServer { waitForTurnStart(): Promise<JsonObject>; nextResponse(): Promise<string>; startsTurn(params: { threadId: string; turnId?: string }): void; + startsCompaction(params: { threadId: string; itemId: string }): void; updatesPlan(params: { threadId: string; steps: string[] }): void; - completeTurn(params?: { threadId?: string }): void; + completeTurn(params?: { + threadId?: string; + status?: "completed" | "failed" | "interrupted"; + error?: { message: string } | null; + }): void; startsSubAgent(params: { callId: string; threadId: string; @@ -320,6 +325,12 @@ export function createFakeCodexAppServer( })}\n`, ); }, + startsCompaction(params) { + writeNotification("item/started", { + threadId: params.threadId, + item: { type: "contextCompaction", id: params.itemId }, + }); + }, updatesPlan(params) { child.stdout.write( `${JSON.stringify({ @@ -337,7 +348,10 @@ export function createFakeCodexAppServer( method: "turn/completed", params: { threadId: params.threadId ?? "thread-1", - turn: { status: "completed" }, + turn: { + status: params.status ?? "completed", + error: params.error ?? null, + }, }, })}\n`, ); From cdb11631450c7d00c3b6b81270ad38c84a081de5 Mon Sep 17 00:00:00 2001 From: "Hanjun(Tony)" <54878785+wilgon456@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:08:41 +0900 Subject: [PATCH 22/73] Notify callers when watched children close and cap responses (#3192) Wake caller agents when watched children close and cap embedded child responses at 4,000 characters. Co-authored-by: Hanjun(Tony) <54878785+wilgon456@users.noreply.github.com> Co-authored-by: Thong Van <minhthong@gmail.com> --- docs/agent-lifecycle.md | 1 + .../src/server/agent/agent-prompt.test.ts | 48 +++++++++++++++++++ .../server/src/server/agent/agent-prompt.ts | 21 ++++---- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index ac3ceb0539..c8cd0e11aa 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -51,6 +51,7 @@ Users can also detach an existing subagent from the subagents track. Detach is d `notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status. +A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown. ## Provider-managed child agents diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 742e19bfb4..cc84c88d61 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -52,6 +52,7 @@ interface FinishNotificationScenario { resolveChildPermissionWhileIdle(requestId?: string): void; finishChild(): void; finishChildAndReadParentPrompt(): Promise<string>; + closeChildAndReadParentPrompt(): Promise<string>; parentPrompts(): string[]; wasParentPrompted(): boolean; } @@ -211,6 +212,25 @@ function createFinishNotificationScenario( return parentPrompt; }, + async closeChildAndReadParentPrompt() { + const parentPrompt = new Promise<string>((resolve) => { + resolveParentPrompt = resolve; + }); + + childAgent.lifecycle = "running"; + subscriber?.({ + type: "agent_state", + agent: childAgent, + }); + + childAgent.lifecycle = "closed"; + subscriber?.({ + type: "agent_state", + agent: childAgent, + }); + + return parentPrompt; + }, parentPrompts() { return parentPrompts; }, @@ -279,6 +299,34 @@ test("finish notifications tell the parent the child's last assistant message", ); }); +test("finish notifications truncate oversized child responses", async () => { + const included = "x".repeat(4000); + const omitted = "TAIL-MARKER".repeat(50); + const scenario = createFinishNotificationScenario({ + childLastAssistantMessage: included + omitted, + }); + + scenario.startWatchingChild(); + const parentPrompt = await scenario.finishChildAndReadParentPrompt(); + + expect(parentPrompt).toContain(included); + expect(parentPrompt).toContain( + `[truncated ${omitted.length} chars; use get_agent_activity for the full response]`, + ); + expect(parentPrompt).not.toContain("TAIL-MARKER"); +}); + +test("closing a watched child notifies the caller", async () => { + const scenario = createFinishNotificationScenario(); + + scenario.startWatchingChild(); + const parentPrompt = await scenario.closeChildAndReadParentPrompt(); + + expect(parentPrompt).toEqual( + formatSystemNotificationPrompt("Agent child-agent (Child Agent) was closed."), + ); +}); + test("finish notifications survive permission responses", async () => { const scenario = createFinishNotificationScenario(); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 375f643b0b..d92b225b39 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -253,10 +253,14 @@ export interface SetupFinishNotificationParams { logger: Logger; } +type FinishNotificationReason = "finished" | "errored" | "needs permission" | "was closed"; + +const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000; + interface FinishNotificationBodyInput { childAgentId: string; title: string; - reason: "finished" | "errored" | "needs permission"; + reason: FinishNotificationReason; lastAssistantMessage: string | null; permissionRequest?: AgentPermissionRequest; } @@ -278,8 +282,12 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri )}\n</permission-request>`, ); } - const lastAssistantMessage = params.lastAssistantMessage?.trim(); + let lastAssistantMessage = params.lastAssistantMessage?.trim(); if (lastAssistantMessage) { + if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) { + const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT; + lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`; + } sections.push(`<agent-response>\n${lastAssistantMessage}\n</agent-response>`); } return sections.join("\n\n"); @@ -312,7 +320,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): } async function notify( - reason: "finished" | "errored" | "needs permission", + reason: FinishNotificationReason, permissionRequest?: AgentPermissionRequest, ): Promise<void> { const callerRecord = await agentStorage.get(callerAgentId); @@ -344,10 +352,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): }); } - function notifySafely( - reason: "finished" | "errored" | "needs permission", - options: NotifySafelyOptions = {}, - ): void { + function notifySafely(reason: FinishNotificationReason, options: NotifySafelyOptions = {}): void { if (stopped) return; if (options.terminal ?? true) stop(); notificationQueue = notificationQueue @@ -387,7 +392,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): return; } if (event.agent.lifecycle === "closed") { - stop(); + notifySafely("was closed"); return; } return; From 163e7d1cc421cdfe4de67b971ff6cea4b51eb0ed Mon Sep 17 00:00:00 2001 From: pi3123 <subhashve4@gmail.com> Date: Tue, 11 Aug 2026 10:19:24 -0700 Subject: [PATCH 23/73] fix(omp): negotiate RPC protocol v2 so large model catalogs don't overflow (#3184) * fix(omp): negotiate RPC protocol v2 so large responses don't overflow OMP caps protocol-v1 single-line RPC frames at 1 MiB. get_available_models returns the full effective model catalog (hundreds of models), which exceeds that cap, so OMP replies with an overflow error ("RPC response exceeded the transport limit") and the OMP provider fails to start sessions. Negotiate RPC protocol v2 after OMP's startup `ready` frame when it is supported: v2 lifts the single-frame ceiling to 64 MiB by chunking oversized frames. Add rpc_chunk reassembly to the shared JSONL transport so chunked responses are reconstructed before routing, and gate the negotiation on the ready frame so it is in place before the first request. Protocol v2 is negotiated only when the peer advertises support in its ready frame; peers that stay on v1 (e.g. Pi's --mode rpc) are unaffected. * fix(omp): make protocol startup atomic --------- Co-authored-by: pi3123 <pi3123@users.noreply.github.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> --- .../agent/providers/jsonl-rpc-process.test.ts | 119 ++++++++++ .../agent/providers/jsonl-rpc-process.ts | 221 ++++++++++++++++++ .../agent/providers/omp/cli-runtime.test.ts | 152 +++++++++++- .../server/agent/providers/omp/cli-runtime.ts | 72 +++++- 4 files changed, 561 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts index 6d5928ac6c..025f1e7b80 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts @@ -219,4 +219,123 @@ describe("JsonlRpcProcess", () => { await rejection; }); + + test("reassembles protocol v2 chunked responses into the logical frame", async () => { + const child = createInMemoryChildProcess(); + const transport = startProcess({ child }); + + try { + let buffer = ""; + const sentRequest = new Promise<Record<string, unknown>>((resolve) => { + child.stdin.on("data", (chunk) => { + buffer += chunk.toString(); + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) return; + resolve(JSON.parse(buffer.slice(0, newlineIndex)) as Record<string, unknown>); + }); + }); + + const request = transport.request({ type: "chunked" }); + const command = await sentRequest; + + // Emit a logical response split into protocol v2 chunk frames. + const value = "x".repeat(1024 * 1024); + const logical = JSON.stringify({ + id: command.id, + type: "response", + command: command.type, + success: true, + data: { value }, + }); + const bytes = Buffer.from(logical, "utf8"); + expect(bytes.byteLength).toBeGreaterThan(1024 * 1024); + const chunkPayload = 256 * 1024; + const count = Math.ceil(bytes.length / chunkPayload); + expect(count).toBeGreaterThan(1); + for (let index = 0; index < count; index++) { + child.stdout.write( + `${JSON.stringify({ + type: "rpc_chunk", + chunkId: "seq1", + index, + count, + byteLength: bytes.length, + data: bytes + .subarray(index * chunkPayload, (index + 1) * chunkPayload) + .toString("base64"), + })}\n`, + ); + } + + await expect(request).resolves.toEqual({ value }); + } finally { + await transport.close(); + } + }); + + test("rejects protocol v2 chunks larger than the transport payload limit", async () => { + const child = createInMemoryChildProcess(); + const transport = startProcess({ child }); + + try { + let buffer = ""; + const sentRequest = new Promise<Record<string, unknown>>((resolve) => { + child.stdin.on("data", (chunk) => { + buffer += chunk.toString(); + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) return; + resolve(JSON.parse(buffer.slice(0, newlineIndex)) as Record<string, unknown>); + }); + }); + + const request = transport.request({ type: "oversized-chunks" }); + const command = await sentRequest; + const logicalResponse = (value: string): Buffer => + Buffer.from( + JSON.stringify({ + id: command.id, + type: "response", + command: command.type, + success: true, + data: { value }, + }), + "utf8", + ); + const oversized = logicalResponse(`invalid-${"x".repeat(1024 * 1024)}`); + const split = Math.ceil(oversized.byteLength / 2); + expect(split).toBeGreaterThan(256 * 1024); + for (let index = 0; index < 2; index += 1) { + child.stdout.write( + `${JSON.stringify({ + type: "rpc_chunk", + chunkId: "oversized", + index, + count: 2, + byteLength: oversized.byteLength, + data: oversized.subarray(index * split, (index + 1) * split).toString("base64"), + })}\n`, + ); + } + + const valid = logicalResponse(`valid-${"x".repeat(1024 * 1024)}`); + const count = Math.ceil(valid.byteLength / (256 * 1024)); + for (let index = 0; index < count; index += 1) { + child.stdout.write( + `${JSON.stringify({ + type: "rpc_chunk", + chunkId: "valid", + index, + count, + byteLength: valid.byteLength, + data: valid.subarray(index * 256 * 1024, (index + 1) * 256 * 1024).toString("base64"), + })}\n`, + ); + } + + const response = (await request) as { value: string }; + expect(response.value.startsWith("valid-")).toBe(true); + } finally { + await transport.close(); + } + }); }); diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts index 2e56a0659f..ddcbaae8a9 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts @@ -15,6 +15,11 @@ export const JSONL_RPC_NO_TIMEOUT = null; const STDERR_BUFFER_LIMIT = 8192; const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000; const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; +const RPC_V2_MAX_FRAME_BYTES = 1024 * 1024; +const RPC_V2_MAX_REASSEMBLED_BYTES = 64 * 1024 * 1024; +const RPC_V2_CHUNK_PAYLOAD_BYTES = 256 * 1024; +const RPC_V2_MAX_ENCODED_CHUNK_LENGTH = Math.ceil(RPC_V2_CHUNK_PAYLOAD_BYTES / 3) * 4; +const RPC_V2_MAX_CHUNK_COUNT = Math.ceil(RPC_V2_MAX_REASSEMBLED_BYTES / RPC_V2_CHUNK_PAYLOAD_BYTES); export interface JsonlRpcLaunch { command: string; @@ -38,6 +43,30 @@ interface PendingRequest { timer: NodeJS.Timeout | null; } +/** + * Reassembly state for a protocol v2 chunked frame sequence. Some agents that + * speak this JSONL RPC protocol (OMP) cap single-line frames at 1 MiB and emit + * larger frames as ordered `rpc_chunk` frames; the transport reassembles them + * into the original logical frame before routing it. + */ +interface PendingChunkSequence { + chunkId: string; + count: number; + byteLength: number; + nextIndex: number; + chunks: Buffer[]; + receivedBytes: number; +} + +/** Validated metadata fields of a protocol v2 `rpc_chunk` frame. */ +interface RpcChunkMetadata { + chunkId: string; + index: number; + count: number; + byteLength: number; + data: string; +} + export interface JsonlRpcExit { code: number | null; signal: NodeJS.Signals | null; @@ -51,6 +80,16 @@ export interface JsonlRpcProcessOptions { spawn?: (launch: JsonlRpcLaunch) => ChildProcessWithoutNullStreams; } +export function supportsJsonlRpcProtocolV2(message: Record<string, unknown>): boolean { + return ( + message.type === "ready" && + Array.isArray(message.supportedProtocolVersions) && + message.supportedProtocolVersions.includes(2) && + message.maxFrameBytes === RPC_V2_MAX_FRAME_BYTES && + message.maxReassembledFrameBytes === RPC_V2_MAX_REASSEMBLED_BYTES + ); +} + function assertChildWithPipes( child: ChildProcess, ): asserts child is ChildProcessWithoutNullStreams { @@ -79,6 +118,7 @@ export class JsonlRpcProcess { private nextRequestId = 1; private disposed = false; private stdoutBuffer = ""; + private pendingChunks: PendingChunkSequence | null = null; constructor(private readonly options: JsonlRpcProcessOptions) { this.diagnosticName = options.diagnosticName ?? "JSONL RPC"; @@ -218,6 +258,20 @@ export class JsonlRpcProcess { return; } const message = parsed as Record<string, unknown>; + if (message.type === "rpc_chunk") { + const reassembled = this.consumeRpcChunk(message); + if (reassembled !== undefined) { + // The reassembled frame is a complete logical frame; route it through + // the same path as any other parsed line. + this.handleLine(JSON.stringify(reassembled)); + } + return; + } + this.dispatchFrame(message); + } + + /** Route a complete logical frame to its consumer (response or subscriber). */ + private dispatchFrame(message: Record<string, unknown>): void { if (message.type === "response") { this.handleResponse(message as unknown as JsonlRpcResponse); return; @@ -227,6 +281,173 @@ export class JsonlRpcProcess { } } + /** + * Reassemble a protocol v2 `rpc_chunk` frame. Returns the reconstructed + * logical frame once the final chunk of a sequence arrives, `undefined` while + * a sequence is still in progress, and drops (returning `undefined`) any + * malformed or out-of-order chunk after logging it. + */ + private consumeRpcChunk(chunk: Record<string, unknown>): Record<string, unknown> | undefined { + const metadata = this.parseChunkMetadata(chunk); + if (metadata === undefined) { + return undefined; + } + const bytes = this.decodeChunkPayload(metadata.data); + if (bytes === undefined) { + return undefined; + } + return this.appendChunk(metadata, bytes); + } + + /** Validate a `rpc_chunk` frame's metadata, or reset the sequence and return undefined. */ + private parseChunkMetadata(chunk: Record<string, unknown>): RpcChunkMetadata | undefined { + const chunkId = chunk.chunkId; + const data = chunk.data; + const index = chunk.index; + const count = chunk.count; + const byteLength = chunk.byteLength; + if ( + typeof chunkId !== "string" || + chunkId.length === 0 || + chunkId.length > 128 || + typeof data !== "string" || + data.length === 0 || + typeof index !== "number" || + !Number.isSafeInteger(index) || + index < 0 || + typeof count !== "number" || + !Number.isSafeInteger(count) || + count < 2 || + count > RPC_V2_MAX_CHUNK_COUNT || + index >= count || + typeof byteLength !== "number" || + !Number.isSafeInteger(byteLength) || + byteLength < RPC_V2_MAX_FRAME_BYTES || + byteLength > RPC_V2_MAX_REASSEMBLED_BYTES + ) { + this.options.logger.warn( + { chunk }, + `Ignoring malformed ${this.diagnosticName} rpc_chunk frame`, + ); + this.pendingChunks = null; + return undefined; + } + return { chunkId, index, count, byteLength, data }; + } + + /** Decode a chunk payload, or reset the sequence and return undefined on bad base64. */ + private decodeChunkPayload(data: string): Buffer | undefined { + try { + if (data.length > RPC_V2_MAX_ENCODED_CHUNK_LENGTH) { + throw new Error("encoded payload exceeds the transport limit"); + } + const bytes = Buffer.from(data, "base64"); + if (bytes.byteLength > RPC_V2_CHUNK_PAYLOAD_BYTES || bytes.toString("base64") !== data) { + throw new Error("invalid base64 payload"); + } + return bytes; + } catch { + this.options.logger.warn({}, `Ignoring ${this.diagnosticName} rpc_chunk with invalid base64`); + this.pendingChunks = null; + return undefined; + } + } + + /** Append a chunk to the in-progress sequence, finalizing it once complete. */ + private appendChunk( + metadata: RpcChunkMetadata, + bytes: Buffer, + ): Record<string, unknown> | undefined { + let pending = this.pendingChunks; + if (pending === null) { + if (metadata.index !== 0) { + this.options.logger.warn( + {}, + `Ignoring ${this.diagnosticName} rpc_chunk sequence that did not start at index 0`, + ); + return undefined; + } + pending = { + chunkId: metadata.chunkId, + count: metadata.count, + byteLength: metadata.byteLength, + nextIndex: 0, + chunks: [], + receivedBytes: 0, + }; + this.pendingChunks = pending; + } + + if ( + pending.chunkId !== metadata.chunkId || + pending.count !== metadata.count || + pending.byteLength !== metadata.byteLength || + pending.nextIndex !== metadata.index + ) { + this.options.logger.warn( + {}, + `Ignoring out-of-order ${this.diagnosticName} rpc_chunk; dropping sequence`, + ); + this.pendingChunks = null; + return undefined; + } + + pending.chunks.push(bytes); + pending.receivedBytes += bytes.byteLength; + pending.nextIndex += 1; + + if (pending.receivedBytes > pending.byteLength) { + this.options.logger.warn( + {}, + `Ignoring ${this.diagnosticName} rpc_chunk sequence exceeding its declared length`, + ); + this.pendingChunks = null; + return undefined; + } + if (pending.nextIndex < pending.count) { + return undefined; + } + if (pending.receivedBytes !== pending.byteLength) { + this.options.logger.warn( + {}, + `Ignoring ${this.diagnosticName} rpc_chunk sequence with a length mismatch`, + ); + this.pendingChunks = null; + return undefined; + } + + this.pendingChunks = null; + return this.finalizeChunks(pending.chunks); + } + + /** Reassemble and parse a complete chunk sequence into its logical frame. */ + private finalizeChunks(chunks: Buffer[]): Record<string, unknown> | undefined { + let json: string; + try { + json = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); + } catch { + this.options.logger.warn( + {}, + `Ignoring ${this.diagnosticName} rpc_chunk sequence with invalid UTF-8`, + ); + return undefined; + } + let frame: unknown; + try { + frame = JSON.parse(json); + } catch { + this.options.logger.warn( + {}, + `Ignoring ${this.diagnosticName} rpc_chunk sequence with invalid JSON`, + ); + return undefined; + } + if (!frame || typeof frame !== "object" || Array.isArray(frame)) { + return undefined; + } + return frame as Record<string, unknown>; + } + private handleResponse(response: JsonlRpcResponse): void { if (!response.id) { return; diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts index da195f057f..66cd3cb0c1 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts @@ -14,7 +14,11 @@ type OmpChild = ChildProcessWithoutNullStreams & { killedSignals: Array<NodeJS.Signals | number | undefined>; }; -function createOmpChild(): OmpChild { +function createOmpChild(options?: { + supportedProtocolVersions?: number[]; + emitReady?: boolean; + maxFrameBytes?: number; +}): OmpChild { const child = Object.assign(new EventEmitter(), { stdin: new PassThrough(), stdout: new PassThrough(), @@ -28,6 +32,20 @@ function createOmpChild(): OmpChild { queueMicrotask(() => child.emit("exit", null, signal ?? null)); return true; }) as ChildProcessWithoutNullStreams["kill"]; + // Real OMP writes a `ready` frame immediately after launch; the runtime waits + // for it before negotiating the RPC protocol. Advertise v1-only by default so + // the session stays on protocol v1 and command streams are unaffected. + if (options?.emitReady !== false) { + child.stdout.write( + `${JSON.stringify({ + type: "ready", + protocolVersion: 1, + supportedProtocolVersions: options?.supportedProtocolVersions ?? [1], + maxFrameBytes: options?.maxFrameBytes ?? 1024 * 1024, + maxReassembledFrameBytes: 64 * 1024 * 1024, + })}\n`, + ); + } return child; } @@ -243,4 +261,136 @@ describe("OMP CLI runtime", () => { await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" }); }); + + test("negotiates RPC protocol v2 when OMP advertises it", async () => { + const child = createOmpChild({ supportedProtocolVersions: [1, 2] }); + const commands: Record<string, unknown>[] = []; + replyToCommands(child, (command) => { + commands.push(command); + if (command.type === "negotiate_protocol") { + return { protocolVersion: command.protocolVersion }; + } + if (command.type === "get_available_models") { + return { + models: [{ provider: "opencode-go", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }], + }; + } + return undefined; + }); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + + await expect(session.getAvailableModels()).resolves.toEqual([ + expect.objectContaining({ provider: "opencode-go", id: "deepseek-v4-flash" }), + ]); + expect(commands.map(withoutRequestId)).toContainEqual({ + type: "negotiate_protocol", + protocolVersion: 2, + }); + }); + + test("stays on protocol v1 when OMP advertises incompatible framing limits", async () => { + const child = createOmpChild({ + supportedProtocolVersions: [1, 2], + maxFrameBytes: 512 * 1024, + }); + const commands: Record<string, unknown>[] = []; + replyToCommands(child, (command) => { + commands.push(command); + return { models: [] }; + }); + + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + await session.getAvailableModels(); + + expect(commands.map(withoutRequestId)).toEqual([{ type: "get_available_models" }]); + await session.close(); + }); + + test("rejects startup when OMP exits before advertising readiness", async () => { + const child = createOmpChild({ emitReady: false }); + const startup = createRuntime(child).startSession({ cwd: "/workspace/project" }); + + child.stderr.write("startup exploded"); + child.emit("exit", 7, null); + + await expect(startup).rejects.toThrow("startup exploded"); + }); + + test("rejects startup when OMP exits during protocol negotiation", async () => { + const child = createOmpChild({ supportedProtocolVersions: [1, 2] }); + child.stdin.on("data", () => { + child.stderr.write("negotiation exploded"); + child.emit("exit", 8, null); + }); + + await expect(createRuntime(child).startSession({ cwd: "/workspace/project" })).rejects.toThrow( + "negotiation exploded", + ); + }); + + test("reassembles chunked protocol v2 responses for large payloads", async () => { + const child = createOmpChild({ supportedProtocolVersions: [1, 2] }); + const models = Array.from({ length: 2_000 }, (_, index) => ({ + provider: "p", + id: `model-${index}`, + name: `model-${index}-${"x".repeat(512)}`, + })); + let buffer = ""; + child.stdin.on("data", (chunk) => { + buffer += chunk.toString(); + for (;;) { + const newlineIndex = buffer.indexOf("\n"); + if (newlineIndex === -1) break; + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + const command = JSON.parse(line) as Record<string, unknown>; + if (command.type === "negotiate_protocol") { + child.stdout.write( + `${JSON.stringify({ + id: command.id, + type: "response", + command: command.type, + success: true, + data: { protocolVersion: 2 }, + })}\n`, + ); + continue; + } + if (command.type === "get_available_models") { + // Emit a logical response larger than the 1 MiB protocol-v1 cap as a + // chunked (protocol v2) frame sequence, like real OMP does. + const logical = JSON.stringify({ + id: command.id, + type: "response", + command: command.type, + success: true, + data: { models }, + }); + const bytes = Buffer.from(logical, "utf8"); + expect(bytes.byteLength).toBeGreaterThan(1024 * 1024); + const chunkPayload = 256 * 1024; + const count = Math.ceil(bytes.length / chunkPayload); + for (let index = 0; index < count; index++) { + child.stdout.write( + `${JSON.stringify({ + type: "rpc_chunk", + chunkId: "c1", + index, + count, + byteLength: bytes.length, + data: bytes + .subarray(index * chunkPayload, (index + 1) * chunkPayload) + .toString("base64"), + })}\n`, + ); + } + } + } + }); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + + const result = await session.getAvailableModels(); + expect(result).toHaveLength(2_000); + expect(result[0]).toEqual(expect.objectContaining({ id: "model-0" })); + }); }); diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.ts index b080a541b6..22acf58ce6 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -2,7 +2,12 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { Logger } from "pino"; import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; -import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js"; +import { + JSONL_RPC_DEFAULT_TIMEOUT_MS, + JsonlRpcProcess, + supportsJsonlRpcProtocolV2, + type JsonlRpcLaunch, +} from "../jsonl-rpc-process.js"; import { buildOmpLaunch, type OmpRuntime, @@ -40,6 +45,8 @@ import { const DEFAULT_OMP_COMMAND: [string, ...string[]] = [process.env.OMP_COMMAND ?? "omp"]; const DEFAULT_COMMANDS_RPC_NAME = "get_available_commands"; +/** How long to wait for OMP's startup `ready` frame before failing startup. */ +const OMP_READY_TIMEOUT_MS = 10_000; export interface OmpCliRuntimeOptions { logger: Logger; @@ -81,10 +88,71 @@ export class OmpCliRuntime implements OmpRuntime { ...(spawn ? { spawn: () => spawn(launch) } : {}), }; const process = new JsonlRpcProcess(processOptions); - return new OmpCliRuntimeSession(process, this.commandsRpcName); + try { + await negotiateOmpProtocolV2(process, this.options.logger); + return new OmpCliRuntimeSession(process, this.commandsRpcName); + } catch (error) { + const startupError = error instanceof Error ? error : new Error(String(error)); + await process.close(startupError); + throw startupError; + } } } +/** + * Wait for OMP to advertise readiness and negotiate RPC protocol v2 when it is + * supported. OMP caps protocol-v1 single-line frames at 1 MiB; `get_available_models` + * (and other large payloads) can exceed that and are returned as an overflow error. + * Protocol v2 lifts the ceiling to 64 MiB by chunking oversized frames, which the + * JSONL transport reassembles. Supported OMP versions send a `ready` frame immediately + * after launch; startup fails if the process exits or never becomes ready. + */ +async function negotiateOmpProtocolV2(process: JsonlRpcProcess, logger: Logger): Promise<void> { + const ready = await waitForOmpReadyFrame(process); + if (!supportsJsonlRpcProtocolV2(ready)) { + return; + } + const response = (await process.request( + { type: "negotiate_protocol", protocolVersion: 2 }, + JSONL_RPC_DEFAULT_TIMEOUT_MS, + )) as { protocolVersion?: unknown } | undefined; + if (response?.protocolVersion !== 2) { + throw new Error("OMP did not accept RPC protocol v2"); + } + logger.debug({}, "Negotiated OMP RPC protocol v2 (chunked frame transport)"); +} + +function waitForOmpReadyFrame(process: JsonlRpcProcess): Promise<Record<string, unknown>> { + return new Promise((resolve, reject) => { + let settled = false; + let unsubscribeMessage = (): void => {}; + let unsubscribeExit = (): void => {}; + let timer: NodeJS.Timeout | undefined; + const finish = (result: Record<string, unknown> | Error): void => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + unsubscribeMessage(); + unsubscribeExit(); + if (result instanceof Error) { + reject(result); + } else { + resolve(result); + } + }; + unsubscribeMessage = process.onMessage((message) => { + if (message.type === "ready") { + finish(message); + } + }); + unsubscribeExit = process.onExit(({ error }) => finish(error)); + timer = setTimeout( + () => finish(new Error("Timed out waiting for OMP to become ready")), + OMP_READY_TIMEOUT_MS, + ); + }); +} + class OmpCliRuntimeSession implements OmpRuntimeSession { private readonly subscribers = new Set<(event: OmpRuntimeEvent) => void>(); activeBranchEntryId?: string; From 1c905a114e513f37e1af6419bd4dfc8294040753 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Tue, 11 Aug 2026 19:28:35 +0200 Subject: [PATCH 24/73] Remove unused session message processing (#3231) * perf(app): remove unused session message state The legacy message accumulator had no readers but copied its full array for each activity and updated Zustand for every assistant chunk. Keep only the live voice-abort notification and voice transcription behavior. * test(app): use live session action in task subscription test --- packages/app/src/contexts/session-context.tsx | 131 +----------------- packages/app/src/stores/session-store.test.ts | 2 +- packages/app/src/stores/session-store.ts | 100 ------------- 3 files changed, 3 insertions(+), 230 deletions(-) diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 76eb785210..2de5bc5236 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -10,7 +10,7 @@ import { createSetAgentInitializing, refreshAgentInitializationTimeout, } from "@/hooks/use-agent-initialization"; -import { generateMessageId, type StreamItem } from "@/types/stream"; +import type { StreamItem } from "@/types/stream"; import { createSessionAgentStreamReducerQueue, deriveAgentStreamTurnLiveness, @@ -43,7 +43,6 @@ import type { AudioPlaybackSource } from "@/voice/audio-engine-types"; import { selectAgentTimelineState, useSessionStore, - type MessageEntry, type SessionState, } from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; @@ -71,7 +70,6 @@ import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalida // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; export type { - MessageEntry, Agent, ExplorerEntry, ExplorerFile, @@ -275,30 +273,6 @@ function finalizeTimelineApplication(input: { } } -function applyToolResultToMessages( - toolCallId: string, - result: unknown, -): (prev: MessageEntry[]) => MessageEntry[] { - return (prev) => - prev.map((msg) => - msg.type === "tool_call" && msg.id === toolCallId - ? { ...msg, result, status: "completed" as const } - : msg, - ); -} - -function applyToolErrorToMessages( - toolCallId: string, - error: unknown, -): (prev: MessageEntry[]) => MessageEntry[] { - return (prev) => - prev.map((msg) => - msg.type === "tool_call" && msg.id === toolCallId - ? { ...msg, error, status: "failed" as const } - : msg, - ); -} - function notifyVoiceAbortFailure( data: Extract<SessionOutboundMessage, { type: "activity_log" }>["payload"], notifyError: (message: string) => void, @@ -342,8 +316,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Zustand store actions const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio); - const setMessages = useSessionStore((state) => state.setMessages); - const setCurrentAssistantMessage = useSessionStore((state) => state.setCurrentAssistantMessage); const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const setAgentStreamState = useSessionStore((state) => state.setAgentStreamState); @@ -935,98 +907,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubActivity = client.on("activity_log", (message) => { if (message.type !== "activity_log") return; - const data = message.payload; - if (data.type === "system" && data.content.includes("Transcribing")) { - return; - } - - if (data.type === "tool_call" && data.metadata) { - const toolCallId = - typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : ""; - const toolName = typeof data.metadata.toolName === "string" ? data.metadata.toolName : ""; - const args = data.metadata.arguments; - - setMessages(serverId, (prev) => [ - ...prev, - { - type: "tool_call", - id: toolCallId, - timestamp: Date.now(), - toolName, - args, - status: "executing", - }, - ]); - return; - } - - if (data.type === "tool_result" && data.metadata) { - const toolCallId = - typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : ""; - const result = data.metadata.result; - - const applyToolResult = applyToolResultToMessages(toolCallId, result); - setMessages(serverId, applyToolResult); - return; - } - - if (data.type === "error" && data.metadata && "toolCallId" in data.metadata) { - const toolCallId = - typeof data.metadata.toolCallId === "string" ? data.metadata.toolCallId : ""; - const error = data.metadata.error; - - const applyToolError = applyToolErrorToMessages(toolCallId, error); - setMessages(serverId, applyToolError); - } - - notifyVoiceAbortFailure(data, toast.error); - - let activityType: "system" | "info" | "success" | "error" = "info"; - if (data.type === "error") activityType = "error"; - - if (data.type === "transcript") { - setMessages(serverId, (prev) => [ - ...prev, - { - type: "user", - id: generateMessageId(), - timestamp: Date.now(), - message: data.content, - }, - ]); - return; - } - - if (data.type === "assistant") { - setMessages(serverId, (prev) => [ - ...prev, - { - type: "assistant", - id: generateMessageId(), - timestamp: Date.now(), - message: data.content, - }, - ]); - setCurrentAssistantMessage(serverId, ""); - return; - } - - setMessages(serverId, (prev) => [ - ...prev, - { - type: "activity", - id: generateMessageId(), - timestamp: Date.now(), - activityType, - message: data.content, - metadata: data.metadata, - }, - ]); - }); - - const unsubChunk = client.on("assistant_chunk", (message) => { - if (message.type !== "assistant_chunk") return; - setCurrentAssistantMessage(serverId, (prev) => prev + message.payload.chunk); + notifyVoiceAbortFailure(message.payload, toast.error); }); const unsubTranscription = client.on("transcription_result", (message) => { @@ -1034,11 +915,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const transcriptText = message.payload.text.trim(); voiceRuntime?.onTranscriptionResult(serverId, transcriptText); - if (!transcriptText) { - return; - } - - setCurrentAssistantMessage(serverId, ""); }); const unsubVoiceInputState = client.on("voice_input_state", (message) => { @@ -1081,7 +957,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubPermissionResolved(); unsubAudioOutput(); unsubActivity(); - unsubChunk(); unsubTranscription(); unsubVoiceInputState(); unsubTerminalAttention(); @@ -1092,8 +967,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider queryClient, serverId, setIsPlayingAudio, - setMessages, - setCurrentAssistantMessage, setAgentStreamTail, setAgentStreamHead, setAgentStreamState, diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index f8ea1a4b1d..201dddaf46 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -97,7 +97,7 @@ describe("agent task state", () => { useSessionStore .getState() .setAgentStreamState("test-server", "agent-1", { taskSnapshot: tasks }); - useSessionStore.getState().setMessages("test-server", []); + useSessionStore.getState().setIsPlayingAudio("test-server", true); useSessionStore .getState() .setAgentStreamState("test-server", "agent-1", { taskSnapshot: [...tasks] }); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index c8ca04cb20..758b02c28c 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -62,47 +62,6 @@ import { type TurnLivenessTransition, } from "@/timeline/turn-liveness"; -// Re-export types that were in session-context -export type MessageEntry = - | { - type: "user"; - id: string; - timestamp: number; - message: string; - } - | { - type: "assistant"; - id: string; - timestamp: number; - message: string; - } - | { - type: "activity"; - id: string; - timestamp: number; - activityType: "system" | "info" | "success" | "error"; - message: string; - metadata?: Record<string, unknown>; - } - | { - type: "artifact"; - id: string; - timestamp: number; - artifactId: string; - artifactType: string; - title: string; - } - | { - type: "tool_call"; - id: string; - timestamp: number; - toolName: string; - args: unknown; - result?: unknown; - error?: unknown; - status: "executing" | "completed" | "failed"; - }; - export interface AgentRuntimeInfo { provider: AgentProvider; sessionId: string | null; @@ -440,10 +399,6 @@ export interface SessionState { focusedAgentId: string | null; focusedTerminalId: string | null; - // Messages - messages: MessageEntry[]; - currentAssistantMessage: string; - // Stream state (head/tail model) agentStreamTail: Map<string, StreamItem[]>; agentStreamHead: Map<string, StreamItem[]>; @@ -515,16 +470,6 @@ interface SessionStoreActions { setFocusedAgentId: (serverId: string, agentId: string | null) => void; setFocusedTerminalId: (serverId: string, terminalId: string | null) => void; - // Messages - setMessages: ( - serverId: string, - messages: MessageEntry[] | ((prev: MessageEntry[]) => MessageEntry[]), - ) => void; - setCurrentAssistantMessage: ( - serverId: string, - message: string | ((prev: string) => string), - ) => void; - // Stream state (head/tail model) setAgentStreamTail: ( serverId: string, @@ -711,8 +656,6 @@ function createInitialSessionState( isPlayingAudio: false, focusedAgentId: null, focusedTerminalId: null, - messages: [], - currentAssistantMessage: "", agentStreamTail: new Map(), agentStreamHead: new Map(), agentTasks: new Map(), @@ -1050,49 +993,6 @@ export const useSessionStore = create<SessionStore>()( }); }, - // Messages - setMessages: (serverId, messages) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - const nextMessages = - typeof messages === "function" ? messages(session.messages) : messages; - if (session.messages === nextMessages) { - return prev; - } - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, messages: nextMessages }, - }, - }; - }); - }, - - setCurrentAssistantMessage: (serverId, message) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - const nextMessage = - typeof message === "function" ? message(session.currentAssistantMessage) : message; - if (session.currentAssistantMessage === nextMessage) { - return prev; - } - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, currentAssistantMessage: nextMessage }, - }, - }; - }); - }, - // Stream state (head/tail model) setAgentStreamTail: (serverId, state) => { set((prev) => { From b9d7d4b18eab5f2bf2390aeefb0cc224022874ee Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Tue, 11 Aug 2026 19:42:51 +0200 Subject: [PATCH 25/73] Reduce storage writes during agent streaming (#3232) * perf(app): skip transient replica cache writes The replica cache subscribed to the entire session store, so live stream-head updates forced repeated JSON serialization and browser storage writes even though the head is not persisted. Select the cache's persisted inputs once and schedule writes only when that projection changes. * test(app): move perf cleanup into fixture Keep the replica-cache performance scenario as a direct setup, stream, measure, and assert flow while the spec-local Playwright fixture owns deterministic mock-agent cleanup. --- .../browser/replica-cache-performance.spec.ts | 65 ++++++++ .../e2e/support/helpers/replica-cache-perf.ts | 80 ++++++++++ .../src/runtime/replica-cache/index.test.ts | 31 +++- .../app/src/runtime/replica-cache/index.ts | 150 +++++++++++------- 4 files changed, 267 insertions(+), 59 deletions(-) create mode 100644 packages/app/e2e/browser/replica-cache-performance.spec.ts create mode 100644 packages/app/e2e/support/helpers/replica-cache-perf.ts diff --git a/packages/app/e2e/browser/replica-cache-performance.spec.ts b/packages/app/e2e/browser/replica-cache-performance.spec.ts new file mode 100644 index 0000000000..11ac76bd73 --- /dev/null +++ b/packages/app/e2e/browser/replica-cache-performance.spec.ts @@ -0,0 +1,65 @@ +import { expect, test as base } from "../support/fixtures"; +import { expectAgentIdle } from "../support/helpers/agent-stream"; +import { expectComposerVisible, submitMessage } from "../support/helpers/composer"; +import { + openAgentRoute, + seedMockAgentWorkspace, + type MockAgentWorkspace, +} from "../support/helpers/mock-agent"; +import { + beginReplicaCacheMeasurement, + observeReplicaCacheWrites, + readReplicaCacheMeasurement, +} from "../support/helpers/replica-cache-perf"; + +const RUN_REPLICA_CACHE_PERF = process.env.PASEO_REPLICA_CACHE_PERF_E2E === "1"; +const MAX_STREAM_WRITES = 12; +const MAX_SERIALIZED_CHARS = 190_000; + +const test = base.extend<{ replicaCacheAgent: MockAgentWorkspace }>({ + replicaCacheAgent: async ({ page: _page }, provide) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "replica-cache-perf-", + title: "Replica cache performance", + model: "ten-second-stream", + }); + try { + await provide(agent); + } finally { + await agent.cleanup(); + } + }, +}); +const replicaCachePerfDescribe = RUN_REPLICA_CACHE_PERF ? test.describe : test.describe.skip; + +replicaCachePerfDescribe("Replica cache performance", () => { + test("streaming writes only persisted replica changes", async ({ + page, + replicaCacheAgent: agent, + }, testInfo) => { + test.setTimeout(60_000); + await observeReplicaCacheWrites(page); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await beginReplicaCacheMeasurement(page); + + await submitMessage(page, "Measure replica persistence during a sustained stream."); + await agent.client.waitForFinish(agent.agentId, 20_000); + await expectAgentIdle(page); + await page.waitForTimeout(1_000); + + const report = await readReplicaCacheMeasurement(page); + await testInfo.attach("replica-cache-performance", { + body: JSON.stringify(report, null, 2), + contentType: "application/json", + }); + console.log(`[perf] Replica cache: ${JSON.stringify(report)}`); + + expect( + report.writes, + "streaming should not continuously persist transient state", + ).toBeLessThanOrEqual(MAX_STREAM_WRITES); + expect(report.redundantWrites).toBe(0); + expect(report.serializedChars).toBeLessThanOrEqual(MAX_SERIALIZED_CHARS); + }); +}); diff --git a/packages/app/e2e/support/helpers/replica-cache-perf.ts b/packages/app/e2e/support/helpers/replica-cache-perf.ts new file mode 100644 index 0000000000..834a694751 --- /dev/null +++ b/packages/app/e2e/support/helpers/replica-cache-perf.ts @@ -0,0 +1,80 @@ +import type { Page } from "@playwright/test"; + +const REPLICA_CACHE_STORAGE_KEY = "@paseo:replica-cache"; + +export interface ReplicaCachePerfReport { + elapsedMs: number; + redundantWrites: number; + serializedChars: number; + storageDurationMs: number; + writes: number; +} + +interface ReplicaCachePerfState { + lastValue: string | null; + resetAt: number; + redundantWrites: number; + serializedChars: number; + storageDurationMs: number; + writes: number; +} + +declare global { + interface Window { + __replicaCachePerf?: ReplicaCachePerfState; + } +} + +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); +} + +export async function beginReplicaCacheMeasurement(page: Page): Promise<void> { + await page.evaluate(() => { + const lastValue = window.__replicaCachePerf?.lastValue ?? null; + window.__replicaCachePerf = { + lastValue, + resetAt: performance.now(), + redundantWrites: 0, + serializedChars: 0, + storageDurationMs: 0, + writes: 0, + }; + }); +} + +export async function readReplicaCacheMeasurement(page: Page): Promise<ReplicaCachePerfReport> { + return 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, + }; + }); +} diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index c634ff250c..eee7f7aff7 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { @@ -15,12 +15,14 @@ const LRU_SERVER_IDS = ["host-a", "host-b", "host-c"] as const; class MemoryStorage implements ReplicaCacheStorage { readonly values = new Map<string, string>(); + writes = 0; async getItem(key: string): Promise<string | null> { return this.values.get(key) ?? null; } async setItem(key: string, value: string): Promise<void> { + this.writes += 1; this.values.set(key, value); } } @@ -154,12 +156,39 @@ function seedTimeline(serverId: string, text: string): void { } afterEach(() => { + vi.useRealTimers(); const store = useSessionStore.getState(); store.clearSession(SERVER_ID); for (const serverId of LRU_SERVER_IDS) store.clearSession(serverId); }); describe("ReplicaCache", () => { + it("persists focused replica changes without writing transient stream head updates", async () => { + vi.useFakeTimers(); + const storage = new MemoryStorage(); + const cache = new ReplicaCache(storage); + cache.setHosts([SERVER_ID]); + seedSession(); + await cache.flush(); + cache.start(); + const writesBeforeStream = storage.writes; + + useSessionStore + .getState() + .setAgentStreamHead(SERVER_ID, new Map([["agent-1", [message("live", "Streaming")]]])); + await vi.advanceTimersByTimeAsync(1_000); + + expect(storage.writes).toBe(writesBeforeStream); + + useSessionStore + .getState() + .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("saved", "Committed")]]])); + await vi.advanceTimersByTimeAsync(1_000); + + expect(storage.writes).toBe(writesBeforeStream + 1); + cache.setHosts([]); + }); + it("restores a displayable stale replica without claiming remote hydration", async () => { const storage = new MemoryStorage(); const writer = new ReplicaCache(storage); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 67c45a5474..c2649c69c6 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -57,6 +57,13 @@ const StoredCacheSchema = z.object({ type StoredAgent = z.infer<typeof StoredAgentSchema>; type StoredHost = z.infer<typeof StoredHostSchema>; +interface ReplicaInput { + agent: Agent | undefined; + workspace: WorkspaceDescriptor | undefined; + project: ProjectDescriptor | undefined; + timelineItems: StreamItem[] | undefined; +} + export interface ReplicaCacheStorage { getItem: (key: string) => Promise<string | null>; setItem: (key: string, value: string) => Promise<void>; @@ -220,6 +227,54 @@ function serializeProject(project: ProjectDescriptor) { }; } +function replicaInputsEqual(left: ReplicaInput, right: ReplicaInput): boolean { + return ( + left.agent === right.agent && + left.workspace === right.workspace && + left.project === right.project && + left.timelineItems === right.timelineItems + ); +} + +function selectReplicaInput(session: SessionState, agentId: string | null): ReplicaInput { + const agent = agentId ? session.agents.get(agentId) : undefined; + const workspace = agent + ? ((agent.workspaceId ? session.workspaces.get(agent.workspaceId) : undefined) ?? + Array.from(session.workspaces.values()).find( + (candidate) => candidate.workspaceDirectory === agent.cwd, + )) + : undefined; + const timeline = agentId + ? selectAgentTimelineState(session, agentId) + : { status: "cold" as const }; + return { + agent, + workspace, + project: workspace ? session.projects.get(workspace.projectId) : undefined, + timelineItems: timeline.status === "cold" ? undefined : timeline.items, + }; +} + +function serializeHost(serverId: string, input: ReplicaInput): StoredHost { + const items = input.timelineItems?.filter( + (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), + ); + return { + serverId, + agents: input.agent ? [serializeAgent(input.agent)] : [], + workspaces: input.workspace ? [serializeWorkspace(input.workspace)] : [], + projects: input.project ? [serializeProject(input.project)] : [], + emptyProjects: [], + timeline: + input.agent && items + ? { + agentId: input.agent.id, + items: encodeDates(items.slice(-MAX_TIMELINE_ITEMS)), + } + : null, + }; +} + function deserializeHost(stored: StoredHost): SessionReplica { const agents = stored.agents.map((entry) => deserializeAgent(stored.serverId, entry)); const workspaces = stored.workspaces.map(normalizeWorkspaceDescriptor); @@ -254,7 +309,7 @@ export class ReplicaCache { private readonly activeServerIds = new Set<string>(); private readonly storedHosts = new Map<string, StoredHost>(); private readonly lastFocusedAgentIds = new Map<string, string>(); - private readonly capturedSessions = new Map<string, SessionState>(); + private readonly capturedInputs = new Map<string, ReplicaInput>(); private readonly maxBytes: number; private needsPersist = false; private unsubscribe: (() => void) | null = null; @@ -286,8 +341,8 @@ export class ReplicaCache { for (const serverId of this.lastFocusedAgentIds.keys()) { if (!next.has(serverId)) this.lastFocusedAgentIds.delete(serverId); } - for (const serverId of this.capturedSessions.keys()) { - if (!next.has(serverId)) this.capturedSessions.delete(serverId); + for (const serverId of this.capturedInputs.keys()) { + if (!next.has(serverId)) this.capturedInputs.delete(serverId); } if (removedStoredHost) this.needsPersist = true; if (this.unsubscribe && this.needsPersist) this.schedulePersist(); @@ -321,21 +376,28 @@ export class ReplicaCache { for (const host of this.storedHosts.values()) { useSessionStore.getState().restoreSessionReplica(host.serverId, deserializeHost(host)); const session = useSessionStore.getState().sessions[host.serverId]; - if (session) this.capturedSessions.set(host.serverId, session); + if (session) { + this.capturedInputs.set( + host.serverId, + selectReplicaInput(session, this.lastFocusedAgentIds.get(host.serverId) ?? null), + ); + } } } start(): void { if (this.unsubscribe) return; + const changedBeforeSubscription = this.captureSessions(); this.unsubscribe = useSessionStore.subscribe((state) => { if (this.activeServerIds.size === 0) return; + let changed = false; for (const serverId of this.activeServerIds) { - const focusedAgentId = state.sessions[serverId]?.focusedAgentId; - if (focusedAgentId) this.lastFocusedAgentIds.set(serverId, focusedAgentId); + const session = state.sessions[serverId]; + if (session && this.captureHost(serverId, session)) changed = true; } - this.schedulePersist(); + if (changed) this.schedulePersist(); }); - if (this.needsPersist) this.schedulePersist(); + if (changedBeforeSubscription || this.needsPersist) this.schedulePersist(); } reconcileServerId(oldServerId: string, newServerId: string): void { @@ -349,10 +411,10 @@ export class ReplicaCache { this.lastFocusedAgentIds.delete(oldServerId); this.lastFocusedAgentIds.set(newServerId, focusedAgentId); } - const capturedSession = this.capturedSessions.get(oldServerId); - if (capturedSession) { - this.capturedSessions.delete(oldServerId); - this.capturedSessions.set(newServerId, capturedSession); + const capturedInput = this.capturedInputs.get(oldServerId); + if (capturedInput) { + this.capturedInputs.delete(oldServerId); + this.capturedInputs.set(newServerId, capturedInput); } if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId); this.needsPersist = true; @@ -374,57 +436,29 @@ export class ReplicaCache { await write.catch(() => undefined); } - private captureSessions(): void { + private captureSessions(): boolean { const sessions = useSessionStore.getState().sessions; + let changed = false; for (const serverId of this.activeServerIds) { const session = sessions[serverId]; if (!session) continue; - if (this.capturedSessions.get(serverId) === session) continue; - this.capturedSessions.set(serverId, session); - if (session.focusedAgentId) { - this.lastFocusedAgentIds.set(serverId, session.focusedAgentId); - } - const focusedAgentId = this.lastFocusedAgentIds.get(serverId) ?? null; - const focusedAgent = focusedAgentId ? session.agents.get(focusedAgentId) : undefined; - const focusedWorkspace = focusedAgent - ? ((focusedAgent.workspaceId - ? session.workspaces.get(focusedAgent.workspaceId) - : undefined) ?? - Array.from(session.workspaces.values()).find( - (workspace) => workspace.workspaceDirectory === focusedAgent.cwd, - )) - : undefined; - const timelineState = focusedAgentId - ? selectAgentTimelineState(session, focusedAgentId) - : { status: "cold" as const }; - const items = - timelineState.status === "cold" - ? undefined - : timelineState.items.filter( - (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), - ); - const timeline = - focusedAgent && items - ? { - agentId: focusedAgent.id, - items: encodeDates(items.slice(-MAX_TIMELINE_ITEMS)), - } - : null; - const stored: StoredHost = { - serverId, - agents: focusedAgent ? [serializeAgent(focusedAgent)] : [], - workspaces: focusedWorkspace ? [serializeWorkspace(focusedWorkspace)] : [], - projects: focusedWorkspace - ? [session.projects.get(focusedWorkspace.projectId)].flatMap((project) => - project ? [serializeProject(project)] : [], - ) - : [], - emptyProjects: [], - timeline, - }; - this.storedHosts.delete(serverId); - this.storedHosts.set(serverId, stored); + if (this.captureHost(serverId, session)) changed = true; } + return changed; + } + + private captureHost(serverId: string, session: SessionState): boolean { + if (session.focusedAgentId) { + this.lastFocusedAgentIds.set(serverId, session.focusedAgentId); + } + const input = selectReplicaInput(session, this.lastFocusedAgentIds.get(serverId) ?? null); + const previous = this.capturedInputs.get(serverId); + if (previous && replicaInputsEqual(previous, input)) return false; + + this.capturedInputs.set(serverId, input); + this.storedHosts.delete(serverId); + this.storedHosts.set(serverId, serializeHost(serverId, input)); + return true; } private buildBoundedPayload(): { payload: string; evicted: boolean } { From 2f54c0e30984b76a7b539939e6519f53b1d45ba1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Tue, 11 Aug 2026 21:20:30 +0200 Subject: [PATCH 26/73] Use saved Project Settings for new worktree setup (#3233) * fix(worktrees): honor saved setup configuration Project Settings writes paseo.json in the source checkout, but existing committed copies in new worktrees previously won silently. Copy the saved project config before lifecycle setup so the command shown to users is the command that runs. * fix(worktrees): replace config symlinks safely A selected ref can place a symlink at paseo.json, so copying over that path may overwrite a file outside the worktree. Materialize the saved source config with atomic rename semantics to replace the path entry without following it. --- docs/development.md | 4 +- .../src/server/paseo-worktree-service.ts | 4 +- .../server/src/utils/worktree.posix.test.ts | 57 ++++++++++++++++--- packages/server/src/utils/worktree.ts | 18 +++--- 4 files changed, 64 insertions(+), 19 deletions(-) diff --git a/docs/development.md b/docs/development.md index 22548cb1ee..bd573b5b96 100644 --- a/docs/development.md +++ b/docs/development.md @@ -295,7 +295,9 @@ another remote fails closed until the worktree records an explicit local target. the optional field and retain the previous local-first behavior; older worktree metadata without the exact ref also resolves through its stored branch name. -Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied. +Worktrees inherit committed Git state. Before lifecycle setup, Paseo copies the source checkout's +`paseo.json` over the worktree copy so saved Project Settings apply without a commit. Other +uncommitted source-checkout changes are not copied. ## paseo.json service scripts diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index b32123cb37..7e72275603 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -13,7 +13,7 @@ import { import { mapWorkspaceRelativeCwdToWorktree, rollbackCreatedPaseoWorktree, - seedPaseoConfigFile, + copySourcePaseoConfigFile, validateBranchSlug, type WorktreeConfig, } from "../utils/worktree.js"; @@ -85,7 +85,7 @@ async function createPaseoWorktreeWithPriority( } if (createdWorktree.created) { - await seedPaseoConfigFile({ + await copySourcePaseoConfigFile({ sourceCwd: workspaceCwdPlan.inputCwd, targetCwd: workspaceCwd, }); diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 2d45b0fe5c..73bd6c01f8 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -40,6 +40,9 @@ import { writeFileSync, readFileSync, chmodSync, + lstatSync, + symlinkSync, + unlinkSync, } from "fs"; import { delimiter, dirname, join } from "path"; import { tmpdir } from "os"; @@ -1188,10 +1191,10 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); - it("does not overwrite a committed paseo.json with uncommitted edits in the main repo", async () => { + it("runs setup from the edited source config when the selected ref already has paseo.json", async () => { writeFileSync( join(repoDir, "paseo.json"), - JSON.stringify({ scripts: { dev: { command: "committed" } } }), + JSON.stringify({ worktree: { setup: 'echo "committed" > committed-setup.log' } }), ); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], { @@ -1200,21 +1203,61 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { writeFileSync( join(repoDir, "paseo.json"), - JSON.stringify({ scripts: { dev: { command: "uncommitted" } } }), + JSON.stringify({ worktree: { setup: 'echo "edited" > edited-setup.log' } }), ); const result = await createLegacyWorktreeForTest({ cwd: repoDir, - worktreeSlug: "preserve-committed", - source: { kind: "branch-off", baseBranch: "main", branchName: "feature/preserve" }, - runSetup: false, + worktreeSlug: "edited-config", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/edited-config" }, + runSetup: true, paseoHome, }); const worktreeConfigPath = join(result.worktreePath, "paseo.json"); expect(JSON.parse(readFileSync(worktreeConfigPath, "utf8"))).toEqual({ - scripts: { dev: { command: "committed" } }, + worktree: { setup: 'echo "edited" > edited-setup.log' }, }); + expect(readFileSync(join(result.worktreePath, "edited-setup.log"), "utf8")).toBe("edited\n"); + expect(existsSync(join(result.worktreePath, "committed-setup.log"))).toBe(false); + }); + + it("replaces a selected ref's paseo.json symlink without writing through it", async () => { + const sourceConfigPath = join(repoDir, "paseo.json"); + const externalConfigPath = join(tempDir, "external-paseo.json"); + const committedConfig = JSON.stringify({ + worktree: { setup: 'echo "committed" > committed-setup.log' }, + }); + const editedConfig = JSON.stringify({ + worktree: { setup: 'echo "edited" > edited-setup.log' }, + }); + writeFileSync(externalConfigPath, committedConfig); + symlinkSync(externalConfigPath, sourceConfigPath); + execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add config link"], { + cwd: repoDir, + }); + unlinkSync(sourceConfigPath); + writeFileSync(sourceConfigPath, editedConfig); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "replace-config-link", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "feature/replace-config-link", + }, + runSetup: true, + paseoHome, + }); + + const worktreeConfigPath = join(result.worktreePath, "paseo.json"); + expect(readFileSync(externalConfigPath, "utf8")).toBe(committedConfig); + expect(lstatSync(worktreeConfigPath).isFile()).toBe(true); + expect(readFileSync(worktreeConfigPath, "utf8")).toBe(editedConfig); + expect(readFileSync(join(result.worktreePath, "edited-setup.log"), "utf8")).toBe("edited\n"); + expect(existsSync(join(result.worktreePath, "committed-setup.log"))).toBe(false); }); it("creates a worktree without error when no paseo.json exists in the main repo", async () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 1403ef7ce8..5add0f76ee 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,7 +1,7 @@ import { execFile } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs"; -import { copyFile, rm, stat } from "fs/promises"; +import { readFile, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; @@ -33,6 +33,7 @@ import { import { runGitCommand } from "./run-git-command.js"; import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; +import { writeFileAtomic } from "../server/atomic-file.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -793,21 +794,20 @@ export async function runWorktreeTeardownCommands(options: { return results; } -export async function seedPaseoConfigFile(options: { +export async function copySourcePaseoConfigFile(options: { sourceCwd: string; targetCwd: string; }): Promise<void> { const sourceConfigPath = join(options.sourceCwd, "paseo.json"); const targetConfigPath = join(options.targetCwd, "paseo.json"); + let sourceConfig: Buffer; try { - await stat(targetConfigPath); - return; + sourceConfig = await readFile(sourceConfigPath); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; } - await copyFile(sourceConfigPath, targetConfigPath).catch((error) => { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - }); + await writeFileAtomic(targetConfigPath, sourceConfig); } /** @@ -1257,7 +1257,7 @@ export const createWorktree = async ({ : {}), }); - await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); + await copySourcePaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); if (runSetup) { await runWorktreeSetupCommands({ From 40bdb9a0433e1e3f5413d337d479de267a03bf99 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Tue, 11 Aug 2026 21:48:06 +0200 Subject: [PATCH 27/73] fix(server): keep daemon workers alive across host sleep (#3235) The worker watchdog treated wall-clock time spent in host sleep as evidence that the worker was unresponsive. Count consecutive observed heartbeat misses instead so sleep does not destroy live terminal sessions while genuinely stalled workers still restart after the same active observation window. --- .../server/scripts/supervisor.logging.test.ts | 53 +++++++++++++++++++ packages/server/scripts/supervisor.ts | 12 ++--- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index 61255358f2..643bad2d79 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -10,9 +10,15 @@ import { resolveSupervisorLogFile } from "./supervisor-log-config.js"; const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url))); const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url)); +interface SupervisorSuspension { + afterMs: number; + durationMs: number; +} + async function runSupervisorFixture(options: { workerSource: string; restartOnCrash?: boolean; + suspension?: SupervisorSuspension; timeoutMs?: number; }): Promise<{ code: number | null; @@ -52,10 +58,23 @@ async function runSupervisorFixture(options: { const startedAt = Date.now(); const child = spawn(process.execPath, ["--import", "tsx", runnerPath], { cwd: repoRoot, + detached: options.suspension !== undefined, env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"], }); + if (options.suspension) { + const supervisorPid = child.pid; + if (supervisorPid === undefined) { + throw new Error("Supervisor fixture did not start"); + } + const { afterMs, durationMs } = options.suspension; + setTimeout(() => { + process.kill(-supervisorPid, "SIGSTOP"); + setTimeout(() => process.kill(-supervisorPid, "SIGCONT"), durationMs); + }, afterMs); + } + let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); @@ -241,6 +260,40 @@ describe("supervisor durable logging", () => { expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"'); }, 10_000); + test.skipIf(isPlatform("win32"))( + "keeps a healthy worker alive after the host resumes from sleep", + async () => { + const result = await runSupervisorFixture({ + suspension: { afterMs: 1_000, durationMs: 16_000 }, + timeoutMs: 25_000, + workerSource: ` + import { existsSync, writeFileSync } from "node:fs"; + + const marker = process.argv[1] + ".started"; + if (!existsSync(marker)) { + writeFileSync(marker, "started"); + setInterval(() => { + process.send?.({ type: "paseo:worker-heartbeat" }); + }, 250); + setTimeout(() => { + process.send?.({ type: "paseo:shutdown", reason: "sleep_resume_test_complete" }); + }, 17_000); + } else { + process.send?.({ type: "paseo:shutdown", reason: "unexpected_sleep_restart" }); + } + setInterval(() => {}, 1_000); + `, + }); + + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.log).toContain('"reason":"sleep_resume_test_complete"'); + expect(result.log).not.toContain('"reason":"unexpected_sleep_restart"'); + expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"'); + }, + 30_000, + ); + test.skipIf(isPlatform("win32"))( "forces shutdown when a worker ignores SIGTERM", async () => { diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index 4554e8d548..c44f2d583e 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -5,7 +5,7 @@ import { createStream as createRotatingFileStream } from "rotating-file-stream"; import { signalProcessTree } from "../src/utils/tree-kill.js"; const WORKER_HEARTBEAT_INTERVAL_MS = 1_000; -const WORKER_HEARTBEAT_TIMEOUT_MS = 15_000; +const WORKER_HEARTBEAT_MISSED_CHECK_LIMIT = 15; const WORKER_TERMINATION_GRACE_MS = 10_000; interface SupervisorLogFileOptions { @@ -250,7 +250,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController } const currentChild = child; - let lastWorkerHeartbeatAt = Date.now(); + let missedWorkerHeartbeatChecks = 0; const heartbeat = setInterval(() => { const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" }; if (currentChild.connected) { @@ -271,12 +271,12 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController if (child !== currentChild || restarting || shuttingDown) { return; } - const heartbeatAgeMs = Date.now() - lastWorkerHeartbeatAt; - if (heartbeatAgeMs < WORKER_HEARTBEAT_TIMEOUT_MS) { + missedWorkerHeartbeatChecks += 1; + if (missedWorkerHeartbeatChecks < WORKER_HEARTBEAT_MISSED_CHECK_LIMIT) { return; } writeLifecycleLog("Worker heartbeat timed out; restarting worker", { - heartbeatAgeMs, + missedHeartbeatChecks: missedWorkerHeartbeatChecks, supervisorPid: process.pid, workerPid: currentChild.pid ?? null, }); @@ -300,7 +300,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController child.on("message", (msg: unknown) => { if (isWorkerHeartbeatMessage(msg)) { - lastWorkerHeartbeatAt = Date.now(); + missedWorkerHeartbeatChecks = 0; return; } const lifecycleMessage = parseLifecycleMessage(msg); From 7b167784d7c692a4ff8676046698fb95e0fed43f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Tue, 11 Aug 2026 22:00:21 +0200 Subject: [PATCH 28/73] fix(app): bound Android provider snapshot storage (#3234) Android AsyncStorage's 6 MiB SQLite ceiling was exhausted by per-workspace provider snapshots with no eviction. Raise the native cap to 10 MiB and bound snapshots to 4 MiB with indexed eviction, clearing legacy unindexed data without scanning payloads on every save. --- packages/app/app.config.js | 2 + .../with-android-async-storage-size.js | 24 + .../src/android-async-storage-config.test.ts | 27 + .../src/data/provider-snapshot-cache.test.ts | 476 +++++++++++++++++- .../app/src/data/provider-snapshot-cache.ts | 191 +++++-- 5 files changed, 678 insertions(+), 42 deletions(-) create mode 100644 packages/app/plugins/with-android-async-storage-size.js create mode 100644 packages/app/src/android-async-storage-config.test.ts diff --git a/packages/app/app.config.js b/packages/app/app.config.js index b2f2a53d86..fef9d0e32c 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -1,6 +1,7 @@ const fs = require("node:fs"); const path = require("node:path"); 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 { getNativeReleaseVersion } = require("./native-release-version"); @@ -138,6 +139,7 @@ export default { }, plugins: [ "expo-router", + [withAndroidAsyncStorageSize, 10], ...buildProfile.cameraPlugins, [ "expo-splash-screen", diff --git a/packages/app/plugins/with-android-async-storage-size.js b/packages/app/plugins/with-android-async-storage-size.js new file mode 100644 index 0000000000..5890583563 --- /dev/null +++ b/packages/app/plugins/with-android-async-storage-size.js @@ -0,0 +1,24 @@ +const { withGradleProperties } = require("expo/config-plugins"); + +const ASYNC_STORAGE_SIZE_PROPERTY = "AsyncStorage_db_size_in_MB"; + +function withAndroidAsyncStorageSize(config, sizeInMegabytes) { + return withGradleProperties(config, (modConfig) => { + const existingProperty = modConfig.modResults.find( + (property) => property.type === "property" && property.key === ASYNC_STORAGE_SIZE_PROPERTY, + ); + if (existingProperty?.type === "property") { + existingProperty.value = String(sizeInMegabytes); + return modConfig; + } + + modConfig.modResults.push({ + type: "property", + key: ASYNC_STORAGE_SIZE_PROPERTY, + value: String(sizeInMegabytes), + }); + return modConfig; + }); +} + +module.exports = withAndroidAsyncStorageSize; diff --git a/packages/app/src/android-async-storage-config.test.ts b/packages/app/src/android-async-storage-config.test.ts new file mode 100644 index 0000000000..03091afbbd --- /dev/null +++ b/packages/app/src/android-async-storage-config.test.ts @@ -0,0 +1,27 @@ +import { execFileSync } from "node:child_process"; +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", () => { + const appDirectory = path.resolve(import.meta.dirname, ".."); + const output = execFileSync( + process.execPath, + [ + "-e", + ` + const plugin = require("./plugins/with-android-async-storage-size"); + const config = plugin({ name: "Paseo", slug: "paseo" }, 10); + const mod = config.mods.android.gradleProperties; + mod({ ...config, modResults: [], modRequest: {}, modRawConfig: config }) + .then((result) => process.stdout.write(JSON.stringify(result.modResults))); + `, + ], + { cwd: appDirectory, encoding: "utf8" }, + ); + + expect(JSON.parse(output)).toEqual([ + { type: "property", key: "AsyncStorage_db_size_in_MB", value: "10" }, + ]); + }); +}); diff --git a/packages/app/src/data/provider-snapshot-cache.test.ts b/packages/app/src/data/provider-snapshot-cache.test.ts index 2722c68747..f92e7e9bf9 100644 --- a/packages/app/src/data/provider-snapshot-cache.test.ts +++ b/packages/app/src/data/provider-snapshot-cache.test.ts @@ -1,24 +1,144 @@ import { describe, expect, it } from "vitest"; +import { Buffer } from "buffer"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; -import { createProviderSnapshotCache } from "./provider-snapshot-cache"; +import { z } from "zod"; +import { createProviderSnapshotCache, type ProviderSnapshotCache } from "./provider-snapshot-cache"; -function createStorage() { +const SNAPSHOT_KEY_PREFIX = "@paseo/provider-snapshot/v1:"; +const SNAPSHOT_INDEX_KEY = "@paseo/provider-snapshot-index/v1"; +const SnapshotIndexSchema = z.object({ + version: z.literal(1), + entries: z.array(z.object({ key: z.string(), bytes: z.number(), writtenAt: z.string() })), +}); + +function createStorage(maxSnapshotBytes = Number.POSITIVE_INFINITY) { const values = new Map<string, string>(); + const stats = { getAllKeysCalls: 0 }; + type Operation = "setItem" | "removeItem" | "multiRemove"; + let failure: { operation: Operation; timing: "before" | "after"; key?: string } | null = null; + + function takeFailure(operation: Operation, timing: "before" | "after", key?: string): boolean { + if ( + failure?.operation !== operation || + failure.timing !== timing || + (failure.key !== undefined && failure.key !== key) + ) { + return false; + } + failure = null; + return true; + } + return { values, + stats, + failNext(operation: Operation, timing: "before" | "after", key?: string) { + failure = { operation, timing, key }; + }, async getItem(key: string) { return values.get(key) ?? null; }, async setItem(key: string, value: string) { + if (takeFailure("setItem", "before", key)) throw new Error("Injected setItem failure"); + if (key.startsWith(SNAPSHOT_KEY_PREFIX)) { + const bytesWithoutReplacement = snapshotBytes( + new Map([...values].filter(([storedKey]) => storedKey !== key)), + ); + if ( + bytesWithoutReplacement + + Buffer.byteLength(key, "utf8") + + Buffer.byteLength(value, "utf8") > + maxSnapshotBytes + ) { + throw new Error("Injected storage capacity failure"); + } + } values.set(key, value); + if (takeFailure("setItem", "after", key)) throw new Error("Injected setItem failure"); }, async removeItem(key: string) { + if (takeFailure("removeItem", "before", key)) throw new Error("Injected removeItem failure"); values.delete(key); + if (takeFailure("removeItem", "after", key)) throw new Error("Injected removeItem failure"); + }, + async getAllKeys() { + stats.getAllKeysCalls += 1; + return [...values.keys()]; + }, + async multiGet(keys: readonly string[]) { + return keys.map((key) => [key, values.get(key) ?? null] as const); + }, + async multiRemove(keys: readonly string[]) { + if (takeFailure("multiRemove", "before")) throw new Error("Injected multiRemove failure"); + for (const [index, key] of keys.entries()) { + values.delete(key); + if (index === 0 && takeFailure("multiRemove", "after")) { + throw new Error("Injected partial multiRemove failure"); + } + } }, }; } +function snapshotEntries(label: string): ProviderSnapshotEntry[] { + return [ + { + provider: "pi", + status: "ready", + enabled: true, + models: [ + { + provider: "pi", + id: label, + label: label.repeat(20), + thinkingOptions: [], + }, + ], + }, + ]; +} + +function snapshotBytes(values: Map<string, string>): number { + return [...values] + .filter(([key]) => key.startsWith(SNAPSHOT_KEY_PREFIX)) + .reduce( + (total, [key, value]) => + total + Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"), + 0, + ); +} + +function expectIndexMatchesSnapshots(values: Map<string, string>): void { + const storedIndex = values.get(SNAPSHOT_INDEX_KEY); + if (!storedIndex) throw new Error("Expected provider snapshot index"); + const index = SnapshotIndexSchema.parse(JSON.parse(storedIndex)); + const storedSnapshots = [...values] + .filter(([key]) => key.startsWith(SNAPSHOT_KEY_PREFIX)) + .map(([key, value]) => ({ + key, + bytes: Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"), + })) + .toSorted((left, right) => left.key.localeCompare(right.key)); + const indexedSnapshots = index.entries + .map(({ key, bytes }) => ({ key, bytes })) + .toSorted((left, right) => left.key.localeCompare(right.key)); + expect(indexedSnapshots).toEqual(storedSnapshots); +} + +function writeSnapshot( + cache: ProviderSnapshotCache, + input: { cwd: string; label: string; generatedAt: string }, +): Promise<void> { + return cache.write({ + serverId: "server-1", + cwd: input.cwd, + hash: input.label, + generatedAt: input.generatedAt, + compactSnapshot: compactProviderSnapshot(snapshotEntries(input.label)), + }); +} + describe("provider snapshot cache", () => { it("round-trips compact snapshots with shared thinking option references", async () => { const thinkingOptions = [{ id: "high", label: "High", isDefault: true }]; @@ -60,6 +180,356 @@ describe("provider snapshot cache", () => { storage.values.set('@paseo/provider-snapshot/v1:["server-1","/repo"]', "not json"); await expect(cache.read("server-1", "/repo")).resolves.toBeNull(); - expect(storage.values.size).toBe(0); + expect([...storage.values.keys()].some((key) => key.startsWith(SNAPSHOT_KEY_PREFIX))).toBe( + false, + ); }); + + it("evicts the oldest snapshots to keep the cache within its byte budget", async () => { + const storage = createStorage(); + const maxBytes = 1_000; + const cache = createProviderSnapshotCache(storage, { maxBytes }); + + await Promise.all([ + cache.write({ + serverId: "server-1", + cwd: "/oldest", + hash: "oldest", + generatedAt: "2026-08-01T00:00:00.000Z", + compactSnapshot: compactProviderSnapshot(snapshotEntries("oldest")), + }), + cache.write({ + serverId: "server-1", + cwd: "/middle", + hash: "middle", + generatedAt: "2026-08-02T00:00:00.000Z", + compactSnapshot: compactProviderSnapshot(snapshotEntries("middle")), + }), + cache.write({ + serverId: "server-1", + cwd: "/newest", + hash: "newest", + generatedAt: "2026-08-03T00:00:00.000Z", + compactSnapshot: compactProviderSnapshot(snapshotEntries("newest")), + }), + ]); + + await expect(cache.read("server-1", "/oldest")).resolves.toBeNull(); + await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expectIndexMatchesSnapshots(storage.values); + expect(storage.stats.getAllKeysCalls).toBe(1); + }); + + it("clears unindexed snapshots once when creating the cache index", async () => { + const storage = createStorage(); + storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy"); + const cache = createProviderSnapshotCache(storage); + + await cache.write({ + serverId: "server-1", + cwd: "/current", + hash: "current", + generatedAt: "2026-08-03T00:00:00.000Z", + compactSnapshot: compactProviderSnapshot(snapshotEntries("current")), + }); + + expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); + await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + }); + + it("clears unindexed snapshots on the first read", async () => { + const storage = createStorage(); + storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy"); + const cache = createProviderSnapshotCache(storage); + + await expect(cache.read("server-1", "/legacy")).resolves.toBeNull(); + + expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); + expectIndexMatchesSnapshots(storage.values); + expect(storage.stats.getAllKeysCalls).toBe(1); + }); + + it("initializes the cache index once when the first read and write race", async () => { + const storage = createStorage(); + storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy"); + const cache = createProviderSnapshotCache(storage); + + await Promise.all([ + cache.read("server-1", "/legacy"), + writeSnapshot(cache, { + cwd: "/current", + label: "current", + generatedAt: "2026-08-02T00:00:00.000Z", + }), + ]); + + await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + expect(storage.stats.getAllKeysCalls).toBe(1); + }); + + it("replaces an indexed snapshot without counting both versions", async () => { + const storage = createStorage(); + const maxBytes = 1_000; + const cache = createProviderSnapshotCache(storage, { maxBytes }); + + await writeSnapshot(cache, { + cwd: "/repo", + label: "first", + generatedAt: "2026-08-01T00:00:00.000Z", + }); + await writeSnapshot(cache, { + cwd: "/repo", + label: "replacement-is-larger", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + await expect(cache.read("server-1", "/repo")).resolves.toMatchObject({ + hash: "replacement-is-larger", + }); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expect( + [...storage.values.keys()].filter((key) => key.startsWith(SNAPSHOT_KEY_PREFIX)), + ).toHaveLength(1); + expectIndexMatchesSnapshots(storage.values); + }); + + it("removes the previous value when a replacement exceeds the entire budget", async () => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage, { maxBytes: 1_000 }); + await writeSnapshot(cache, { + cwd: "/repo", + label: "cached", + generatedAt: "2026-08-01T00:00:00.000Z", + }); + + const tinyCache = createProviderSnapshotCache(storage, { maxBytes: 1 }); + await writeSnapshot(tinyCache, { + cwd: "/repo", + label: "too-large", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + await expect(tinyCache.read("server-1", "/repo")).resolves.toBeNull(); + expect(snapshotBytes(storage.values)).toBe(0); + expectIndexMatchesSnapshots(storage.values); + }); + + it("resets corrupted index state before accepting another snapshot", async () => { + const storage = createStorage(); + storage.values.set(SNAPSHOT_INDEX_KEY, "corrupt"); + storage.values.set(`${SNAPSHOT_KEY_PREFIX}legacy`, "legacy"); + const cache = createProviderSnapshotCache(storage); + + await writeSnapshot(cache, { + cwd: "/current", + label: "current", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}legacy`)).toBe(false); + await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + expect(storage.stats.getAllKeysCalls).toBe(1); + }); + + it("measures UTF-8 bytes rather than JavaScript string length", async () => { + const stagingStorage = createStorage(); + const stagingCache = createProviderSnapshotCache(stagingStorage); + await writeSnapshot(stagingCache, { + cwd: "/emoji", + label: "🧨".repeat(100), + generatedAt: "2026-08-02T00:00:00.000Z", + }); + const storedSnapshot = [...stagingStorage.values].find(([key]) => + key.startsWith(SNAPSHOT_KEY_PREFIX), + ); + if (!storedSnapshot) throw new Error("Expected staged provider snapshot"); + const [key, value] = storedSnapshot; + const codeUnitBytes = key.length + value.length; + const utf8Bytes = Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"); + expect(utf8Bytes).toBeGreaterThan(codeUnitBytes); + + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage, { maxBytes: codeUnitBytes }); + await writeSnapshot(cache, { + cwd: "/emoji", + label: "🧨".repeat(100), + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + await expect(cache.read("server-1", "/emoji")).resolves.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + }); + + it("keeps hundreds of concurrent writes bounded without rescanning snapshots", async () => { + const storage = createStorage(); + const maxBytes = 8_000; + const cache = createProviderSnapshotCache(storage, { maxBytes }); + const writes = Array.from({ length: 250 }, (_, index) => + writeSnapshot(cache, { + cwd: `/repo-${index}`, + label: `snapshot-${index}-${"x".repeat(index % 40)}`, + generatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), + }), + ); + + await Promise.all(writes); + + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + await expect(cache.read("server-1", "/repo-249")).resolves.not.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + expect(storage.stats.getAllKeysCalls).toBe(1); + }); + + it.each(["before", "after"] as const)( + "recovers when a snapshot write fails %s its side effect", + async (timing) => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); + await writeSnapshot(cache, { + cwd: "/stable", + label: "stable", + generatedAt: "2026-08-01T00:00:00.000Z", + }); + const failedKey = `${SNAPSHOT_KEY_PREFIX}["server-1","/failed"]`; + storage.failNext("setItem", timing, failedKey); + + await writeSnapshot(cache, { + cwd: "/failed", + label: "failed", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); + await writeSnapshot(restarted, { + cwd: "/next", + label: "next", + generatedAt: "2026-08-03T00:00:00.000Z", + }); + await expect(restarted.read("server-1", "/stable")).resolves.not.toBeNull(); + await expect(restarted.read("server-1", "/next")).resolves.not.toBeNull(); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(2_000); + expectIndexMatchesSnapshots(storage.values); + }, + ); + + it.each(["before", "after"] as const)( + "reconciles snapshot records when the derived index write fails %s its side effect", + async (timing) => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); + await writeSnapshot(cache, { + cwd: "/stable", + label: "stable", + generatedAt: "2026-08-01T00:00:00.000Z", + }); + storage.failNext("setItem", timing, SNAPSHOT_INDEX_KEY); + + await writeSnapshot(cache, { + cwd: "/orphan-after-index-failure", + label: "orphan", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); + await expect( + restarted.read("server-1", "/orphan-after-index-failure"), + ).resolves.not.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + expect(storage.stats.getAllKeysCalls).toBe(2); + }, + ); + + it("repairs partial eviction, stale index metadata, and preserves unrelated storage", async () => { + const storage = createStorage(); + const maxBytes = 1_000; + const cache = createProviderSnapshotCache(storage, { maxBytes }); + storage.values.set("@paseo/unrelated", "keep-me"); + await writeSnapshot(cache, { + cwd: "/oldest", + label: "oldest", + generatedAt: "2026-08-01T00:00:00.000Z", + }); + await writeSnapshot(cache, { + cwd: "/middle", + label: "middle", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + storage.failNext("multiRemove", "after"); + await writeSnapshot(cache, { + cwd: "/large-newest", + label: "newest", + generatedAt: "2026-08-03T00:00:00.000Z", + }); + storage.values.set( + SNAPSHOT_INDEX_KEY, + JSON.stringify({ + version: 1, + entries: [{ key: `${SNAPSHOT_KEY_PREFIX}phantom`, bytes: 999_999, writtenAt: "bad" }], + }), + ); + + const restarted = createProviderSnapshotCache(storage, { maxBytes }); + await writeSnapshot(restarted, { + cwd: "/final", + label: "final", + generatedAt: "2026-08-04T00:00:00.000Z", + }); + + expect(storage.values.get("@paseo/unrelated")).toBe("keep-me"); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expectIndexMatchesSnapshots(storage.values); + await expect(restarted.read("server-1", "/final")).resolves.not.toBeNull(); + }); + + it("evicts before writing when storage has no temporary headroom", async () => { + const maxBytes = 1_000; + const storage = createStorage(maxBytes); + const cache = createProviderSnapshotCache(storage, { maxBytes }); + await writeSnapshot(cache, { + cwd: "/oldest", + label: "oldest", + generatedAt: "2026-08-01T00:00:00.000Z", + }); + await writeSnapshot(cache, { + cwd: "/middle", + label: "middle", + generatedAt: "2026-08-02T00:00:00.000Z", + }); + + await writeSnapshot(cache, { + cwd: "/newest", + label: "newest", + generatedAt: "2026-08-03T00:00:00.000Z", + }); + + await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expectIndexMatchesSnapshots(storage.values); + }); + + it.each(["before", "after"] as const)( + "recovers when invalid-record cleanup fails %s deletion", + async (timing) => { + const storage = createStorage(); + const key = `${SNAPSHOT_KEY_PREFIX}["server-1","/invalid"]`; + storage.values.set(key, "invalid"); + storage.failNext("multiRemove", timing); + const cache = createProviderSnapshotCache(storage); + + await expect(cache.read("server-1", "/invalid")).resolves.toBeNull(); + await writeSnapshot(cache, { + cwd: "/recovered", + label: "recovered", + generatedAt: "2026-08-03T00:00:00.000Z", + }); + + expect(storage.values.has(key)).toBe(false); + await expect(cache.read("server-1", "/recovered")).resolves.not.toBeNull(); + expectIndexMatchesSnapshots(storage.values); + }, + ); }); diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index c7da601123..43b0156616 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -1,4 +1,5 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import { Buffer } from "buffer"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { expandProviderSnapshot, @@ -8,11 +9,21 @@ import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages"; const CACHE_VERSION = 1; const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1"; +const CACHE_INDEX_KEY = "@paseo/provider-snapshot-index/v1"; +const CACHE_INDEX_VERSION = 1; +const DEFAULT_MAX_CACHE_BYTES = 4 * 1024 * 1024; interface ProviderSnapshotStorage { getItem(key: string): Promise<string | null>; setItem(key: string, value: string): Promise<void>; removeItem(key: string): Promise<void>; + getAllKeys(): Promise<readonly string[]>; + multiGet(keys: readonly string[]): Promise<ReadonlyArray<readonly [string, string | null]>>; + multiRemove(keys: readonly string[]): Promise<void>; +} + +interface ProviderSnapshotCacheOptions { + maxBytes?: number; } interface StoredProviderSnapshot { @@ -22,6 +33,17 @@ interface StoredProviderSnapshot { compactSnapshot: CompactProviderSnapshot; } +interface ProviderSnapshotIndexEntry { + key: string; + bytes: number; + writtenAt: string; +} + +interface ProviderSnapshotIndex { + version: typeof CACHE_INDEX_VERSION; + entries: ProviderSnapshotIndexEntry[]; +} + export interface CachedProviderSnapshot extends StoredProviderSnapshot { entries: ProviderSnapshotEntry[]; } @@ -48,6 +70,15 @@ function cacheKey(serverId: string, cwd: string | null): string { return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`; } +function storedBytes(key: string, value: string): number { + return Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"); +} + +function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIndexEntry): number { + const writtenAtOrder = left.writtenAt.localeCompare(right.writtenAt); + return writtenAtOrder !== 0 ? writtenAtOrder : left.key.localeCompare(right.key); +} + function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null { const parsed: unknown = JSON.parse(value); if (typeof parsed !== "object" || parsed === null) return null; @@ -65,60 +96,142 @@ function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | nu ) { return null; } - return { - version, - hash, - generatedAt, - compactSnapshot: compactSnapshot.data, - }; -} - -async function discardCachedSnapshot(storage: ProviderSnapshotStorage, key: string): Promise<void> { - try { - await storage.removeItem(key); - } catch (error) { - if (!(error instanceof Error)) throw error; - } + return { version, hash, generatedAt, compactSnapshot: compactSnapshot.data }; } export function createProviderSnapshotCache( storage: ProviderSnapshotStorage, + options: ProviderSnapshotCacheOptions = {}, ): ProviderSnapshotCache { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_CACHE_BYTES; + let index: ProviderSnapshotIndex | null = null; + let operationQueue = Promise.resolve(); + + async function persistIndex(entries: ProviderSnapshotIndexEntry[]): Promise<void> { + const nextIndex: ProviderSnapshotIndex = { version: CACHE_INDEX_VERSION, entries }; + await storage.setItem(CACHE_INDEX_KEY, JSON.stringify(nextIndex)); + index = nextIndex; + } + + async function reconcileCache(): Promise<void> { + const keys = (await storage.getAllKeys()).filter((key) => + key.startsWith(`${CACHE_KEY_PREFIX}:`), + ); + const values = await storage.multiGet(keys); + const keysToRemove: string[] = []; + const entries: ProviderSnapshotIndexEntry[] = []; + for (const [key, value] of values) { + if (value === null) continue; + try { + const stored = parseStoredProviderSnapshot(value); + if (!stored) { + keysToRemove.push(key); + continue; + } + entries.push({ key, bytes: storedBytes(key, value), writtenAt: stored.generatedAt }); + } catch (error) { + if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; + keysToRemove.push(key); + } + } + + let totalBytes = entries.reduce((total, entry) => total + entry.bytes, 0); + const retainedEntries = entries.toSorted(oldestFirst); + while (totalBytes > maxBytes) { + const evicted = retainedEntries.shift(); + if (!evicted) break; + totalBytes -= evicted.bytes; + keysToRemove.push(evicted.key); + } + if (keysToRemove.length > 0) await storage.multiRemove(keysToRemove); + await persistIndex(retainedEntries); + } + + async function ensureCacheIndex(): Promise<ProviderSnapshotIndex> { + if (index === null) await reconcileCache(); + if (index === null) throw new Error("Provider snapshot cache reconciliation failed"); + return index; + } + + async function runSerialized<T>(operation: () => Promise<T>): Promise<T> { + const result = operationQueue.then(operation); + operationQueue = result.then( + () => undefined, + () => { + index = null; + }, + ); + return result; + } + + async function writeSnapshot(input: { + serverId: string; + cwd: string | null; + hash: string; + generatedAt: string; + compactSnapshot: CompactProviderSnapshot; + }): Promise<void> { + const currentIndex = await ensureCacheIndex(); + const key = cacheKey(input.serverId, input.cwd); + const value = JSON.stringify({ + version: CACHE_VERSION, + hash: input.hash, + generatedAt: input.generatedAt, + compactSnapshot: input.compactSnapshot, + } satisfies StoredProviderSnapshot); + const incomingBytes = storedBytes(key, value); + const canStoreIncoming = incomingBytes <= maxBytes; + const retainedEntries = currentIndex.entries.filter((entry) => entry.key !== key); + let projectedBytes = retainedEntries.reduce((total, entry) => total + entry.bytes, 0); + if (canStoreIncoming) projectedBytes += incomingBytes; + + const keysToRemove: string[] = []; + for (const candidate of retainedEntries.toSorted(oldestFirst)) { + if (projectedBytes <= maxBytes) break; + projectedBytes -= candidate.bytes; + keysToRemove.push(candidate.key); + } + const removedKeys = new Set(keysToRemove); + const nextEntries = retainedEntries.filter((entry) => !removedKeys.has(entry.key)); + + if (!canStoreIncoming && currentIndex.entries.some((entry) => entry.key === key)) { + keysToRemove.push(key); + } + if (keysToRemove.length > 0) await storage.multiRemove(keysToRemove); + if (canStoreIncoming) { + await storage.setItem(key, value); + nextEntries.push({ key, bytes: incomingBytes, writtenAt: input.generatedAt }); + } + await persistIndex(nextEntries); + } + return { async read(serverId, cwd) { - const key = cacheKey(serverId, cwd); - let value: string | null; try { - value = await storage.getItem(key); + return await runSerialized(async () => { + const currentIndex = await ensureCacheIndex(); + const key = cacheKey(serverId, cwd); + const value = await storage.getItem(key); + if (value === null) return null; + try { + const stored = parseStoredProviderSnapshot(value); + if (!stored) throw new SyntaxError("Invalid provider snapshot"); + return { ...stored, entries: expandProviderSnapshot(stored.compactSnapshot) }; + } catch (error) { + if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; + await storage.removeItem(key); + await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key)); + return null; + } + }); } catch (error) { if (error instanceof Error) return null; throw error; } - if (value === null) return null; - try { - const stored = parseStoredProviderSnapshot(value); - if (!stored) { - await discardCachedSnapshot(storage, key); - return null; - } - return { ...stored, entries: expandProviderSnapshot(stored.compactSnapshot) }; - } catch (error) { - if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) { - throw error; - } - await discardCachedSnapshot(storage, key); - return null; - } }, async write(input) { - const stored: StoredProviderSnapshot = { - version: CACHE_VERSION, - hash: input.hash, - generatedAt: input.generatedAt, - compactSnapshot: input.compactSnapshot, - }; try { - await storage.setItem(cacheKey(input.serverId, input.cwd), JSON.stringify(stored)); + await runSerialized(() => writeSnapshot(input)); } catch (error) { if (!(error instanceof Error)) throw error; } From 23d7a8eaf9b31afae5d7d7d7f047eb2b36232403 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 00:18:30 +0200 Subject: [PATCH 29/73] Improve Markdown file preview readability (#3240) * feat(app): improve markdown file previews Keep rendered documents on the chat reading rail, surface YAML front matter as readable metadata, and make shared Markdown links retain the chat accent and hover treatment. * fix(app): preserve unsafe frontmatter source * test(app): cover markdown link behavior --- nix/npm-deps.hash | 2 +- package-lock.json | 1 + packages/app/package.json | 1 + .../app/src/assistant-file-links/link.tsx | 40 ++------ .../src/components/markdown/link-children.ts | 21 ++++ .../app/src/components/markdown/link-text.tsx | 41 ++++++++ .../src/components/markdown/renderer.test.ts | 72 +++++++++++++- .../app/src/components/markdown/renderer.tsx | 14 ++- packages/app/src/components/message.tsx | 12 +-- .../markdown-preview/document.test.ts | 84 ++++++++++++++++ .../file-pane/markdown-preview/document.ts | 70 ++++++++++++++ .../src/file-pane/markdown-preview/index.tsx | 95 +++++++++++++++++++ packages/app/src/file-pane/pane.tsx | 8 +- 13 files changed, 411 insertions(+), 50 deletions(-) create mode 100644 packages/app/src/components/markdown/link-children.ts create mode 100644 packages/app/src/components/markdown/link-text.tsx create mode 100644 packages/app/src/file-pane/markdown-preview/document.test.ts create mode 100644 packages/app/src/file-pane/markdown-preview/document.ts create mode 100644 packages/app/src/file-pane/markdown-preview/index.tsx diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index c955eb4271..f2b4328a7f 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-m6anpp3ESvWCVQAoTsOhnTMpoewhpJTeVha2Gv4Xnc4= +sha256-L73o8u6OsY3SsA5t2s5mWsMsMNc3qr5bCE4cd1NpbgI= diff --git a/package-lock.json b/package-lock.json index de6b745bf5..e95d8602aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36179,6 +36179,7 @@ "turndown": "^7.2.4", "turndown-plugin-gfm": "^1.0.2", "use-sync-external-store": "^1.6.0", + "yaml": "^2.8.4", "zod": "^4.4.3", "zustand": "^5.0.9" }, diff --git a/packages/app/package.json b/packages/app/package.json index cd15f23a4e..18bdb283f9 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -119,6 +119,7 @@ "turndown": "^7.2.4", "turndown-plugin-gfm": "^1.0.2", "use-sync-external-store": "^1.6.0", + "yaml": "^2.8.4", "zod": "^4.4.3", "zustand": "^5.0.9" }, diff --git a/packages/app/src/assistant-file-links/link.tsx b/packages/app/src/assistant-file-links/link.tsx index d99188cff4..9b2838c3d2 100644 --- a/packages/app/src/assistant-file-links/link.tsx +++ b/packages/app/src/assistant-file-links/link.tsx @@ -1,16 +1,9 @@ -import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"; -import { - Platform, - Pressable, - Text, - View, - type StyleProp, - type TextStyle, - type ViewStyle, -} from "react-native"; +import { useMemo, type CSSProperties, type MouseEvent, type ReactNode } from "react"; +import { Platform, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { isNative, isWeb } from "@/constants/platform"; import { MarkdownTextSpan } from "@/components/markdown-text"; +import { MarkdownLinkText } from "@/components/markdown/link-text"; import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -39,7 +32,6 @@ export function AssistantMarkdownLink({ monoSurface, children, }: AssistantMarkdownLinkProps) { - const [hovered, setHovered] = useState(false); const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source); const { configRef } = useAssistantFileLinkResolverContext(); const workspaceRoot = configRef.current.workspaceRoot; @@ -55,15 +47,6 @@ export function AssistantMarkdownLink({ event.stopPropagation(); onAuxPress(); }); - const handleHoverIn = useStableEvent(() => { - setHovered(true); - onHoverIn(); - }); - const handleHoverOut = useStableEvent(() => setHovered(false)); - const hoveredTextStyle = useMemo<StyleProp<TextStyle>>( - () => [style, hovered && { textDecorationLine: "underline" as const }], - [style, hovered], - ); const linkPress = useMemo<AssistantLinkPress>( () => ({ onPress, accessibilityRole: "link" }), [onPress], @@ -114,19 +97,14 @@ export function AssistantMarkdownLink({ onAuxClickCapture={preventAnchorNavigation} style={LINK_ANCHOR_STYLE} > - <Pressable - accessibilityRole="link" + <MarkdownLinkText + dataSet={monoSurface ? MARKDOWN_CODE_LINK_DATASET : undefined} + style={style} onPress={onPress} - onHoverIn={handleHoverIn} - onHoverOut={handleHoverOut} + onHoverIn={onHoverIn} > - <Text - dataSet={monoSurface ? MARKDOWN_CODE_LINK_DATASET : undefined} - style={hoveredTextStyle} - > - {children} - </Text> - </Pressable> + {children} + </MarkdownLinkText> </a> ); diff --git a/packages/app/src/components/markdown/link-children.ts b/packages/app/src/components/markdown/link-children.ts new file mode 100644 index 0000000000..9a81042c86 --- /dev/null +++ b/packages/app/src/components/markdown/link-children.ts @@ -0,0 +1,21 @@ +import { Children, cloneElement, isValidElement, type ReactNode } from "react"; +import type { StyleProp, TextStyle } from "react-native"; + +export function colorMarkdownLinkChildren( + children: ReactNode, + color: TextStyle["color"], +): ReactNode { + return Children.map(children, (child) => { + if (!isValidElement<{ style?: StyleProp<TextStyle> }>(child)) return child; + return cloneElement(child, { style: [child.props.style, { color }] }); + }); +} + +const MARKDOWN_LINK_HOVER_STYLE: TextStyle = { textDecorationLine: "underline" }; + +export function markdownLinkTextStyle( + style: StyleProp<TextStyle>, + hovered: boolean, +): StyleProp<TextStyle> { + return [style, hovered && MARKDOWN_LINK_HOVER_STYLE]; +} diff --git a/packages/app/src/components/markdown/link-text.tsx b/packages/app/src/components/markdown/link-text.tsx new file mode 100644 index 0000000000..18b1b6c93b --- /dev/null +++ b/packages/app/src/components/markdown/link-text.tsx @@ -0,0 +1,41 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { Pressable, Text, type StyleProp, type TextStyle } from "react-native"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { markdownLinkTextStyle } from "./link-children"; + +interface MarkdownLinkTextProps { + style: StyleProp<TextStyle>; + dataSet?: Record<string, string>; + onPress(): void; + onHoverIn?(): void; + children?: ReactNode; +} + +export function MarkdownLinkText({ + style, + dataSet, + onPress, + onHoverIn, + children, +}: MarkdownLinkTextProps) { + const [hovered, setHovered] = useState(false); + const handleHoverIn = useStableEvent(() => { + setHovered(true); + onHoverIn?.(); + }); + const handleHoverOut = useStableEvent(() => setHovered(false)); + const textStyle = useMemo(() => markdownLinkTextStyle(style, hovered), [hovered, style]); + + return ( + <Pressable + accessibilityRole="link" + onPress={onPress} + onHoverIn={handleHoverIn} + onHoverOut={handleHoverOut} + > + <Text dataSet={dataSet} style={textStyle}> + {children} + </Text> + </Pressable> + ); +} diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 9412d46332..1dc279a2d5 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -1,5 +1,48 @@ -import { describe, expect, it } from "vitest"; +/** + * @vitest-environment jsdom + */ +import * as React from "react"; +import { createElement, type ReactNode } from "react"; +import { fireEvent, render } from "@testing-library/react"; +import { Text, type StyleProp, type TextStyle } from "react-native"; +import { describe, expect, it, vi } from "vitest"; import { resolveInlineImageSize } from "./inline-image-size"; +import { colorMarkdownLinkChildren } from "./link-children"; +import { MarkdownLinkText } from "./link-text"; + +vi.stubGlobal("React", React); + +vi.mock("react-native", () => ({ + Pressable: ({ + accessibilityRole, + children, + onHoverIn, + onHoverOut, + onPress, + }: { + accessibilityRole?: string; + children?: ReactNode; + onHoverIn?(): void; + onHoverOut?(): void; + onPress?(): void; + }) => + createElement( + "div", + { + role: accessibilityRole, + onClick: onPress, + onMouseEnter: onHoverIn, + onMouseLeave: onHoverOut, + }, + children, + ), + Text: ({ children, style }: { children?: ReactNode; style?: StyleProp<TextStyle> }) => + createElement("span", { style: flattenStyle(style) }, children), +})); + +function flattenStyle(style: StyleProp<TextStyle>): TextStyle { + return Object.assign({}, ...(Array.isArray(style) ? style.filter(Boolean) : [style])); +} describe("resolveInlineImageSize", () => { it("respects a one-sided explicit width using natural aspect ratio", () => { @@ -27,3 +70,30 @@ describe("resolveInlineImageSize", () => { }); }); }); + +describe("shared Markdown links", () => { + it("renders accent text and underlines it while hovered", () => { + const onPress = vi.fn(); + const children = colorMarkdownLinkChildren( + createElement(Text, { style: { color: "white" } }, "Paseo"), + "rgb(0, 122, 255)", + ); + const view = render( + createElement(MarkdownLinkText, { style: { color: "rgb(0, 122, 255)" }, onPress }, children), + ); + const link = view.getByRole("link"); + const linkText = link.firstElementChild as HTMLElement; + + expect((view.getByText("Paseo") as HTMLElement).style.color).toBe("rgb(0, 122, 255)"); + expect(linkText.style.textDecorationLine).toBe(""); + + fireEvent.mouseEnter(link); + expect(linkText.style.textDecorationLine).toBe("underline"); + + fireEvent.mouseLeave(link); + expect(linkText.style.textDecorationLine).toBe(""); + + fireEvent.click(link); + expect(onPress).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index ddb82ee337..d9b0cc2be1 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -31,6 +31,7 @@ import { markdownNodeContainsType } from "@/utils/markdown-ast"; import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles"; import type { Theme } from "@/styles/theme"; import { openExternalUrl } from "@/utils/open-external-url"; +import { isNative } from "@/constants/platform"; import { splitHtmlishMarkdown, type MarkdownDisplayPart, @@ -38,6 +39,8 @@ import { } from "./html-ish"; import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size"; import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups"; +import { colorMarkdownLinkChildren } from "./link-children"; +import { MarkdownLinkText } from "./link-text"; export type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>; @@ -478,6 +481,15 @@ function SharedMarkdownLink({ if (onLinkPress?.(href) === false) return; void openExternalUrl(href); }, [href, onLinkPress]); + const style = useMemo(() => [inheritedStyles, linkStyle], [inheritedStyles, linkStyle]); + + if (!isNative) { + return ( + <MarkdownLinkText style={style} onPress={handlePress}> + {children} + </MarkdownLinkText> + ); + } return ( <MarkdownInheritedText @@ -715,7 +727,7 @@ export function createSharedMarkdownRules(): RenderRules { linkStyle={styles.link} onLinkPress={onLinkPress} > - {children} + {colorMarkdownLinkChildren(children, styles.link.color)} </SharedMarkdownLink> ), }; diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index b6a0162fdf..1615c7b73c 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -23,9 +23,6 @@ import { useCallback, createContext, useContext, - isValidElement, - Children, - cloneElement, } from "react"; import type { ComponentType, ReactNode } from "react"; import type MarkdownIt from "markdown-it"; @@ -77,6 +74,7 @@ import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { MarkdownFenceBlock } from "@/components/markdown/fence"; import type { MarkdownPhase } from "@/components/markdown/fence/types"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; +import { colorMarkdownLinkChildren } from "@/components/markdown/link-children"; import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; @@ -1863,13 +1861,7 @@ export const AssistantMessage = memo(function AssistantMessage({ source={getMarkdownLinkSource(node)} style={styles.link} > - {Children.map(children, (child) => { - if (!isValidElement(child)) return child; - const childProps = child.props as { style?: StyleProp<TextStyle> }; - return cloneElement(child, { - style: [childProps.style, { color: styles.link.color }], - } as Partial<{ style: StyleProp<TextStyle> }>); - })} + {colorMarkdownLinkChildren(children, styles.link.color)} </AssistantMarkdownLink> ), image: ( diff --git a/packages/app/src/file-pane/markdown-preview/document.test.ts b/packages/app/src/file-pane/markdown-preview/document.test.ts new file mode 100644 index 0000000000..020d7246d6 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/document.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { parseMarkdownPreviewDocument } from "./document"; + +describe("parseMarkdownPreviewDocument", () => { + it("separates YAML front matter from the Markdown body", () => { + expect( + parseMarkdownPreviewDocument(`--- +name: release-beta +description: Cut a beta release +allowed-tools: + - Bash + - Read +--- +# Release beta + +Ship it. +`), + ).toEqual({ + frontMatter: [ + { key: "name", value: "release-beta" }, + { key: "description", value: "Cut a beta release" }, + { key: "allowed-tools", value: "Bash, Read" }, + ], + body: "# Release beta\n\nShip it.\n", + }); + }); + + it("formats nested metadata without flattening its meaning", () => { + expect( + parseMarkdownPreviewDocument(`--- +metadata: + audience: maintainers + stable: true +--- +Body +`), + ).toEqual({ + frontMatter: [{ key: "metadata", value: "audience: maintainers\nstable: true" }], + body: "Body\n", + }); + }); + + it("leaves the document intact when front matter is invalid", () => { + const source = `--- +name: [broken +--- +Body +`; + + expect(parseMarkdownPreviewDocument(source)).toEqual({ + frontMatter: [], + body: source, + }); + }); + + it("leaves the document intact when YAML conversion rejects excessive aliases", () => { + const aliases = Array.from({ length: 101 }, () => "*item").join(", "); + const source = `--- +item: &item value +items: [${aliases}] +--- +Body +`; + + expect(parseMarkdownPreviewDocument(source)).toEqual({ + frontMatter: [], + body: source, + }); + }); + + it("does not treat a thematic break inside the document as front matter", () => { + const source = `Introduction + +--- + +Body +`; + + expect(parseMarkdownPreviewDocument(source)).toEqual({ + frontMatter: [], + body: source, + }); + }); +}); diff --git a/packages/app/src/file-pane/markdown-preview/document.ts b/packages/app/src/file-pane/markdown-preview/document.ts new file mode 100644 index 0000000000..7e2fff5206 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/document.ts @@ -0,0 +1,70 @@ +import { parseDocument, stringify } from "yaml"; + +export interface MarkdownFrontMatterRow { + key: string; + value: string; +} + +export interface MarkdownPreviewDocument { + frontMatter: MarkdownFrontMatterRow[]; + body: string; +} + +const FRONT_MATTER_PATTERN = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; + +export function parseMarkdownPreviewDocument(source: string): MarkdownPreviewDocument { + const match = FRONT_MATTER_PATTERN.exec(source); + if (!match) { + return { frontMatter: [], body: source }; + } + + try { + return parseFrontMatter(match, source); + } catch { + return { frontMatter: [], body: source }; + } +} + +function parseFrontMatter(match: RegExpExecArray, source: string): MarkdownPreviewDocument { + const document = parseDocument(match[1], { schema: "core" }); + if (document.errors.length > 0) { + return { frontMatter: [], body: source }; + } + + const metadata = document.toJS({ maxAliasCount: 100 }); + if (!isMetadataRecord(metadata)) { + return { frontMatter: [], body: source }; + } + + const frontMatter = Object.entries(metadata).map(([key, value]) => ({ + key, + value: formatFrontMatterValue(value), + })); + + return { + frontMatter, + body: source.slice(match[0].length), + }; +} + +function isMetadataRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatFrontMatterValue(value: unknown): string { + if (Array.isArray(value) && value.every(isScalar)) { + return value.map(formatScalar).join(", "); + } + if (isScalar(value)) { + return formatScalar(value); + } + return stringify(value).trim(); +} + +function isScalar(value: unknown): value is string | number | boolean | null { + return value === null || ["string", "number", "boolean"].includes(typeof value); +} + +function formatScalar(value: string | number | boolean | null): string { + return value === null ? "null" : String(value); +} diff --git a/packages/app/src/file-pane/markdown-preview/index.tsx b/packages/app/src/file-pane/markdown-preview/index.tsx new file mode 100644 index 0000000000..1e8a3bfbd6 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/index.tsx @@ -0,0 +1,95 @@ +import { useMemo } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { MarkdownRenderer } from "@/components/markdown/renderer"; +import { MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { parseMarkdownPreviewDocument } from "./document"; + +export function FileMarkdownPreview({ source }: { source: string }) { + const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]); + + return ( + <View style={styles.outerGutter}> + <View style={styles.readingFrame}> + {document.frontMatter.length > 0 ? ( + <View style={styles.frontMatterTable} testID="markdown-front-matter"> + {document.frontMatter.map((row, index) => ( + <View + key={row.key} + style={[styles.frontMatterRow, index > 0 && styles.frontMatterRowBorder]} + > + <View style={styles.frontMatterKeyCell}> + <Text selectable style={styles.frontMatterKey}> + {row.key} + </Text> + </View> + <View style={styles.frontMatterValueCell}> + <Text selectable style={styles.frontMatterValue}> + {row.value} + </Text> + </View> + </View> + ))} + </View> + ) : null} + <MarkdownRenderer text={document.body} /> + </View> + </View> + ); +} + +const styles = StyleSheet.create((theme) => ({ + outerGutter: { + width: "100%", + paddingHorizontal: { + xs: theme.spacing[3], + md: theme.spacing[4], + }, + paddingVertical: theme.spacing[4], + }, + readingFrame: { + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + alignSelf: "center", + paddingHorizontal: theme.spacing[2], + }, + frontMatterTable: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + overflow: "hidden", + marginBottom: theme.spacing[6], + }, + frontMatterRow: { + flexDirection: "row", + alignItems: "stretch", + }, + frontMatterRowBorder: { + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + }, + frontMatterKeyCell: { + flexBasis: "30%", + maxWidth: 180, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.surface2, + }, + frontMatterValueCell: { + flex: 1, + minWidth: 0, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + frontMatterKey: { + color: theme.colors.foregroundMuted, + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.sm, + lineHeight: 20, + }, + frontMatterValue: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + lineHeight: 20, + }, +})); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 07b0ee2671..c412504e0c 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -12,7 +12,6 @@ import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/dae import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native"; import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { MarkdownRenderer } from "@/components/markdown/renderer"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; @@ -36,6 +35,7 @@ import { useAppSettings } from "@/hooks/use-settings"; import { useLiveFile } from "./live-file/hook"; import { FilePanelBar } from "./bar"; import { FileHtmlPreview } from "./html-preview"; +import { FileMarkdownPreview } from "./markdown-preview"; import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model"; import { createFileObservationSource } from "./editor/observation-source"; import { FileEditorView } from "./editor/view"; @@ -305,10 +305,9 @@ function FilePreviewBody({ <RNScrollView ref={previewScrollRef} style={styles.previewContent} - contentContainerStyle={styles.previewMarkdownScrollContent} showsVerticalScrollIndicator > - <MarkdownRenderer text={preview.content ?? ""} /> + <FileMarkdownPreview source={preview.content ?? ""} /> </RNScrollView> </View> ); @@ -881,9 +880,6 @@ const styles = StyleSheet.create((theme) => ({ previewCodeScrollContent: { padding: theme.spacing[4], }, - previewMarkdownScrollContent: { - padding: theme.spacing[4], - }, previewImageScrollContent: { flexGrow: 1, padding: theme.spacing[4], From 5f5c585e68ffe580065fc9f4349a5b4384c442d1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 00:23:22 +0200 Subject: [PATCH 30/73] fix(app): avoid unsupported Android array sorting (#3241) Hermes in the production Android app does not provide Array.prototype.toSorted. Multi-host project selection and provider cache eviction could therefore crash when they reached deterministic sorting paths. --- .../app/src/data/provider-snapshot-cache.ts | 4 +-- .../app/src/projects/host-project-model.ts | 2 +- .../app/src/projects/host-projects.test.ts | 26 +++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index 43b0156616..d252613d19 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -136,7 +136,7 @@ export function createProviderSnapshotCache( } let totalBytes = entries.reduce((total, entry) => total + entry.bytes, 0); - const retainedEntries = entries.toSorted(oldestFirst); + const retainedEntries = [...entries].sort(oldestFirst); while (totalBytes > maxBytes) { const evicted = retainedEntries.shift(); if (!evicted) break; @@ -186,7 +186,7 @@ export function createProviderSnapshotCache( if (canStoreIncoming) projectedBytes += incomingBytes; const keysToRemove: string[] = []; - for (const candidate of retainedEntries.toSorted(oldestFirst)) { + for (const candidate of [...retainedEntries].sort(oldestFirst)) { if (projectedBytes <= maxBytes) break; projectedBytes -= candidate.bytes; keysToRemove.push(candidate.key); diff --git a/packages/app/src/projects/host-project-model.ts b/packages/app/src/projects/host-project-model.ts index 22f185238f..fb90ebff97 100644 --- a/packages/app/src/projects/host-project-model.ts +++ b/packages/app/src/projects/host-project-model.ts @@ -194,7 +194,7 @@ export function resolveEquivalentHostProjectCandidate(input: { (project) => project.projectKey === input.candidate.projectKey, ); if (equivalents.length === 0) return null; - return equivalents.toSorted((left, right) => + return equivalents.sort((left, right) => compareHostProjectsForServer(left, right, input.serverId), )[0]!; } diff --git a/packages/app/src/projects/host-projects.test.ts b/packages/app/src/projects/host-projects.test.ts index 798d08b08e..2806f5b90f 100644 --- a/packages/app/src/projects/host-projects.test.ts +++ b/packages/app/src/projects/host-projects.test.ts @@ -7,6 +7,7 @@ import { getWorktreeSupportForHostProject, hostProjectFromRoute, hostProjectFromWorkspace, + resolveEquivalentHostProjectCandidate, } from "./host-project-model"; import { normalizeWorkspaceDescriptor } from "@/stores/session-store"; @@ -36,6 +37,31 @@ function project(): HostProjectListItem { } describe("host project lookups", () => { + test("resolves equivalent projects without Array.prototype.toSorted", () => { + const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, "toSorted"); + Reflect.deleteProperty(Array.prototype, "toSorted"); + const first = project(); + first.viewKey = "view:first"; + first.projectName = "First"; + const second = project(); + second.viewKey = "view:second"; + second.projectName = "Second"; + const projects = [second, first]; + + try { + expect( + resolveEquivalentHostProjectCandidate({ + candidate: first, + projects, + serverId: "host-a", + }), + ).toBe(first); + expect(projects).toEqual([second, first]); + } finally { + if (descriptor) Reflect.defineProperty(Array.prototype, "toSorted", descriptor); + } + }); + test("returns host-local ids and roots without falling back to the grouping key", () => { expect(getHostProjectId(project(), "host-b")).toBe("prj_b"); expect(getHostProjectSourceDirectory(project(), "host-b")).toBe("/repo/b"); From d012b6f0717e2c92c6f591c9e653f7409fd77fd9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 00:57:33 +0200 Subject: [PATCH 31/73] Help agents diagnose Paseo provider problems (#3243) * feat(skills): add Paseo help and provider diagnostics Give support agents a topology-aware troubleshooting entry point backed by the deployed documentation index, and expose the daemon provider diagnostic through the CLI so agents can inspect the affected host directly. Agent-launching skills now use configured profiles instead of the removed orchestration preferences. * test(cli): keep provider diagnostics on real boundaries Remove the internal client mock and preserve its normalization and host-targeting assertions through the existing isolated-daemon coverage. --- .../cli/src/commands/provider/diagnostic.ts | 45 +++++++++ packages/cli/src/commands/provider/index.ts | 8 ++ packages/cli/tests/15-provider.test.ts | 41 +++++++++ public-docs/cli.md | 12 +++ public-docs/troubleshooting.md | 9 ++ skills/paseo-advisor/SKILL.md | 15 ++- skills/paseo-committee/SKILL.md | 14 +-- skills/paseo-handoff/SKILL.md | 4 +- skills/paseo-help/SKILL.md | 92 +++++++++++++++++++ skills/paseo/SKILL.md | 77 ++++------------ 10 files changed, 239 insertions(+), 78 deletions(-) create mode 100644 packages/cli/src/commands/provider/diagnostic.ts create mode 100644 skills/paseo-help/SKILL.md diff --git a/packages/cli/src/commands/provider/diagnostic.ts b/packages/cli/src/commands/provider/diagnostic.ts new file mode 100644 index 0000000000..0ff05556a1 --- /dev/null +++ b/packages/cli/src/commands/provider/diagnostic.ts @@ -0,0 +1,45 @@ +import type { Command } from "commander"; +import { connectToDaemon } from "../../utils/client.js"; +import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js"; + +export interface ProviderDiagnosticResult { + provider: string; + diagnostic: string; +} + +export const providerDiagnosticSchema: OutputSchema<ProviderDiagnosticResult> = { + idField: "provider", + columns: [ + { header: "PROVIDER", field: "provider" }, + { header: "DIAGNOSTIC", field: "diagnostic" }, + ], + renderHuman(result) { + if (result.type === "single") return result.data.diagnostic; + return result.data.map((entry) => entry.diagnostic).join("\n\n"); + }, +}; + +export interface ProviderDiagnosticOptions extends CommandOptions { + host?: string; +} + +export async function runDiagnosticCommand( + provider: string, + options: ProviderDiagnosticOptions, + _command: Command, +): Promise<SingleResult<ProviderDiagnosticResult>> { + const client = await connectToDaemon({ host: options.host }); + try { + const result = await client.getProviderDiagnostic(provider.trim().toLowerCase()); + return { + type: "single", + data: { + provider: result.provider, + diagnostic: result.diagnostic, + }, + schema: providerDiagnosticSchema, + }; + } finally { + await client.close(); + } +} diff --git a/packages/cli/src/commands/provider/index.ts b/packages/cli/src/commands/provider/index.ts index acd513fa0b..1e934940ee 100644 --- a/packages/cli/src/commands/provider/index.ts +++ b/packages/cli/src/commands/provider/index.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { runLsCommand } from "./ls.js"; import { runModelsCommand } from "./models.js"; +import { runDiagnosticCommand } from "./diagnostic.js"; import { withOutput } from "../../output/index.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; @@ -19,5 +20,12 @@ export function createProviderCommand(): Command { .option("--thinking", "Include thinking option IDs for each model"), ).action(withOutput(runModelsCommand)); + addJsonAndDaemonHostOptions( + provider + .command("diagnostic") + .description("Show provider installation, environment, and availability diagnostics") + .argument("<provider>", "Provider name"), + ).action(withOutput(runDiagnosticCommand)); + return provider; } diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index 3dba994fe6..ee2eaaf298 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -17,6 +17,8 @@ * - provider models opencode lists opencode models * - provider models unknown fails with error * - provider models --json outputs valid JSON + * - provider diagnostic shows the daemon's provider diagnostic + * - provider diagnostic --json returns structured output */ import assert from "node:assert"; @@ -44,6 +46,11 @@ interface ProviderListRow { enabled: string; } +interface ProviderDiagnostic { + provider: string; + diagnostic: string; +} + const EXPECTED_CLAUDE_MODELS = [ { id: "claude-opus-5", @@ -190,6 +197,7 @@ try { assert.strictEqual(result.exitCode, 0, "provider --help should exit 0"); assert(result.stdout.includes("ls"), "help should mention ls"); assert(result.stdout.includes("models"), "help should mention models"); + assert(result.stdout.includes("diagnostic"), "help should mention diagnostic"); console.log("✓ provider --help shows subcommands\n"); } @@ -424,6 +432,39 @@ try { ); console.log("✓ provider models --quiet outputs model IDs only\n"); } + + // Test 12: provider diagnostic shows the daemon's provider diagnostic + { + console.log("Test 12: provider diagnostic shows the daemon's provider diagnostic"); + const result = await ctx.paseo([ + "provider", + "diagnostic", + " Claude ", + "--host", + `127.0.0.1:${ctx.port}`, + ]); + assert.strictEqual(result.exitCode, 0, "provider diagnostic should exit 0"); + assert(result.stdout.includes("Claude Code"), "diagnostic should identify the provider"); + assert(result.stdout.includes("Daemon PATH:"), "diagnostic should include the daemon PATH"); + assert(result.stdout.includes("Resolved path:"), "diagnostic should include binary resolution"); + assert(result.stdout.includes("Version:"), "diagnostic should include the provider version"); + assert(result.stdout.includes("Status:"), "diagnostic should include provider status"); + console.log("✓ provider diagnostic shows the daemon's provider diagnostic\n"); + } + + // Test 13: provider diagnostic --json returns structured output + { + console.log("Test 13: provider diagnostic --json returns structured output"); + const result = await ctx.paseo(["provider", "diagnostic", "claude", "--json"]); + assert.strictEqual(result.exitCode, 0, "provider diagnostic --json should exit 0"); + const data = JSON.parse(result.stdout.trim()) as ProviderDiagnostic; + assert.strictEqual(data.provider, "claude", "JSON should identify the provider"); + assert(data.diagnostic.includes("Daemon PATH:"), "JSON should include the daemon PATH"); + assert(data.diagnostic.includes("Resolved path:"), "JSON should include binary resolution"); + assert(data.diagnostic.includes("Version:"), "JSON should include the provider version"); + assert(data.diagnostic.includes("Status:"), "JSON should include provider status"); + console.log("✓ provider diagnostic --json returns structured output\n"); + } } finally { await ctx.stop(); } diff --git a/public-docs/cli.md b/public-docs/cli.md index 9fb60c6e77..ae3491b0af 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -23,6 +23,18 @@ paseo logs <id> # View agent timeline paseo stop <id> # Stop an agent ``` +## Provider diagnostics + +Ask the daemon to inspect the provider environment it actually uses: + +```bash +paseo provider diagnostic claude +paseo provider diagnostic codex --json +paseo provider diagnostic opencode --host devbox:6767 +``` + +The diagnostic includes the configured command, daemon `PATH` and shell, matching binaries, resolved path, version, model count, and provider status. Use `--host` for a remote daemon. This is the same diagnostic shown under **Settings → your host → Providers → provider → Diagnostic**. + ## Running agents Use `paseo run` to start a new agent with a task: diff --git a/public-docs/troubleshooting.md b/public-docs/troubleshooting.md index 70630e1ee9..0512a7aa3d 100644 --- a/public-docs/troubleshooting.md +++ b/public-docs/troubleshooting.md @@ -24,6 +24,15 @@ Open **Settings → your host → Providers**, tap the provider, then tap **Diag - **Daemon PATH** — the `PATH` Paseo is searching. Compare it to `echo $PATH` in a fresh terminal. - **Version** — whether the binary actually runs. +From a terminal or agent, request the same diagnostic from the affected daemon: + +```bash +paseo provider diagnostic <provider> +paseo provider diagnostic <provider> --host <host:port> --json +``` + +Use `--host` when the affected daemon is not the CLI's default local daemon. + `not found` together with a **Daemon PATH** that's missing your binary's directory is the common case: that directory is on your terminal's `PATH` but not on Paseo's. ### Fix it diff --git a/skills/paseo-advisor/SKILL.md b/skills/paseo-advisor/SKILL.md index cca43f6ee4..94d4558e5a 100644 --- a/skills/paseo-advisor/SKILL.md +++ b/skills/paseo-advisor/SKILL.md @@ -2,7 +2,7 @@ name: paseo-advisor description: Spin up a single agent as an advisor — second opinion on the current task. Use when the user says "advisor", "second opinion", "what does X think", or wants an outside take without delegating the work itself. user-invocable: true -argument-hint: "[--provider <name>] <question or topic>" +argument-hint: "[--profile <name>] <question or topic>" --- # Paseo Advisor @@ -13,16 +13,15 @@ Single agent. Reads the situation you're in. Gives a judgment. You decide what t ## Prerequisites -Read the **paseo** skill. Before choosing a provider, read `~/.paseo/orchestration-preferences.json` unless the user explicitly named a provider in this request. Do not create the advisor until you have read it. +Read the **paseo** skill. Call `list_profiles` before choosing the advisor. Do not create the advisor until you have read the configured profiles and their `notes`. ## Picking the advisor -1. **User named one** (`--provider claude/opus`) → use it. -2. **Otherwise** resolve from preferences — pick the category that matches the question: - - Design / approach question → `planning` - - "Did I miss something" review → `audit` - - "Is this even right" → `research` -3. **Contrast helps.** If your own provider matches what preferences would pick, swap to a different family on purpose — fresh perspective is the point. +1. **User named a profile** (`--profile UI Work`) → select it by name. +2. **Otherwise** choose the profile whose `notes` best fit the question. Match the actual work: design and approach, audit and review, or research and root-cause analysis. +3. **Contrast helps.** When several profiles fit, prefer a different provider family from your own so the second opinion is genuinely fresh. + +Materialize the selected profile into `create_agent` as described by the **paseo** skill. If no profile fits, use Paseo's provider discovery fallback. ## The briefing diff --git a/skills/paseo-committee/SKILL.md b/skills/paseo-committee/SKILL.md index 6ba7dc8b6d..2bb666e4ed 100644 --- a/skills/paseo-committee/SKILL.md +++ b/skills/paseo-committee/SKILL.md @@ -6,7 +6,7 @@ user-invocable: true # Committee Skill -Two agents from contrasting providers, fresh context, planning a solution in parallel. They stay alive for review after implementation. +Two agents from contrasting profiles, fresh context, planning a solution in parallel. They stay alive for review after implementation. The purpose is to step back, not double down. The committee may propose a completely different approach. @@ -14,18 +14,18 @@ The purpose is to step back, not double down. The committee may propose a comple ## Prerequisites -Read the **paseo** skill. Before choosing committee members, read `~/.paseo/orchestration-preferences.json` unless the user explicitly named providers in this request. Do not create committee agents until you have read it. +Read the **paseo** skill. Call `list_profiles` before choosing committee members. Do not create committee agents until you have read the configured profiles and their `notes`. -Contrast is the point of a committee, so pick across providers deliberately using the configured preferences rather than hardcoded defaults. +Contrast is the point of a committee, so pick profiles from different provider families when possible. Materialize each profile into `create_agent`. ## Composition -Two members with different reasoning styles, selected from orchestration preferences: +Two members with different reasoning styles, selected from configured Agent profiles: -- one planning/research-strength provider -- one contrasting high-reasoning provider +- one whose notes fit planning, research, or root-cause analysis +- one contrasting high-reasoning profile from another provider family -Override only when the user explicitly asks for different members. +If the user names profiles, use those. If fewer than two suitable profiles are configured, use Paseo's provider discovery fallback for the missing member and tell the user. Override the selection only when the user explicitly asks for different members. ## Hard rules diff --git a/skills/paseo-handoff/SKILL.md b/skills/paseo-handoff/SKILL.md index 102297544b..6f2579d2a8 100644 --- a/skills/paseo-handoff/SKILL.md +++ b/skills/paseo-handoff/SKILL.md @@ -12,11 +12,11 @@ Transfer the current task — context, decisions, failed attempts, constraints ## Prerequisites -Read the **paseo** skill. Before choosing a provider, read `~/.paseo/orchestration-preferences.json` unless the user explicitly named a provider in this request. Do not create the receiving agent until you have read it. +Read the **paseo** skill. Call `list_profiles` before choosing the receiving agent. Do not create it until you have read the configured profiles and their `notes`. ## Parsing arguments -1. **Provider** — explicit user request first; otherwise resolve from `impl` preference (or `ui` if the task is styling-only). +1. **Agent profile** — explicit profile name first; otherwise choose the profile whose `notes` best match the work. Materialize it into `create_agent` as described by the **paseo** skill. If no profile fits, use Paseo's provider discovery fallback. 2. **Isolation** — "in a worktree" / "worktree" → create a workspace with `isolation: "worktree"`, using a short branch name derived from the task. 3. **Task description** — anything else the user said. diff --git a/skills/paseo-help/SKILL.md b/skills/paseo-help/SKILL.md new file mode 100644 index 0000000000..951c1207bc --- /dev/null +++ b/skills/paseo-help/SKILL.md @@ -0,0 +1,92 @@ +--- +name: paseo-help +description: Answer questions about the Paseo product and app, including setup, configuration, connectivity, providers, workspaces, updates, logs, and troubleshooting. Use when a user inside Paseo asks how Paseo works, how to configure it, or why something is broken; use the paseo skill instead to operate agents and workspaces through MCP or the CLI. +--- + +# Paseo Help + +You are helping a user understand, configure, or troubleshoot Paseo itself. Answer their question directly, verify the answer against the current public documentation, and include the relevant documentation link. Do not send the user away to read the docs in place of helping them. + +**User's question:** $ARGUMENTS + +## Use current documentation + +Fetch [https://paseo.sh/llms.txt](https://paseo.sh/llms.txt) first. It is the current index of Paseo documentation, with a description and Markdown URL for each page. + +Use that index to select the page that owns the user's question, then fetch the linked `.md` page before answering. For troubleshooting, begin with [Common problems](https://paseo.sh/docs/troubleshooting.md) and follow its links when the issue belongs to a more specific page. + +Prefer the deployed docs over memory. Answer the user directly, then link the relevant `.md` page as supporting documentation. + +## Establish the topology first + +Identify the daemon involved before diagnosing versions, paths, providers, logs, updates, or connectivity. Do not infer the daemon from the client: Paseo Desktop can manage its bundled local daemon and connect to other remote daemons at the same time. + +Establish two facts: + +1. **Where and how the daemon runs** + - **Desktop-managed:** Paseo Desktop bundles, starts, and updates a daemon on that computer. No separate daemon install is required. + - **Standalone:** the daemon was installed separately, commonly through the npm CLI, and runs independently of the desktop app. + - **Docker:** the daemon, its home, provider CLIs, credentials, and code mounts live in the container runtime. +2. **How the affected client reaches it** + - same-machine local connection + - relay connection + - direct LAN, VPN, or Tailscale connection + - daemon-served web UI + +Use **Settings → About** to compare the app version with each connected host. For the affected host, open **Settings → your host → Overview → Full status**. On the daemon machine, `paseo daemon status --json` reports facts such as server ID, hostname, version, home, listen address, process owner, log path, and whether the daemon is desktop-managed. + +Record which host the user is viewing and which machine or container runs it. A local `paseo daemon status` describes the daemon for that CLI's local `PASEO_HOME`; it may not be the remote host visible in the app. + +Apply later checks to the daemon runtime, not automatically to the client device: + +- Provider binaries, credentials, `PATH`, workspaces, config, and daemon logs live on the daemon machine or inside its container. +- App version and app logs live on the client device. +- A desktop-managed daemon follows the Desktop app lifecycle and update path. +- A standalone daemon follows its own CLI/npm lifecycle and may use a different `PASEO_HOME` or listen address. +- A Docker daemon uses container paths, volumes, user permissions, image versions, and container lifecycle commands. + +## Diagnose before changing state + +After identifying the affected host, compare that daemon's version with the client app version. Ask the user to update both through the correct topology-specific update path. Old versions and app/daemon version skew cause many apparent bugs, and fixes ship frequently. Use the Updates page and the installation-specific docs for current instructions. + +Use the smallest relevant read-only checks: + +```bash +paseo --version +paseo daemon status --json +paseo provider diagnostic <provider> --json +``` + +Use the status-reported home, listen address, and log path for further checks. Probe `http://127.0.0.1:6767/api/health` or read `~/.paseo/daemon.log` only when those values match the affected daemon. Do not restart the daemon, edit config, update software, or expose a network listener without the user's explicit permission. A daemon restart can interrupt the agent doing the diagnosis. + +For a missing provider or `command not found`, run `paseo provider diagnostic <provider>` against the affected host, or open **Settings → your host → Providers → provider → Diagnostic**. Compare its resolved binary, daemon `PATH`, and provider version with a brand-new login shell. Shell aliases and functions are not executable paths. + +## Logs and local files + +Use these defaults on the machine where the daemon or Desktop app actually runs. Do not look for a remote daemon's files on the client device. + +- Daemon config: `~/.paseo/config.json` +- Daemon log: `~/.paseo/daemon.log` +- Agent state directory: `~/.paseo/agents/` +- Default managed worktree root: `~/.paseo/worktrees/` +- macOS desktop log: `~/Library/Logs/Paseo/main.log` +- Linux desktop log: `~/.config/Paseo/logs/main.log` +- Windows desktop log: `%APPDATA%\Paseo\logs\main.log` + +Substitute the status-reported `PASEO_HOME` for `~/.paseo`. In the official Docker image, the default is `/home/paseo/.paseo`; its host path depends on the volume mount, and container stdout is available through Docker. Desktop app logs describe the Desktop process; daemon logs describe the selected daemon. Read the narrowest useful slice and redact credentials, pairing offers, tokens, passwords, and user code before sharing logs. + +If diagnosing the bundled daemon on a computer with Paseo Desktop installed, but `paseo` is not on `PATH`, the bundled CLI is at: + +- macOS: `/Applications/Paseo.app/Contents/Resources/bin/paseo` +- Linux: `<install-dir>/resources/bin/paseo` +- Windows: `C:\Program Files\Paseo\resources\bin\paseo.cmd` + +Offer to fix the PATH or symlink; do not change shell configuration silently. + +## Escalate with evidence + +If the current docs and diagnostics do not resolve the problem, collect the app and daemon versions, OS, install method, connection method, exact error, minimal reproduction, and a small redacted log excerpt. + +- Bugs: [GitHub Issues](https://github.com/getpaseo/paseo/issues) +- Questions and quick help: [Paseo Discord](https://discord.gg/jz8T2uahpH) +- Product workflow discussions: [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in Discord diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 428217ecb0..41371fb31b 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -37,7 +37,7 @@ paseo script stop <name> [--cwd <path> | --workspace <workspace-id>] **`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Optional: `workspaceId`, `notifyOnFinish`, `settings`, `labels`. Returns `{ agentId, workspaceId, … }`. -Initial runtime settings live under `settings`: `modeId`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }` when creating the agent. +Initial runtime settings live under `settings`: `modeId`, `thinkingOptionId`, and provider-specific `features`. Agent profiles are the preferred source for these values. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }` when creating the agent. Agent-scoped creation always creates your subagent. Omit `workspaceId` to use your current workspace; pass a workspace returned by `create_workspace` for isolated delegation. Placement never changes parentage. @@ -53,7 +53,20 @@ Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` **`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list. -## Provider discovery +## Agent profiles and provider discovery + +**`list_profiles`** — named launch bundles configured by the human. Before choosing how to launch a delegated agent, call this tool and read every profile's `notes`. Pick a named profile the user requested, or the profile whose notes best match the work. + +There is no `profile` parameter on `create_agent`. Materialize the selected profile into the call: + +- combine `provider` and `model` as the `provider/model` value for `create_agent.provider` +- copy `modeId` to `settings.modeId` +- copy `thinkingOptionId` to `settings.thinkingOptionId` +- copy `featureValues` to `settings.features` + +Omit absent values. Do not remember a selected profile or infer drift later; a profile is only launch configuration. + +If no profile fits, or no profiles are configured, use the provider discovery tools below rather than guessing. Tell the user when you fall back because no configured profile fits. **`list_providers`** — compact provider availability and modes. @@ -73,36 +86,6 @@ Only set feature IDs returned by `inspect_provider`. For Codex fast mode, look f Schedules have the full list/inspect/update/pause/resume/run-once/log/delete surface. Heartbeats deliberately do not. -## Orchestration preferences - -User-specific configuration at `~/.paseo/orchestration-preferences.json`. **Before any Paseo skill chooses a provider or creates an agent, it must read this file.** Reading means an actual file read, not relying on these examples or defaults. Never hardcode a provider string in another skill — resolve through this file. - -Two parts: - -- `providers` — map of role categories to provider strings. Pass straight to `create_agent`'s `provider` field. -- `preferences` — freeform string array. Read on startup; weave into agent prompts contextually. - -Categories: `impl`, `ui`, `research`, `planning`, `audit`. Skills pick the category that matches the role they're launching. - -```json -{ - "providers": { - "impl": "codex/gpt-5.4", - "ui": "claude/opus", - "research": "codex/gpt-5.4", - "planning": "codex/gpt-5.4", - "audit": "codex/gpt-5.4" - }, - "preferences": [ - "Claude Opus is the right choice for anything artistic or human-skill-oriented: copywriting, naming, UX copy, visual design, styling. Codex is the workhorse for mechanical work." - ] -} -``` - -If the file is missing, use sensible defaults and tell the user once. - -All providers shown in this document are examples, you must resolve real providers either found in the preferences or by calling Paseo provider tooling to find a valid provider. - ## Waiting Agents take time — 10–30+ minutes is routine. Favor asynchronous workflows. @@ -129,32 +112,4 @@ paseo heartbeat create --cron "*/15 * * * *" "check the build" Discover with `paseo --help` and `paseo <cmd> --help`. -**If `paseo` isn't on PATH but the desktop app is installed**, the bundled CLI is at: - -- macOS: `/Applications/Paseo.app/Contents/Resources/bin/paseo` -- Linux: `<install-dir>/resources/bin/paseo` -- Windows: `C:\Program Files\Paseo\resources\bin\paseo.cmd` - -The desktop app's first-run hook (`installCli`) symlinks this to `~/.local/bin/paseo` (macOS/Linux) or drops a `.cmd` trampoline (Windows) and adds `~/.local/bin` to PATH via shell rc files. If that didn't take, offer to symlink it — don't do it silently. - -## Ops and debugging - -Daemon-client architecture: the daemon owns agent lifecycle, state, and the WebSocket API. Tools, CLI, mobile, and desktop apps are all clients. - -| | Default | -| -------------- | --------------------------------------------------------------- | -| Listen address | `127.0.0.1:6767` (override `PASEO_LISTEN`) | -| Home | `~/.paseo` (override `PASEO_HOME`) | -| Daemon log | `$PASEO_HOME/daemon.log` | -| Agent state | `$PASEO_HOME/agents/<id>.json` | -| Worktrees | `$PASEO_HOME/worktrees/` (or `worktrees.root` in `config.json`) | -| PID file | `$PASEO_HOME/paseo.pid` | -| Health | `GET http://127.0.0.1:6767/api/health` | - -Debug order: - -1. `tail -n 200 ~/.paseo/daemon.log`. -2. `paseo daemon status` for liveness. -3. `curl -s localhost:6767/api/health` if the CLI itself is suspect. - -**Never restart the daemon without explicit user approval** — it kills every running agent, including, often, the one asking. +For product questions, setup, logs, version problems, or troubleshooting, use the **paseo-help** skill. From 74f2158c454b09eb3558422ef094bd79a1c81eab Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 10:58:58 +0200 Subject: [PATCH 32/73] Preserve daemon sessions when workers stall (#3263) Stop treating heartbeat silence as proof that a live daemon worker should be destroyed. Crash recovery, explicit lifecycle requests, and supervisor-loss cleanup remain unchanged. --- .../server/scripts/supervisor.logging.test.ts | 130 ++---------------- packages/server/scripts/supervisor.ts | 37 ----- packages/server/src/server/daemon-worker.ts | 15 -- skills/paseo-committee/SKILL.md | 55 ++------ skills/paseo-handoff/SKILL.md | 4 +- skills/paseo/SKILL.md | 4 +- 6 files changed, 20 insertions(+), 225 deletions(-) diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index 643bad2d79..9f61aa283d 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -10,15 +10,9 @@ import { resolveSupervisorLogFile } from "./supervisor-log-config.js"; const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url))); const supervisorPath = fileURLToPath(new URL("./supervisor.ts", import.meta.url)); -interface SupervisorSuspension { - afterMs: number; - durationMs: number; -} - async function runSupervisorFixture(options: { workerSource: string; restartOnCrash?: boolean; - suspension?: SupervisorSuspension; timeoutMs?: number; }): Promise<{ code: number | null; @@ -58,23 +52,10 @@ async function runSupervisorFixture(options: { const startedAt = Date.now(); const child = spawn(process.execPath, ["--import", "tsx", runnerPath], { cwd: repoRoot, - detached: options.suspension !== undefined, env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"], }); - if (options.suspension) { - const supervisorPid = child.pid; - if (supervisorPid === undefined) { - throw new Error("Supervisor fixture did not start"); - } - const { afterMs, durationMs } = options.suspension; - setTimeout(() => { - process.kill(-supervisorPid, "SIGSTOP"); - setTimeout(() => process.kill(-supervisorPid, "SIGCONT"), durationMs); - }, afterMs); - } - let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); @@ -208,46 +189,21 @@ describe("supervisor durable logging", () => { expect(result.log).toContain('"workerPid":'); }); - test("keeps a worker alive while heartbeats continue", async () => { + test("does not restart a worker based on heartbeat absence", async () => { const result = await runSupervisorFixture({ - timeoutMs: 10_000, - workerSource: ` - setInterval(() => { - process.send?.({ type: "paseo:worker-heartbeat" }); - }, 250); - setTimeout(() => { - process.send?.({ type: "paseo:shutdown", reason: "healthy_heartbeat_test_complete" }); - }, 6000); - `, - }); - - expect(result.code).toBe(0); - expect(result.signal).toBeNull(); - expect(result.log).toContain('"reason":"healthy_heartbeat_test_complete"'); - expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"'); - }, 10_000); - - test("tolerates a transient seven-second heartbeat pause", async () => { - const result = await runSupervisorFixture({ - timeoutMs: 10_000, + timeoutMs: 20_000, workerSource: ` import { existsSync, writeFileSync } from "node:fs"; const marker = process.argv[1] + ".started"; if (!existsSync(marker)) { writeFileSync(marker, "started"); - let heartbeatCount = 0; - const heartbeat = setInterval(() => { - process.send?.({ type: "paseo:worker-heartbeat" }); - heartbeatCount += 1; - if (heartbeatCount === 3) clearInterval(heartbeat); - }, 100); setTimeout(() => { - process.send?.({ type: "paseo:shutdown", reason: "heartbeat_pause_tolerated" }); - }, 7_000); + process.send?.({ type: "paseo:shutdown", reason: "silent_worker_test_complete" }); + }, 16_000); setInterval(() => {}, 1_000); } else { - process.send?.({ type: "paseo:shutdown", reason: "unexpected_heartbeat_restart" }); + process.send?.({ type: "paseo:shutdown", reason: "unexpected_silent_worker_restart" }); setInterval(() => {}, 1_000); } `, @@ -255,44 +211,10 @@ describe("supervisor durable logging", () => { expect(result.code).toBe(0); expect(result.signal).toBeNull(); - expect(result.log).toContain('"reason":"heartbeat_pause_tolerated"'); - expect(result.log).not.toContain('"reason":"unexpected_heartbeat_restart"'); + expect(result.log).toContain('"reason":"silent_worker_test_complete"'); + expect(result.log).not.toContain('"reason":"unexpected_silent_worker_restart"'); expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"'); - }, 10_000); - - test.skipIf(isPlatform("win32"))( - "keeps a healthy worker alive after the host resumes from sleep", - async () => { - const result = await runSupervisorFixture({ - suspension: { afterMs: 1_000, durationMs: 16_000 }, - timeoutMs: 25_000, - workerSource: ` - import { existsSync, writeFileSync } from "node:fs"; - - const marker = process.argv[1] + ".started"; - if (!existsSync(marker)) { - writeFileSync(marker, "started"); - setInterval(() => { - process.send?.({ type: "paseo:worker-heartbeat" }); - }, 250); - setTimeout(() => { - process.send?.({ type: "paseo:shutdown", reason: "sleep_resume_test_complete" }); - }, 17_000); - } else { - process.send?.({ type: "paseo:shutdown", reason: "unexpected_sleep_restart" }); - } - setInterval(() => {}, 1_000); - `, - }); - - expect(result.code).toBe(0); - expect(result.signal).toBeNull(); - expect(result.log).toContain('"reason":"sleep_resume_test_complete"'); - expect(result.log).not.toContain('"reason":"unexpected_sleep_restart"'); - expect(result.log).not.toContain('"msg":"Worker heartbeat timed out; restarting worker"'); - }, - 30_000, - ); + }, 25_000); test.skipIf(isPlatform("win32"))( "forces shutdown when a worker ignores SIGTERM", @@ -315,42 +237,6 @@ describe("supervisor durable logging", () => { 20_000, ); - // POSIX-only: the watchdog uses SIGKILL after its graceful shutdown window. - test.skipIf(isPlatform("win32"))( - "restarts a worker that stops heartbeating", - async () => { - const result = await runSupervisorFixture({ - timeoutMs: 35_000, - workerSource: ` - import { existsSync, writeFileSync } from "node:fs"; - - const marker = process.argv[1] + ".started"; - if (!existsSync(marker)) { - writeFileSync(marker, "started"); - let heartbeatCount = 0; - const heartbeat = setInterval(() => { - process.send?.({ type: "paseo:worker-heartbeat" }); - heartbeatCount += 1; - if (heartbeatCount === 3) clearInterval(heartbeat); - }, 100); - process.on("SIGTERM", () => {}); - setInterval(() => {}, 1000); - } else { - process.send?.({ type: "paseo:shutdown", reason: "watchdog_test_complete" }); - setInterval(() => {}, 1000); - } - `, - }); - - expect(result.code).toBe(0); - expect(result.signal).toBeNull(); - expect(result.log).toContain('"msg":"Worker heartbeat timed out; restarting worker"'); - expect(result.log).toContain('"msg":"Worker did not exit after SIGTERM; forcing SIGKILL"'); - expect(result.log).toContain('"signal":"SIGKILL"'); - }, - 40_000, - ); - test.skipIf(isPlatform("win32"))( "restarts after worker exit while a descendant retains the worker stdio", async () => { diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index c44f2d583e..9c6b33f06b 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -5,7 +5,6 @@ import { createStream as createRotatingFileStream } from "rotating-file-stream"; import { signalProcessTree } from "../src/utils/tree-kill.js"; const WORKER_HEARTBEAT_INTERVAL_MS = 1_000; -const WORKER_HEARTBEAT_MISSED_CHECK_LIMIT = 15; const WORKER_TERMINATION_GRACE_MS = 10_000; interface SupervisorLogFileOptions { @@ -34,10 +33,6 @@ interface SupervisorHeartbeatMessage { type: "paseo:supervisor-heartbeat"; } -interface WorkerHeartbeatMessage { - type: "paseo:worker-heartbeat"; -} - interface SupervisorOptions { name: string; startupMessage: string; @@ -93,15 +88,6 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null { return null; } -function isWorkerHeartbeatMessage(msg: unknown): msg is WorkerHeartbeatMessage { - return ( - typeof msg === "object" && - msg !== null && - "type" in msg && - (msg as { type?: unknown }).type === "paseo:worker-heartbeat" - ); -} - function toRotatingFileStreamSize(size: string): string { const trimmed = size.trim(); const match = trimmed.match(/^(\d+)\s*([bBkKmMgG])?$/); @@ -250,7 +236,6 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController } const currentChild = child; - let missedWorkerHeartbeatChecks = 0; const heartbeat = setInterval(() => { const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" }; if (currentChild.connected) { @@ -267,23 +252,6 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController }, WORKER_HEARTBEAT_INTERVAL_MS); heartbeat.unref(); - const workerWatchdog = setInterval(() => { - if (child !== currentChild || restarting || shuttingDown) { - return; - } - missedWorkerHeartbeatChecks += 1; - if (missedWorkerHeartbeatChecks < WORKER_HEARTBEAT_MISSED_CHECK_LIMIT) { - return; - } - writeLifecycleLog("Worker heartbeat timed out; restarting worker", { - missedHeartbeatChecks: missedWorkerHeartbeatChecks, - supervisorPid: process.pid, - workerPid: currentChild.pid ?? null, - }); - requestRestart("worker_heartbeat_timeout"); - }, WORKER_HEARTBEAT_INTERVAL_MS); - workerWatchdog.unref(); - child.on("disconnect", () => { writeLifecycleLog("Worker IPC channel disconnected"); }); @@ -299,10 +267,6 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController }); child.on("message", (msg: unknown) => { - if (isWorkerHeartbeatMessage(msg)) { - missedWorkerHeartbeatChecks = 0; - return; - } const lifecycleMessage = parseLifecycleMessage(msg); if (!lifecycleMessage) { return; @@ -333,7 +297,6 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController child.on("exit", (code, signal) => { clearInterval(heartbeat); - clearInterval(workerWatchdog); clearForceKillTimer(); const exitDescriptor = describeExit(code, signal); writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor }); diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index 4188e97d2c..4656523ef7 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -27,10 +27,6 @@ interface SupervisorHeartbeatMessage { type: "paseo:supervisor-heartbeat"; } -interface WorkerHeartbeatMessage { - type: "paseo:worker-heartbeat"; -} - interface BootstrapResult { paseoHome: string; logger: ReturnType<typeof createRootLogger>; @@ -232,13 +228,6 @@ async function main() { const supervisorPid = process.ppid; let lastSupervisorHeartbeatAt = Date.now(); let supervisorExitRequested = false; - const sendWorkerHeartbeat = () => { - try { - process.send?.({ type: "paseo:worker-heartbeat" } satisfies WorkerHeartbeatMessage); - } catch { - // The disconnect handler below owns supervisor-loss shutdown. - } - }; const exitAfterSupervisorLoss = (reason: string) => { if (supervisorExitRequested) { return; @@ -272,10 +261,6 @@ async function main() { }); process.on("disconnect", () => exitAfterSupervisorLoss("ipc_disconnect_event")); - sendWorkerHeartbeat(); - const workerHeartbeat = setInterval(sendWorkerHeartbeat, 1_000); - workerHeartbeat.unref(); - const timer = setInterval(() => { const ipcConnected = typeof process.connected === "boolean" ? process.connected : true; const heartbeatExpired = Date.now() - lastSupervisorHeartbeatAt > 3500; diff --git a/skills/paseo-committee/SKILL.md b/skills/paseo-committee/SKILL.md index 2bb666e4ed..2b16189556 100644 --- a/skills/paseo-committee/SKILL.md +++ b/skills/paseo-committee/SKILL.md @@ -6,9 +6,7 @@ user-invocable: true # Committee Skill -Two agents from contrasting profiles, fresh context, planning a solution in parallel. They stay alive for review after implementation. - -The purpose is to step back, not double down. The committee may propose a completely different approach. +Two agents from contrasting profiles, fresh context, planning a solution in parallel. **User's additional context:** $ARGUMENTS @@ -35,49 +33,14 @@ If the user names profiles, use those. If fewer than two suitable profiles are c This is analysis only. Do NOT edit, create, or delete any files. Do NOT write code. ``` -- **Trust the wait.** Do not poll, send hurry-ups, or interrupt. GPT-5.4 can reason 15–30 minutes; Opus does extended thinking. Long waits mean it found something worth thinking about. -- **You are the middleman.** Drive plan → implement → review without yielding to the user, except for divergences that need their call. +- **Trust the finish notification.** Do not poll, send hurry-ups, or interrupt. Models can reason for 15–30 minutes. You can go idle and Paseo will notify you. -## Phase 1: Plan +## Workflow -Write a problem-level prompt: +1. Write a problem-level prompt +2. Create both agents in parallel via Paseo with `[Committee] <task>` titles and the same prompt +3. Wait for both responses +4. Resolve disagreements by passing their arguments between each other +5. Keep going until they converge into a response -- High-level goal and acceptance criteria -- Constraints -- Symptoms (if a bug) -- What you tried and why it failed -- Explicit: "do root cause analysis" -- Explicit: "state assumptions, ask why three levels deep, check whether you're patching a symptom or removing the problem" - -Create both agents in parallel via Paseo with `[Committee] <task>` titles and the same prompt. Wait for both — not just whichever finishes first. - -Read both responses. Challenge them — do not accept at face value: - -- "Why does <underlying thing> happen? Symptom or cause?" -- Verify any assumption the plan makes about the code. -- "What did you consider and reject?" - -Send follow-ups until the plan addresses root cause. - -Synthesize: - -- Convergence → unified plan. -- Significant divergence → involve the user. - -Confirm the merged plan with both members. Multi-turn until consensus. - -## Phase 2: Implement - -Default: implement yourself. If the user said **"delegate"**, launch one impl agent and pass the merged plan. - -The committee stays clean — not involved in implementation. - -## Phase 3: Review - -Send the diff to the committee: - -> Implementation is done. Review changes against the plan. Flag drift or missing pieces. <no-edits suffix> - -Apply feedback yourself, or send to the impl agent. Repeat 2 → 3 until consensus. - -After ~10 iterations without convergence, start a fresh committee with the full history of what was tried — the current committee's context may have drifted too far. +Share the consensus with the user. Summarize where the agents diverged and how they resolved it. diff --git a/skills/paseo-handoff/SKILL.md b/skills/paseo-handoff/SKILL.md index 6f2579d2a8..b321365306 100644 --- a/skills/paseo-handoff/SKILL.md +++ b/skills/paseo-handoff/SKILL.md @@ -62,6 +62,4 @@ Prepare the handoff in a dedicated workspace: Do not encode independence as a create mode and do not invoke CLI or wire-level detach operations. Detach is a user gesture in the subagents track. -Leave `notifyOnFinish` omitted unless the user explicitly wants no callback. - -Don't wait by default — the user decides whether to follow along or move on. Tell them the agent ID and how to follow along (the paseo skill explains). +Do not wait or poll for the agent to finish. diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 41371fb31b..8d686fb35c 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -3,7 +3,7 @@ name: paseo description: Paseo reference for managing workspaces, workspace scripts, agents, schedules, and heartbeats. --- -Paseo is a daemon that supervises AI coding agents on your machine. Control it through tools or a CLI. +Paseo is a remote daemon that manages coding agents, terminals. Control it through MCP tools or the CLI. ## Workspaces @@ -13,7 +13,7 @@ Paseo is a daemon that supervises AI coding agents on your machine. Control it t **`archive_workspace`** — `{ workspaceId }`. Archives the workspace, its agents, and its terminals. Local directories remain; Paseo removes an owned worktree only after its final active workspace reference is archived. -Worktree creation and reference accounting are implementation details of `isolation: "worktree"`. +**`rename_workspace`** — `{ workspaceId, name }`. Rename workspace. ## Workspace scripts From 6ed364ffe281d926b7a794f2f5bcde6cab411acd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 12:20:08 +0200 Subject: [PATCH 33/73] Document per-execution Hub worktree branch names (#3267) * docs(hub): document per-execution worktree branch names Hub now materializes `${{ paseo.execution.id }}` inside a daemon environment's `worktree.newBranch` before it persists or dispatches the launch, so a reusable branch-off environment can get a stable branch per execution instead of every trigger contending for one literal name. The reference states the constraint precisely because it is the part the YAML cannot show: `newBranch` accepts that one execution-scoped path and nothing else, and any other expression fails bundle activation with the authored file and field in the error. Literal branch names stay valid. * docs(hub): reshape the per-execution worktree branch docs Split the two pages by the job each reader is doing. The daemon page is a task guide, so it now carries the worktree YAML and only the two facts that reader needs to choose a branch name, then links out. The reference owns the exhaustive contract at the `newBranch` field: a table contrasting a literal with the template, then the accepted expression scope and what activation rejects. Dropped the render-before-dispatch ordering. Readers cannot act on where Hub materializes the value; the reachable behavior is that a retried or recovered execution keeps its branch, which is what the table says. * docs(hub): make the per-execution worktree branch the only guidance A branch-off Hub environment that aims every execution at one branch name is not a configuration worth teaching, so the docs no longer present it as an alternative. Both pages now show `newBranch: trigger-${{ paseo.execution.id }}` and say what it does, with no literal-branch example and no comparison. The reference keeps the field's type as a bare syntax fact, along with the accepted expression, what activation rejects, and the authored field the failure names. --- public-docs/hub/configuration/hub-yml.md | 10 +++++++++- public-docs/hub/daemons.md | 6 ++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/public-docs/hub/configuration/hub-yml.md b/public-docs/hub/configuration/hub-yml.md index 9d8a44247a..a478dcf1e9 100644 --- a/public-docs/hub/configuration/hub-yml.md +++ b/public-docs/hub/configuration/hub-yml.md @@ -72,10 +72,18 @@ environments: cwd: /workspace/project worktree: mode: branch-off - newBranch: paseo/review + newBranch: trigger-${{ paseo.execution.id }} base: origin/main ``` +`newBranch` is a branch-name string. Embed `${{ paseo.execution.id }}`, which renders the execution's UUID, so every execution branches off `base` on its own branch and keeps it when Hub retries or recovers that execution. + +One execution is one step run, so two steps selecting the same environment get separate branches. + +`${{ paseo.execution.id }}` is the only expression `newBranch` accepts. `paseo.prompt`, `paseo.context`, `paseo.inputs.*`, `values.*`, `steps.<id>.outputs.*`, and provider event fields are unavailable here, and each one fails bundle activation at the authored field, such as `.paseo/hub.yml.environments.review.worktree.newBranch`. + +`${{ paseo.execution.id }}` fails activation the same way anywhere else in a bundle. `branch` and `prNumber` take literal values. + An environment is a complete named object. A step selects its name; objects are not inherited, merged, or partially overridden. ### Named agents diff --git a/public-docs/hub/daemons.md b/public-docs/hub/daemons.md index 2e0dce8595..24ba7b5c69 100644 --- a/public-docs/hub/daemons.md +++ b/public-docs/hub/daemons.md @@ -80,11 +80,13 @@ To keep executions off your working tree, add a worktree: ```yaml worktree: mode: branch-off - newBranch: hub/investigation + newBranch: trigger-${{ paseo.execution.id }} base: origin/main ``` -See [Git worktrees](/docs/worktrees) for setup hooks and scripts. +`${{ paseo.execution.id }}` renders the execution's UUID, so every execution gets its own branch off `origin/main`. + +[Environment fields](/docs/hub/configuration/hub-yml#environments) lists what `newBranch` accepts. See [Git worktrees](/docs/worktrees) for setup hooks and scripts. ## What Hub owns From 2c5c629db87360b573e7988dde781955ebf1a2e8 Mon Sep 17 00:00:00 2001 From: QuteSaltyFish <41997780+QuteSaltyFish@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:25:04 -0700 Subject: [PATCH 34/73] fix(usage): read Cursor token from modern state.vscdb key via node:sqlite (#2704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(usage): read Cursor token from modern state.vscdb key via node:sqlite Settings → Usage showed Cursor as unavailable for a normal Cursor desktop login. The quota fetcher read the access token from the legacy `cursorAuthStatus` key, but current Cursor builds store a plain JWT under `cursorAuth/accessToken` with no such legacy row, so the lookup returned null. Read the token with built-in node:sqlite instead of shelling out to a `sqlite3` CLI (absent by default on Windows) — a second, independent cause of the same "token not found → unavailable" failure. Prefer the modern key, fall back to the legacy `cursorAuthStatus` blob for older builds, and log read failures at debug so an unavailable card stays diagnosable. Fixes #2586 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(usage): cover Cursor sqlite text and blob auth --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com> --- .../quota-fetcher/providers/cursor.ts | 90 ++++++++++++---- .../services/quota-fetcher/service.test.ts | 102 +++++++++++++++++- 2 files changed, 172 insertions(+), 20 deletions(-) diff --git a/packages/server/src/services/quota-fetcher/providers/cursor.ts b/packages/server/src/services/quota-fetcher/providers/cursor.ts index 5f30cae5fc..98b499b8da 100644 --- a/packages/server/src/services/quota-fetcher/providers/cursor.ts +++ b/packages/server/src/services/quota-fetcher/providers/cursor.ts @@ -1,8 +1,6 @@ -import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { promisify } from "node:util"; import type { Logger } from "pino"; import { z } from "zod"; import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js"; @@ -16,8 +14,25 @@ import { unavailableUsage, } from "../usage.js"; -const execFileAsync = promisify(execFile); -const CURSOR_SQLITE_TIMEOUT_MS = 2_000; +// Cursor stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep the +// access token as a plain JWT string under `cursorAuth/accessToken`; older builds +// kept a JSON blob under `cursorAuthStatus`. Read it with node:sqlite so we don't +// depend on a `sqlite3` CLI, which isn't installed by default on Windows (or on many +// Linux hosts) — a missing binary silently rendered Cursor usage unavailable. +const CURSOR_ACCESS_TOKEN_KEY = "cursorAuth/accessToken"; +const CURSOR_LEGACY_AUTH_KEY = "cursorAuthStatus"; + +// @types/node@20 predates the node:sqlite typings; declare the slice we use. +interface CursorStateStatement { + get(...params: unknown[]): Record<string, unknown> | undefined; +} +interface CursorStateDatabase { + prepare(sql: string): CursorStateStatement; + close(): void; +} +interface NodeSqliteModule { + DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => CursorStateDatabase; +} const CursorBillingCycleTimestampSchema = z.preprocess( (value) => (typeof value === "string" || typeof value === "number" ? value : null), @@ -47,6 +62,7 @@ type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>; interface CursorQuotaProviderOptions { logger: Logger; fetch?: ProviderApiFetch; + homeDir?: string; } function parseCursorBillingCycleTimestamp( @@ -70,14 +86,38 @@ function centsToDollars(value: number | null): number | null { return value === null ? null : value / 100; } -async function readCursorTokenFromSqlite(): Promise<string | null> { +function readItemTableValue(db: CursorStateDatabase, key: string): string | null { + const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key); + const value = row?.["value"]; + if (typeof value === "string") return value; + if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8"); + return null; +} + +function cursorTokenFromDb(db: CursorStateDatabase): string | null { + const modern = readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY)?.trim(); + if (modern) return modern; + + const legacy = readItemTableValue(db, CURSOR_LEGACY_AUTH_KEY); + if (legacy) { + try { + const parsed = CursorAuthStatusSchema.parse(JSON.parse(legacy)); + if (parsed.accessToken) return parsed.accessToken; + } catch { + // ignore a malformed legacy blob + } + } + return null; +} + +async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promise<string | null> { const dbPaths: string[] = []; if (process.env["APPDATA"]) { dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb")); } dbPaths.push( join( - homedir(), + homeDir, "Library", "Application Support", "Cursor", @@ -86,22 +126,32 @@ async function readCursorTokenFromSqlite(): Promise<string | null> { "state.vscdb", ), ); - dbPaths.push(join(homedir(), ".config", "Cursor", "User", "globalStorage", "state.vscdb")); + dbPaths.push(join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb")); + + // Held in a variable so TypeScript skips module resolution: @types/node@20 has no + // node:sqlite typings yet, while the runtime (Node 22+ / Electron) provides it. + const sqliteSpecifier: string = "node:sqlite"; + let sqlite: NodeSqliteModule; + try { + sqlite = (await import(sqliteSpecifier)) as unknown as NodeSqliteModule; + } catch (err) { + logger.debug({ err }, "node:sqlite unavailable; cannot read Cursor state.vscdb"); + return null; // runtime without node:sqlite + } for (const path of dbPaths) { if (!existsSync(path)) continue; + let db: CursorStateDatabase | undefined; try { - const { stdout } = await execFileAsync( - "sqlite3", - [path, "SELECT value FROM ItemTable WHERE key = 'cursorAuthStatus'"], - { timeout: CURSOR_SQLITE_TIMEOUT_MS }, - ); - if (stdout) { - const parsed = CursorAuthStatusSchema.parse(JSON.parse(stdout.trim())); - if (parsed.accessToken) return parsed.accessToken; - } - } catch { - continue; + db = new sqlite.DatabaseSync(path, { readOnly: true }); + const token = cursorTokenFromDb(db); + if (token) return token; + } catch (err) { + // Locked/permission/corrupt/schema failures all land here; log so an + // unavailable Cursor card is diagnosable, then try the next candidate. + logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb"); + } finally { + db?.close(); } } return null; @@ -113,17 +163,19 @@ export class CursorQuotaProvider implements ProviderUsageFetcher { private readonly logger: Logger; private readonly fetchApi: ProviderApiFetch; + private readonly homeDir: string; constructor(options: CursorQuotaProviderOptions) { this.logger = options.logger; this.fetchApi = options.fetch ?? fetch; + this.homeDir = options.homeDir ?? homedir(); } async fetchUsage(): Promise<ProviderUsage> { const token = process.env["CURSOR_ACCESS_TOKEN"] || process.env["CURSOR_TOKEN"] || - (await readCursorTokenFromSqlite()); + (await readCursorTokenFromSqlite(this.homeDir, this.logger)); if (!token) return unavailableUsage(this); diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts index 087f7fcf7d..3d4c6ed29b 100644 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ b/packages/server/src/services/quota-fetcher/service.test.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs"; import { promises as fsPromises } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -58,6 +59,31 @@ function writeKimiCredentials(dir: string, accessToken: string, overrides: objec ); } +// node:sqlite has no @types/node@20 typings; require it with a narrow local type. +const testRequire = createRequire(import.meta.url); +interface TestSqliteDb { + exec(sql: string): void; + prepare(sql: string): { run(...params: unknown[]): void }; + close(): void; +} + +// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the +// real layouts: a plain modern token or the legacy JSON object. +function writeCursorStateDb(homeDir: string, rows: Record<string, string | Uint8Array>): void { + const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage"); + mkdirSync(dir, { recursive: true }); + const { DatabaseSync } = testRequire("node:sqlite") as { + DatabaseSync: new (path: string) => TestSqliteDb; + }; + const db = new DatabaseSync(join(dir, "state.vscdb")); + db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); + const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)"); + for (const [key, value] of Object.entries(rows)) { + insert.run(key, value); + } + db.close(); +} + function writeGrokAuth(home: string, auth: Record<string, unknown>): void { mkdirSync(join(home, ".grok"), { recursive: true }); writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); @@ -372,6 +398,7 @@ describe("real provider usage fetchers", () => { platform?: typeof process.platform; keychain?: () => Promise<unknown | null>; kimiHomeDir?: string; + cursorHomeDir?: string; miniMaxConfigPath?: string; miniMaxCredentialsPath?: string; } = {}, @@ -392,7 +419,11 @@ describe("real provider usage fetchers", () => { }), new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }), new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }), - new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }), + new CursorQuotaProvider({ + logger, + fetch: fetchThroughTestDouble, + homeDir: options.cursorHomeDir, + }), new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }), new GrokQuotaProvider({ logger, @@ -702,6 +733,75 @@ describe("real provider usage fetchers", () => { }); }); + it("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => { + writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" }); + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null; + return jsonResponse({ + planUsage: { + totalSpend: "1500", + includedSpend: "1000", + bonusSpend: "500", + remaining: "2500", + limit: "4000", + }, + billingCycleStart: "2026-01-14T12:42:14.000Z", + billingCycleEnd: "2026-02-14T12:42:14.000Z", + }); + }) as unknown as typeof fetch; + + const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); + + expect(authorization).toBe("Bearer cursor_state_jwt"); + expect(cursor).toMatchObject({ + status: "available", + balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], + }); + }); + + it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => { + writeCursorStateDb(homeDir, { + cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"), + }); + let authorization: string | null = null; + fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { + authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null; + return jsonResponse({ + planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, + billingCycleStart: null, + billingCycleEnd: null, + }); + }) as unknown as typeof fetch; + + const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); + + expect(authorization).toBe("Bearer cursor_legacy_jwt"); + expect(cursor.status).toBe("available"); + }); + + it("logs a debug diagnostic and stays unavailable when state.vscdb is unreadable", async () => { + const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "state.vscdb"), "not a sqlite database"); + const logger = createLogger(); + const provider = new CursorQuotaProvider({ + logger, + fetch: (() => { + throw new Error("usage API should not be called without a token"); + }) as unknown as typeof fetch, + homeDir, + }); + + const usage = await provider.fetchUsage(); + + expect(usage.status).toBe("unavailable"); + expect((logger as unknown as { debug: ReturnType<typeof vi.fn> }).debug).toHaveBeenCalledWith( + expect.objectContaining({ path: expect.stringContaining("state.vscdb") }), + expect.stringContaining("Failed to read Cursor token"), + ); + }); + it("fetches Z.ai usage from ZAI_API_KEY", async () => { process.env["ZAI_API_KEY"] = "zai_test_token"; fetchApi = mockFetch( From 2f6b585a3a17d850be939638857d5dc685baf43e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 13:00:55 +0200 Subject: [PATCH 35/73] fix(desktop): shut down cleanly with workspace terminals (#3268) Workspace terminal shutdown only signaled an npm wrapper, leaving Electron alive with broken output pipes. Make the signal-handling runner the terminal-owned process and route external Electron termination through the existing quit lifecycle. --- docs/development.md | 8 ++-- .../desktop/scripts/dev-runner-config.mjs | 6 +++ .../scripts/dev-runner-config.test.mjs | 38 ++++++++++++++++++- packages/desktop/scripts/dev-runner.mjs | 9 +++-- packages/desktop/scripts/dev.sh | 2 +- .../desktop/src/daemon/quit-lifecycle.test.ts | 20 ++++++++++ packages/desktop/src/daemon/quit-lifecycle.ts | 21 ++++++++++ packages/desktop/src/main.ts | 2 + paseo.json | 2 +- 9 files changed, 99 insertions(+), 9 deletions(-) diff --git a/docs/development.md b/docs/development.md index bd573b5b96..244db268ee 100644 --- a/docs/development.md +++ b/docs/development.md @@ -131,9 +131,11 @@ desktop-only environment inherited by terminals opened inside Paseo from couplin a new worktree instance to the parent desktop instance's profile or single-instance lock. -Electron remains in the desktop dev runner's process group. Closing or stopping the -workspace-script terminal must terminate Electron with Metro; detaching Electron -leaves an orphan holding the worktree's single-instance lock and broken output pipes. +The desktop workspace script `exec`s the dev runner so the terminal owns the runner +PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and +asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or +detach Electron: either change leaves an orphan holding the worktree's single-instance +lock and broken output pipes. With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen transition, and 751-pixel settings split with: diff --git a/packages/desktop/scripts/dev-runner-config.mjs b/packages/desktop/scripts/dev-runner-config.mjs index 8efbd3a033..c855b3e5d1 100644 --- a/packages/desktop/scripts/dev-runner-config.mjs +++ b/packages/desktop/scripts/dev-runner-config.mjs @@ -15,3 +15,9 @@ export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl }) { export function resolveChildKillTarget(pid, detached) { return detached ? -pid : pid; } + +export function registerDevRunnerShutdownSignals({ signalSource, stop }) { + for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) { + signalSource.on(signal, () => stop("SIGTERM")); + } +} diff --git a/packages/desktop/scripts/dev-runner-config.test.mjs b/packages/desktop/scripts/dev-runner-config.test.mjs index 813bc6760c..ca34fe8c16 100644 --- a/packages/desktop/scripts/dev-runner-config.test.mjs +++ b/packages/desktop/scripts/dev-runner-config.test.mjs @@ -1,7 +1,23 @@ import { describe, expect, test } from "vitest"; -import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs"; +import { readFileSync } from "node:fs"; +import { + createElectronSpawnOptions, + registerDevRunnerShutdownSignals, + resolveChildKillTarget, +} from "./dev-runner-config.mjs"; describe("desktop dev process ownership", () => { + test("makes the signal-handling runner the workspace terminal process", () => { + const paseoConfig = JSON.parse( + readFileSync(new URL("../../../paseo.json", import.meta.url), "utf8"), + ); + const devScript = readFileSync(new URL("./dev.sh", import.meta.url), "utf8"); + + expect(paseoConfig.scripts.desktop.command).toContain("exec ./packages/desktop/scripts/dev.sh"); + expect(devScript).toContain('npm --prefix "$DESKTOP_DIR" run build:main'); + expect(devScript).toContain('exec node "$SCRIPT_DIR/dev-runner.mjs"'); + }); + test("keeps Electron in the runner process group", () => { const options = createElectronSpawnOptions({ env: { PATH: "/usr/bin" }, @@ -23,4 +39,24 @@ describe("desktop dev process ownership", () => { expect(resolveChildKillTarget(42, true)).toBe(-42); expect(resolveChildKillTarget(42, false)).toBe(42); }); + + test("stops children when the owning terminal hangs up", () => { + const listeners = new Map(); + const receivedSignals = []; + + registerDevRunnerShutdownSignals({ + signalSource: { + on(signal, listener) { + listeners.set(signal, listener); + }, + }, + stop(signal) { + receivedSignals.push(signal); + }, + }); + + expect(Array.from(listeners.keys())).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]); + listeners.get("SIGHUP")(); + expect(receivedSignals).toEqual(["SIGTERM"]); + }); }); diff --git a/packages/desktop/scripts/dev-runner.mjs b/packages/desktop/scripts/dev-runner.mjs index c653f31b39..5e0344c0d1 100644 --- a/packages/desktop/scripts/dev-runner.mjs +++ b/packages/desktop/scripts/dev-runner.mjs @@ -4,7 +4,11 @@ import path from "node:path"; import { execFileSync, spawn } from "node:child_process"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; -import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs"; +import { + createElectronSpawnOptions, + registerDevRunnerShutdownSignals, + resolveChildKillTarget, +} from "./dev-runner-config.mjs"; import { resolveDevElectronArgs } from "./dev-runner-args.mjs"; @@ -159,8 +163,7 @@ function canConnect(port, host) { }); } -process.on("SIGINT", () => stopAll("SIGTERM")); -process.on("SIGTERM", () => stopAll("SIGTERM")); +registerDevRunnerShutdownSignals({ signalSource: process, stop: stopAll }); spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { cwd: appDir, diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh index f00625d873..90d5ffa095 100755 --- a/packages/desktop/scripts/dev.sh +++ b/packages/desktop/scripts/dev.sh @@ -31,7 +31,7 @@ export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT" export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}" -npm run build:main +npm --prefix "$DESKTOP_DIR" run build:main echo "══════════════════════════════════════════════════════" echo " Paseo Desktop Dev" diff --git a/packages/desktop/src/daemon/quit-lifecycle.test.ts b/packages/desktop/src/daemon/quit-lifecycle.test.ts index 5dd1e66806..9985817d23 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.test.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { createQuitLifecycle, + registerExternalQuitSignals, shouldStopDesktopManagedDaemonOnQuit, stopDesktopManagedDaemonOnQuitIfNeeded, } from "./quit-lifecycle"; @@ -29,6 +30,25 @@ function waitForQuitLifecycle(): Promise<void> { } describe("quit-lifecycle", () => { + it("turns external termination signals into one Electron quit", () => { + const listeners = new Map<NodeJS.Signals, () => void>(); + const quits: string[] = []; + + registerExternalQuitSignals({ + signals: { + on: (signal, listener) => { + listeners.set(signal, listener); + }, + }, + quit: () => quits.push("quit"), + }); + + expect(Array.from(listeners.keys())).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]); + listeners.get("SIGTERM")?.(); + listeners.get("SIGHUP")?.(); + expect(quits).toEqual(["quit"]); + }); + it("stops by default and only keeps running when keepRunningAfterQuit is enabled", () => { expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true); expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false); diff --git a/packages/desktop/src/daemon/quit-lifecycle.ts b/packages/desktop/src/daemon/quit-lifecycle.ts index 1998985f56..bd1847ce6b 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.ts @@ -14,6 +14,10 @@ interface BeforeQuitApp { exit(code: number): void; } +interface ExternalQuitSignalSource { + on(signal: NodeJS.Signals, listener: () => void): unknown; +} + interface QuitLifecycle { handleBeforeQuit(event: BeforeQuitEvent): void; handleBeforeQuitForUpdate(): void; @@ -31,6 +35,23 @@ export interface StopOnQuitDeps { showShutdownFeedback: () => void; } +export function registerExternalQuitSignals({ + signals, + quit, +}: { + signals: ExternalQuitSignalSource; + quit: () => void; +}): void { + let quitRequested = false; + for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] satisfies NodeJS.Signals[]) { + signals.on(signal, () => { + if (quitRequested) return; + quitRequested = true; + quit(); + }); + } +} + export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean { return !settings.daemon.keepRunningAfterQuit; } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 36b66783ad..cb0ae0aa5a 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -83,6 +83,7 @@ import { } from "./daemon/daemon-manager.js"; import { createQuitLifecycle, + registerExternalQuitSignals, stopDesktopManagedDaemonOnQuitIfNeeded, } from "./daemon/quit-lifecycle.js"; import { runDesktopStartup } from "./desktop-startup.js"; @@ -1081,6 +1082,7 @@ const quitLifecycle = createQuitLifecycle({ // electron-updater forwards this event through Electron's built-in autoUpdater. electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate); app.on("before-quit", quitLifecycle.handleBeforeQuit); +registerExternalQuitSignals({ signals: process, quit: () => app.quit() }); app.on("window-all-closed", () => { if (process.platform !== "darwin") { diff --git a/paseo.json b/paseo.json index 076d1f2c58..4e7b0e8116 100644 --- a/paseo.json +++ b/paseo.json @@ -19,7 +19,7 @@ }, "desktop": { "type": "service", - "command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} npm run dev --workspace=@getpaseo/desktop" + "command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} exec ./packages/desktop/scripts/dev.sh" }, "ios-simulator": { "type": "service", From b58ccf926988d0ab6ba70d405541922d4caf38de Mon Sep 17 00:00:00 2001 From: James Strain <jstrain@nvidia.com> Date: Wed, 12 Aug 2026 22:05:45 +1000 Subject: [PATCH 36/73] fix: stop completed Codex subagents appearing active (#3188) --- .../providers/codex-app-server-agent.test.ts | 82 +++++++++++++++++++ .../agent/providers/codex-app-server-agent.ts | 33 ++++++-- .../providers/codex/tool-call-mapper.test.ts | 22 +++++ .../agent/providers/codex/tool-call-mapper.ts | 13 ++- 4 files changed, 139 insertions(+), 11 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 1c62182dd1..7806c51489 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -2241,6 +2241,80 @@ describe("Codex app-server provider", () => { }); }); + test("keeps a settled child completed until Codex starts another child turn", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + try { + const resultPromise = session.run("Delegate the investigation."); + await appServer.waitForTurnStart(); + + appServer.startsSubAgent({ + callId: "call-settled-child", + threadId: "settled-child-thread", + agentPath: "/root/settled-child", + }); + appServer.completeTurn({ threadId: "settled-child-thread" }); + appServer.says({ + threadId: "settled-child-thread", + itemId: "late-child-message", + text: "Late trailing output.", + }); + + const providerUpserts = events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], + ); + expect(providerUpserts.at(-1)).toMatchObject({ + id: "settled-child-thread", + status: "completed", + }); + expect(events.at(-1)).toMatchObject({ + type: "timeline", + item: { + callId: "call-settled-child", + status: "completed", + detail: { type: "sub_agent", log: "[Assistant] Late trailing output." }, + }, + }); + + appServer.completesSubAgentActivity({ + callId: "late-child-interaction", + threadId: "settled-child-thread", + agentPath: "/root/settled-child", + kind: "interacted", + }); + expect( + events.findLast( + (event) => event.type === "provider_subagent" && event.event.type === "upsert", + ), + ).toMatchObject({ event: { id: "settled-child-thread", status: "completed" } }); + + appServer.startsTurn({ + threadId: "settled-child-thread", + turnId: "next-child-turn", + }); + expect( + events.findLast( + (event) => event.type === "provider_subagent" && event.event.type === "upsert", + ), + ).toMatchObject({ event: { id: "settled-child-thread", status: "running" } }); + + appServer.completeTurn({ threadId: "settled-child-thread" }); + appServer.completeTurn(); + await resultPromise; + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("updates a registered child with its later native activity name", () => { const session = createSession(); const events: AgentStreamEvent[] = []; @@ -3453,6 +3527,10 @@ describe("Codex app-server provider", () => { const liveEvents: AgentStreamEvent[] = []; session.subscribe((event) => liveEvents.push(event)); + asInternals(session).handleNotification("turn/started", { + threadId: "v2-child-thread", + turn: { id: "v2-child-turn-after-resume" }, + }); asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { @@ -3481,6 +3559,10 @@ describe("Codex app-server provider", () => { }); liveEvents.length = 0; + asInternals(session).handleNotification("turn/started", { + threadId: "legacy-child-thread", + turn: { id: "legacy-child-turn-after-resume" }, + }); asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "legacy-child-thread", itemId: "legacy-child-message-after-resume", diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 0a628498a6..9680d62a0f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1660,6 +1660,12 @@ interface CodexSubAgentActivity { kind: "started" | "interacted" | "interrupted"; } +function isTerminalSubAgentStatus( + status: ToolCallTimelineItem["status"], +): status is "completed" | "failed" | "canceled" { + return status === "completed" || status === "failed" || status === "canceled"; +} + function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null { const record = toObjectRecord(item); if (!record) { @@ -4901,7 +4907,7 @@ export class CodexAppServerAgentSession implements AgentSession { private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void { switch (parsed.kind) { case "thread_started": - this.emitSubAgentActivityUpdate(callId, "running"); + this.emitSubAgentActivityUpdate(callId, "running", { reopen: true }); return; case "turn_started": case "turn_completed": @@ -5174,10 +5180,13 @@ export class CodexAppServerAgentSession implements AgentSession { }, }; } - this.emitSubAgentActivityUpdate( - callId, - activity.kind === "interrupted" ? "canceled" : "running", - ); + let nextStatus: ToolCallTimelineItem["status"] | undefined = "running"; + if (activity.kind === "interrupted") { + nextStatus = "canceled"; + } else if (isTerminalSubAgentStatus(state.toolCall.status)) { + nextStatus = undefined; + } + this.emitSubAgentActivityUpdate(callId, nextStatus); return true; } @@ -5271,6 +5280,7 @@ export class CodexAppServerAgentSession implements AgentSession { private emitSubAgentActivityUpdate( callId: string, status?: ToolCallTimelineItem["status"], + options?: { reopen?: boolean }, ): void { const state = this.subAgentCallsByCallId.get(callId); if (!state || state.toolCall.detail.type !== "sub_agent") { @@ -5281,7 +5291,14 @@ export class CodexAppServerAgentSession implements AgentSession { childTimeline.length > 0 ? curateAgentActivity(childTimeline, { labelAssistantMessages: true }) : ""; - const resolvedStatus = status ?? state.toolCall.status; + let resolvedStatus = status ?? state.toolCall.status; + if ( + status === "running" && + !options?.reopen && + isTerminalSubAgentStatus(state.toolCall.status) + ) { + resolvedStatus = state.toolCall.status; + } for (const childThreadId of state.childThreadIds) { this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus); } @@ -5420,7 +5437,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingCommandOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId); } - this.emitSubAgentActivityUpdate(callId, "running"); + this.emitSubAgentActivityUpdate(callId); } private handleSubAgentContextCompactionItem( @@ -5556,7 +5573,7 @@ export class CodexAppServerAgentSession implements AgentSession { ): void { const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (subAgentCallId) { - this.emitSubAgentActivityUpdate(subAgentCallId, "running"); + this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true }); return; } this.currentTurnId = parsed.turnId; diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index f9df7ee62b..35a96b6db9 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -379,6 +379,28 @@ describe("codex tool-call mapper", () => { }); }); + it.each([ + ["shutdown", "canceled"], + ["notFound", "failed"], + ] as const)("maps terminal child state %s to %s", (childStatus, expectedStatus) => { + const item = mapCodexToolCallFromThreadItem({ + type: "collabAgentToolCall", + id: `call-sub-agent-${childStatus}`, + tool: "closeAgent", + status: "completed", + receiverThreadIds: ["child-thread-1"], + agentsStates: { + "child-thread-1": { status: childStatus, message: null }, + }, + }); + + expect(item).toMatchObject({ + type: "tool_call", + callId: `call-sub-agent-${childStatus}`, + status: expectedStatus, + }); + }); + it("maps mcp read_file completion with detail", () => { const item = expectMapped( mapCodexToolCallFromThreadItem( diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index b3775e42ce..7884c9bc78 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -569,10 +569,17 @@ function readStatus(value: unknown): string | undefined { function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] { const normalized = status.trim().toLowerCase(); - if (normalized === "error" || normalized === "errored") { - return "running"; + switch (normalized) { + case "error": + case "errored": + return "running"; + case "shutdown": + return "canceled"; + case "notfound": + return "failed"; + default: + return normalizeToolCallStatus(status, null, null); } - return normalizeToolCallStatus(status, null, null); } function resolveCollabAgentStatus( From 0ba7251280061532ccda6636a974873185f5e0dc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 15:27:18 +0200 Subject: [PATCH 37/73] Revert "Make large diffs open quickly and scroll as one surface (#3212)" This reverts commit b599d38a772f621e0001abfb90a769de11c8cd8b. --- packages/app/e2e/browser/changes-pane.spec.ts | 161 +--- .../app/e2e/browser/commit-diff-panel.spec.ts | 2 +- packages/app/src/components/diff-scroll.tsx | 59 +- .../app/src/components/diff-scroll.web.tsx | 46 +- packages/app/src/git/diff-flat-items.test.ts | 134 +++ packages/app/src/git/diff-flat-items.ts | 121 +++ .../app/src/git/diff-horizontal-surface.tsx | 80 -- packages/app/src/git/diff-pane.tsx | 902 ++++++++---------- packages/app/src/git/diff-rows/model.test.ts | 286 ------ packages/app/src/git/diff-rows/model.ts | 422 -------- .../diff-rows/virtualized-list-range.test.ts | 24 - packages/app/src/review/surface.tsx | 34 +- .../src/types/virtualized-list-internals.d.ts | 8 - packages/app/src/utils/diff-rendering.test.ts | 20 - packages/app/src/utils/diff-rendering.ts | 22 - ...eact-native+virtualized-lists+0.81.5.patch | 105 -- scripts/postinstall-patches.mjs | 4 - 17 files changed, 715 insertions(+), 1715 deletions(-) create mode 100644 packages/app/src/git/diff-flat-items.test.ts create mode 100644 packages/app/src/git/diff-flat-items.ts delete mode 100644 packages/app/src/git/diff-horizontal-surface.tsx delete mode 100644 packages/app/src/git/diff-rows/model.test.ts delete mode 100644 packages/app/src/git/diff-rows/model.ts delete mode 100644 packages/app/src/git/diff-rows/virtualized-list-range.test.ts delete mode 100644 packages/app/src/types/virtualized-list-internals.d.ts delete mode 100644 patches/@react-native+virtualized-lists+0.81.5.patch diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index c3b68b1459..98f69808b6 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import { readFile, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; -import { type Locator, type Page } from "@playwright/test"; +import { type Page } from "@playwright/test"; import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes"; import { test, expect } from "../support/fixtures"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; @@ -63,10 +63,6 @@ async function failNextDiscardRequest(page: Page): Promise<void> { const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences"; -function diffGroups(scope: Page | Locator, fileIndex = 0): Locator { - return scope.getByTestId(new RegExp(`^diff-file-${fileIndex}-group-\\d+$`)); -} - const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react"; interface UseMountedTabSetInput { @@ -469,12 +465,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page }) await expect(visiblePanel).toBeVisible(); await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible(); await expect(visiblePanel).toContainText("zz-deleted.ts"); - await expect(diffGroups(visiblePanel).first()).toBeVisible(); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0); await visiblePanel.getByTestId("diff-file-0-toggle").click(); - await expect(diffGroups(visiblePanel)).toHaveCount(0); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0); await visiblePanel.getByTestId("diff-file-0-toggle").click(); - await expect(diffGroups(visiblePanel).first()).toBeVisible(); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout"); await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff"); await workingDiffLayoutToggle.click(); @@ -490,12 +486,14 @@ test("Changes switches between inline and full-tab navigation", async ({ page }) ); await page.keyboard.press("Escape"); await visiblePanel.getByTestId("working-diff-toggle-expand-all").click(); - await expect(visiblePanel.getByTestId(/^diff-file-\d+-group-\d+$/)).toHaveCount(0); + await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0); await visiblePanel.getByTestId("working-diff-toggle-expand-all").click(); - await expect(diffGroups(visiblePanel).first()).toBeVisible(); + await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click(); - await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0); + await expect( + page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), + ).toHaveCount(0); await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); @@ -518,9 +516,13 @@ test("Changes switches between inline and full-tab navigation", async ({ page }) await changesTabToggle.click(); await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0); - await expect(diffGroups(page.getByTestId("explorer-content-area")).first()).toBeVisible(); + await expect( + page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), + ).toBeVisible(); await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click(); - await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0); + await expect( + page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"), + ).toHaveCount(0); }); test("changes diff switches between flat and tree file lists", async ({ page }) => { @@ -583,7 +585,7 @@ test("changes diff switches between flat and tree file lists", async ({ page }) await page.getByRole("button", { name: "Collapse all" }).click(); await expect(page.getByTestId("diff-file-0")).toHaveCount(0); await page.getByRole("button", { name: "Expand all" }).click(); - await expect(diffGroups(page).first()).toBeVisible(); + await expect(page.getByTestId("diff-file-0-body")).toBeVisible(); await page.getByTestId("diff-folder-src-toggle").click(); await expect(page.getByTestId("diff-file-0")).toHaveCount(0); @@ -607,90 +609,6 @@ test("changes diff applies code size changes to gutter and code typography", asy await expectVisibleDiffRowsShareTypography(page); }); -const LARGE_DIFF_WIDE_LINE_INDEX = 900; - -async function createWorkspaceWithLargeUnwrappedDiff(): Promise<DirtyWorkspace> { - const workspace = await createWorkspaceWithMountedTabDiff(); - const addedLines = Array.from({ length: 1_200 }, (_, index) => { - if (index === 0) return `\t\t\t\tconst tabbed_${index} = "tabbed";`; - if (index === LARGE_DIFF_WIDE_LINE_INDEX) { - return `const wide_${index} = "${"界".repeat(80)}";`; - } - return `const generated_${index} = ${index};`; - }).join("\n"); - await writeFile( - path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), - `${AFTER}${addedLines}\n`, - ); - return workspace; -} - -async function scrollToWideDiffLine(page: Page): Promise<Locator> { - await page.getByTestId("git-diff-scroll").evaluate((element, lineIndex) => { - element.scrollTop = lineIndex * 18; - element.dispatchEvent(new Event("scroll", { bubbles: false })); - }, LARGE_DIFF_WIDE_LINE_INDEX); - const wideLine = page.getByText(new RegExp(`const wide_${LARGE_DIFF_WIDE_LINE_INDEX}`)); - await expect(wideLine).toHaveCount(1); - return wideLine; -} - -test("unwrapped virtualized rows use one horizontal file surface", async ({ page }) => { - const workspace = await createWorkspaceWithLargeUnwrappedDiff(); - await useUnwrappedDiffLines(page); - await openWorkspaceChanges(page, workspace); - - const horizontalSurface = page.getByTestId("diff-horizontal-scroll"); - await expect(horizontalSurface).toHaveCount(1); - await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0); - const codeBefore = await page.getByTestId("diff-code-row-0").boundingBox(); - const gutterBefore = await page.getByTestId("diff-gutter-row-0").boundingBox(); - expect(codeBefore).not.toBeNull(); - expect(gutterBefore).not.toBeNull(); - await horizontalSurface.evaluate((element, left) => { - element.scrollLeft = left; - element.dispatchEvent(new Event("scroll", { bubbles: false })); - }, 120); - await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120); - await expect - .poll(async () => (await page.getByTestId("diff-code-row-0").boundingBox())?.x ?? 0) - .toBeLessThanOrEqual(codeBefore!.x - 119); - await expect - .poll(async () => (await page.getByTestId("diff-gutter-row-0").boundingBox())?.x ?? 0) - .toBeCloseTo(gutterBefore!.x, 0); - const initiallyMountedGroups = await readMountedDiffGroupIds(page); - await page.getByTestId("git-diff-scroll").evaluate((element) => { - element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight); - element.dispatchEvent(new Event("scroll", { bubbles: false })); - }); - await expect - .poll(async () => { - const mountedGroups = await readMountedDiffGroupIds(page); - return mountedGroups.some((group) => !initiallyMountedGroups.includes(group)); - }) - .toBe(true); - await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120); - await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0); - - const wideLine = await scrollToWideDiffLine(page); - await horizontalSurface.evaluate((element) => { - element.scrollLeft = element.scrollWidth; - element.dispatchEvent(new Event("scroll", { bubbles: false })); - }); - await expect - .poll(async () => { - const surfaceBounds = await horizontalSurface.boundingBox(); - const wideTextRight = await wideLine.evaluate((element) => { - const range = document.createRange(); - range.selectNodeContents(element); - return range.getBoundingClientRect().right; - }); - if (!surfaceBounds) return false; - return wideTextRight <= surfaceBounds.x + surfaceBounds.width + 1; - }) - .toBe(true); -}); - async function useCodeFont(page: Page, codeFontSize: number): Promise<void> { await page.addInitScript( ({ settingsKey, fontSize }) => { @@ -716,12 +634,6 @@ async function useCodeFont(page: Page, codeFontSize: number): Promise<void> { ); } -async function readMountedDiffGroupIds(page: Page): Promise<(string | null)[]> { - return diffGroups(page).evaluateAll((groups) => - groups.map((group) => group.getAttribute("data-testid")), - ); -} - async function useUnwrappedDiffLines(page: Page): Promise<void> { await page.addInitScript( ({ preferencesKey }) => { @@ -770,22 +682,21 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{ codeLineHeight: number; }[]; }> { - return diffGroups(page).evaluateAll((groups) => { - if (groups.length === 0) { + return page.locator("body").evaluate(({ ownerDocument }) => { + const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]'); + if (!root) { throw new Error("Expanded diff body is not mounted"); } const readRows = (prefix: string, textPrefix: string) => - groups.flatMap((group) => - Array.from(group.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => { - const testId = row.getAttribute("data-testid") ?? ""; - const index = Number(testId.slice(prefix.length)); - const rect = row.getBoundingClientRect(); - const text = row.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`); - const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0; - return { index, top: rect.top, height: rect.height, lineHeight }; - }), - ); + Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => { + const testId = row.getAttribute("data-testid") ?? ""; + const index = Number(testId.slice(prefix.length)); + const rect = row.getBoundingClientRect(); + const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`); + const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0; + return { index, top: rect.top, height: rect.height, lineHeight }; + }); const gutters = new Map( readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]), @@ -889,7 +800,7 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> { } async function expectExpandedMountedTabDiff(page: Page): Promise<void> { - await expect(diffGroups(page).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({ timeout: 30_000, }); @@ -928,9 +839,15 @@ async function returnToWorkspaceChanges(page: Page): Promise<void> { } async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> { - const groups = diffGroups(page); - await expect.poll(() => groups.count()).toBeGreaterThan(1); - const lastMountedGroup = groups.last(); - await lastMountedGroup.scrollIntoViewIfNeeded(); - await expect(lastMountedGroup.locator('[data-testid^="diff-code-row-"]').last()).toBeVisible(); + const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => { + const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]')); + if (rows.length === 0) { + throw new Error("No unwrapped code rows are mounted"); + } + return Math.max( + ...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))), + ); + }); + await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded(); + await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible(); } diff --git a/packages/app/e2e/browser/commit-diff-panel.spec.ts b/packages/app/e2e/browser/commit-diff-panel.spec.ts index aa416bb115..1f658fac0d 100644 --- a/packages/app/e2e/browser/commit-diff-panel.spec.ts +++ b/packages/app/e2e/browser/commit-diff-panel.spec.ts @@ -55,7 +55,7 @@ test("commit history shows dates and shares diff layout preferences", async ({ await layoutToggle.click(); await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff"); await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0); - await expect(panel.getByTestId(/^diff-file-0-group-\d+$/).first()).toBeVisible(); + await expect(panel.getByTestId("diff-file-0-body")).toBeVisible(); await page.getByTestId(/^workspace-commit-diff-close-/).click(); await expect(panel).toHaveCount(0); diff --git a/packages/app/src/components/diff-scroll.tsx b/packages/app/src/components/diff-scroll.tsx index d6e3a2a8d3..49110c3c7b 100644 --- a/packages/app/src/components/diff-scroll.tsx +++ b/packages/app/src/components/diff-scroll.tsx @@ -1,75 +1,58 @@ -import { useState, useCallback, useEffect, useId } from "react"; -import { type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; -import { ScrollView } from "react-native-gesture-handler"; +import { useState, useCallback, useEffect, useId, useRef } from "react"; import { - createAnimatedComponent, - useAnimatedScrollHandler, - useSharedValue, - type SharedValue, -} from "react-native-reanimated"; -import { scheduleOnRN } from "react-native-worklets"; + type LayoutChangeEvent, + type NativeSyntheticEvent, + type NativeScrollEvent, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures"; -const AnimatedScrollView = createAnimatedComponent(ScrollView); - interface DiffScrollProps { children: React.ReactNode; + scrollViewWidth: number; onScrollViewWidthChange: (width: number) => void; style?: StyleProp<ViewStyle>; contentContainerStyle?: StyleProp<ViewStyle>; - offset: SharedValue<number>; - testID?: string; } export function DiffScroll({ children, + scrollViewWidth: _scrollViewWidth, onScrollViewWidthChange, style, contentContainerStyle, - offset, - testID, }: DiffScrollProps) { + const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); const horizontalScroll = useHorizontalScrollOptional(); const scrollId = useId(); - const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); - const isAtLeftEdgeValue = useSharedValue(true); + const scrollViewRef = useRef<ScrollViewType>(null); const closeGestureRef = useFileExplorerCloseGestureRef(); // Register/unregister scroll offset tracking useEffect(() => { if (!horizontalScroll) return; + // Start at 0 (not scrolled) horizontalScroll.registerScrollOffset(scrollId, 0); return () => { horizontalScroll.unregisterScrollOffset(scrollId); }; }, [horizontalScroll, scrollId]); - const recordScrollOffset = useCallback( - (offsetX: number) => { + const handleScroll = useCallback( + (event: NativeSyntheticEvent<NativeScrollEvent>) => { + const offsetX = event.nativeEvent.contentOffset.x; + // Track if we're at the left edge (with small threshold for float precision) + setIsAtLeftEdge(offsetX <= 1); if (horizontalScroll) { horizontalScroll.registerScrollOffset(scrollId, offsetX); } }, [horizontalScroll, scrollId], ); - const recordLeftEdge = useCallback((atLeftEdge: boolean) => { - setIsAtLeftEdge(atLeftEdge); - }, []); - - const handleScroll = useAnimatedScrollHandler({ - onScroll: (event) => { - const offsetX = event.contentOffset.x; - offset.value = offsetX; - const atLeftEdge = offsetX <= 1; - if (atLeftEdge !== isAtLeftEdgeValue.value) { - isAtLeftEdgeValue.value = atLeftEdge; - scheduleOnRN(recordLeftEdge, atLeftEdge); - } - scheduleOnRN(recordScrollOffset, offsetX); - }, - }); const handleLayout = useCallback( (e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width), @@ -77,7 +60,8 @@ export function DiffScroll({ ); return ( - <AnimatedScrollView + <ScrollView + ref={scrollViewRef} horizontal nestedScrollEnabled showsHorizontalScrollIndicator @@ -87,7 +71,6 @@ export function DiffScroll({ onScroll={handleScroll} scrollEventThrottle={16} onLayout={handleLayout} - testID={testID} // When at left edge, wait for close gesture to fail before scrolling. // The close gesture fails quickly on leftward swipes (failOffsetX=-10), // so scrolling left works normally. On rightward swipes, close gesture @@ -95,6 +78,6 @@ export function DiffScroll({ waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined} > {children} - </AnimatedScrollView> + </ScrollView> ); } diff --git a/packages/app/src/components/diff-scroll.web.tsx b/packages/app/src/components/diff-scroll.web.tsx index 22cca08561..4caa3ac40f 100644 --- a/packages/app/src/components/diff-scroll.web.tsx +++ b/packages/app/src/components/diff-scroll.web.tsx @@ -1,23 +1,12 @@ -import { useCallback, useId, useLayoutEffect, useState } from "react"; -import { - type LayoutChangeEvent, - type NativeScrollEvent, - type NativeSyntheticEvent, - type StyleProp, - type ViewStyle, -} from "react-native"; -import { ScrollView } from "react-native-gesture-handler"; -import type { SharedValue } from "react-native-reanimated"; -import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; -import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures"; +import { useCallback } from "react"; +import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; interface DiffScrollProps { children: React.ReactNode; + scrollViewWidth: number; onScrollViewWidthChange: (width: number) => void; style?: StyleProp<ViewStyle>; contentContainerStyle?: StyleProp<ViewStyle>; - offset: SharedValue<number>; - testID?: string; } export function DiffScroll({ @@ -25,31 +14,9 @@ export function DiffScroll({ onScrollViewWidthChange, style, contentContainerStyle, - offset, - testID, }: DiffScrollProps) { - const horizontalScroll = useHorizontalScrollOptional(); - const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); - const scrollId = useId(); - const closeGestureRef = useFileExplorerCloseGestureRef(); - - useLayoutEffect(() => { - horizontalScroll?.registerScrollOffset(scrollId, 0); - return () => horizontalScroll?.unregisterScrollOffset(scrollId); - }, [horizontalScroll, scrollId]); - - const handleScroll = useCallback( - (event: NativeSyntheticEvent<NativeScrollEvent>) => { - const offsetX = event.nativeEvent.contentOffset.x; - offset.value = offsetX; - setIsAtLeftEdge(offsetX <= 1); - horizontalScroll?.registerScrollOffset(scrollId, offsetX); - }, - [horizontalScroll, offset, scrollId], - ); - const handleLayout = useCallback( - (event: LayoutChangeEvent) => onScrollViewWidthChange(event.nativeEvent.layout.width), + (e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width), [onScrollViewWidthChange], ); @@ -58,14 +25,9 @@ export function DiffScroll({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator - bounces={false} style={style} contentContainerStyle={contentContainerStyle} - onScroll={handleScroll} - scrollEventThrottle={16} onLayout={handleLayout} - testID={testID} - waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined} > {children} </ScrollView> diff --git a/packages/app/src/git/diff-flat-items.test.ts b/packages/app/src/git/diff-flat-items.test.ts new file mode 100644 index 0000000000..a3df76492d --- /dev/null +++ b/packages/app/src/git/diff-flat-items.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "./diff-flat-items"; +import type { ParsedDiffFile } from "@/git/use-diff-query"; + +function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile { + return { path, isNew: false, isDeleted: false, additions, deletions, hunks: [] }; +} + +function summarize(items: DiffFlatItem[]): string[] { + return items.map((item) => { + if (item.type === "folder") { + return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`; + } + const base = item.file.path.split("/").pop(); + return `${" ".repeat(item.depth)}${item.type === "body" ? "body:" : ""}${base}`; + }); +} + +describe("buildDiffFlatItems", () => { + const files = [createFile("src/app/a.ts"), createFile("src/app/nested/b.ts")]; + + it("emits a body and a sticky index for each expanded file", () => { + const { items, stickyHeaderIndices } = buildDiffFlatItems({ + files: [createFile("a.ts"), createFile("b.ts")], + viewMode: "tree", + collapsedFolders: new Set(), + expandedPaths: new Set(["a.ts"]), + }); + // Root-level files (no folder): header, body (for expanded a.ts), header + expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]); + // sticky points at the header (index 0), not the body + expect(stickyHeaderIndices).toEqual([0]); + expect(items[0].type).toBe("header"); + }); + + it("emits the old flat list without folder rows or indentation", () => { + const { items, stickyHeaderIndices } = buildDiffFlatItems({ + files, + viewMode: "flat", + collapsedFolders: new Set(), + expandedPaths: new Set(["src/app/a.ts"]), + }); + + expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]); + expect(stickyHeaderIndices).toEqual([0]); + expect(items.every((item) => item.type !== "folder")).toBe(true); + }); + + it("groups files under compressed folder rows, all expanded by default", () => { + const { items } = buildDiffFlatItems({ + files, + viewMode: "tree", + collapsedFolders: new Set(), + expandedPaths: new Set(), + }); + // dirs sort before files within a level: [nested] precedes a.ts + expect(summarize(items)).toEqual(["[src/app]", " [nested]", " b.ts", " a.ts"]); + }); + + it("collapsing a folder hides its descendants but keeps the row", () => { + const { items } = buildDiffFlatItems({ + files, + viewMode: "tree", + collapsedFolders: new Set(["src/app/nested"]), + expandedPaths: new Set(), + }); + expect(summarize(items)).toEqual(["[src/app]", " [nested] (collapsed)", " a.ts"]); + }); + + it("collapsing an ancestor hides everything below it", () => { + const { items } = buildDiffFlatItems({ + files, + viewMode: "tree", + collapsedFolders: new Set(["src/app"]), + expandedPaths: new Set(), + }); + expect(summarize(items)).toEqual(["[src/app] (collapsed)"]); + }); + + it("derives sticky indices file-headers-only from the post-collapse list", () => { + // dirs-first: [src/app], [nested], b.ts, a.ts. Expanding a.ts puts its + // header at index 3, with the body right after. + const { items, stickyHeaderIndices } = buildDiffFlatItems({ + files, + viewMode: "tree", + collapsedFolders: new Set(), + expandedPaths: new Set(["src/app/a.ts"]), + }); + expect(summarize(items)).toEqual([ + "[src/app]", + " [nested]", + " b.ts", + " a.ts", + " body:a.ts", + ]); + expect(stickyHeaderIndices).toEqual([3]); + for (const idx of stickyHeaderIndices) { + expect(items[idx].type).toBe("header"); + } + }); + + it("maps tree file rows back to their original files array index", () => { + const { items } = buildDiffFlatItems({ + files, + viewMode: "tree", + collapsedFolders: new Set(), + expandedPaths: new Set(), + }); + // Tree order is b.ts (fileIndex 1) then a.ts (fileIndex 0) + const headers = items.filter((i) => i.type === "header"); + expect(headers.map((h) => (h.type === "header" ? h.fileIndex : -1))).toEqual([1, 0]); + }); +}); + +describe("sumHeightsBefore", () => { + const items = buildDiffFlatItems({ + files: [createFile("src/a.ts"), createFile("src/b.ts")], + viewMode: "tree", + collapsedFolders: new Set(), + expandedPaths: new Set(), + }).items; // [folder, a.ts, b.ts] + + const heightFor = (item: DiffFlatItem) => (item.type === "folder" ? 10 : 20); + + it("sums the heights of items before the index, counting folder rows", () => { + expect(sumHeightsBefore(items, 0, heightFor)).toBe(0); + expect(sumHeightsBefore(items, 1, heightFor)).toBe(10); // folder above a.ts + expect(sumHeightsBefore(items, 2, heightFor)).toBe(30); // folder + a.ts above b.ts + }); + + it("clamps an out-of-range index to the list length", () => { + expect(sumHeightsBefore(items, 999, heightFor)).toBe(50); + }); +}); diff --git a/packages/app/src/git/diff-flat-items.ts b/packages/app/src/git/diff-flat-items.ts new file mode 100644 index 0000000000..cd60dd97c2 --- /dev/null +++ b/packages/app/src/git/diff-flat-items.ts @@ -0,0 +1,121 @@ +import type { ParsedDiffFile } from "@/git/use-diff-query"; +import { + buildDiffTree, + compressSingleChildChains, + flattenDiffTree, + type DiffTreeDirNode, +} from "@/git/diff-tree"; + +// The row model for the Changes FlatList. `fileIndex` is the file's index within +// the (path-sorted) `files` array — a stable identity for testIDs/keys that is +// independent of folder rows and collapse state. `depth` drives indentation. +export type DiffFlatItem = + | { + type: "folder"; + dirPath: string; + displayName: string; + depth: number; + collapsed: boolean; + additions: number; + deletions: number; + } + | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean; depth: number } + | { type: "body"; file: ParsedDiffFile; fileIndex: number; depth: number }; + +export interface DiffFlatItemsResult { + items: DiffFlatItem[]; + /** Indices into `items` of expanded file headers — folder rows are never sticky. */ + stickyHeaderIndices: number[]; +} + +export interface BuildDiffFlatItemsInput { + files: ParsedDiffFile[]; + viewMode: "flat" | "tree"; + /** Full uncompressed directory paths that are collapsed (empty = all expanded). */ + collapsedFolders: ReadonlySet<string>; + /** File paths whose diff body is expanded. */ + expandedPaths: ReadonlySet<string>; + /** + * Pre-built compressed tree (from the same `files`). Pass it to avoid rebuilding + * the tree on every collapse toggle; omitted callers (tests) build it internally. + */ + tree?: DiffTreeDirNode; +} + +/** + * Build the FlatList item model plus its sticky-header indices: files grouped + * under directory rows, single-child chains compressed, descendants of collapsed + * folders omitted. + * + * Sticky indices are derived from the FINAL (post-collapse) item list and only + * ever point at expanded FILE headers, so folder rows can't stack or collide in + * the sticky header stack. + */ +export function buildDiffFlatItems({ + files, + viewMode, + collapsedFolders, + expandedPaths, + tree, +}: BuildDiffFlatItemsInput): DiffFlatItemsResult { + const items: DiffFlatItem[] = []; + const stickyHeaderIndices: number[] = []; + + const pushFile = (file: ParsedDiffFile, fileIndex: number, depth: number): void => { + const isExpanded = expandedPaths.has(file.path); + items.push({ type: "header", file, fileIndex, isExpanded, depth }); + if (isExpanded) { + stickyHeaderIndices.push(items.length - 1); + items.push({ type: "body", file, fileIndex, depth }); + } + }; + + if (viewMode === "flat") { + for (const [fileIndex, file] of files.entries()) { + pushFile(file, fileIndex, 0); + } + return { items, stickyHeaderIndices }; + } + + const indexByPath = new Map(files.map((file, index) => [file.path, index])); + const compressedTree = tree ?? compressSingleChildChains(buildDiffTree(files)); + const rows = flattenDiffTree(compressedTree, collapsedFolders); + + for (const row of rows) { + if (row.kind === "folder") { + items.push({ + type: "folder", + dirPath: row.dirPath, + displayName: row.displayName, + depth: row.depth, + collapsed: collapsedFolders.has(row.dirPath), + additions: row.additions, + deletions: row.deletions, + }); + continue; + } + const fileIndex = indexByPath.get(row.file.path); + if (fileIndex === undefined) { + // Should never happen: the tree is built from the same `files` array. + continue; + } + pushFile(row.file, fileIndex, row.depth); + } + + return { items, stickyHeaderIndices }; +} + +/** Cumulative height of every item before `index`. Single source of truth for + * FlatList's getItemLayout AND the collapse scroll-anchor math. */ +export function sumHeightsBefore( + items: DiffFlatItem[], + index: number, + heightFor: (item: DiffFlatItem) => number, +): number { + let offset = 0; + const end = Math.min(index, items.length); + for (let i = 0; i < end; i++) { + offset += heightFor(items[i]); + } + return Math.max(0, offset); +} diff --git a/packages/app/src/git/diff-horizontal-surface.tsx b/packages/app/src/git/diff-horizontal-surface.tsx deleted file mode 100644 index c16be414ba..0000000000 --- a/packages/app/src/git/diff-horizontal-surface.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { createContext, useContext, useMemo, type ReactNode } from "react"; -import { type LayoutChangeEvent, type StyleProp, type ViewStyle, View } from "react-native"; -import Animated, { - useAnimatedStyle, - useSharedValue, - type SharedValue, -} from "react-native-reanimated"; -import { DiffScroll } from "@/components/diff-scroll"; - -const FILL: ViewStyle = { flex: 1 }; -const CLIP: ViewStyle = { flex: 1, overflow: "hidden" }; - -const DiffHorizontalOffsetContext = createContext<SharedValue<number> | null>(null); - -export function DiffHorizontalSurface({ - children, - contentWidth, - viewportWidth, - onViewportWidthChange, -}: { - children: ReactNode; - contentWidth: number; - viewportWidth: number; - onViewportWidthChange: (width: number) => void; -}) { - const offset = useSharedValue(0); - const pinnedViewportStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: offset.value }], - })); - const contentContainerStyle = useMemo<StyleProp<ViewStyle>>( - () => [{ flexGrow: 1 }, { minWidth: Math.max(viewportWidth, contentWidth) }], - [contentWidth, viewportWidth], - ); - return ( - <DiffHorizontalOffsetContext.Provider value={offset}> - <DiffScroll - offset={offset} - onScrollViewWidthChange={onViewportWidthChange} - style={FILL} - contentContainerStyle={contentContainerStyle} - testID="diff-horizontal-scroll" - > - <Animated.View style={[FILL, { width: viewportWidth }, pinnedViewportStyle]}> - {children} - </Animated.View> - </DiffScroll> - </DiffHorizontalOffsetContext.Provider> - ); -} - -export function DiffHorizontalContent({ - children, - viewportStyle, - contentStyle, - onContentLayout, - testID, -}: { - children: ReactNode; - viewportStyle?: StyleProp<ViewStyle>; - contentStyle?: StyleProp<ViewStyle>; - onContentLayout?: (event: LayoutChangeEvent) => void; - testID?: string; -}) { - const offset = useContext(DiffHorizontalOffsetContext); - const translatedContentStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: -(offset?.value ?? 0) }], - })); - - return ( - <View style={[CLIP, viewportStyle]}> - <Animated.View - style={[contentStyle, translatedContentStyle]} - onLayout={onContentLayout} - testID={testID} - > - {children} - </Animated.View> - </View> - ); -} diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index a594faa9c3..f0756b34dd 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1,5 +1,4 @@ import { - Fragment, useState, useCallback, useEffect, @@ -43,6 +42,7 @@ import { WrapText, } from "lucide-react-native"; import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query"; +import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items"; import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree"; import { DiffFolderRow } from "@/git/diff-folder-row"; import { @@ -59,10 +59,13 @@ import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; import { CommitsSection } from "@/git/commits-section/commits-section"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useAppSettings } from "@/hooks/use-settings"; +import { DiffScroll } from "@/components/diff-scroll"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll"; import { + buildSplitDiffRows, + buildUnifiedDiffLines, type ReviewableDiffTarget, type SplitDiffDisplayLine, type SplitDiffRow, @@ -104,7 +107,6 @@ import { formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens, - compactHighlightTokens, } from "@/utils/diff-rendering"; import { isWeb, isNative } from "@/constants/platform"; import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; @@ -120,15 +122,6 @@ import { import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; import { DiffTooLargeState } from "@/git/diff-too-large-state"; import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; -import { - buildDiffRowIndex, - createDiffRowCache, - DIFF_VIRTUALIZATION_CONFIG, - type DiffListItem, - type SplitRowGroupItem, - type UnifiedRowGroupItem, -} from "@/git/diff-rows/model"; -import { DiffHorizontalContent, DiffHorizontalSurface } from "@/git/diff-horizontal-surface"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -183,6 +176,10 @@ function useDiffRowMetricsStyle(textMetricsStyle: TextStyle): StyleProp<ViewStyl ); } +function HighlightedToken({ token }: { token: HighlightToken }) { + return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>; +} + function HighlightedText({ tokens, textMetricsStyle, @@ -199,21 +196,15 @@ function HighlightedText({ [textMetricsStyle, wrapLines], ); - const keyedTokens = useMemo(() => { - let characterOffset = 0; - return compactHighlightTokens(tokens).map((token) => { - const keyedToken = { key: `${characterOffset}-${token.style ?? "base"}`, token }; - characterOffset += token.text.length; - return keyedToken; - }); - }, [tokens]); + const keyedTokens = useMemo( + () => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), + [tokens], + ); return ( <Text style={containerStyle} testID={testID}> {keyedTokens.map(({ key, token }) => ( - <Text key={key} style={syntaxTokenStyleFor(token.style)}> - {token.text} - </Text> + <HighlightedToken key={key} token={token} /> ))} </Text> ); @@ -308,7 +299,6 @@ function LongPressableLine({ hoverTargetKey, onHoverTargetChange, style, - testID, children, }: { reviewTarget: ReviewableDiffTarget | null | undefined; @@ -317,7 +307,6 @@ function LongPressableLine({ hoverTargetKey?: string | null; onHoverTargetChange?: (key: string | null) => void; style: StyleProp<ViewStyle>; - testID?: string; children: ReactNode; }) { const onStartComment = reviewActions?.onStartComment; @@ -347,26 +336,17 @@ function LongPressableLine({ if (isWeb && (onHoverChange || onHoverTargetChange)) { return ( - <Pressable - onHoverIn={handleHoverIn} - onHoverOut={handleHoverOut} - style={hoverStyle} - testID={testID} - > + <Pressable onHoverIn={handleHoverIn} onHoverOut={handleHoverOut} style={hoverStyle}> {children} </Pressable> ); } if (!isNative || !reviewTarget || !onStartComment) { - return ( - <View style={style} testID={testID}> - {children} - </View> - ); + return <View style={style}>{children}</View>; } return ( - <Pressable onPress={handlePress} style={style} testID={testID}> + <Pressable onPress={handlePress} style={style}> {children} </Pressable> ); @@ -391,7 +371,6 @@ function DiffGutterCell({ style, textTestID, actionTestID, - rowTestID, }: { lineNumber: number | null; type: DiffLine["type"] | undefined | null; @@ -403,7 +382,6 @@ function DiffGutterCell({ style?: StyleProp<ViewStyle>; textTestID?: string; actionTestID?: string; - rowTestID?: string; }) { const lineHeight = getNumericLineHeight(textMetricsStyle); const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); @@ -447,7 +425,6 @@ function DiffGutterCell({ onStartComment={onStartComment} style={containerStyle} actionTestID={actionTestID} - testID={rowTestID} > <Text numberOfLines={1} style={textStyle} testID={textTestID}> {formatDiffGutterText(lineNumber)} @@ -466,7 +443,6 @@ function DiffTextLine({ hoverTargetKey, onHoverTargetChange, textTestID, - rowTestID, }: { line: DiffLine; wrapLines: boolean; @@ -477,7 +453,6 @@ function DiffTextLine({ hoverTargetKey?: string | null; onHoverTargetChange?: (key: string | null) => void; textTestID?: string; - rowTestID?: string; }) { const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle); @@ -508,7 +483,6 @@ function DiffTextLine({ hoverTargetKey={hoverTargetKey} onHoverTargetChange={onHoverTargetChange} style={containerStyle} - testID={rowTestID} > {line.type !== "header" && visibleTokens ? ( <HighlightedText @@ -837,10 +811,6 @@ function SplitDiffColumn({ wrapLines, textMetricsStyle, reviewActions, - startRowIndex, - minContentWidth, - textViewportWidth, - contentTestID, showDivider = false, }: { rows: SplitDiffRow[]; @@ -849,12 +819,9 @@ function SplitDiffColumn({ wrapLines: boolean; textMetricsStyle: TextStyle; reviewActions?: InlineReviewActions; - startRowIndex: number; - minContentWidth: number; - textViewportWidth: number; - contentTestID: string; showDivider?: boolean; }) { + const [scrollWidth, setScrollWidth] = useState(0); const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null); const wrapCellStyle = useMemo( @@ -868,19 +835,16 @@ function SplitDiffColumn({ const linesContainerRowStyle = useMemo( () => [ styles.linesContainer, - inlineUnistylesStyle({ minWidth: Math.max(textViewportWidth, minContentWidth) }), + scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }), ], - [minContentWidth, textViewportWidth], + [scrollWidth], ); const headerLineTextStyle = useMemo( () => [styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, styles.headerLineText], [textMetricsStyle], ); - const keyedRows = useMemo( - () => rows.map((row, index) => ({ key: `row-${startRowIndex + index}`, row })), - [rows, startRowIndex], - ); + const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]); if (wrapLines) { return ( @@ -968,47 +932,50 @@ function SplitDiffColumn({ ); })} </View> - <DiffHorizontalContent - viewportStyle={styles.splitColumnScroll} - contentStyle={[styles.diffContentInner, linesContainerRowStyle]} - testID={contentTestID} + <DiffScroll + scrollViewWidth={scrollWidth} + onScrollViewWidthChange={setScrollWidth} + style={styles.splitColumnScroll} + contentContainerStyle={styles.diffContentInner} > - {keyedRows.map(({ key, row }) => { - if (row.kind === "header") { + <View style={linesContainerRowStyle}> + {keyedRows.map(({ key, row }) => { + if (row.kind === "header") { + return ( + <View key={key} style={styles.splitHeaderRow}> + <Text style={headerLineTextStyle}>{row.content}</Text> + </View> + ); + } + const line = side === "left" ? row.left : row.right; + const reviewTargetKey = line?.reviewTarget?.key ?? null; + const reviewRowState = getSplitInlineReviewThreadState({ + left: row.left?.reviewTarget, + right: row.right?.reviewTarget, + reviewActions, + }); return ( - <View key={key} style={styles.splitHeaderRow}> - <Text style={headerLineTextStyle}>{row.content}</Text> + <View key={key}> + <SplitTextLine + line={line} + wrapLines={false} + textMetricsStyle={textMetricsStyle} + reviewActions={reviewActions} + hoverTargetKey={reviewTargetKey} + onHoverTargetChange={setHoveredReviewTargetKey} + /> + <InlineReviewThreadContent + reviewTarget={line?.reviewTarget} + reviewActions={reviewActions} + reservedHeight={reviewRowState?.height} + viewportWidth={scrollWidth} + pinToViewport + /> </View> ); - } - const line = side === "left" ? row.left : row.right; - const reviewTargetKey = line?.reviewTarget?.key ?? null; - const reviewRowState = getSplitInlineReviewThreadState({ - left: row.left?.reviewTarget, - right: row.right?.reviewTarget, - reviewActions, - }); - return ( - <View key={key}> - <SplitTextLine - line={line} - wrapLines={false} - textMetricsStyle={textMetricsStyle} - reviewActions={reviewActions} - hoverTargetKey={reviewTargetKey} - onHoverTargetChange={setHoveredReviewTargetKey} - /> - <InlineReviewThreadContent - reviewTarget={line?.reviewTarget} - reviewActions={reviewActions} - reservedHeight={reviewRowState?.height} - viewportWidth={textViewportWidth} - pinToViewport - /> - </View> - ); - })} - </DiffHorizontalContent> + })} + </View> + </DiffScroll> </View> ); } @@ -1287,251 +1254,194 @@ const DiffFileHeader = memo(function DiffFileHeader({ ); }); -function maxLineNumber(file: ParsedDiffFile): number { - let maximum = 0; - for (const hunk of file.hunks) { - maximum = Math.max(maximum, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount); - } - return maximum; -} - -function DiffFileStatus({ file }: { file: ParsedDiffFile }) { - const { t } = useTranslation(); - return ( - <View style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}> - <View style={[styles.diffContent, styles.statusMessageContainer]}> - <Text style={styles.statusMessageText}> - {file.status === "binary" - ? t("workspace.git.diff.binaryFile") - : t("workspace.git.diff.tooLarge")} - </Text> - </View> - </View> - ); -} - -interface DiffRowGroupProps { - item: UnifiedRowGroupItem | SplitRowGroupItem; - viewportWidth: number; - wrapLines: boolean; - codeFontSize: number; - textMetricsStyle: TextStyle; - reviewActions?: InlineReviewActions; - onHeightChange?: (key: string, height: number) => void; -} - -function DiffRowGroupView({ - item, - viewportWidth, +export function DiffFileBody({ + file, + layout, wrapLines, codeFontSize, textMetricsStyle, reviewActions, - onHeightChange, -}: DiffRowGroupProps) { + onBodyHeightChange, + testID, +}: { + file: ParsedDiffFile; + layout: "unified" | "split"; + wrapLines: boolean; + codeFontSize: number; + textMetricsStyle: TextStyle; + reviewActions?: InlineReviewActions; + onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void; + testID?: string; +}) { + const [scrollViewWidth, setScrollViewWidth] = useState(0); + const [bodyWidth, setBodyWidth] = useState(0); const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null); + const { t } = useTranslation(); const handleLayout = useCallback( (event: LayoutChangeEvent) => { - if (wrapLines) { - onHeightChange?.(item.key, event.nativeEvent.layout.height); - } + setBodyWidth(event.nativeEvent.layout.width); + onBodyHeightChange?.(file, event.nativeEvent.layout.height); }, - [item.key, onHeightChange, wrapLines], + [file, onBodyHeightChange], ); - const minimumContentWidth = Math.ceil(item.contentColumns * codeFontSize * 0.75 + SPACING[4]); - const gutterWidth = lineNumberGutterWidth(maxLineNumber(item.file), codeFontSize); - const textViewportWidth = Math.max(0, viewportWidth - gutterWidth); - const minimumTextWidth = Math.max(textViewportWidth, minimumContentWidth); + const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth; const linesContainerRowStyle = useMemo( - () => [styles.linesContainer, inlineUnistylesStyle({ minWidth: minimumTextWidth })], - [minimumTextWidth], - ); - const groupStyle = useMemo( () => [ - styles.fileSectionBodyContainer, - item.isLast && styles.fileSectionBorder, - item.isFirst && styles.diffContent, - styles.diffGroupContent, + styles.linesContainer, + availableWidth > 0 && inlineUnistylesStyle({ minWidth: availableWidth }), ], - [item.isFirst, item.isLast], + [availableWidth], ); - if (item.type === "split_group") { - return ( - <View - style={[groupStyle, styles.splitRow]} - onLayout={handleLayout} - dataSet={CODE_SURFACE_DATASET} - testID={`diff-file-${item.fileIndex}-group-${item.groupIndex}`} - > - <SplitDiffColumn - rows={item.rows} - side="left" - gutterWidth={gutterWidth} - wrapLines={wrapLines} - textMetricsStyle={textMetricsStyle} - reviewActions={reviewActions} - startRowIndex={item.startRowIndex} - minContentWidth={minimumContentWidth} - textViewportWidth={Math.max(0, viewportWidth / 2 - gutterWidth)} - contentTestID={`diff-file-${item.fileIndex}-group-${item.groupIndex}-left-content`} - /> - <SplitDiffColumn - rows={item.rows} - side="right" - gutterWidth={gutterWidth} - wrapLines={wrapLines} - textMetricsStyle={textMetricsStyle} - reviewActions={reviewActions} - startRowIndex={item.startRowIndex} - minContentWidth={minimumContentWidth} - textViewportWidth={Math.max(0, viewportWidth / 2 - gutterWidth)} - contentTestID={`diff-file-${item.fileIndex}-group-${item.groupIndex}-right-content`} - showDivider - /> - </View> - ); - } - - if (wrapLines) { - return ( - <View - style={groupStyle} - onLayout={handleLayout} - dataSet={CODE_SURFACE_DATASET} - testID={`diff-file-${item.fileIndex}-group-${item.groupIndex}`} - > - <View style={styles.linesContainer}> - {item.lines.map(({ line, lineNumber, key, reviewTarget }, localIndex) => { - const index = item.startRowIndex + localIndex; - return ( - <View key={key} testID={`diff-wrapped-row-${index}`}> - <DiffLineView - line={line} - lineNumber={lineNumber} - gutterWidth={gutterWidth} - wrapLines - textMetricsStyle={textMetricsStyle} - reviewTarget={reviewTarget} - reviewActions={reviewActions} - /> - <InlineReviewRow - reviewTarget={reviewTarget} - reviewActions={reviewActions} - gutterWidth={gutterWidth} - /> - </View> - ); - })} - </View> - </View> - ); - } - return ( <View - style={[groupStyle, styles.diffContentRow]} + style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]} onLayout={handleLayout} - dataSet={CODE_SURFACE_DATASET} - testID={`diff-file-${item.fileIndex}-group-${item.groupIndex}`} + testID={testID} > - <View style={styles.gutterColumn}> - {item.lines.map(({ line, lineNumber, key, reviewTarget }, localIndex) => { - const index = item.startRowIndex + localIndex; - const hasReviewThread = - getInlineReviewThreadState({ reviewTarget, reviewActions }) !== null; + {(() => { + if (file.status === "too_large" || file.status === "binary") { return ( - <Fragment key={key}> - <DiffGutterCell - lineNumber={lineNumber} - type={line.type} + <View style={styles.statusMessageContainer}> + <Text style={styles.statusMessageText}> + {file.status === "binary" + ? t("workspace.git.diff.binaryFile") + : t("workspace.git.diff.tooLarge")} + </Text> + </View> + ); + } + + let maxLineNo = 0; + for (const hunk of file.hunks) { + maxLineNo = Math.max( + maxLineNo, + hunk.oldStart + hunk.oldCount, + hunk.newStart + hunk.newCount, + ); + } + const gutterWidth = lineNumberGutterWidth(maxLineNo, codeFontSize); + + if (layout === "split") { + const rows = buildSplitDiffRows(file); + return ( + <View style={[styles.diffContent, styles.splitRow]} dataSet={CODE_SURFACE_DATASET}> + <SplitDiffColumn + rows={rows} + side="left" gutterWidth={gutterWidth} + wrapLines={wrapLines} textMetricsStyle={textMetricsStyle} - reviewTarget={reviewTarget} reviewActions={reviewActions} - isLineHovered={ - reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key - } - textTestID={`diff-gutter-text-${index}`} - actionTestID={`diff-gutter-action-${index}`} - rowTestID={`diff-gutter-row-${index}`} /> - {hasReviewThread ? ( - <InlineReviewGutterSpacer - reviewTarget={reviewTarget} - reviewActions={reviewActions} - gutterWidth={gutterWidth} - /> - ) : null} - </Fragment> + <SplitDiffColumn + rows={rows} + side="right" + gutterWidth={gutterWidth} + wrapLines={wrapLines} + textMetricsStyle={textMetricsStyle} + reviewActions={reviewActions} + showDivider + /> + </View> ); - })} - </View> - <DiffHorizontalContent - viewportStyle={styles.splitColumnScroll} - contentStyle={[styles.diffContentInner, linesContainerRowStyle]} - testID={`diff-file-${item.fileIndex}-group-${item.groupIndex}-content`} - > - {item.lines.map(({ line, key, reviewTarget }, localIndex) => { - const index = item.startRowIndex + localIndex; - const hasReviewThread = - getInlineReviewThreadState({ reviewTarget, reviewActions }) !== null; + } + + const computedLines = buildUnifiedDiffLines(file); + + if (wrapLines) { return ( - <Fragment key={key}> - <DiffTextLine - line={line} - wrapLines={false} - textMetricsStyle={textMetricsStyle} - reviewTarget={reviewTarget} - reviewActions={reviewActions} - hoverTargetKey={reviewTarget?.key ?? null} - onHoverTargetChange={setHoveredReviewTargetKey} - textTestID={`diff-code-text-${index}`} - rowTestID={`diff-code-row-${index}`} - /> - {hasReviewThread ? ( - <InlineReviewThreadContent - reviewTarget={reviewTarget} - reviewActions={reviewActions} - viewportWidth={textViewportWidth} - pinToViewport - /> - ) : null} - </Fragment> + <View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}> + <View style={styles.linesContainer}> + {computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => ( + <View key={key} testID={`diff-wrapped-row-${index}`}> + <DiffLineView + line={line} + lineNumber={lineNumber} + gutterWidth={gutterWidth} + wrapLines={wrapLines} + textMetricsStyle={textMetricsStyle} + reviewTarget={reviewTarget} + reviewActions={reviewActions} + /> + <InlineReviewRow + reviewTarget={reviewTarget} + reviewActions={reviewActions} + gutterWidth={gutterWidth} + /> + </View> + ))} + </View> + </View> ); - })} - </DiffHorizontalContent> + } + + const textViewportWidth = + scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth); + return ( + <View style={[styles.diffContent, styles.diffContentRow]} dataSet={CODE_SURFACE_DATASET}> + <View style={styles.gutterColumn}> + {computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => ( + <View key={key} testID={`diff-gutter-row-${index}`}> + <DiffGutterCell + lineNumber={lineNumber} + type={line.type} + gutterWidth={gutterWidth} + textMetricsStyle={textMetricsStyle} + reviewTarget={reviewTarget} + reviewActions={reviewActions} + isLineHovered={ + reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key + } + textTestID={`diff-gutter-text-${index}`} + actionTestID={`diff-gutter-action-${index}`} + /> + <InlineReviewGutterSpacer + reviewTarget={reviewTarget} + reviewActions={reviewActions} + gutterWidth={gutterWidth} + /> + </View> + ))} + </View> + <DiffScroll + scrollViewWidth={scrollViewWidth} + onScrollViewWidthChange={setScrollViewWidth} + style={styles.splitColumnScroll} + contentContainerStyle={styles.diffContentInner} + > + <View style={linesContainerRowStyle}> + {computedLines.map(({ line, key, reviewTarget }, index) => ( + <View key={key} testID={`diff-code-row-${index}`}> + <DiffTextLine + line={line} + wrapLines={false} + textMetricsStyle={textMetricsStyle} + reviewTarget={reviewTarget} + reviewActions={reviewActions} + hoverTargetKey={reviewTarget?.key ?? null} + onHoverTargetChange={setHoveredReviewTargetKey} + textTestID={`diff-code-text-${index}`} + /> + <InlineReviewThreadContent + reviewTarget={reviewTarget} + reviewActions={reviewActions} + viewportWidth={textViewportWidth} + pinToViewport + /> + </View> + ))} + </View> + </DiffScroll> + </View> + ); + })()} </View> ); } -function areDiffRowGroupPropsEqual(previous: DiffRowGroupProps, next: DiffRowGroupProps): boolean { - // Row arrays are rebuilt with the index. File identity pins cached row content, while the - // geometry key and compared grouping inputs pin group membership. - return ( - previous.item.key === next.item.key && - previous.item.type === next.item.type && - previous.item.file === next.item.file && - previous.item.fileIndex === next.item.fileIndex && - previous.item.depth === next.item.depth && - previous.item.groupIndex === next.item.groupIndex && - previous.item.startRowIndex === next.item.startRowIndex && - previous.item.isFirst === next.item.isFirst && - previous.item.isLast === next.item.isLast && - previous.item.contentColumns === next.item.contentColumns && - previous.viewportWidth === next.viewportWidth && - previous.wrapLines === next.wrapLines && - previous.codeFontSize === next.codeFontSize && - previous.textMetricsStyle === next.textMetricsStyle && - previous.reviewActions === next.reviewActions && - previous.onHeightChange === next.onHeightChange - ); -} - -const DiffRowGroup = memo(DiffRowGroupView, areDiffRowGroupPropsEqual); - interface GitDiffPaneProps { serverId: string; workspaceId?: string | null; @@ -1914,15 +1824,42 @@ export function DiffOptionsMenu({ const ThemedRotateCw = withUnistyles(RotateCw); -type DiffListItemLayoutGetter = NonNullable<FlatListProps<DiffListItem>["getItemLayout"]>; - -// COMPAT(react-native-virtualized-list-bounded-data-growth): RN 0.81 carries the old index range -// through middle insertions, synchronously mounting every inserted diff group in that range. -const BOUNDED_DIFF_LIST_DATA_GROWTH_PROPS = { - unstable_boundedRangeOnDataChange: true, -} as const; +type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>; const EMPTY_PATH_LIST: string[] = []; -const EMPTY_NUMBER_LIST: number[] = []; + +interface DiffFileMetrics { + contentLength: number; + splitLineCount?: number; + unifiedLineCount: number; +} + +const diffFileMetricsCache = new WeakMap<ParsedDiffFile, DiffFileMetrics>(); + +function getDiffFileMetrics(file: ParsedDiffFile): DiffFileMetrics { + const cached = diffFileMetricsCache.get(file); + if (cached) { + return cached; + } + let contentLength = 0; + let unifiedLineCount = 0; + for (const hunk of file.hunks) { + unifiedLineCount += hunk.lines.length; + for (const line of hunk.lines) { + contentLength += line.content.length; + } + } + const metrics = { contentLength, unifiedLineCount }; + diffFileMetricsCache.set(file, metrics); + return metrics; +} + +function getSplitDiffLineCount(file: ParsedDiffFile): number { + const metrics = getDiffFileMetrics(file); + if (metrics.splitLineCount === undefined) { + metrics.splitLineCount = buildSplitDiffRows(file).length; + } + return metrics.splitLineCount; +} function computeEmptyMessage( hideWhitespace: boolean, @@ -2061,9 +1998,6 @@ interface SharedDiffViewProps { }; } -type WorkingTreeDiffMode = Extract<SharedDiffViewProps["mode"], { kind: "working_tree" }>; -const EMPTY_WORKING_TREE_DIFF_MODE: Partial<WorkingTreeDiffMode> = {}; - export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) { const isCompact = useIsCompactFormFactor(); const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences; @@ -2077,22 +2011,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ...(trimmedMonoFontFamily ? { fontFamily: trimmedMonoFontFamily } : null), }; }, [codeFontSize, diffBodyLineHeight, monoFontFamily]); - const workingTreeMode = mode.kind === "working_tree" ? mode : EMPTY_WORKING_TREE_DIFF_MODE; - const { - viewMode = "flat", - collapsedFolders: collapsedFoldersArray = EMPTY_PATH_LIST, - onFilePress, - onOpenFile, - onAddToChat, - workspaceFileDragScope, - onCopyPath, - onCopyRelativePath, - onReveal, - revealTargetName, - onDownload, - onDuplicate, - onRevert, - } = workingTreeMode; + const viewMode = mode.kind === "working_tree" ? mode.viewMode : "flat"; const expandedPathsArray = useMemo(() => { if (mode.kind === "working_tree") { return mode.expandedPaths; @@ -2103,12 +2022,26 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi return files.map((file) => file.path); }, [files, mode]); const expandedPaths = useMemo(() => new Set(expandedPathsArray), [expandedPathsArray]); + const collapsedFoldersArray = + mode.kind === "working_tree" ? mode.collapsedFolders : EMPTY_PATH_LIST; const collapsedFolders = useMemo(() => new Set(collapsedFoldersArray), [collapsedFoldersArray]); const stickyHeaders = mode.kind !== "commit"; const interactive = mode.kind !== "commit"; const reviewActions = mode.kind === "commit" ? undefined : mode.reviewActions; + const onFilePress = mode.kind === "working_tree" ? mode.onFilePress : undefined; const focusPath = mode.kind === "working_tab" ? mode.focusPath : undefined; const focusRequestId = mode.kind === "working_tab" ? mode.focusRequestId : undefined; + const onOpenFile = mode.kind === "working_tree" ? mode.onOpenFile : undefined; + const onAddToChat = mode.kind === "working_tree" ? mode.onAddToChat : undefined; + const workspaceFileDragScope = + mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined; + const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined; + const onCopyRelativePath = mode.kind === "working_tree" ? mode.onCopyRelativePath : undefined; + const onReveal = mode.kind === "working_tree" ? mode.onReveal : undefined; + const revealTargetName = mode.kind === "working_tree" ? mode.revealTargetName : undefined; + const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined; + const onDuplicate = mode.kind === "working_tree" ? mode.onDuplicate : undefined; + const onRevert = mode.kind === "working_tree" ? mode.onRevert : undefined; // Keep selection independent from expansion so future keyboard actions (such as R to rename) // can target the current VCS file or folder without changing its open state. const [selectedPath, setSelectedPath] = useState<string | null>(null); @@ -2129,24 +2062,19 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi () => new Set(Array.from(collapsedFolders).filter((path) => allFolderPathSet.has(path))), [allFolderPathSet, collapsedFolders], ); - const diffListRef = useRef<FlatList<DiffListItem>>(null); + const diffListRef = useRef<FlatList<DiffFlatItem>>(null); const scrollbar = useOverlayFlatListScrollbar(diffListRef, { enabled: !isCompact }); const { onLayout: updateScrollbarLayout, onScroll: updateScrollbarOffset } = scrollbar; const consumedFocusRequestRef = useRef<string | null>(null); const pendingFocusRequestRef = useRef<string | null>(null); const diffListScrollOffsetRef = useRef(0); const diffListViewportHeightRef = useRef(0); - const pendingMeasuredHeightCorrectionRef = useRef(0); - const [diffListWidth, setDiffListWidth] = useState(800); const headerHeightByPathRef = useRef<Record<string, number>>({}); - const measuredGroupHeightsRef = useRef(new Map<string, number>()); - const rowCacheRef = useRef(createDiffRowCache()); + const bodyHeightByKeyRef = useRef<Record<string, number>>({}); const folderRowHeightRef = useRef<number>(0); const defaultHeaderHeightRef = useRef<number>(44); const [heightVersion, setHeightVersion] = useState(0); - const [geometryVersion, setGeometryVersion] = useState(0); const heightVersionFrameRef = useRef<number | null>(null); - const geometryVersionFrameRef = useRef<number | null>(null); const scheduleHeightVersionUpdate = useCallback(() => { if (heightVersionFrameRef.current !== null) { return; @@ -2156,106 +2084,82 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi setHeightVersion((version) => version + 1); }); }, []); - const scheduleGeometryVersionUpdate = useCallback(() => { - if (geometryVersionFrameRef.current !== null) { - return; - } - geometryVersionFrameRef.current = requestAnimationFrame(() => { - geometryVersionFrameRef.current = null; - setGeometryVersion((version) => version + 1); - }); - }, []); useEffect( () => () => { if (heightVersionFrameRef.current !== null) { cancelAnimationFrame(heightVersionFrameRef.current); } - if (geometryVersionFrameRef.current !== null) { - cancelAnimationFrame(geometryVersionFrameRef.current); - } }, [], ); const diffBodyChromeHeight = BORDER_WIDTH[1] * 2; const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight; - const reviewHeightFor = useCallback( - (target: ReviewableDiffTarget | null | undefined): number => - getInlineReviewThreadState({ reviewTarget: target, reviewActions })?.height ?? 0, - [reviewActions], - ); - const geometryKey = `${layout}:${wrapLines ? "wrap" : "scroll"}:${typographyKey}:${Math.round(diffListWidth)}`; - const rowIndex = useMemo(() => { - void heightVersion; - return buildDiffRowIndex({ + const { flatItems, stickyHeaderIndices } = useMemo(() => { + const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({ files, viewMode, tree: compressedTree, collapsedFolders: effectiveCollapsedFolders, expandedPaths, - layout, - wrapLines, - lineHeight: diffBodyLineHeight, - viewportWidth: diffListWidth, - codeFontSize, - headerHeightFor: (file) => - headerHeightByPathRef.current[file.path] ?? defaultHeaderHeightRef.current, - folderHeight: folderRowHeightRef.current || defaultHeaderHeightRef.current, - statusHeight: statusBodyHeightEstimate, - measuredGroupHeights: measuredGroupHeightsRef.current, - geometryKey, - reviewHeightFor, - rowCache: rowCacheRef.current, }); - }, [ - codeFontSize, - compressedTree, - diffBodyLineHeight, - diffListWidth, - effectiveCollapsedFolders, - expandedPaths, - files, - geometryKey, - heightVersion, - layout, - reviewHeightFor, - statusBodyHeightEstimate, - viewMode, - wrapLines, - ]); - const flatItems = rowIndex.items; - const horizontalContentColumns = useMemo( - () => - flatItems.reduce( - (maximum, item) => - item.type === "unified_group" || item.type === "split_group" - ? Math.max(maximum, item.contentColumns) - : maximum, - 0, - ), - [flatItems], + return { + flatItems: items, + stickyHeaderIndices: stickyHeaders ? stickyIndices : [], + }; + }, [compressedTree, effectiveCollapsedFolders, expandedPaths, files, stickyHeaders, viewMode]); + + const getBodyHeightKey = useCallback( + (file: ParsedDiffFile): string => { + if (file.status === "too_large" || file.status === "binary") { + return `${layout}:${wrapLines ? "wrap" : "scroll"}:${typographyKey}:${file.path}:${file.status}`; + } + + const metrics = getDiffFileMetrics(file); + return [ + layout, + wrapLines ? "wrap" : "scroll", + typographyKey, + file.path, + file.status ?? "ok", + file.additions, + file.deletions, + file.hunks.length, + metrics.unifiedLineCount, + metrics.contentLength, + ].join(":"); + }, + [layout, typographyKey, wrapLines], ); - const maximumGutterWidth = useMemo( - () => - files.reduce( - (maximum, file) => - Math.max(maximum, lineNumberGutterWidth(maxLineNumber(file), codeFontSize)), - 0, - ), - [codeFontSize, files], + + const estimateBodyHeight = useCallback( + (file: ParsedDiffFile): number => { + if (file.status === "too_large" || file.status === "binary") { + return statusBodyHeightEstimate; + } + + const lineCount = + layout === "split" + ? getSplitDiffLineCount(file) + : getDiffFileMetrics(file).unifiedLineCount; + return diffBodyChromeHeight + lineCount * diffBodyLineHeight; + }, + [diffBodyChromeHeight, diffBodyLineHeight, layout, statusBodyHeightEstimate], ); - const horizontalCodeWidth = Math.ceil( - horizontalContentColumns * codeFontSize * 0.75 + SPACING[4], + + const getFlatItemHeight = useCallback( + (item: DiffFlatItem): number => { + if (item.type === "folder") { + return folderRowHeightRef.current || defaultHeaderHeightRef.current; + } + if (item.type === "header") { + return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current; + } + const bodyHeightKey = getBodyHeightKey(item.file); + return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file); + }, + [estimateBodyHeight, getBodyHeightKey], ); - const horizontalCodeViewportWidth = Math.max( - 0, - (layout === "split" ? diffListWidth / 2 : diffListWidth) - maximumGutterWidth, - ); - const horizontalSurfaceWidth = - diffListWidth + Math.max(0, horizontalCodeWidth - horizontalCodeViewportWidth); - const rowIndexRef = useRef(rowIndex); - rowIndexRef.current = rowIndex; - const stickyHeaderIndices = stickyHeaders ? rowIndex.stickyHeaderIndices : EMPTY_NUMBER_LIST; const handleFolderRowHeightChange = useCallback( (height: number) => { @@ -2291,48 +2195,28 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [scheduleHeightVersionUpdate], ); - const handleGroupHeightChange = useCallback( - (key: string, height: number) => { + const handleBodyHeightChange = useCallback( + (file: ParsedDiffFile, height: number) => { if (!Number.isFinite(height) || height < 0) { return; } - const previousHeight = measuredGroupHeightsRef.current.get(key); + const heightKey = getBodyHeightKey(file); + const previousHeight = bodyHeightByKeyRef.current[heightKey]; if ( previousHeight !== undefined && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON ) { return; } - const currentIndex = rowIndexRef.current; - const update = currentIndex.updateHeight(key, height); - if (!update) return; - if ( - currentIndex.offsetAt(update.index) + update.previousHeight <= - diffListScrollOffsetRef.current - ) { - pendingMeasuredHeightCorrectionRef.current += update.delta; - } - measuredGroupHeightsRef.current.set(key, height); - scheduleGeometryVersionUpdate(); + bodyHeightByKeyRef.current[heightKey] = height; + scheduleHeightVersionUpdate(); }, - [scheduleGeometryVersionUpdate], + [getBodyHeightKey, scheduleHeightVersionUpdate], ); - useEffect(() => { - const correction = pendingMeasuredHeightCorrectionRef.current; - if (correction === 0) { - return; - } - pendingMeasuredHeightCorrectionRef.current = 0; - const offset = Math.max(0, diffListScrollOffsetRef.current + correction); - diffListScrollOffsetRef.current = offset; - diffListRef.current?.scrollToOffset({ offset, animated: false }); - }, [geometryVersion]); - const handleDiffListScroll = useCallback( (event: NativeSyntheticEvent<NativeScrollEvent>) => { - const offset = event.nativeEvent.contentOffset.y; - diffListScrollOffsetRef.current = offset; + diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; updateScrollbarOffset(event); }, [updateScrollbarOffset], @@ -2344,7 +2228,6 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi if (!Number.isFinite(height) || height <= 0) { return; } - setDiffListWidth(event.nativeEvent.layout.width); diffListViewportHeightRef.current = height; updateScrollbarLayout(event); }, @@ -2352,19 +2235,20 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ); const computeItemOffset = useCallback( - (predicate: (item: DiffListItem) => boolean): number | null => { + (predicate: (item: DiffFlatItem) => boolean): number | null => { const index = flatItems.findIndex(predicate); if (index < 0) { return null; } - return rowIndex.offsetAt(index); + return sumHeightsBefore(flatItems, index, getFlatItemHeight); }, - [flatItems, rowIndex], + [flatItems, getFlatItemHeight], ); const computeHeaderOffset = useCallback( - (path: string): number => rowIndex.offsetOfFile(path), - [rowIndex], + (path: string): number => + computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0, + [computeItemOffset], ); useEffect(() => { @@ -2504,7 +2388,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ); const renderFlatItem = useCallback( - ({ item }: { item: DiffListItem }) => { + ({ item }: { item: DiffFlatItem }) => { if (item.type === "folder") { return ( <DiffFolderRow @@ -2555,31 +2439,29 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi /> ); } - if (item.type === "status") { - return <DiffFileStatus file={item.file} />; - } return ( - <DiffRowGroup - item={item} - viewportWidth={diffListWidth} + <DiffFileBody + file={item.file} + layout={layout} wrapLines={wrapLines} codeFontSize={codeFontSize} textMetricsStyle={textMetricsStyle} reviewActions={reviewActions} - onHeightChange={handleGroupHeightChange} + onBodyHeightChange={handleBodyHeightChange} + testID={`diff-file-${item.fileIndex}-body`} /> ); }, [ codeFontSize, - diffListWidth, - handleGroupHeightChange, + handleBodyHeightChange, handleFolderRowHeightChange, handleHeaderHeightChange, handleCollapseFolder, handleSelectPath, handleToggleExpanded, handleToggleFolder, + layout, reviewActions, workspaceFileDragScope, textMetricsStyle, @@ -2600,26 +2482,20 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ], ); - const flatKeyExtractor = useCallback((item: DiffListItem) => { - if (item.type === "folder") { - return `folder-${item.dirPath}`; - } - if (item.type === "unified_group" || item.type === "split_group") { - return item.key; - } - return `${item.type}-${item.file.path}`; - }, []); + const flatKeyExtractor = useCallback( + (item: DiffFlatItem) => + item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`, + [], + ); - const getFlatItemLayout = useCallback<DiffListItemLayoutGetter>( + const getFlatItemLayout = useCallback<DiffFlatItemLayoutGetter>( (_data, index) => { - void geometryVersion; - return { - length: rowIndex.heightAt(index), - offset: rowIndex.offsetAt(index), - index, - }; + const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight); + const item = flatItems[index]; + const length = item ? getFlatItemHeight(item) : 0; + return { length, offset, index }; }, - [geometryVersion, rowIndex], + [flatItems, getFlatItemHeight], ); const flatExtraData = useMemo( @@ -2629,7 +2505,6 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi layout, typographyKey, heightVersion, - geometryVersion, viewMode, wrapLines, reviewActions, @@ -2639,7 +2514,6 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi expandedPathsArray, collapsedFoldersArray, heightVersion, - geometryVersion, layout, reviewActions, typographyKey, @@ -2649,45 +2523,29 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ], ); - const diffList = ( - <FlatList - {...BOUNDED_DIFF_LIST_DATA_GROWTH_PROPS} - ref={diffListRef} - data={flatItems} - renderItem={renderFlatItem} - keyExtractor={flatKeyExtractor} - getItemLayout={getFlatItemLayout} - stickyHeaderIndices={stickyHeaderIndices} - extraData={flatExtraData} - style={styles.scrollView} - contentContainerStyle={styles.contentContainer} - testID="git-diff-scroll" - onLayout={handleDiffListLayout} - onScroll={handleDiffListScroll} - onContentSizeChange={scrollbar.onContentSizeChange} - scrollEventThrottle={16} - showsVerticalScrollIndicator={!scrollbar.enabled} - removeClippedSubviews={false} - initialNumToRender={DIFF_VIRTUALIZATION_CONFIG.initialNumToRender} - maxToRenderPerBatch={DIFF_VIRTUALIZATION_CONFIG.maxToRenderPerBatch} - updateCellsBatchingPeriod={DIFF_VIRTUALIZATION_CONFIG.updateCellsBatchingPeriod} - windowSize={DIFF_VIRTUALIZATION_CONFIG.windowSize} - /> - ); - return ( <View style={styles.scrollContainer}> - {wrapLines ? ( - diffList - ) : ( - <DiffHorizontalSurface - contentWidth={horizontalSurfaceWidth} - viewportWidth={diffListWidth} - onViewportWidthChange={setDiffListWidth} - > - {diffList} - </DiffHorizontalSurface> - )} + <FlatList + ref={diffListRef} + data={flatItems} + renderItem={renderFlatItem} + keyExtractor={flatKeyExtractor} + getItemLayout={getFlatItemLayout} + stickyHeaderIndices={stickyHeaderIndices} + extraData={flatExtraData} + style={styles.scrollView} + contentContainerStyle={styles.contentContainer} + testID="git-diff-scroll" + onLayout={handleDiffListLayout} + onScroll={handleDiffListScroll} + onContentSizeChange={scrollbar.onContentSizeChange} + scrollEventThrottle={16} + showsVerticalScrollIndicator={!scrollbar.enabled} + removeClippedSubviews={false} + initialNumToRender={12} + maxToRenderPerBatch={12} + windowSize={10} + /> {scrollbar.overlay} </View> ); @@ -3644,9 +3502,6 @@ const styles = StyleSheet.create((theme) => ({ borderTopColor: theme.colors.border, backgroundColor: theme.colors.surface1, }, - diffGroupContent: { - backgroundColor: theme.colors.surface1, - }, diffContentRow: { flexDirection: "row", alignItems: "stretch", @@ -3693,7 +3548,6 @@ const styles = StyleSheet.create((theme) => ({ }, splitColumnScroll: { flex: 1, - minWidth: 0, }, splitHeaderRow: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/git/diff-rows/model.test.ts b/packages/app/src/git/diff-rows/model.test.ts deleted file mode 100644 index bd74ffe247..0000000000 --- a/packages/app/src/git/diff-rows/model.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ParsedDiffFile } from "@/git/use-diff-query"; -import { - buildDiffRowIndex, - createDiffRowCache, - DIFF_VIRTUALIZATION_CONFIG, - type DiffRowCache, -} from "./model"; - -function createFile(path: string, lineCount: number): ParsedDiffFile { - return { - path, - isNew: false, - isDeleted: false, - additions: lineCount, - deletions: 0, - hunks: [ - { - oldStart: 1, - oldCount: 0, - newStart: 1, - newCount: lineCount, - lines: [ - { type: "header", content: `@@ -1,0 +1,${lineCount} @@` }, - ...Array.from({ length: lineCount }, (_, index) => ({ - type: "add" as const, - content: `line ${index + 1}`, - })), - ], - }, - ], - }; -} - -function buildIndex(files: ParsedDiffFile[], expandedPaths: string[]) { - return buildDiffRowIndex({ - files, - viewMode: "flat", - collapsedFolders: new Set(), - expandedPaths: new Set(expandedPaths), - layout: "unified", - wrapLines: false, - lineHeight: 18, - viewportWidth: 400, - codeFontSize: 12, - headerHeightFor: () => 44, - folderHeight: 44, - statusHeight: 64, - measuredGroupHeights: new Map(), - geometryKey: "test", - reviewHeightFor: () => 0, - }); -} - -function containsReviewedLine(group: { - lines: Array<{ reviewTarget?: { lineNumber?: number } | null }>; -}): boolean { - return group.lines.some((line) => line.reviewTarget?.lineNumber === 10); -} - -function summarizeTree(items: ReturnType<typeof buildIndex>["items"]): string[] { - return items.map((item) => { - if (item.type === "folder") { - return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`; - } - const base = item.file.path.split("/").pop(); - const isGroup = item.type === "unified_group" || item.type === "split_group"; - return `${" ".repeat(item.depth)}${isGroup ? "group:" : ""}${base}`; - }); -} - -describe("buildDiffRowIndex", () => { - it("turns a large expanded file into bounded native-list cells", () => { - const index = buildIndex([createFile("large.ts", 1_200)], ["large.ts"]); - const groups = index.items.filter((item) => item.type === "unified_group"); - - expect(groups.length).toBeGreaterThan(1); - expect(Math.max(...groups.map((group) => group.lines.length))).toBeLessThanOrEqual( - DIFF_VIRTUALIZATION_CONFIG.groupRowLimit, - ); - expect(groups.reduce((count, group) => count + group.lines.length, 0)).toBe(1_201); - expect(index.stickyHeaderIndices).toEqual([0]); - }); - - it("counts wide glyphs and tabs conservatively for horizontal scroll extent", () => { - for (const [path, content] of [ - ["cjk.ts", "界".repeat(40)], - ["emoji.ts", "😀".repeat(40)], - ["tabs.ts", "\t".repeat(10)], - ]) { - const file = createFile(path, 1); - file.hunks[0].lines[1].content = content; - - const index = buildIndex([file], [file.path]); - const group = index.items.find((item) => item.type === "unified_group"); - - expect(group?.contentColumns).toBe(80); - } - }); - - it("precomputes exact offsets and preserves total file height", () => { - const index = buildIndex( - [createFile("first.ts", 65), createFile("second.ts", 1)], - ["first.ts"], - ); - const firstGroups = index.items.filter( - (item) => item.type === "unified_group" && item.file.path === "first.ts", - ); - const secondHeaderIndex = index.items.findIndex( - (item) => item.type === "header" && item.file.path === "second.ts", - ); - const expectedBodyHeight = 66 * 18 + 2; - - expect(firstGroups).toHaveLength(5); - expect(index.offsetAt(secondHeaderIndex)).toBe(44 + expectedBodyHeight); - expect(index.offsetOfFile("second.ts")).toBe(44 + expectedBodyHeight); - expect(index.totalHeight).toBe(44 + expectedBodyHeight + 44); - }); - - it("keeps a review row whole and shifts every later offset by its height", () => { - const file = createFile("review.ts", 40); - const baseline = buildIndex([file], [file.path]); - const reviewed = buildDiffRowIndex({ - files: [file], - viewMode: "flat", - collapsedFolders: new Set(), - expandedPaths: new Set([file.path]), - layout: "unified", - wrapLines: false, - lineHeight: 18, - viewportWidth: 400, - codeFontSize: 12, - headerHeightFor: () => 44, - folderHeight: 44, - statusHeight: 64, - measuredGroupHeights: new Map(), - geometryKey: "test", - reviewHeightFor: (target) => (target?.lineNumber === 10 ? 132 : 0), - }); - - expect(reviewed.totalHeight - baseline.totalHeight).toBe(132); - const reviewGroup = reviewed.items - .filter((item) => item.type === "unified_group") - .find(containsReviewedLine); - expect(reviewGroup).toBeDefined(); - expect(reviewed.heightAt(reviewed.items.indexOf(reviewGroup!))).toBeGreaterThanOrEqual(150); - }); - - it("preserves compressed tree order, indentation, and collapse state", () => { - const files = [createFile("src/app/a.ts", 1), createFile("src/app/nested/b.ts", 1)]; - const index = buildDiffRowIndex({ - files, - viewMode: "tree", - collapsedFolders: new Set(["src/app/nested"]), - expandedPaths: new Set(["src/app/a.ts"]), - layout: "unified", - wrapLines: false, - lineHeight: 18, - viewportWidth: 400, - codeFontSize: 12, - headerHeightFor: () => 44, - folderHeight: 44, - statusHeight: 64, - measuredGroupHeights: new Map(), - geometryKey: "tree", - reviewHeightFor: () => 0, - }); - - expect(summarizeTree(index.items)).toEqual([ - "[src/app]", - " [nested] (collapsed)", - " a.ts", - " group:a.ts", - ]); - expect(index.stickyHeaderIndices).toEqual([2]); - }); - - it("keeps split groups bounded and sticky indices on headers", () => { - const file = createFile("split.ts", 100); - const index = buildDiffRowIndex({ - files: [file], - viewMode: "flat", - collapsedFolders: new Set(), - expandedPaths: new Set([file.path]), - layout: "split", - wrapLines: false, - lineHeight: 18, - viewportWidth: 800, - codeFontSize: 12, - headerHeightFor: () => 44, - folderHeight: 44, - statusHeight: 64, - measuredGroupHeights: new Map(), - geometryKey: "split", - reviewHeightFor: () => 0, - }); - const groups = index.items.filter((item) => item.type === "split_group"); - - expect(Math.max(...groups.map((group) => group.rows.length))).toBeLessThanOrEqual( - DIFF_VIRTUALIZATION_CONFIG.groupRowLimit, - ); - expect(index.items[index.stickyHeaderIndices[0]].type).toBe("header"); - }); - - it("updates measured group geometry without deriving any file rows again", () => { - const delegate = createDiffRowCache(); - let derivations = 0; - const rowCache: DiffRowCache = { - unifiedFor(file) { - derivations += 1; - return delegate.unifiedFor(file); - }, - splitFor(file) { - derivations += 1; - return delegate.splitFor(file); - }, - }; - const files = [createFile("first.ts", 100), createFile("second.ts", 100)]; - const index = buildDiffRowIndex({ - files, - viewMode: "flat", - collapsedFolders: new Set(), - expandedPaths: new Set(files.map((file) => file.path)), - layout: "unified", - wrapLines: true, - lineHeight: 18, - viewportWidth: 400, - codeFontSize: 12, - headerHeightFor: () => 44, - folderHeight: 44, - statusHeight: 64, - measuredGroupHeights: new Map(), - geometryKey: "wrap", - reviewHeightFor: () => 0, - rowCache, - }); - const group = index.items.find((item) => item.type === "unified_group"); - expect(group).toBeDefined(); - expect(derivations).toBe(2); - - const groupIndex = index.indexOfGroup(group!.key); - const laterOffset = index.offsetAt(groupIndex + 1); - const update = index.updateHeight(group!.key, index.heightAt(groupIndex) + 20); - - expect(update?.delta).toBe(20); - expect(index.offsetAt(groupIndex + 1)).toBe(laterOffset + 20); - expect(derivations).toBe(2); - }); - - it("uses the key's rounded viewport width for wrapped group membership", () => { - const file = createFile("wrapped.ts", 40); - for (const line of file.hunks[0].lines) { - if (line.type !== "header") { - line.content = "x".repeat(88); - } - } - const buildWrapped = (viewportWidth: number) => - buildDiffRowIndex({ - files: [file], - viewMode: "flat", - collapsedFolders: new Set(), - expandedPaths: new Set([file.path]), - layout: "unified", - wrapLines: true, - lineHeight: 18, - viewportWidth, - codeFontSize: 12, - headerHeightFor: () => 44, - folderHeight: 44, - statusHeight: 64, - measuredGroupHeights: new Map(), - geometryKey: `wrap:${Math.round(viewportWidth)}`, - reviewHeightFor: () => 0, - }); - const summarizeGroups = (viewportWidth: number) => - buildWrapped(viewportWidth) - .items.filter((item) => item.type === "unified_group") - .map((group) => ({ - key: group.key, - startRowIndex: group.startRowIndex, - lineCount: group.lines.length, - })); - - expect(summarizeGroups(399.2)).toEqual(summarizeGroups(399.49)); - }); -}); diff --git a/packages/app/src/git/diff-rows/model.ts b/packages/app/src/git/diff-rows/model.ts deleted file mode 100644 index 0206c8aa62..0000000000 --- a/packages/app/src/git/diff-rows/model.ts +++ /dev/null @@ -1,422 +0,0 @@ -import type { ParsedDiffFile } from "@/git/use-diff-query"; -import { - buildDiffTree, - compressSingleChildChains, - flattenDiffTree, - type DiffTreeDirNode, -} from "@/git/diff-tree"; -import { - buildSplitDiffRows, - buildUnifiedDiffLines, - type ReviewableDiffTarget, - type SplitDiffRow, - type UnifiedDiffDisplayLine, -} from "@/utils/diff-layout"; - -// Tune cell size and the outer list window together. On the 1,200-row fixture, the exact -// three-fling repro filled immediately with both 32/720 + window 21 and this lower-cost pair -// on an iPhone 17 Pro simulator. Simulator frame percentiles are unavailable in xctrace. -export const DIFF_VIRTUALIZATION_CONFIG = { - groupRowLimit: 16, - groupHeightLimit: 360, - initialNumToRender: 8, - maxToRenderPerBatch: 8, - updateCellsBatchingPeriod: 16, - windowSize: 10, -} as const; - -interface FolderItem { - type: "folder"; - dirPath: string; - displayName: string; - depth: number; - collapsed: boolean; - additions: number; - deletions: number; -} - -interface HeaderItem { - type: "header"; - file: ParsedDiffFile; - fileIndex: number; - isExpanded: boolean; - depth: number; -} - -interface StatusItem { - type: "status"; - file: ParsedDiffFile; - fileIndex: number; - depth: number; -} - -interface RowGroupBase { - file: ParsedDiffFile; - fileIndex: number; - depth: number; - groupIndex: number; - startRowIndex: number; - isFirst: boolean; - isLast: boolean; - contentColumns: number; - key: string; -} - -export interface UnifiedRowGroupItem extends RowGroupBase { - type: "unified_group"; - lines: UnifiedDiffDisplayLine[]; -} - -export interface SplitRowGroupItem extends RowGroupBase { - type: "split_group"; - rows: SplitDiffRow[]; -} - -export type DiffListItem = - | FolderItem - | HeaderItem - | StatusItem - | UnifiedRowGroupItem - | SplitRowGroupItem; - -export interface DiffRowIndex { - items: DiffListItem[]; - stickyHeaderIndices: number[]; - heightAt: (index: number) => number; - offsetAt: (index: number) => number; - offsetOfFile: (path: string) => number; - indexOfFile: (path: string) => number; - indexOfGroup: (key: string) => number; - updateHeight: ( - key: string, - height: number, - ) => { delta: number; index: number; previousHeight: number } | null; - totalHeight: number; -} - -export interface DiffRowCache { - unifiedFor: (file: ParsedDiffFile) => UnifiedDiffDisplayLine[]; - splitFor: (file: ParsedDiffFile) => SplitDiffRow[]; -} - -export function createDiffRowCache(): DiffRowCache { - const unified = new WeakMap<ParsedDiffFile, UnifiedDiffDisplayLine[]>(); - const split = new WeakMap<ParsedDiffFile, SplitDiffRow[]>(); - return { - unifiedFor(file) { - const cached = unified.get(file); - if (cached) return cached; - const lines = buildUnifiedDiffLines(file); - unified.set(file, lines); - return lines; - }, - splitFor(file) { - const cached = split.get(file); - if (cached) return cached; - const rows = buildSplitDiffRows(file); - split.set(file, rows); - return rows; - }, - }; -} - -const defaultRowCache = createDiffRowCache(); - -export interface BuildDiffRowIndexInput { - files: ParsedDiffFile[]; - viewMode: "flat" | "tree"; - collapsedFolders: ReadonlySet<string>; - expandedPaths: ReadonlySet<string>; - layout: "unified" | "split"; - wrapLines: boolean; - lineHeight: number; - viewportWidth: number; - codeFontSize: number; - headerHeightFor: (file: ParsedDiffFile) => number; - folderHeight: number; - statusHeight: number; - measuredGroupHeights: ReadonlyMap<string, number>; - geometryKey: string; - reviewHeightFor: (target: ReviewableDiffTarget | null | undefined) => number; - rowCache?: DiffRowCache; - tree?: DiffTreeDirNode; -} - -interface PendingGroup<Row> { - rows: Row[]; - startRowIndex: number; - height: number; -} - -const FULL_WIDTH_CODE_POINT_RANGES = [ - [0x1100, 0x115f], - [0x2329, 0x232a], - [0x2e80, 0x303e], - [0x3040, 0x3247], - [0x3250, 0x4dbf], - [0x4e00, 0xa4c6], - [0xa960, 0xa97c], - [0xac00, 0xd7a3], - [0xf900, 0xfaff], - [0xfe10, 0xfe19], - [0xfe30, 0xfe6b], - [0xff01, 0xff60], - [0xffe0, 0xffe6], - [0x1b000, 0x1b001], - [0x1f200, 0x1f251], - [0x1f300, 0x1faff], - [0x20000, 0x3fffd], -] as const; - -function isFullWidthCodePoint(codePoint: number): boolean { - return FULL_WIDTH_CODE_POINT_RANGES.some( - ([start, end]) => codePoint >= start && codePoint <= end, - ); -} - -function displayColumns(content: string): number { - let columns = 0; - for (const character of content) { - if (character === "\t") { - columns += 8 - (columns % 8); - continue; - } - columns += isFullWidthCodePoint(character.codePointAt(0) ?? 0) ? 2 : 1; - } - return columns; -} - -function estimatedVisualLines(content: string, columns: number): number { - return Math.max(1, Math.ceil(displayColumns(content) / columns)); -} - -function groupRows<Row>(rows: Row[], heightFor: (row: Row) => number): PendingGroup<Row>[] { - const groups: PendingGroup<Row>[] = []; - let current: PendingGroup<Row> = { rows: [], startRowIndex: 0, height: 0 }; - - for (const [rowIndex, row] of rows.entries()) { - const rowHeight = heightFor(row); - const reachedRowLimit = current.rows.length >= DIFF_VIRTUALIZATION_CONFIG.groupRowLimit; - const reachedHeightLimit = - current.rows.length > 0 && - current.height + rowHeight > DIFF_VIRTUALIZATION_CONFIG.groupHeightLimit; - if (reachedRowLimit || reachedHeightLimit) { - groups.push(current); - current = { rows: [], startRowIndex: rowIndex, height: 0 }; - } - current.rows.push(row); - current.height += rowHeight; - } - - if (current.rows.length > 0) { - groups.push(current); - } - return groups; -} - -function contentColumnsForUnified(lines: UnifiedDiffDisplayLine[]): number { - return lines.reduce((longest, line) => Math.max(longest, displayColumns(line.line.content)), 0); -} - -function contentColumnsForSplit(rows: SplitDiffRow[]): number { - return rows.reduce((longest, row) => { - if (row.kind === "header") { - return Math.max(longest, displayColumns(row.content)); - } - return Math.max( - longest, - displayColumns(row.left?.content ?? ""), - displayColumns(row.right?.content ?? ""), - ); - }, 0); -} - -function availableTextColumns(input: BuildDiffRowIndexInput): number { - const viewportWidth = Math.round(input.viewportWidth); - const columnWidth = input.layout === "split" ? viewportWidth / 2 : viewportWidth; - const characterWidth = input.codeFontSize * 0.62; - return Math.max(8, Math.floor((columnWidth - 72) / characterWidth)); -} - -export function buildDiffRowIndex(input: BuildDiffRowIndexInput): DiffRowIndex { - const items: DiffListItem[] = []; - const heights: number[] = []; - const stickyHeaderIndices: number[] = []; - const fileIndexByPath = new Map<string, number>(); - const groupIndexByKey = new Map<string, number>(); - const availableColumns = availableTextColumns(input); - const rowCache = input.rowCache ?? defaultRowCache; - - function push(item: DiffListItem, height: number): void { - if (item.type === "unified_group" || item.type === "split_group") { - groupIndexByKey.set(item.key, items.length); - } - items.push(item); - heights.push(height); - } - - function pushUnifiedGroups(file: ParsedDiffFile, fileIndex: number, depth: number): void { - const lines = rowCache.unifiedFor(file); - const contentColumns = contentColumnsForUnified(lines); - const groups = groupRows(lines, (line) => { - const visualLines = input.wrapLines - ? estimatedVisualLines(line.line.content, availableColumns) - : 1; - return visualLines * input.lineHeight + input.reviewHeightFor(line.reviewTarget); - }); - - for (const [groupIndex, group] of groups.entries()) { - const isFirst = groupIndex === 0; - const isLast = groupIndex === groups.length - 1; - const key = `${input.geometryKey}:${file.path}:unified:${group.startRowIndex}`; - const estimatedHeight = group.height + (isFirst ? 1 : 0) + (isLast ? 1 : 0); - push( - { - type: "unified_group", - file, - fileIndex, - depth, - groupIndex, - startRowIndex: group.startRowIndex, - isFirst, - isLast, - contentColumns, - key, - lines: group.rows, - }, - input.measuredGroupHeights.get(key) ?? estimatedHeight, - ); - } - } - - function pushSplitGroups(file: ParsedDiffFile, fileIndex: number, depth: number): void { - const rows = rowCache.splitFor(file); - const contentColumns = contentColumnsForSplit(rows); - const groups = groupRows(rows, (row) => { - if (row.kind === "header") { - const visualLines = input.wrapLines - ? estimatedVisualLines(row.content, availableColumns) - : 1; - return visualLines * input.lineHeight; - } - const visualLines = input.wrapLines - ? Math.max( - estimatedVisualLines(row.left?.content ?? "", availableColumns), - estimatedVisualLines(row.right?.content ?? "", availableColumns), - ) - : 1; - const reviewHeight = Math.max( - input.reviewHeightFor(row.left?.reviewTarget), - input.reviewHeightFor(row.right?.reviewTarget), - ); - return visualLines * input.lineHeight + reviewHeight; - }); - - for (const [groupIndex, group] of groups.entries()) { - const isFirst = groupIndex === 0; - const isLast = groupIndex === groups.length - 1; - const key = `${input.geometryKey}:${file.path}:split:${group.startRowIndex}`; - const estimatedHeight = group.height + (isFirst ? 1 : 0) + (isLast ? 1 : 0); - push( - { - type: "split_group", - file, - fileIndex, - depth, - groupIndex, - startRowIndex: group.startRowIndex, - isFirst, - isLast, - contentColumns, - key, - rows: group.rows, - }, - input.measuredGroupHeights.get(key) ?? estimatedHeight, - ); - } - } - - function pushFile(file: ParsedDiffFile, fileIndex: number, depth: number): void { - const isExpanded = input.expandedPaths.has(file.path); - const headerIndex = items.length; - fileIndexByPath.set(file.path, headerIndex); - push({ type: "header", file, fileIndex, isExpanded, depth }, input.headerHeightFor(file)); - if (!isExpanded) { - return; - } - stickyHeaderIndices.push(headerIndex); - if (file.status === "binary" || file.status === "too_large") { - push({ type: "status", file, fileIndex, depth }, input.statusHeight); - return; - } - if (input.layout === "split") { - pushSplitGroups(file, fileIndex, depth); - return; - } - pushUnifiedGroups(file, fileIndex, depth); - } - - if (input.viewMode === "flat") { - for (const [fileIndex, file] of input.files.entries()) { - pushFile(file, fileIndex, 0); - } - } else { - const indexByPath = new Map(input.files.map((file, index) => [file.path, index])); - const tree = input.tree ?? compressSingleChildChains(buildDiffTree(input.files)); - for (const row of flattenDiffTree(tree, input.collapsedFolders)) { - if (row.kind === "folder") { - push( - { - type: "folder", - dirPath: row.dirPath, - displayName: row.displayName, - depth: row.depth, - collapsed: input.collapsedFolders.has(row.dirPath), - additions: row.additions, - deletions: row.deletions, - }, - input.folderHeight, - ); - continue; - } - const fileIndex = indexByPath.get(row.file.path); - if (fileIndex !== undefined) { - pushFile(row.file, fileIndex, row.depth); - } - } - } - - const offsets = Array.from({ length: items.length + 1 }, () => 0); - for (let index = 0; index < items.length; index += 1) { - offsets[index + 1] = offsets[index] + heights[index]; - } - - return { - items, - stickyHeaderIndices, - heightAt: (index) => heights[index] ?? 0, - offsetAt: (index) => offsets[Math.max(0, Math.min(index, items.length))], - offsetOfFile: (path) => offsets[fileIndexByPath.get(path) ?? 0], - indexOfFile: (path) => fileIndexByPath.get(path) ?? -1, - indexOfGroup: (key) => groupIndexByKey.get(key) ?? -1, - updateHeight: (key, height) => { - const index = groupIndexByKey.get(key); - if (index === undefined || !Number.isFinite(height) || height < 0) { - return null; - } - const previousHeight = heights[index]; - const delta = height - previousHeight; - if (delta === 0) { - return { delta, index, previousHeight }; - } - heights[index] = height; - for (let offsetIndex = index + 1; offsetIndex < offsets.length; offsetIndex += 1) { - offsets[offsetIndex] += delta; - } - return { delta, index, previousHeight }; - }, - get totalHeight() { - return offsets[items.length]; - }, - }; -} diff --git a/packages/app/src/git/diff-rows/virtualized-list-range.test.ts b/packages/app/src/git/diff-rows/virtualized-list-range.test.ts deleted file mode 100644 index 6392b4912d..0000000000 --- a/packages/app/src/git/diff-rows/virtualized-list-range.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { boundRetainedRangeOnDataGrowth } from "@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth"; - -describe("bounded VirtualizedList data growth", () => { - it("limits the carried range when middle insertion grows the list", () => { - expect(boundRetainedRangeOnDataGrowth({ first: 0, last: 39 }, 40, 115, 16)).toEqual({ - first: 0, - last: 15, - }); - }); - - it("bounds from the current scrolled range instead of jumping to the start", () => { - expect(boundRetainedRangeOnDataGrowth({ first: 84, last: 123 }, 160, 235, 16)).toEqual({ - first: 84, - last: 99, - }); - }); - - it("does not alter the range when data shrinks or the carried range is already bounded", () => { - const shortRange = { first: 12, last: 18 }; - expect(boundRetainedRangeOnDataGrowth(shortRange, 40, 32, 16)).toBe(shortRange); - expect(boundRetainedRangeOnDataGrowth(shortRange, 40, 115, 16)).toEqual(shortRange); - }); -}); diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index 7e80fd86cd..fd79290b5d 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -260,7 +260,6 @@ export function InlineReviewGutterCell({ onStartComment, style, actionTestID, - testID, }: { children: ReactNode; reviewTarget: ReviewableDiffTarget | null | undefined; @@ -271,7 +270,6 @@ export function InlineReviewGutterCell({ onStartComment: (target: ReviewableDiffTarget) => void; style?: StyleProp<ViewStyle>; actionTestID?: string; - testID?: string; }) { const { t } = useTranslation(); const canComment = Boolean(reviewTarget); @@ -311,6 +309,7 @@ export function InlineReviewGutterCell({ } }, [isInteractionActive]); + const pressableStyle = useCallback((): StyleProp<ViewStyle> => style, [style]); const lineHeightStyle = useMemo<StyleProp<ViewStyle>>( () => lineHeight !== undefined @@ -319,15 +318,13 @@ export function InlineReviewGutterCell({ [lineHeight], ); - const pressableStyle = useCallback( - (): StyleProp<ViewStyle> => [ - style, - styles.gutterInner, - styles.gutterLabel, - lineHeightStyle, - hasComments && styles.gutterLabelActive, - ], - [hasComments, lineHeightStyle, style], + const labelStyle = useMemo<StyleProp<ViewStyle>>( + () => [styles.gutterLabel, lineHeightStyle, hasComments && styles.gutterLabelActive], + [hasComments, lineHeightStyle], + ); + const innerStyle = useMemo<StyleProp<ViewStyle>>( + () => [styles.gutterInner, lineHeightStyle], + [lineHeightStyle], ); const actionIconStyle = useMemo<StyleProp<ViewStyle>>( () => [ @@ -349,14 +346,17 @@ export function InlineReviewGutterCell({ onPressIn={handlePressIn} onPressOut={handlePressOut} style={pressableStyle} - testID={testID} > - {children} - {showAction ? ( - <View style={actionIconStyle} testID={actionTestID}> - <ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} /> + <View style={innerStyle}> + <View style={labelStyle}> + {children} + {showAction ? ( + <View style={actionIconStyle} testID={actionTestID}> + <ThemedPlus size={16} strokeWidth={2.4} uniProps={accentForegroundIconColorMapping} /> + </View> + ) : null} </View> - ) : null} + </View> </Pressable> ); } diff --git a/packages/app/src/types/virtualized-list-internals.d.ts b/packages/app/src/types/virtualized-list-internals.d.ts deleted file mode 100644 index ae357034ca..0000000000 --- a/packages/app/src/types/virtualized-list-internals.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare module "@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth" { - export function boundRetainedRangeOnDataGrowth( - cellsAroundViewport: { first: number; last: number }, - previousItemCount: number, - itemCount: number, - retainedCellLimit: number, - ): { first: number; last: number }; -} diff --git a/packages/app/src/utils/diff-rendering.test.ts b/packages/app/src/utils/diff-rendering.test.ts index 384bd22912..4d6e35e89d 100644 --- a/packages/app/src/utils/diff-rendering.test.ts +++ b/packages/app/src/utils/diff-rendering.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { - compactHighlightTokens, formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens, @@ -29,23 +28,4 @@ describe("diff-rendering", () => { expect(hasVisibleDiffTokens([{ text: "" }])).toBe(false); expect(hasVisibleDiffTokens([{ text: "const value = 1;" }])).toBe(true); }); - - it("compacts adjacent syntax tokens without changing their text or styles", () => { - expect( - compactHighlightTokens([ - { text: "const", style: "keyword" }, - { text: " ", style: null }, - { text: "value", style: null }, - { text: " ", style: null }, - { text: "=", style: "operator" }, - { text: "", style: "operator" }, - { text: " 1", style: "number" }, - ]), - ).toEqual([ - { text: "const", style: "keyword" }, - { text: " value ", style: null }, - { text: "=", style: "operator" }, - { text: " 1", style: "number" }, - ]); - }); }); diff --git a/packages/app/src/utils/diff-rendering.ts b/packages/app/src/utils/diff-rendering.ts index c3cd9f8bb0..a189c56b6a 100644 --- a/packages/app/src/utils/diff-rendering.ts +++ b/packages/app/src/utils/diff-rendering.ts @@ -2,28 +2,6 @@ interface HighlightLikeToken { text: string; } -interface StyledHighlightToken extends HighlightLikeToken { - style: string | null; -} - -export function compactHighlightTokens( - tokens: readonly StyledHighlightToken[], -): StyledHighlightToken[] { - const compacted: StyledHighlightToken[] = []; - for (const token of tokens) { - if (token.text.length === 0) { - continue; - } - const previous = compacted.at(-1); - if (previous?.style === token.style) { - previous.text += token.text; - continue; - } - compacted.push({ text: token.text, style: token.style }); - } - return compacted; -} - // Preserve row height when a gutter or diff cell is intentionally blank. // Non-breaking space because the gutter <Text> uses numberOfLines={1}, which // collapses a plain ASCII space to zero height on web. diff --git a/patches/@react-native+virtualized-lists+0.81.5.patch b/patches/@react-native+virtualized-lists+0.81.5.patch deleted file mode 100644 index 0ab5e8abcd..0000000000 --- a/patches/@react-native+virtualized-lists+0.81.5.patch +++ /dev/null @@ -1,105 +0,0 @@ -diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js -index c92ac9d..6bdc84f 100644 ---- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js -+++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js -@@ -47,6 +47,7 @@ import { - windowSizeOrDefault, - } from './VirtualizedListProps'; -+import {boundRetainedRangeOnDataGrowth} from './boundRetainedRangeOnDataGrowth'; - import { - computeWindowedRenderLimits, - keyExtractor as defaultKeyExtractor, - } from './VirtualizeUtils'; -@@ -706,7 +706,8 @@ class VirtualizedList extends StateSafePureComponent< - // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make - // sure we're rendering a reasonable range here. - const itemCount = newProps.getItemCount(newProps.data); -- if (itemCount === prevState.renderMask.numCells()) { -+ const previousItemCount = prevState.renderMask.numCells(); -+ if (itemCount === previousItemCount) { - return prevState; - } - -@@ -741,7 +742,7 @@ class VirtualizedList extends StateSafePureComponent< - } - } - -- const constrainedCells = VirtualizedList._constrainToItemCount( -+ let constrainedCells = VirtualizedList._constrainToItemCount( - maintainVisibleContentPositionAdjustment != null - ? { - first: -@@ -756,6 +758,20 @@ class VirtualizedList extends StateSafePureComponent< - newProps, - ); - -+ if ( -+ newProps.unstable_boundedRangeOnDataChange === true && -+ itemCount > previousItemCount -+ ) { -+ const retainedCellLimit = -+ maxToRenderPerBatchOrDefault(newProps.maxToRenderPerBatch) * 2; -+ constrainedCells = boundRetainedRangeOnDataGrowth( -+ constrainedCells, -+ previousItemCount, -+ itemCount, -+ retainedCellLimit, -+ ); -+ } -+ - return { - cellsAroundViewport: constrainedCells, - renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), -diff --git a/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js b/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js -new file mode 100644 -index 0000000..1e5b1f5 ---- /dev/null -+++ b/node_modules/@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth.js -@@ -0,0 +1,28 @@ -+/** -+ * Copyright (c) Meta Platforms, Inc. and affiliates. -+ * -+ * This source code is licensed under the MIT license found in the -+ * LICENSE file in the root directory of this source tree. -+ * -+ * @format -+ */ -+ -+'use strict'; -+ -+export function boundRetainedRangeOnDataGrowth( -+ cellsAroundViewport, -+ previousItemCount, -+ itemCount, -+ retainedCellLimit, -+) { -+ if (itemCount <= previousItemCount) { -+ return cellsAroundViewport; -+ } -+ return { -+ first: cellsAroundViewport.first, -+ last: Math.min( -+ cellsAroundViewport.last, -+ cellsAroundViewport.first + retainedCellLimit - 1, -+ ), -+ }; -+} -diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js -index 9d6d49e..54324a8 100644 ---- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js -+++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedListProps.js -@@ -264,8 +264,14 @@ type OptionalVirtualizedListProps = { - * Amount of time between low-pri item render batches, e.g. for rendering items quite a ways off - * screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`. - */ - updateCellsBatchingPeriod?: ?number, -+ -+ /** -+ * Limits the synchronous render range retained when data grows. This is useful for lists that -+ * insert many bounded cells in the middle of an already-rendered range. -+ */ -+ unstable_boundedRangeOnDataChange?: ?boolean, - /** - * See `ViewabilityHelper` for flow type and further documentation. - */ - viewabilityConfig?: ViewabilityConfig, diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 465a78fc4d..781294b6d4 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -8,10 +8,6 @@ import { join, relative } from "node:path"; // workspace root live in their workspace's own node_modules, and patch-package resolves // the patch's node_modules/... paths relative to its working directory. const patchedPackages = [ - { - nodeModulesPath: "node_modules/@react-native/virtualized-lists", - patchPrefix: "@react-native+virtualized-lists+", - }, { nodeModulesPath: "node_modules/react-native-markdown-display", patchPrefix: "react-native-markdown-display+", From 124e512b580b8a7289b3c823e264ef0a9a2a6cbf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 15:53:00 +0200 Subject: [PATCH 38/73] fix(app): align track header hover states The subagent track used a custom hover path that left its label and chevron muted. Reuse the ghost button interaction so track headers promote their content consistently. --- packages/app/src/subagents/track.tsx | 42 ++++++++++------------------ 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index f49ed12f61..8878851da1 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -1,9 +1,10 @@ import { useCallback, useMemo, useState, type ReactElement } from "react"; -import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; +import { Pressable, ScrollView, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { getProviderIcon } from "@/components/provider-icons"; +import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useIsCompactFormFactor, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { isNative } from "@/constants/platform"; @@ -20,8 +21,6 @@ import { } from "./track-presentation"; const ThemedArchive = withUnistyles(Archive); -const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedChevronRight = withUnistyles(ChevronRight); const ThemedUnlink = withUnistyles(Unlink); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); @@ -70,17 +69,11 @@ export function SubagentsTrack({ [expanded], ); - const headerStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType) => [ - styles.headerToggle, - (hovered || pressed) && styles.headerActive, - ], - [], - ); const headerContainerStyle = useMemo( () => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed], [expanded], ); + const headerAccessibilityState = useMemo(() => ({ expanded }), [expanded]); if (rows.length === 0) { return null; @@ -94,22 +87,19 @@ export function SubagentsTrack({ <View style={styles.track}> <View style={surfaceStyle}> <View style={headerContainerStyle}> - <Pressable - accessibilityRole="button" + <Button + variant="ghost" + size="xs" accessibilityLabel={headerLabel} + accessibilityState={headerAccessibilityState} testID="subagents-track-header" onPress={toggleExpanded} - style={headerStyle} + leftIcon={expanded ? ChevronDown : ChevronRight} + style={styles.headerToggle} + textStyle={styles.headerLabel} > - {expanded ? ( - <ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} /> - ) : ( - <ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} /> - )} - <Text style={styles.headerLabel} numberOfLines={1}> - {headerLabel} - </Text> - </Pressable> + {headerLabel} + </Button> {finishedCount > 0 && onArchiveFinished ? ( <View style={styles.headerAction}> <SubagentActionButton @@ -346,9 +336,8 @@ const styles = StyleSheet.create((theme) => ({ headerToggle: { flex: 1, minWidth: 0, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], + justifyContent: "flex-start", + borderRadius: 0, paddingLeft: theme.spacing[3], paddingRight: theme.spacing[1], paddingVertical: theme.spacing[2], @@ -359,9 +348,6 @@ const styles = StyleSheet.create((theme) => ({ headerCollapsed: { paddingBottom: theme.spacing[4], }, - headerActive: { - backgroundColor: theme.colors.surface2, - }, headerDivider: { borderBottomWidth: theme.borderWidth[1], borderBottomColor: theme.colors.border, From 474b4e70b4482feba4231b74947622d59f55c872 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 15:54:11 +0200 Subject: [PATCH 39/73] fix(app): restore file tree layout Restore the established tree rails while retaining file actions. Keep Escape local to generic editable fields so cancelling a file draft cannot interrupt an agent. --- packages/app/e2e/browser/changes-pane.spec.ts | 15 ++++++-- .../e2e/browser/composer-attachments.spec.ts | 23 ++++++++++++ .../file-explorer-context-actions.spec.ts | 30 +++++++++++++++- .../app/src/components/file-explorer-pane.tsx | 35 ++++--------------- .../app/src/components/tree-primitives.tsx | 16 ++++----- packages/app/src/git/diff-folder-row.tsx | 22 ++---------- packages/app/src/git/diff-pane.tsx | 6 ---- .../src/keyboard/keyboard-shortcuts.test.ts | 7 ++++ .../app/src/keyboard/keyboard-shortcuts.ts | 15 +++++--- 9 files changed, 101 insertions(+), 68 deletions(-) diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 98f69808b6..87f41af01a 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -553,8 +553,19 @@ test("changes diff switches between flat and tree file lists", async ({ page }) "none", ); await expect(page.getByTestId("diff-file-0")).toBeVisible(); - await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(2); + await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1); await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1); + const folderToggleBounds = await page.getByTestId("diff-folder-src-toggle").boundingBox(); + const folderChevronBounds = await page + .getByTestId("diff-folder-src-toggle") + .locator("svg") + .boundingBox(); + expect(folderToggleBounds).not.toBeNull(); + expect(folderChevronBounds).not.toBeNull(); + expect(folderChevronBounds!.y + folderChevronBounds!.height / 2).toBeCloseTo( + folderToggleBounds!.y + folderToggleBounds!.height / 2, + 0, + ); const folderLabelBounds = await page .getByTestId("diff-folder-src") .getByText("src", { exact: true }) @@ -565,7 +576,7 @@ test("changes diff switches between flat and tree file lists", async ({ page }) .boundingBox(); expect(folderLabelBounds).not.toBeNull(); expect(fileLabelBounds).not.toBeNull(); - expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(14, 0); + expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(16, 0); const folderToggle = page.getByTestId("diff-folder-src-toggle"); await folderToggle.click(); diff --git a/packages/app/e2e/browser/composer-attachments.spec.ts b/packages/app/e2e/browser/composer-attachments.spec.ts index 26a16f5179..acf1941c23 100644 --- a/packages/app/e2e/browser/composer-attachments.spec.ts +++ b/packages/app/e2e/browser/composer-attachments.spec.ts @@ -37,6 +37,7 @@ import { import { seedWorkspace } from "../support/helpers/seed-client"; import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures"; import { getServerId } from "../support/helpers/server-id"; +import { openFileExplorer } from "../support/helpers/file-explorer"; const MINIMAL_PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", @@ -294,6 +295,28 @@ test.describe("Composer attachments", () => { } }); + test("Escape cancels file creation without interrupting the running agent", async ({ page }) => { + test.setTimeout(120_000); + const agent = await startRunningMockAgent(page, { + prefix: "file-create-escape-", + model: "one-minute-stream", + prompt: "Stay running while a file draft is cancelled.", + }); + try { + await openFileExplorer(page); + await page.getByTestId("files-new-file").click(); + const nameInput = page.getByTestId("file-explorer-name-input"); + await expect(nameInput).toBeVisible(); + + await nameInput.press("Escape"); + + await expect(nameInput).toBeHidden(); + await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible(); + } finally { + await agent.cleanup(); + } + }); + test("composer is locked while new workspace agent is being created", async ({ page }) => { test.setTimeout(120_000); const serverId = getServerId(); diff --git a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts index e4643b87e9..d8622a6165 100644 --- a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts +++ b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts @@ -124,6 +124,14 @@ test("creates, renames, copies, and deletes entries through the file explorer", await page.getByTestId("files-new-folder").click(); await expect(nameInput).toBeVisible(); + const draftRow = nameInput.locator("xpath=.."); + const placeholderColor = await nameInput.evaluate( + (input) => getComputedStyle(input, "::placeholder").color, + ); + const extraMutedChevronColor = await draftRow + .locator("svg") + .evaluate((icon) => getComputedStyle(icon).stroke); + expect(placeholderColor).toBe(extraMutedChevronColor); await nameInput.press("Tab"); await expect(nameInput).toBeHidden(); @@ -189,18 +197,38 @@ test("creates, renames, copies, and deletes entries through the file explorer", const fileRow = entry("created.txt").locator( "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", ); - await expect(folderRow.locator("svg")).toHaveCount(2); + await expect(folderRow.locator("svg")).toHaveCount(1); await expect(fileRow.locator("svg")).toHaveCount(1); const gitRow = entry(".git").locator( "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", ); const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox(); const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox(); + const collapsedChevronSlotBounds = await gitRow + .locator("svg") + .first() + .locator("xpath=../..") + .boundingBox(); + const expandedChevronSlotBounds = await folderRow + .locator("svg") + .first() + .locator("xpath=../..") + .boundingBox(); expect(collapsedChevronBounds).not.toBeNull(); expect(expandedChevronBounds).not.toBeNull(); + expect(collapsedChevronSlotBounds).not.toBeNull(); + expect(expandedChevronSlotBounds).not.toBeNull(); const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2; const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2; expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0); + expect(collapsedChevronCenter).toBeCloseTo( + collapsedChevronSlotBounds!.x + collapsedChevronSlotBounds!.width / 2, + 0, + ); + expect(expandedChevronCenter).toBeCloseTo( + expandedChevronSlotBounds!.x + expandedChevronSlotBounds!.width / 2, + 0, + ); const folderLabelBounds = await entry("folder").boundingBox(); const fileLabelBounds = await entry("created.txt").boundingBox(); expect(folderLabelBounds).not.toBeNull(); diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 969a1b5960..c24268a6d6 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -27,15 +27,7 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles" import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import * as Clipboard from "expo-clipboard"; -import { - ChevronDown, - Eye, - EyeOff, - FilePlus, - Folder, - FolderPlus, - RotateCw, -} from "lucide-react-native"; +import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native"; import { MaterialFileIcon } from "@/components/material-file-icon"; import { TreeChevron, @@ -89,7 +81,6 @@ const SORT_OPTIONS: { value: SortOption }[] = [ ]; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const ThemedFolder = withUnistyles(Folder); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); @@ -213,12 +204,9 @@ function EntryNameInputRow({ <View style={[styles.entryRow, { paddingLeft: treeRowPaddingLeft(depth) }]}> <TreeIndentGuides depth={depth} /> <View style={styles.entryInfo}> - <View style={styles.entryChevron}> - {kind === "directory" ? <TreeChevron expanded={false} /> : null} - </View> <View style={styles.entryIcon}> {kind === "directory" ? ( - <ThemedFolder size={WORKSPACE_TREE_ICON_SIZE} uniProps={foregroundMutedColorMapping} /> + <TreeChevron expanded={false} /> ) : ( <MaterialFileIcon fileName={name || "untitled"} size={WORKSPACE_TREE_ICON_SIZE} /> )} @@ -237,6 +225,7 @@ function EntryNameInputRow({ } autoCapitalize="none" autoCorrect={false} + placeholderTextColor={styles.draftPlaceholder.color} style={styles.draftInput} selectTextOnFocus={Boolean(initialName)} testID="file-explorer-name-input" @@ -381,15 +370,9 @@ function TreeRowItem({ > <TreeIndentGuides depth={depth} /> <View ref={dragSourceRef} style={styles.entryInfo}> - <View style={styles.entryChevron}> - {isDirectory ? <DirectoryChevronIcon loading={loading} expanded={isExpanded} /> : null} - </View> <View style={styles.entryIcon}> {isDirectory ? ( - <ThemedFolder - size={WORKSPACE_TREE_ICON_SIZE} - uniProps={foregroundMutedColorMapping} - /> + <DirectoryChevronIcon loading={loading} expanded={isExpanded} /> ) : ( <MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} /> )} @@ -1772,13 +1755,6 @@ const styles = StyleSheet.create((theme) => ({ gap: WORKSPACE_TREE_ICON_LABEL_GAP, minWidth: 0, }, - entryChevron: { - width: WORKSPACE_TREE_ICON_SIZE, - height: WORKSPACE_TREE_ICON_SIZE, - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - }, entryIcon: { width: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE, @@ -1799,6 +1775,9 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: 0, paddingHorizontal: 0, }, + draftPlaceholder: { + color: theme.colors.foregroundExtraMuted, + }, contextMetaBlock: { paddingVertical: theme.spacing[1], }, diff --git a/packages/app/src/components/tree-primitives.tsx b/packages/app/src/components/tree-primitives.tsx index 4a0e18a4f1..9d3c129d4b 100644 --- a/packages/app/src/components/tree-primitives.tsx +++ b/packages/app/src/components/tree-primitives.tsx @@ -10,22 +10,22 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; // paths) render different data, but their ROWS should look identical — same // indentation, guide lines, and chevron. Keep those here so the two trees can't // drift apart. -export const TREE_INDENT_PER_LEVEL = 14; -export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1]; -export const WORKSPACE_TREE_ICON_SIZE = 14; -export const WORKSPACE_TREE_LOADING_ICON_SIZE = 12; -export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[1]; +export const TREE_INDENT_PER_LEVEL = 16; +export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5]; +export const WORKSPACE_TREE_ICON_SIZE = 16; +export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14; +export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2]; /** * Trailing glyph rail shared with the explorer X and Changes options chevron. * The extra 2px is optical: text ink ends inside its layout box, while the * header icons' strokes extend to theirs. */ -export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[3] + 2; +export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2; /** Left padding for a tree row at `depth`. Shared by folder rows and file headers * in the Changes tree so their indentation can't drift apart. */ export function treeRowPaddingLeft(depth: number): number { - return SPACING[2] + depth * TREE_INDENT_PER_LEVEL; + return SPACING[3] + depth * TREE_INDENT_PER_LEVEL; } const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({ @@ -46,7 +46,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) { key: index, style: [ styles.indentGuide, - inlineUnistylesStyle({ left: SPACING[2] + index * TREE_INDENT_PER_LEVEL + 3 }), + inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }), ], })), [depth], diff --git a/packages/app/src/git/diff-folder-row.tsx b/packages/app/src/git/diff-folder-row.tsx index f32bc6859f..be5b76fc7b 100644 --- a/packages/app/src/git/diff-folder-row.tsx +++ b/packages/app/src/git/diff-folder-row.tsx @@ -1,7 +1,6 @@ import { useCallback, useMemo } from "react"; import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { Folder } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; import { DiffStat } from "@/components/diff-stat"; import { TreeChevron, @@ -18,9 +17,6 @@ import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; import { isWeb } from "@/constants/platform"; -const ThemedFolder = withUnistyles(Folder); -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - interface DiffFolderRowProps { /** full uncompressed directory path — the collapse identity */ dirPath: string; @@ -145,15 +141,9 @@ export function DiffFolderRow({ testID={testID ? `${testID}-toggle` : undefined} > <View style={leftStyle}> - <View style={styles.chevronOpticalOffset}> + <View style={styles.chevronSlot}> <TreeChevron expanded={!collapsed} /> </View> - <View style={styles.folderIcon}> - <ThemedFolder - size={WORKSPACE_TREE_ICON_SIZE} - uniProps={foregroundMutedColorMapping} - /> - </View> <Text style={styles.folderName} numberOfLines={1}> {displayName} </Text> @@ -204,13 +194,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ flex: 1, minWidth: 0, }, - chevronOpticalOffset: { - width: WORKSPACE_TREE_ICON_SIZE, - height: WORKSPACE_TREE_ICON_SIZE, - alignItems: "center", - justifyContent: "center", - }, - folderIcon: { + chevronSlot: { width: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE, alignItems: "center", diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index f0756b34dd..5c70f61dab 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1161,7 +1161,6 @@ const DiffFileHeader = memo(function DiffFileHeader({ ref={dragSourceRef} style={showDir ? styles.fileHeaderLeft : [styles.fileHeaderLeft, styles.fileHeaderLeftTree]} > - {showDir ? null : <View style={styles.fileChevronSpacer} />} {showDir ? null : ( <View style={styles.fileIcon}> <MaterialFileIcon fileName={fileName} size={WORKSPACE_TREE_ICON_SIZE} /> @@ -3465,11 +3464,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], flexShrink: 0, }, - fileChevronSpacer: { - width: WORKSPACE_TREE_ICON_SIZE, - height: WORKSPACE_TREE_ICON_SIZE, - flexShrink: 0, - }, fileIcon: { width: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 69fd45b8b2..9a88975d24 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -404,6 +404,13 @@ describe("keyboard-shortcuts", () => { }, ); + it("leaves Escape to an editable field", () => { + expectNoShortcutResolution({ + event: { key: "Escape", code: "Escape" }, + context: { focusScope: "editable" }, + }); + }); + const nonMatchingCases: NonMatchingShortcutCase[] = [ { name: "does not keep old Mod+Alt+N binding", diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index bd0afaf44c..6653e412d8 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -76,8 +76,8 @@ interface ShortcutWhen { terminal?: false; /** false = disabled when command center is open */ commandCenter?: false; - /** Exact focus scope match */ - focusScope?: KeyboardFocusScope; + /** Allowed focus scope or scopes */ + focusScope?: KeyboardFocusScope | readonly KeyboardFocusScope[]; } type ShortcutPayloadDef = @@ -1042,7 +1042,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "agent-interrupt", action: "agent.interrupt", combo: "Escape", - when: { commandCenter: false, terminal: false }, + when: { commandCenter: false, focusScope: ["message-input", "other"] }, preventDefault: false, stopPropagation: false, help: { @@ -1215,7 +1215,14 @@ export function matchesKeyboardShortcutContext( } if (when.terminal === false && context.focusScope === "terminal") return false; if (when.commandCenter === false && context.commandCenterOpen) return false; - if (when.focusScope !== undefined && context.focusScope !== when.focusScope) return false; + if ( + when.focusScope !== undefined && + !(typeof when.focusScope === "string" + ? context.focusScope === when.focusScope + : when.focusScope.includes(context.focusScope)) + ) { + return false; + } return true; } From 8e916fd2dca95381a0a795ec060432f868b2a6f5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 17:27:08 +0200 Subject: [PATCH 40/73] fix(app): align agent profile controls Keep trailing edit controls from changing section heading rhythm, and center each profile's icon with its own text block so one-line and noted rows share the same visual alignment. --- .../settings/agent-profile-row.tsx | 53 +++++++++++-------- packages/app/src/components/model-browser.tsx | 14 ++++- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/packages/app/src/agent-profiles/settings/agent-profile-row.tsx b/packages/app/src/agent-profiles/settings/agent-profile-row.tsx index 4a766ec0fd..e8da192191 100644 --- a/packages/app/src/agent-profiles/settings/agent-profile-row.tsx +++ b/packages/app/src/agent-profiles/settings/agent-profile-row.tsx @@ -73,30 +73,32 @@ export function AgentProfileRow({ return ( <View style={rowStyle} testID={`agent-profile-row-${profile.id}`}> - <View style={styles.iconWrapper}> - <AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} /> - </View> - <View style={settingsStyles.rowContent}> - <View style={styles.titleLine}> - <Text style={settingsStyles.rowTitle} numberOfLines={1}> - {profile.name} - </Text> - <Text style={styles.summary} numberOfLines={1}> - {summary} - </Text> + <View style={styles.rowMain}> + <View style={styles.iconWrapper}> + <AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} /> </View> - {profile.notes ? ( - <View style={styles.notes}> - <ThemedFileText size={ICON_SIZE.xs} uniProps={mutedColorMapping} /> - <Text - style={styles.notesText} - numberOfLines={2} - testID={`agent-profile-notes-${profile.id}`} - > - {profile.notes} + <View style={settingsStyles.rowContent}> + <View style={styles.titleLine}> + <Text style={settingsStyles.rowTitle} numberOfLines={1}> + {profile.name} + </Text> + <Text style={styles.summary} numberOfLines={1}> + {summary} </Text> </View> - ) : null} + {profile.notes ? ( + <View style={styles.notes}> + <ThemedFileText size={ICON_SIZE.xs} uniProps={mutedColorMapping} /> + <Text + style={styles.notesText} + numberOfLines={2} + testID={`agent-profile-notes-${profile.id}`} + > + {profile.notes} + </Text> + </View> + ) : null} + </View> </View> <View style={styles.rowActions}> <Button @@ -145,12 +147,17 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", paddingVertical: theme.spacing[3], }, + rowMain: { + flex: 1, + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[2], + }, iconWrapper: { width: theme.iconSize.md, - alignSelf: "flex-start", alignItems: "center", justifyContent: "center", - marginTop: theme.spacing[1], + marginTop: 1, }, titleLine: { flexDirection: "row", diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index e00a0328b6..5fa2e45f7f 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -114,7 +114,7 @@ function AgentProfilesEditAction({ onPress }: { onPress: () => void }) { accessibilityLabel={t("modelSelector.editProfilesLabel")} testID="model-profiles-edit" > - <ThemedPencil size={ICON_SIZE.sm} uniProps={foregroundExtraMutedMapping} /> + <ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} /> </Pressable> </TooltipTrigger> <TooltipContent side="top" align="center" offset={8}> @@ -675,7 +675,11 @@ function AgentProfilesPickerSection({ <View style={styles.profilesContainer}> <View style={styles.sectionHeading}> <Text style={styles.sectionHeadingText}>{t("modelSelector.profiles")}</Text> - {onEditProfiles ? <AgentProfilesEditAction onPress={onEditProfiles} /> : null} + {onEditProfiles ? ( + <View style={styles.sectionHeadingAction}> + <AgentProfilesEditAction onPress={onEditProfiles} /> + </View> + ) : null} </View> {rows.map((row) => ( <AgentProfilePickerRowView key={row.id} row={row} onApply={handleApply} /> @@ -1217,6 +1221,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.border, }, sectionHeading: { + position: "relative", flexDirection: "row", alignItems: "center", gap: theme.spacing[2], @@ -1224,6 +1229,11 @@ const styles = StyleSheet.create((theme) => ({ paddingTop: theme.spacing[2], paddingBottom: theme.spacing[1], }, + sectionHeadingAction: { + position: "absolute", + top: theme.spacing[1], + right: isWeb ? theme.spacing[3] : theme.spacing[6], + }, sectionHeadingText: { flex: 1, fontSize: theme.fontSize.xs, From 635a8be437f390841786c04955bc867ecb84d619 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 19:54:06 +0200 Subject: [PATCH 41/73] Restore model-default variant selection (#3281) * fix(providers): restore implicit model variant selection Provider catalogs list only explicit overrides, so treating the first override as the default made base model behavior unreachable. Represent the base choice in the catalog and translate it back to an omitted variant at the provider boundary. * test(providers): exercise variant catalog boundary The regression concerns the model catalog callers receive, so verify normalization through fetchCatalog instead of coupling the test to its internal builder. --- .../agent/providers/opencode-agent.test.ts | 41 ++++++++++++++++++- .../server/agent/providers/opencode-agent.ts | 38 +++++++++++------ 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 3a480f7b9a..3931fe0358 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -480,6 +480,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { models: { "big-pickle": { name: "Big Pickle", + variants: { max: { reasoningEffort: "max" } }, limit: { context: 200_000, }, @@ -529,6 +530,11 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { expect(catalog.models[0]).toMatchObject({ id: TEST_MODEL, label: "Big Pickle", + thinkingOptions: [ + { id: "default", label: "Default", isDefault: true }, + { id: "max", label: "max" }, + ], + defaultThinkingOptionId: "default", metadata: { providerId: "opencode", modelId: "big-pickle", @@ -824,7 +830,40 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { }, 180_000); }); -describe("OpenCode adapter context-window normalization", () => { +describe("OpenCode adapter normalization", () => { + test("omits OpenCode's implicit default variant from new and updated sessions", async () => { + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + openCode.sessionCreateResponse = { data: { id: "ses_default_variant" } }; + openCode.sessionPromptAsyncEvents = [ + { type: "session.idle", properties: { sessionID: "ses_default_variant" } }, + ]; + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ + provider: "opencode", + cwd: "/workspace/repo", + model: "catalog-provider/single-variant-model", + thinkingOptionId: "default", + }); + + await collectTurnEvents(streamSession(session, "Use the model default")); + await session.setThinkingOption?.("max"); + await collectTurnEvents(streamSession(session, "Use max")); + await session.setThinkingOption?.("default"); + await collectTurnEvents(streamSession(session, "Return to the model default")); + + expect(openCode.calls.sessionPromptAsync).toEqual([ + expect.not.objectContaining({ variant: expect.anything() }), + expect.objectContaining({ variant: "max" }), + expect.not.objectContaining({ variant: expect.anything() }), + ]); + await session.close(); + }); + test("builds OpenCode file parts for image prompt blocks", () => { expect( __openCodeInternals.buildOpenCodePromptParts([ diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 3ea0baab7a..7dbe5d3b73 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -111,6 +111,7 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { const OPENCODE_BUILD_MODE_ID = "build"; const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access"; +const OPENCODE_DEFAULT_VARIANT_ID = "default"; const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept"; const OPENCODE_PERSISTED_SESSION_LIMIT = 200; const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000; @@ -580,6 +581,14 @@ function normalizeOpenCodeModeId(modeId: string | null | undefined): string | nu return trimmed; } +function normalizeOpenCodeVariantId(variantId: string | null | undefined): string | null { + const trimmed = typeof variantId === "string" ? variantId.trim() : ""; + if (!trimmed || trimmed === OPENCODE_DEFAULT_VARIANT_ID) { + return null; + } + return trimmed; +} + function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string | undefined { const normalizedModeId = normalizeOpenCodeModeId(modeId); if (normalizedModeId === null) { @@ -591,15 +600,19 @@ function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): strin } function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConfig { - if (normalizeOpenCodeModeId(config.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) { - return { ...config }; + const normalized = { + ...config, + thinkingOptionId: normalizeOpenCodeVariantId(config.thinkingOptionId) ?? undefined, + }; + if (normalizeOpenCodeModeId(normalized.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) { + return normalized; } return { - ...config, + ...normalized, modeId: OPENCODE_BUILD_MODE_ID, featureValues: { - ...config.featureValues, + ...normalized.featureValues, [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true, }, }; @@ -723,11 +736,13 @@ function buildOpenCodeModelDefinition( }, ): AgentModelDefinition { const rawVariants = model.variants ? Object.keys(model.variants) : []; - const thinkingOptions = rawVariants.map((id, index) => ({ - id, - label: id, - isDefault: index === 0, - })); + // OpenCode lists only overrides; its base model behavior is selected by omitting `variant`. + const thinkingOptions = rawVariants.length + ? [ + { id: OPENCODE_DEFAULT_VARIANT_ID, label: "Default", isDefault: true }, + ...rawVariants.map((id) => ({ id, label: id })), + ] + : []; return { provider: "opencode", @@ -3195,10 +3210,7 @@ class OpenCodeAgentSession implements AgentSession { } async setThinkingOption(thinkingOptionId: string | null): Promise<void> { - const normalizedThinkingOptionId = - typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0 - ? thinkingOptionId - : null; + const normalizedThinkingOptionId = normalizeOpenCodeVariantId(thinkingOptionId); this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined; } From 94106a30ab302f1b164a4150244fae501690a9ed Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 20:34:16 +0200 Subject: [PATCH 42/73] docs(changelog): add 0.4.0-beta.1 --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0fd69103..83292efd3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## 0.4.0-beta.1 - 2026-08-12 + +### Breaking + +- `paseo chat` and `paseo loop` have been removed ([#3053](https://github.com/getpaseo/paseo/pull/3053)) + +### Added + +- Save host-wide agent profiles and apply them from the model picker ([#3208](https://github.com/getpaseo/paseo/pull/3208)) +- Search workspace files with Cmd/Ctrl+P ([#3059](https://github.com/getpaseo/paseo/pull/3059)) +- Create, rename, duplicate, delete, reveal, and discard files from Files and Changes ([#3027](https://github.com/getpaseo/paseo/pull/3027) by [@nikuscs](https://github.com/nikuscs)) +- View interactive Mermaid diagrams in chats and Markdown previews ([#2306](https://github.com/getpaseo/paseo/pull/2306) by [@dmeledon](https://github.com/dmeledon)) +- Follow live task progress above the composer and through the timeline ([#3227](https://github.com/getpaseo/paseo/pull/3227)) +- Choose the model used for workspace titles, branch names, commits, and pull request drafts ([#3215](https://github.com/getpaseo/paseo/pull/3215)) +- Deploy Hub workflows from multi-file `.paseo` bundles ([#3128](https://github.com/getpaseo/paseo/pull/3128)) +- Build integrations with the supported `@getpaseo/client` TypeScript SDK ([#3141](https://github.com/getpaseo/paseo/pull/3141)) +- Diagnose provider installations with `paseo provider diagnostic` ([#3243](https://github.com/getpaseo/paseo/pull/3243)) +- Rename workspaces from the CLI ([#3209](https://github.com/getpaseo/paseo/pull/3209) by [@martinhanzik](https://github.com/martinhanzik)) + +### Improved + +- Markdown file previews use a centered reading layout with formatted YAML front matter ([#3240](https://github.com/getpaseo/paseo/pull/3240)) +- Switch sidebar grouping directly from the Command Center ([#3063](https://github.com/getpaseo/paseo/pull/3063) by [@cleiter](https://github.com/cleiter)) +- Theme shortcuts now cycle through every built-in theme ([#3214](https://github.com/getpaseo/paseo/pull/3214)) +- Newly enrolled Hub hosts use their machine hostname ([#3172](https://github.com/getpaseo/paseo/pull/3172)) + +### Fixed + +- Terminal sessions survive host sleep and severe resource stalls ([#3235](https://github.com/getpaseo/paseo/pull/3235), [#3263](https://github.com/getpaseo/paseo/pull/3263)) +- Archiving a workspace no longer risks hanging the daemon ([#3107](https://github.com/getpaseo/paseo/pull/3107)) +- New Workspace no longer crashes on Android when projects span multiple hosts ([#3241](https://github.com/getpaseo/paseo/pull/3241)) +- Provider caching no longer exhausts Android local storage ([#3234](https://github.com/getpaseo/paseo/pull/3234)) +- Dictation recordings survive connection interruptions ([#3159](https://github.com/getpaseo/paseo/pull/3159)) +- Repeated worktree creation always produces a fresh workspace ([#3224](https://github.com/getpaseo/paseo/pull/3224)) +- New worktrees use the setup command currently saved in Project Settings ([#3233](https://github.com/getpaseo/paseo/pull/3233)) +- Removed hosts stop sending push notifications ([#3176](https://github.com/getpaseo/paseo/pull/3176)) +- Delegated work still reports back after permission prompts or workspace closure ([#3177](https://github.com/getpaseo/paseo/pull/3177), [#3192](https://github.com/getpaseo/paseo/pull/3192) by [@thomasvan](https://github.com/thomasvan), [@wilgon456](https://github.com/wilgon456)) +- OMP agents start correctly with large model catalogs ([#3184](https://github.com/getpaseo/paseo/pull/3184) by [@pi3123](https://github.com/pi3123)) +- OpenCode models can return to their default variant ([#3281](https://github.com/getpaseo/paseo/pull/3281)) +- Completed Codex subagents no longer remain marked active ([#3188](https://github.com/getpaseo/paseo/pull/3188) by [@Strainy](https://github.com/Strainy)) +- Codex compaction no longer leaves turns marked as working ([#3211](https://github.com/getpaseo/paseo/pull/3211) by [@edihasaj](https://github.com/edihasaj)) +- Cursor usage works with current Cursor logins without requiring `sqlite3` ([#2704](https://github.com/getpaseo/paseo/pull/2704) by [@QuteSaltyFish](https://github.com/QuteSaltyFish)) +- SVG project icons render correctly on mobile ([#2579](https://github.com/getpaseo/paseo/pull/2579) by [@colonelpanic8](https://github.com/colonelpanic8)) + ## 0.3.1 - 2026-08-09 ### Added From 0c6dbefa40e2c4948eb1550fc1b5749f01a8a7b0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 20:39:35 +0200 Subject: [PATCH 43/73] chore(release): cut 0.4.0-beta.1 --- package-lock.json | 42 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++--- packages/client/package.json | 6 ++-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 10 +++--- packages/website/package.json | 2 +- 12 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index e95d8602aa..3186ef0e3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.3.1", + "version": "0.4.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.3.1", + "version": "0.4.0-beta.1", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -36091,7 +36091,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.3.1", + "version": "0.4.0-beta.1", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37139,12 +37139,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.3.1", + "version": "0.4.0-beta.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.3.1", - "@getpaseo/protocol": "0.3.1", - "@getpaseo/server": "0.3.1", + "@getpaseo/client": "0.4.0-beta.1", + "@getpaseo/protocol": "0.4.0-beta.1", + "@getpaseo/server": "0.4.0-beta.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -37391,10 +37391,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.3.1", + "version": "0.4.0-beta.1", "dependencies": { - "@getpaseo/protocol": "0.3.1", - "@getpaseo/relay": "0.3.1", + "@getpaseo/protocol": "0.4.0-beta.1", + "@getpaseo/relay": "0.4.0-beta.1", "zod": "^4.4.3" }, "devDependencies": { @@ -37405,7 +37405,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.3.1", + "version": "0.4.0-beta.1", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -37648,7 +37648,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.3.1", + "version": "0.4.0-beta.1", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -38544,7 +38544,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.3.1", + "version": "0.4.0-beta.1", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -38573,7 +38573,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.3.1", + "version": "0.4.0-beta.1", "dependencies": { "zod": "^4.4.3" }, @@ -38586,7 +38586,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.3.1", + "version": "0.4.0-beta.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38804,15 +38804,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.3.1", + "version": "0.4.0-beta.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.3.1", - "@getpaseo/highlight": "0.3.1", - "@getpaseo/protocol": "0.3.1", - "@getpaseo/relay": "0.3.1", + "@getpaseo/client": "0.4.0-beta.1", + "@getpaseo/highlight": "0.4.0-beta.1", + "@getpaseo/protocol": "0.4.0-beta.1", + "@getpaseo/relay": "0.4.0-beta.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -39360,7 +39360,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.3.1", + "version": "0.4.0-beta.1", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index b16ae39975..50b1fbb075 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.3.1", + "version": "0.4.0-beta.1", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index 18bdb283f9..5aa48ecdd3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.3.1", + "version": "0.4.0-beta.1", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 04be4e1b6c..c299446305 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.3.1", + "version": "0.4.0-beta.1", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.3.1", - "@getpaseo/protocol": "0.3.1", - "@getpaseo/server": "0.3.1", + "@getpaseo/client": "0.4.0-beta.1", + "@getpaseo/protocol": "0.4.0-beta.1", + "@getpaseo/server": "0.4.0-beta.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index e0b3fabb7e..e483b7be22 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.3.1", + "version": "0.4.0-beta.1", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.3.1", - "@getpaseo/relay": "0.3.1", + "@getpaseo/protocol": "0.4.0-beta.1", + "@getpaseo/relay": "0.4.0-beta.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index b641d8ce30..9a1c59d1b1 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.3.1", + "version": "0.4.0-beta.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 922a2d20ae..f4e15ece1d 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.3.1", + "version": "0.4.0-beta.1", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index c14a1af1cb..2e2ad2652e 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.3.1", + "version": "0.4.0-beta.1", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 46e2db0c98..85b177e22c 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.3.1", + "version": "0.4.0-beta.1", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 8a8172adb3..fded016453 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.3.1", + "version": "0.4.0-beta.1", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 31ced62034..2cc571a273 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.3.1", + "version": "0.4.0-beta.1", "description": "Paseo backend server", "files": [ "dist/server", @@ -71,10 +71,10 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.3.1", - "@getpaseo/highlight": "0.3.1", - "@getpaseo/protocol": "0.3.1", - "@getpaseo/relay": "0.3.1", + "@getpaseo/client": "0.4.0-beta.1", + "@getpaseo/highlight": "0.4.0-beta.1", + "@getpaseo/protocol": "0.4.0-beta.1", + "@getpaseo/relay": "0.4.0-beta.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 5f83185f3b..980ac8f115 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.3.1", + "version": "0.4.0-beta.1", "private": true, "type": "module", "scripts": { From fc1cb82e023f652eccac6b69e91e4482b7b55a67 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:48:31 +0000 Subject: [PATCH 44/73] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index f2b4328a7f..fc705b82ae 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-L73o8u6OsY3SsA5t2s5mWsMsMNc3qr5bCE4cd1NpbgI= +sha256-yj2kO4tZiyGD29aEhsYHtc9JMikWr6khlOCGZKFIM6U= From 4426a6b11fd9f1135d2e48076b83430a1531f973 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 21:18:55 +0200 Subject: [PATCH 45/73] Preserve opened subagents when parents are archived (#3279) * fix(subagents): preserve opened children on parent archive Opening a managed child represents user ownership only while that client's tab remains open. Track that intent per client, detach surviving children during parent archive, and keep single and bulk tab-close behavior consistent. * fix(subagents): mark generic tab openings Centralize managed child tab ownership in agent navigation so list, command-center, deep-link, notification, and subagent-track openings all mark ownership before creating the tab. Use stable localized errors for lifecycle failures. * fix(subagents): classify cold tab openings Fetch uncached agents before deciding whether tab ownership must be recorded, so notification, desktop, and cold-route openings cannot bypass parent archive protection. * fix(app): translate navigation fallback errors * fix(navigation): allow offline workspace intents * refactor(app): derive subagent ownership from open tabs * fix(server): serialize subagent lifecycle ownership --- docs/agent-lifecycle.md | 33 ++- docs/data-model.md | 2 +- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../workspace/workspace-bulk-close.test.ts | 47 ++++- .../screens/workspace/workspace-bulk-close.ts | 123 ++++++++---- .../screens/workspace/workspace-screen.tsx | 62 +++++- .../app/src/subagents/open-tab-labels.test.ts | 53 +++++ packages/app/src/subagents/open-tab-labels.ts | 25 +++ .../subagents/use-open-agent-tab-labels.ts | 86 ++++++++ packages/protocol/src/agent-labels.test.ts | 18 ++ packages/protocol/src/agent-labels.ts | 15 ++ .../src/server/agent/agent-manager.test.ts | 189 +++++++++++++++++- .../server/src/server/agent/agent-manager.ts | 104 +++++++++- 21 files changed, 696 insertions(+), 70 deletions(-) create mode 100644 packages/app/src/subagents/open-tab-labels.test.ts create mode 100644 packages/app/src/subagents/open-tab-labels.ts create mode 100644 packages/app/src/subagents/use-open-agent-tab-labels.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index c8cd0e11aa..4da30bf0e0 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -44,9 +44,21 @@ Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Age - **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it. - **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it. +Parent archive detaches a subagent instead of archiving it when either condition holds: + +- The child belongs to another workspace. +- The child is currently open in an agent tab. + +All other children archive with the parent. After the workspace layout hydrates, the client marks +every managed subagent present in its tabs with `paseo.open-agent-tab.<client-id>=true` through the +generic agent metadata update. This includes background and restored tabs; navigation does not own +the marker. Closing a tab sets that client's label to `false`. Any `true` client label keeps the child +open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a +normal root agent immediately, and closing its still-open tab archives it. + Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace. -Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. +Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. `notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. @@ -74,7 +86,7 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/ 2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing` 3. Notify subscribers 4. Close the runtime (kills the process if still running) -5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively +5. **Resolve children** — detach cross-workspace and open-tab children; cascade-archive the rest recursively Cascade is what keeps subagent fleets from outliving their orchestrator. @@ -109,7 +121,7 @@ These are two distinct concepts that used to be conflated: Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident. -Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`). +Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy. The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached. @@ -155,7 +167,7 @@ Archived Paseo subagents disappear from the track, by design. To remove one from 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. -To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. +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. ## Why this shape @@ -189,11 +201,12 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json Each agent is a single JSON file. Fields relevant to this doc: -| Field | Type | Meaning | -| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- | -| `id` | `string` | Stable identifier | -| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) | -| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach | -| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | +| Field | Type | Meaning | +| -------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- | +| `id` | `string` | Stable identifier | +| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) | +| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach | +| `labels["paseo.open-agent-tab.<client-id>"]` | `string?` | `"true"` protects an open tab on that client; detach clears every matching label | +| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | See [`docs/data-model.md`](./data-model.md) for the full agent record. diff --git a/docs/data-model.md b/docs/data-model.md index 3a29076ea3..bc1d8bd17b 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -85,7 +85,7 @@ Each agent is stored as a separate JSON file, grouped by project directory. | `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | | `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | | `title` | `string?` | User-visible title | -| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) | +| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) | | `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. | | `lastModeId` | `string?` | Last active mode ID | | `config` | `SerializableConfig?` | Agent session configuration (see below) | diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 1f0d16ec0e..b42e61b666 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -614,6 +614,7 @@ export const ar: TranslationResources = { reloadingAgent: "وكيل إعادة التحميل...", reloadedAgent: "وكيل إعادة تحميل", failedToReloadAgent: "فشل في إعادة تحميل الوكيل", + failedToCloseAgent: "فشل في إغلاق الوكيل", }, confirmations: { unsavedTitle: "تغييرات غير محفوظة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 477051f2ea..af43bec2eb 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -613,6 +613,7 @@ export const en = { reloadingAgent: "Reloading agent...", reloadedAgent: "Reloaded agent", failedToReloadAgent: "Failed to reload agent", + failedToCloseAgent: "Failed to close agent", }, confirmations: { close: "Close", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1b32460d33..60e14d81e6 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -619,6 +619,7 @@ export const es: TranslationResources = { reloadingAgent: "Agente de recarga...", reloadedAgent: "Agente recargado", failedToReloadAgent: "No se pudo recargar el agente", + failedToCloseAgent: "No se pudo cerrar el agente", }, confirmations: { unsavedTitle: "Cambios sin guardar", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index b6da767993..471de64823 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -619,6 +619,7 @@ export const fr: TranslationResources = { reloadingAgent: "Agent de rechargement...", reloadedAgent: "Agent rechargé", failedToReloadAgent: "Échec du rechargement de l'agent", + failedToCloseAgent: "Échec de la fermeture de l'agent", }, confirmations: { unsavedTitle: "Modifications non enregistrées", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index f10037ef72..57331e14a5 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -619,6 +619,7 @@ export const ja: TranslationResources = { reloadingAgent: "エージェントを再読み込み中...", reloadedAgent: "エージェントを再読み込みしました", failedToReloadAgent: "エージェントの再読み込みに失敗しました", + failedToCloseAgent: "エージェントを閉じられませんでした", }, confirmations: { unsavedTitle: "未保存の変更", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 66dc58c16d..6be87e7b94 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -616,6 +616,7 @@ export const ko: TranslationResources = { reloadingAgent: "에이전트 다시 로드 중...", reloadedAgent: "에이전트를 다시 로드했습니다", failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다", + failedToCloseAgent: "에이전트를 닫지 못했습니다", }, confirmations: { close: "닫기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index c87eed9a10..7c05f5e00b 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -618,6 +618,7 @@ export const ptBR: TranslationResources = { reloadingAgent: "Recarregando agente...", reloadedAgent: "Agente recarregado", failedToReloadAgent: "Falha ao recarregar agente", + failedToCloseAgent: "Falha ao fechar agente", }, confirmations: { unsavedTitle: "Alterações não salvas", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 33d54be3da..e68c7e4e78 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -618,6 +618,7 @@ export const ru: TranslationResources = { reloadingAgent: "Перезагрузка агента...", reloadedAgent: "Перезагруженный агент", failedToReloadAgent: "Не удалось перезагрузить агент", + failedToCloseAgent: "Не удалось закрыть агент", }, confirmations: { unsavedTitle: "Несохранённые изменения", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 2283dad923..9c70d7b701 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -614,6 +614,7 @@ export const zhCN: TranslationResources = { reloadingAgent: "正在重新加载 Agent...", reloadedAgent: "已重新加载 Agent", failedToReloadAgent: "重新加载 Agent 失败", + failedToCloseAgent: "关闭 Agent 失败", }, confirmations: { unsavedTitle: "未保存的更改", diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts index efa3b333b2..4dfe308c14 100644 --- a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts +++ b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts @@ -42,7 +42,8 @@ describe("workspace bulk close helpers", () => { ]); expect(groups).toEqual({ - agentTabs: [{ tabId: "agent_a1", agentId: "a1" }], + archiveAgentTabs: [{ tabId: "agent_a1", agentId: "a1" }], + layoutOnlyAgentTabs: [], terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }], otherTabs: [ { @@ -53,6 +54,21 @@ describe("workspace bulk close helpers", () => { }); }); + it("separates subagent tabs from agents that archive on close", () => { + const groups = classifyBulkClosableTabs( + [makeAgentTab("root"), makeAgentTab("child")], + (agentId) => (agentId === "child" ? "layout-only" : "archive"), + ); + + expect(groups).toMatchObject({ + archiveAgentTabs: [{ tabId: "agent_root", agentId: "root" }], + layoutOnlyAgentTabs: [{ tabId: "agent_child", agentId: "child" }], + }); + expect(buildBulkCloseConfirmationMessage(groups)).toBe( + "This will archive 1 agent(s) and close 1 tab(s).", + ); + }); + it("describes mixed destructive bulk close operations in the confirmation copy", () => { const message = buildBulkCloseConfirmationMessage( classifyBulkClosableTabs([ @@ -106,6 +122,7 @@ describe("workspace bulk close helpers", () => { closeWorkspaceTabWithCleanup: (input) => { cleanupCalls.push(input); }, + closeLayoutOnlyAgent: async () => {}, logLabel: "all tabs", }); @@ -152,6 +169,7 @@ describe("workspace bulk close helpers", () => { closeWorkspaceTabWithCleanup: (input) => { cleanupCalls.push(input); }, + closeLayoutOnlyAgent: async () => {}, warn, logLabel: "others", }); @@ -166,4 +184,31 @@ describe("workspace bulk close helpers", () => { { tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } }, ]); }); + + it("closes subagent tabs without sending them to closeItems", async () => { + const groups = classifyBulkClosableTabs( + [makeAgentTab("root"), makeAgentTab("child")], + (agentId) => (agentId === "child" ? "layout-only" : "archive"), + ); + const closeItems = vi.fn(async () => ({ agents: [], terminals: [], requestId: "req-1" })); + const preparedSubagents: string[] = []; + const cleanedTabs: string[] = []; + + await closeBulkWorkspaceTabs({ + groups, + client: { closeItems }, + closeTab: async (_tabId, action) => action(), + closeLayoutOnlyAgent: async (agentId) => { + preparedSubagents.push(agentId); + }, + closeWorkspaceTabWithCleanup: ({ tabId }) => { + cleanedTabs.push(tabId); + }, + logLabel: "others", + }); + + expect(closeItems).toHaveBeenCalledWith({ agentIds: ["root"], terminalIds: [] }); + expect(preparedSubagents).toEqual(["child"]); + expect(cleanedTabs).toEqual(["agent_child", "agent_root"]); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.ts b/packages/app/src/screens/workspace/workspace-bulk-close.ts index fc986de41c..8e9cf9dad1 100644 --- a/packages/app/src/screens/workspace/workspace-bulk-close.ts +++ b/packages/app/src/screens/workspace/workspace-bulk-close.ts @@ -3,7 +3,8 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs- import { i18n } from "@/i18n/i18next"; export interface BulkClosableTabGroups { - agentTabs: Array<{ tabId: string; agentId: string }>; + archiveAgentTabs: Array<{ tabId: string; agentId: string }>; + layoutOnlyAgentTabs: Array<{ tabId: string; agentId: string }>; terminalTabs: Array<{ tabId: string; terminalId: string }>; otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>; } @@ -43,20 +44,30 @@ interface CloseBulkWorkspaceTabsInput { groups: BulkClosableTabGroups; closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>; closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void; + closeLayoutOnlyAgent: (agentId: string) => Promise<void>; logLabel: string; warn?: (message: string, payload: object) => void; } -export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups { +export function classifyBulkClosableTabs( + tabs: WorkspaceTabDescriptor[], + resolveAgentCloseKind: (agentId: string) => "archive" | "layout-only" = () => "archive", +): BulkClosableTabGroups { const groups: BulkClosableTabGroups = { - agentTabs: [], + archiveAgentTabs: [], + layoutOnlyAgentTabs: [], terminalTabs: [], otherTabs: [], }; for (const tab of tabs) { if (tab.target.kind === "agent") { - groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.target.agentId }); + const agentTab = { tabId: tab.tabId, agentId: tab.target.agentId }; + if (resolveAgentCloseKind(tab.target.agentId) === "layout-only") { + groups.layoutOnlyAgentTabs.push(agentTab); + } else { + groups.archiveAgentTabs.push(agentTab); + } continue; } if (tab.target.kind === "terminal") { @@ -73,49 +84,73 @@ export function buildBulkCloseConfirmationMessage( input: BulkClosableTabGroups, labels: BulkCloseConfirmationLabels = DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS, ): string { - const { agentTabs, terminalTabs, otherTabs } = input; - if (agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0) { + const { archiveAgentTabs, layoutOnlyAgentTabs, terminalTabs, otherTabs } = input; + const tabCount = layoutOnlyAgentTabs.length + otherTabs.length; + if (archiveAgentTabs.length > 0 && terminalTabs.length > 0 && tabCount > 0) { return labels.all({ - agents: agentTabs.length, + agents: archiveAgentTabs.length, terminals: terminalTabs.length, - tabs: otherTabs.length, + tabs: tabCount, }); } - if (agentTabs.length > 0 && terminalTabs.length > 0) { + if (archiveAgentTabs.length > 0 && terminalTabs.length > 0) { return labels.agentsAndTerminals({ - agents: agentTabs.length, + agents: archiveAgentTabs.length, terminals: terminalTabs.length, }); } - if (terminalTabs.length > 0 && otherTabs.length > 0) { + if (terminalTabs.length > 0 && tabCount > 0) { return labels.terminalsAndTabs({ terminals: terminalTabs.length, - tabs: otherTabs.length, + tabs: tabCount, }); } - if (agentTabs.length > 0 && otherTabs.length > 0) { + if (archiveAgentTabs.length > 0 && tabCount > 0) { return labels.agentsAndTabs({ - agents: agentTabs.length, - tabs: otherTabs.length, + agents: archiveAgentTabs.length, + tabs: tabCount, }); } if (terminalTabs.length > 0) { return labels.terminals({ terminals: terminalTabs.length }); } - if (otherTabs.length > 0) { - return labels.tabs({ tabs: otherTabs.length }); + if (tabCount > 0) { + return labels.tabs({ tabs: tabCount }); } - return labels.agents({ agents: agentTabs.length }); + return labels.agents({ agents: archiveAgentTabs.length }); } export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> { - const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input; - const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0; + const { + client, + groups, + closeTab, + closeWorkspaceTabWithCleanup, + closeLayoutOnlyAgent, + logLabel, + warn, + } = input; + const hasDestructiveTabs = groups.archiveAgentTabs.length > 0 || groups.terminalTabs.length > 0; + + for (const { tabId, agentId } of groups.layoutOnlyAgentTabs) { + await closeTab(tabId, async () => { + try { + await closeLayoutOnlyAgent(agentId); + } catch (error) { + warn?.(`[WorkspaceScreen] Failed to close subagent tab ${logLabel}`, { error, agentId }); + return; + } + closeWorkspaceTabWithCleanup({ + tabId, + target: { kind: "agent", agentId }, + }); + }); + } if (hasDestructiveTabs && client) { void client .closeItems({ - agentIds: groups.agentTabs.map((tab) => tab.agentId), + agentIds: groups.archiveAgentTabs.map((tab) => tab.agentId), terminalIds: groups.terminalTabs.map((tab) => tab.terminalId), }) .catch((error) => { @@ -127,27 +162,27 @@ export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput) }); } - for (const { tabId, agentId } of groups.agentTabs) { - void closeTab(tabId, async () => { - closeWorkspaceTabWithCleanup({ - tabId, - target: { kind: "agent", agentId }, - }); - }); - } - - for (const { tabId, terminalId } of groups.terminalTabs) { - void closeTab(tabId, async () => { - closeWorkspaceTabWithCleanup({ - tabId, - target: { kind: "terminal", terminalId }, - }); - }); - } - - for (const { tabId, target } of groups.otherTabs) { - void closeTab(tabId, async () => { - closeWorkspaceTabWithCleanup({ tabId, target }); - }); - } + await Promise.all([ + ...groups.archiveAgentTabs.map(({ tabId, agentId }) => + closeTab(tabId, async () => { + closeWorkspaceTabWithCleanup({ + tabId, + target: { kind: "agent", agentId }, + }); + }), + ), + ...groups.terminalTabs.map(({ tabId, terminalId }) => + closeTab(tabId, async () => { + closeWorkspaceTabWithCleanup({ + tabId, + target: { kind: "terminal", terminalId }, + }); + }), + ), + ...groups.otherTabs.map(({ tabId, target }) => + closeTab(tabId, async () => { + closeWorkspaceTabWithCleanup({ tabId, target }); + }), + ), + ]); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 9b0e718b35..f10e627072 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,4 +1,5 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels"; import { memo, useCallback, @@ -66,6 +67,7 @@ import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open- import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; import { ImportSessionSheet } from "@/components/import-session-sheet"; import { useToast } from "@/contexts/toast-context"; +import { getOrCreateClientId } from "@/utils/client-id"; import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { traceInstant } from "@/performance/native-trace"; @@ -112,6 +114,7 @@ import { getDesktopHost } from "@/desktop/host"; import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; import { resolveWorkspaceRouteId } from "@/utils/workspace-identity"; +import { useOpenAgentTabLabels } from "@/subagents/use-open-agent-tab-labels"; import { WorkspaceTabPresentationResolver, WorkspaceTabIcon, @@ -1970,6 +1973,12 @@ function WorkspaceScreenContent({ () => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS), [workspaceLayout], ); + useOpenAgentTabLabels({ + client, + serverId: normalizedServerId, + tabs: uiTabs, + enabled: hasHydratedWorkspaceLayoutStore, + }); useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused, @@ -2651,7 +2660,7 @@ function WorkspaceScreenContent({ const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; - const closePolicy = resolveCloseAgentTabPolicy(agent); + let closePolicy = resolveCloseAgentTabPolicy(agent); const isRunning = agent?.status === "running"; if (isRunning && closePolicy.kind === "archive-on-close") { @@ -2667,6 +2676,27 @@ function WorkspaceScreenContent({ } } + if (closePolicy.kind === "layout-only") { + const sessionClient = useSessionStore.getState().sessions[normalizedServerId]?.client; + if (!sessionClient) { + toast.error(t("common.errors.daemonClientUnavailable")); + return; + } + try { + const clientId = await getOrCreateClientId(); + await sessionClient.updateAgent(agentId, { + labels: { [getOpenAgentTabLabel(clientId)]: "false" }, + }); + const latestAgent = + useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + closePolicy = resolveCloseAgentTabPolicy(latestAgent); + } catch (error) { + console.error("[WorkspaceScreen] Failed to close subagent tab", { error, agentId }); + toast.error(t("workspace.tabs.toasts.failedToCloseAgent")); + return; + } + } + setHoveredCloseTabKey((current) => (current === tabId ? null : current)); if (persistenceKey) { closeWorkspaceTabWithCleanup({ @@ -2683,7 +2713,15 @@ function WorkspaceScreenContent({ void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {}); }); }, - [archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t], + [ + archiveAgent, + closeTab, + closeWorkspaceTabWithCleanup, + normalizedServerId, + persistenceKey, + t, + toast, + ], ); const handleClosePassiveTab = useCallback( @@ -2899,7 +2937,10 @@ function WorkspaceScreenContent({ return; } - const groups = classifyBulkClosableTabs(tabsToClose); + const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => { + const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId); + return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive"; + }); const modifiedCount = tabsToClose.filter( (tab) => getPanelInstanceAttributes({ @@ -2927,6 +2968,20 @@ function WorkspaceScreenContent({ client, groups, closeTab, + closeLayoutOnlyAgent: async (agentId) => { + if (!client) { + throw new Error(t("common.errors.daemonClientUnavailable")); + } + const clientId = await getOrCreateClientId(); + await client.updateAgent(agentId, { + labels: { [getOpenAgentTabLabel(clientId)]: "false" }, + }); + const latestAgent = + useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + if (resolveCloseAgentTabPolicy(latestAgent).kind === "archive-on-close") { + await archiveAgent({ serverId: normalizedServerId, agentId }); + } + }, closeWorkspaceTabWithCleanup: (cleanupInput) => { if (!persistenceKey) { return; @@ -2943,6 +2998,7 @@ function WorkspaceScreenContent({ setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current)); }, [ + archiveAgent, bulkCloseConfirmationLabels, client, closeTab, diff --git a/packages/app/src/subagents/open-tab-labels.test.ts b/packages/app/src/subagents/open-tab-labels.test.ts new file mode 100644 index 0000000000..61874cd058 --- /dev/null +++ b/packages/app/src/subagents/open-tab-labels.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import type { Agent } from "@/stores/session-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; +import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels"; + +const label = "paseo.open-agent-tab.client-1"; + +function tab(agentId: string, createdAt: number): WorkspaceTab { + return { tabId: `agent_${agentId}`, target: { kind: "agent", agentId }, createdAt }; +} + +function agent(input: { id: string; parentAgentId?: string | null; open?: boolean }): Agent { + return { + id: input.id, + parentAgentId: input.parentAgentId ?? null, + labels: input.open ? { [label]: "true" } : {}, + } as Agent; +} + +describe("getAgentTabsNeedingOpenLabel", () => { + it("marks every present parented tab, regardless of tab order or focus", () => { + const agents = new Map([ + ["background-child", agent({ id: "background-child", parentAgentId: "parent" })], + ["focused-child", agent({ id: "focused-child", parentAgentId: "parent" })], + ["root", agent({ id: "root" })], + ]); + + expect( + getAgentTabsNeedingOpenLabel({ + tabs: [tab("background-child", 1), tab("root", 2), tab("focused-child", 3)], + getAgent: (agentId) => agents.get(agentId), + label, + pendingAgentIds: new Set(), + }), + ).toEqual(["background-child", "focused-child"]); + }); + + it("skips tabs already marked or currently being marked", () => { + const agents = new Map([ + ["marked", agent({ id: "marked", parentAgentId: "parent", open: true })], + ["pending", agent({ id: "pending", parentAgentId: "parent" })], + ]); + + expect( + getAgentTabsNeedingOpenLabel({ + tabs: [tab("marked", 1), tab("pending", 2)], + getAgent: (agentId) => agents.get(agentId), + label, + pendingAgentIds: new Set(["pending"]), + }), + ).toEqual([]); + }); +}); diff --git a/packages/app/src/subagents/open-tab-labels.ts b/packages/app/src/subagents/open-tab-labels.ts new file mode 100644 index 0000000000..ca7d8e63dc --- /dev/null +++ b/packages/app/src/subagents/open-tab-labels.ts @@ -0,0 +1,25 @@ +import type { Agent } from "@/stores/session-store"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; + +export function getAgentTabsNeedingOpenLabel(input: { + tabs: WorkspaceTab[]; + getAgent: (agentId: string) => Agent | null | undefined; + label: string; + pendingAgentIds: ReadonlySet<string>; +}): string[] { + const agentIds = new Set<string>(); + for (const tab of input.tabs) { + if (tab.target.kind !== "agent") { + continue; + } + const agent = input.getAgent(tab.target.agentId); + if ( + agent?.parentAgentId && + agent.labels[input.label] !== "true" && + !input.pendingAgentIds.has(agent.id) + ) { + agentIds.add(agent.id); + } + } + return [...agentIds]; +} diff --git a/packages/app/src/subagents/use-open-agent-tab-labels.ts b/packages/app/src/subagents/use-open-agent-tab-labels.ts new file mode 100644 index 0000000000..ea02fd1b16 --- /dev/null +++ b/packages/app/src/subagents/use-open-agent-tab-labels.ts @@ -0,0 +1,86 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels"; +import { useEffect, useRef, useState } from "react"; +import { useSessionStore } from "@/stores/session-store"; +import { getOrCreateClientId } from "@/utils/client-id"; +import type { WorkspaceTab } from "@/workspace-tabs/model"; +import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels"; + +function increment(value: number): number { + return value + 1; +} + +export function useOpenAgentTabLabels(input: { + client: DaemonClient | null; + serverId: string; + tabs: WorkspaceTab[]; + enabled: boolean; +}): void { + const agents = useSessionStore((state) => state.sessions[input.serverId]?.agents ?? null); + const agentDetails = useSessionStore( + (state) => state.sessions[input.serverId]?.agentDetails ?? null, + ); + const pendingAgentIdsRef = useRef(new Set<string>()); + const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const [retryVersion, setRetryVersion] = useState(0); + + useEffect( + () => () => { + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + } + }, + [], + ); + + useEffect(() => { + const client = input.client; + if (!client || !input.enabled) { + return; + } + + const openAgentIds = new Set( + input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])), + ); + for (const agentId of pendingAgentIdsRef.current) { + if (!openAgentIds.has(agentId)) { + pendingAgentIdsRef.current.delete(agentId); + } + } + if (openAgentIds.size === 0) { + return; + } + + void (async () => { + try { + const clientId = await getOrCreateClientId(); + const label = getOpenAgentTabLabel(clientId); + const agentIds = getAgentTabsNeedingOpenLabel({ + tabs: input.tabs, + getAgent: (agentId) => agents?.get(agentId) ?? agentDetails?.get(agentId), + label, + pendingAgentIds: pendingAgentIdsRef.current, + }); + for (const agentId of agentIds) { + pendingAgentIdsRef.current.add(agentId); + try { + await client.updateAgent(agentId, { labels: { [label]: "true" } }); + } catch (error) { + console.warn("[OpenAgentTabLabels] Failed to mark open subagent tab", { + error, + agentId, + }); + retryTimerRef.current ??= setTimeout(() => { + retryTimerRef.current = null; + setRetryVersion(increment); + }, 2_000); + } finally { + pendingAgentIdsRef.current.delete(agentId); + } + } + } catch (error) { + console.warn("[OpenAgentTabLabels] Failed to resolve client ID", { error }); + } + })(); + }, [agentDetails, agents, input.client, input.enabled, input.tabs, retryVersion]); +} diff --git a/packages/protocol/src/agent-labels.test.ts b/packages/protocol/src/agent-labels.test.ts index c57db3a09e..1f52a652fc 100644 --- a/packages/protocol/src/agent-labels.test.ts +++ b/packages/protocol/src/agent-labels.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "vitest"; import { getParentAgentIdFromLabels, + getOpenAgentTabLabel, + hasOpenAgentTab, isDelegatedAgent, + isOpenAgentTabLabel, PARENT_AGENT_ID_LABEL, } from "./agent-labels.js"; @@ -18,4 +21,19 @@ describe("agent label policy", () => { expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: " " } })).toBe(false); expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } })).toBe(false); }); + + test("treats any true client-scoped open-tab label as open", () => { + const desktopLabel = getOpenAgentTabLabel("desktop-client"); + const mobileLabel = getOpenAgentTabLabel("mobile-client"); + + expect(hasOpenAgentTab({ [desktopLabel]: "false", [mobileLabel]: "true" })).toBe(true); + expect(hasOpenAgentTab({ [desktopLabel]: "false", [mobileLabel]: "false" })).toBe(false); + expect(hasOpenAgentTab({})).toBe(false); + }); + + test("recognizes only client-scoped open-tab labels", () => { + expect(isOpenAgentTabLabel(getOpenAgentTabLabel("client-a"))).toBe(true); + expect(isOpenAgentTabLabel("paseo.open-agent-tab")).toBe(false); + expect(isOpenAgentTabLabel("custom.open-agent-tab.client-a")).toBe(false); + }); }); diff --git a/packages/protocol/src/agent-labels.ts b/packages/protocol/src/agent-labels.ts index 3ad40022ea..2bf05950dd 100644 --- a/packages/protocol/src/agent-labels.ts +++ b/packages/protocol/src/agent-labels.ts @@ -1,4 +1,13 @@ export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id"; +const OPEN_AGENT_TAB_LABEL_PREFIX = "paseo.open-agent-tab."; + +export function getOpenAgentTabLabel(clientId: string): string { + return `${OPEN_AGENT_TAB_LABEL_PREFIX}${clientId}`; +} + +export function isOpenAgentTabLabel(label: string): boolean { + return label.startsWith(OPEN_AGENT_TAB_LABEL_PREFIX); +} export interface AgentLabelSource { labels?: Record<string, unknown> | null; @@ -14,3 +23,9 @@ export function getParentAgentIdFromLabels(labels: Record<string, unknown> | nul export function isDelegatedAgent(agent: AgentLabelSource): boolean { return getParentAgentIdFromLabels(agent.labels) !== null; } + +export function hasOpenAgentTab(labels: Record<string, unknown> | null | undefined): boolean { + return Object.entries(labels ?? {}).some( + ([label, value]) => isOpenAgentTabLabel(label) && value === "true", + ); +} diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 82a8e3e3c3..821828f4ab 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -15,7 +15,7 @@ import { } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import { toAgentPayload } from "./agent-projections.js"; -import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js"; import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js"; import type { StoredAgentRecord } from "./agent-storage.js"; @@ -41,6 +41,9 @@ import type { import type { PaseoToolCatalog } from "./tools/types.js"; import type { ProviderDefinition } from "./provider-registry.js"; +const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client"); +const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client"); + interface Deferred<T> { promise: Promise<T>; resolve: (value: T) => void; @@ -3590,7 +3593,7 @@ test("setLabels merges and persists labels", async () => { }); }); -test("detachAgent removes only the parent label from a live agent and emits state", async () => { +test("detachAgent removes relationship lifecycle labels from a live agent and emits state", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-live-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -3621,6 +3624,8 @@ test("detachAgent removes only the parent label from a live agent and emits stat { labels: { [PARENT_AGENT_ID_LABEL]: parent.id, + [DESKTOP_OPEN_AGENT_TAB_LABEL]: "true", + [MOBILE_OPEN_AGENT_TAB_LABEL]: "false", team: "infra", }, workspaceId: undefined, @@ -3648,7 +3653,7 @@ test("detachAgent removes only the parent label from a live agent and emits stat expect(emittedLabels).toContainEqual({ team: "infra" }); }); -test("detachAgent removes the parent label from a stored-only agent", async () => { +test("detachAgent removes relationship lifecycle labels from a stored-only agent", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-stored-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -3679,6 +3684,8 @@ test("detachAgent removes the parent label from a stored-only agent", async () = { labels: { [PARENT_AGENT_ID_LABEL]: parent.id, + [DESKTOP_OPEN_AGENT_TAB_LABEL]: "true", + [MOBILE_OPEN_AGENT_TAB_LABEL]: "false", role: "reviewer", }, workspaceId: undefined, @@ -6724,6 +6731,182 @@ test("archiveAgent cascade archives in-memory children with the full archive con expect(storedUnrelated?.archivedAt).toBeUndefined(); }); +test("archiveAgent detaches an open same-workspace child instead of cascading", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-child-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + const parent = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Parent" }, + undefined, + { workspaceId: "workspace-a" }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Open child" }, + undefined, + { + workspaceId: "workspace-a", + labels: { + [PARENT_AGENT_ID_LABEL]: parent.id, + [DESKTOP_OPEN_AGENT_TAB_LABEL]: "false", + [MOBILE_OPEN_AGENT_TAB_LABEL]: "true", + }, + }, + ); + + await manager.archiveAgent(parent.id); + + const storedChild = await storage.get(child.id); + expect(storedChild?.archivedAt).toBeUndefined(); + expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined(); + expect(storedChild?.labels[DESKTOP_OPEN_AGENT_TAB_LABEL]).toBeUndefined(); + expect(storedChild?.labels[MOBILE_OPEN_AGENT_TAB_LABEL]).toBeUndefined(); + expect(manager.getAgent(child.id)?.id).toBe(child.id); +}); + +test("archiveAgent detaches a cross-workspace child even when its tab is closed", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-cross-workspace-child-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + const parent = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Parent" }, + undefined, + { workspaceId: "workspace-a" }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Cross-workspace child" }, + undefined, + { + workspaceId: "workspace-b", + labels: { + [PARENT_AGENT_ID_LABEL]: parent.id, + [DESKTOP_OPEN_AGENT_TAB_LABEL]: "false", + }, + }, + ); + + await manager.archiveAgent(parent.id); + + const storedChild = await storage.get(child.id); + expect(storedChild?.archivedAt).toBeUndefined(); + expect(storedChild?.workspaceId).toBe("workspace-b"); + expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined(); +}); + +test("archiveAgent re-reads a child before deciding whether to cascade", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-fresh-child-")); + + class ChildOpensAfterCascadeListStorage extends AgentStorage { + childId: string | null = null; + + override async list(): Promise<StoredAgentRecord[]> { + const records = await super.list(); + const parentIsArchived = records.some( + (record) => record.id !== this.childId && Boolean(record.archivedAt), + ); + const staleChild = records.find((record) => record.id === this.childId); + if (parentIsArchived && staleChild) { + await super.upsert({ + ...staleChild, + labels: { ...staleChild.labels, [MOBILE_OPEN_AGENT_TAB_LABEL]: "true" }, + }); + } + return records; + } + } + + const storage = new ChildOpensAfterCascadeListStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + const parent = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Parent" }, + undefined, + { workspaceId: "workspace-a" }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Late-open child" }, + undefined, + { + workspaceId: "workspace-a", + labels: { + [PARENT_AGENT_ID_LABEL]: parent.id, + [MOBILE_OPEN_AGENT_TAB_LABEL]: "false", + }, + }, + ); + storage.childId = child.id; + + await manager.archiveAgent(parent.id); + + const storedChild = await storage.get(child.id); + expect(storedChild?.archivedAt).toBeUndefined(); + expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined(); +}); + +test("archiveAgent cannot overtake a received child open-tab update", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-race-")); + const markerWriteStarted = deferred<void>(); + const releaseMarkerWrite = deferred<void>(); + + class BlockingOpenMarkerStorage extends AgentStorage { + childId: string | null = null; + + override async applySnapshot( + agent: ManagedAgent, + options?: { title?: string | null; internal?: boolean }, + ): Promise<void> { + if (agent.id === this.childId && agent.labels[MOBILE_OPEN_AGENT_TAB_LABEL] === "true") { + markerWriteStarted.resolve(); + await releaseMarkerWrite.promise; + } + await super.applySnapshot(agent, options); + } + } + + const storage = new BlockingOpenMarkerStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + const parent = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Parent" }, + undefined, + { workspaceId: "workspace-a" }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Opening child" }, + undefined, + { + workspaceId: "workspace-a", + labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, + }, + ); + storage.childId = child.id; + + const markOpen = manager.updateAgentMetadata(child.id, { + labels: { [MOBILE_OPEN_AGENT_TAB_LABEL]: "true" }, + }); + await markerWriteStarted.promise; + const archiveParent = manager.archiveAgent(parent.id); + releaseMarkerWrite.resolve(); + await Promise.all([markOpen, archiveParent]); + + const storedChild = await storage.get(child.id); + expect(storedChild?.archivedAt).toBeUndefined(); + expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined(); +}); + test("archiveAgent cascade closes a running child runtime", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-running-child-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index ea775bbf7c..f3db4f385e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -7,7 +7,9 @@ import { } from "@getpaseo/protocol/agent-lifecycle"; import { getParentAgentIdFromLabels, + hasOpenAgentTab, isDelegatedAgent, + isOpenAgentTabLabel, PARENT_AGENT_ID_LABEL, } from "@getpaseo/protocol/agent-labels"; import type { Logger } from "pino"; @@ -613,6 +615,27 @@ function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): str return null; } +function shouldDetachFromArchivedParent( + parent: StoredAgentRecord, + child: StoredAgentRecord, +): boolean { + const isCrossWorkspace = + parent.workspaceId !== undefined && + child.workspaceId !== undefined && + parent.workspaceId !== child.workspaceId; + return isCrossWorkspace || hasOpenAgentTab(child.labels); +} + +function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatch { + const patch: AgentLabelPatch = { [PARENT_AGENT_ID_LABEL]: null }; + for (const label of Object.keys(labels)) { + if (isOpenAgentTabLabel(label)) { + patch[label] = null; + } + } + return patch; +} + export class AgentManager { private readonly clients = new Map<AgentProvider, AgentClient>(); private readonly providerEnabled = new Map<AgentProvider, boolean>(); @@ -631,6 +654,7 @@ export class AgentManager { private readonly backgroundTasks = new Set<Promise<void>>(); private readonly agentRegistrationTasks = new Set<Promise<void>>(); private readonly inFlightAgentCloses = new Map<string, Promise<void>>(); + private readonly lifecycleMutationTails = new Map<string, Promise<void>>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; private readonly mcpAuthToken: string | null; @@ -1494,6 +1518,10 @@ export class AgentManager { } async archiveAgent(agentId: string): Promise<{ archivedAt: string }> { + return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId)); + } + + private async archiveAgentUnlocked(agentId: string): Promise<{ archivedAt: string }> { const agent = this.requireAgent(agentId); if (!this.registry) { throw new Error("Agent storage is not configured"); @@ -1527,6 +1555,10 @@ export class AgentManager { return; } const records = await registry.list(); + const parent = records.find((record) => record.id === parentAgentId); + if (!parent) { + throw new Error(`Archived parent ${parentAgentId} not found in storage`); + } for (const record of records) { if (record.archivedAt) { continue; @@ -1534,11 +1566,27 @@ export class AgentManager { if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) { continue; } - if (this.agents.has(record.id)) { - await this.archiveAgent(record.id); - } else { - await this.archiveSnapshot(record.id, new Date().toISOString()); + const child = await registry.get(record.id); + if (!child || child.archivedAt || child.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) { + continue; } + await this.runLifecycleMutation(child.id, async () => { + const currentChild = await registry.get(child.id); + if ( + !currentChild || + currentChild.archivedAt || + currentChild.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId + ) { + return; + } + if (shouldDetachFromArchivedParent(parent, currentChild)) { + await this.detachAgentUnlocked(currentChild.id); + } else if (this.agents.has(currentChild.id)) { + await this.archiveAgentUnlocked(currentChild.id); + } else { + await this.archiveSnapshot(currentChild.id, new Date().toISOString()); + } + }); } } @@ -1711,8 +1759,10 @@ export class AgentManager { } async setLabels(agentId: string, labels: Record<string, string>): Promise<void> { - const agent = this.requireAgent(agentId); - await this.writeLabels(agent.id, labels); + await this.runLifecycleMutation(agentId, async () => { + const agent = this.requireAgent(agentId); + await this.writeLabels(agent.id, labels); + }); } private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> { @@ -1754,6 +1804,14 @@ export class AgentManager { record: StoredAgentRecord; live: boolean; previousParentAgentId: string | null; + }> { + return this.runLifecycleMutation(agentId, () => this.detachAgentUnlocked(agentId)); + } + + private async detachAgentUnlocked(agentId: string): Promise<{ + record: StoredAgentRecord; + live: boolean; + previousParentAgentId: string | null; }> { const registry = this.requireRegistry(); const liveAgent = this.agents.get(agentId); @@ -1768,7 +1826,7 @@ export class AgentManager { return { record, live: true, previousParentAgentId: null }; } - const { record } = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null }); + const { record } = await this.writeLabels(agentId, detachedAgentLabelPatch(liveAgent.labels)); if (!record) { throw new Error(`Agent not found in storage after detach: ${agentId}`); } @@ -1784,7 +1842,7 @@ export class AgentManager { return { record, live: false, previousParentAgentId: null }; } - const result = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null }); + const result = await this.writeLabels(agentId, detachedAgentLabelPatch(record.labels)); if (!result.record) { throw new Error(`Agent not found in storage after detach: ${agentId}`); } @@ -1890,6 +1948,18 @@ export class AgentManager { title?: string; labels?: Record<string, string>; }, + ): Promise<void> { + await this.runLifecycleMutation(agentId, () => + this.updateAgentMetadataUnlocked(agentId, updates), + ); + } + + private async updateAgentMetadataUnlocked( + agentId: string, + updates: { + title?: string; + labels?: Record<string, string>; + }, ): Promise<void> { const liveAgent = this.getAgent(agentId); if (liveAgent) { @@ -1905,6 +1975,24 @@ export class AgentManager { await this.writeStoredMetadata(agentId, updates); } + private async runLifecycleMutation<T>(agentId: string, mutation: () => Promise<T>): Promise<T> { + // Parent cascade classifies a child inside the same lane used by open-tab + // label writes, so a received ownership update cannot be overtaken. + const previous = this.lifecycleMutationTails.get(agentId) ?? Promise.resolve(); + const result = previous.catch(() => undefined).then(mutation); + const tail = result.then( + () => undefined, + () => undefined, + ); + this.lifecycleMutationTails.set(agentId, tail); + void tail.finally(() => { + if (this.lifecycleMutationTails.get(agentId) === tail) { + this.lifecycleMutationTails.delete(agentId); + } + }); + return result; + } + async runAgent( agentId: string, prompt: AgentPromptInput, From 1b887e534b9de2317da704f64ec7e8e43194c2ae Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 23:07:14 +0200 Subject: [PATCH 46/73] fix(app): invalidate incompatible replica cache Todo timeline entries gained required activity metadata in beta.1 while the persisted cache format remained unchanged. Reject version 3 snapshots so upgrades hydrate authoritative state instead of rendering legacy rows. --- packages/app/src/runtime/replica-cache/index.test.ts | 8 ++++---- packages/app/src/runtime/replica-cache/index.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index eee7f7aff7..8149032f38 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -272,7 +272,7 @@ describe("ReplicaCache", () => { version: number; hosts: Array<{ timeline: Record<string, unknown> | null }>; }; - expect(persisted.version).toBe(3); + expect(persisted.version).toBe(4); expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]); }); @@ -331,12 +331,12 @@ describe("ReplicaCache", () => { expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]); }); - it("rejects version 1 cache data and overwrites it on flush", async () => { + it("rejects version 3 cache data and overwrites it on flush", async () => { const storage = new MemoryStorage(); storage.values.set( "@paseo:replica-cache", JSON.stringify({ - version: 1, + version: 3, hosts: [ { serverId: SERVER_ID, @@ -361,7 +361,7 @@ describe("ReplicaCache", () => { expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined(); expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({ - version: 3, + version: 4, hosts: [], }); }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index c2649c69c6..c773327bb6 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -23,7 +23,7 @@ import { isUnreconciledLocalUserMessage, type StreamItem } from "@/types/stream" import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; const STORAGE_KEY = "@paseo:replica-cache"; -const CACHE_VERSION = 3; +const CACHE_VERSION = 4; const PERSIST_DELAY_MS = 750; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 1024 * 1024; From ed0d4e6c5b3f21dc76f01d5cf14df8070bb5bcef Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 23:14:48 +0200 Subject: [PATCH 47/73] docs(changelog): add 0.4.0-beta.2 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83292efd3e..2bbb664d6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.4.0-beta.2 - 2026-08-12 + +### Fixed + +- Fix old chat caches making the app refuse to start +- Open delegated agents remain available when their parent is archived ([#3279](https://github.com/getpaseo/paseo/pull/3279)) + ## 0.4.0-beta.1 - 2026-08-12 ### Breaking From 44b9980a707647bb15d7a969cf682f3e27279db2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Wed, 12 Aug 2026 23:18:28 +0200 Subject: [PATCH 48/73] chore(release): cut 0.4.0-beta.2 --- package-lock.json | 42 +++++++++---------- package.json | 2 +- packages/app/package.json | 2 +- .../src/runtime/replica-cache/index.test.ts | 36 ++++++++++++++++ packages/cli/package.json | 8 ++-- packages/client/package.json | 6 +-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 10 ++--- packages/website/package.json | 2 +- 13 files changed, 77 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3186ef0e3a..39716fc5a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -36091,7 +36091,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37139,12 +37139,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.4.0-beta.1", - "@getpaseo/protocol": "0.4.0-beta.1", - "@getpaseo/server": "0.4.0-beta.1", + "@getpaseo/client": "0.4.0-beta.2", + "@getpaseo/protocol": "0.4.0-beta.2", + "@getpaseo/server": "0.4.0-beta.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -37391,10 +37391,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "dependencies": { - "@getpaseo/protocol": "0.4.0-beta.1", - "@getpaseo/relay": "0.4.0-beta.1", + "@getpaseo/protocol": "0.4.0-beta.2", + "@getpaseo/relay": "0.4.0-beta.2", "zod": "^4.4.3" }, "devDependencies": { @@ -37405,7 +37405,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -37648,7 +37648,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -38544,7 +38544,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -38573,7 +38573,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "dependencies": { "zod": "^4.4.3" }, @@ -38586,7 +38586,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38804,15 +38804,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.4.0-beta.1", - "@getpaseo/highlight": "0.4.0-beta.1", - "@getpaseo/protocol": "0.4.0-beta.1", - "@getpaseo/relay": "0.4.0-beta.1", + "@getpaseo/client": "0.4.0-beta.2", + "@getpaseo/highlight": "0.4.0-beta.2", + "@getpaseo/protocol": "0.4.0-beta.2", + "@getpaseo/relay": "0.4.0-beta.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -39360,7 +39360,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index 50b1fbb075..a503d49c92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index 5aa48ecdd3..33aeec069a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 8149032f38..f97b63ef72 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -25,6 +25,10 @@ class MemoryStorage implements ReplicaCacheStorage { this.writes += 1; this.values.set(key, value); } + + async removeItem(key: string): Promise<void> { + this.values.delete(key); + } } function workspace( @@ -226,6 +230,38 @@ describe("ReplicaCache", () => { }); }); + it("clears a cache containing a timeline row that fails its schema", async () => { + const storage = new MemoryStorage(); + const writer = new ReplicaCache(storage); + writer.setHosts([SERVER_ID]); + seedSession(); + await writer.flush(); + + const payload = JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null") as { + hosts: Array<{ timeline: { items: unknown[] } | null }>; + }; + const timeline = payload.hosts[0]?.timeline; + if (!timeline) throw new Error("Expected cached timeline"); + timeline.items = [ + { + kind: "todo_list", + id: "legacy-todo", + timestamp: { __paseoDate: "2026-08-11T08:02:00.000Z" }, + provider: "codex", + items: [{ text: "Ship it", completed: false }], + }, + ]; + storage.values.set("@paseo:replica-cache", JSON.stringify(payload)); + useSessionStore.getState().clearSession(SERVER_ID); + + const reader = new ReplicaCache(storage); + reader.setHosts([SERVER_ID]); + await reader.restore(); + + expect(storage.values.has("@paseo:replica-cache")).toBe(false); + expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined(); + }); + it("persists only the focused agent view with a short timeline tail", async () => { const storage = new MemoryStorage(); const cache = new ReplicaCache(storage); diff --git a/packages/cli/package.json b/packages/cli/package.json index c299446305..3277a62533 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.4.0-beta.1", - "@getpaseo/protocol": "0.4.0-beta.1", - "@getpaseo/server": "0.4.0-beta.1", + "@getpaseo/client": "0.4.0-beta.2", + "@getpaseo/protocol": "0.4.0-beta.2", + "@getpaseo/server": "0.4.0-beta.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index e483b7be22..c63f1f08e4 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.4.0-beta.1", - "@getpaseo/relay": "0.4.0-beta.1", + "@getpaseo/protocol": "0.4.0-beta.2", + "@getpaseo/relay": "0.4.0-beta.2", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9a1c59d1b1..0bb283d300 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index f4e15ece1d..f3d6aed8e2 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 2e2ad2652e..81982b00c2 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 85b177e22c..865ff1e6ec 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index fded016453..7f8822a6f0 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 2cc571a273..c3881672d6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "description": "Paseo backend server", "files": [ "dist/server", @@ -71,10 +71,10 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.4.0-beta.1", - "@getpaseo/highlight": "0.4.0-beta.1", - "@getpaseo/protocol": "0.4.0-beta.1", - "@getpaseo/relay": "0.4.0-beta.1", + "@getpaseo/client": "0.4.0-beta.2", + "@getpaseo/highlight": "0.4.0-beta.2", + "@getpaseo/protocol": "0.4.0-beta.2", + "@getpaseo/relay": "0.4.0-beta.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 980ac8f115..63e60738e2 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.4.0-beta.1", + "version": "0.4.0-beta.2", "private": true, "type": "module", "scripts": { From 781e07f4ea72f1dcb125c4c25cc7e53bac09b366 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:27:32 +0000 Subject: [PATCH 49/73] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index fc705b82ae..a6ac0caf10 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-yj2kO4tZiyGD29aEhsYHtc9JMikWr6khlOCGZKFIM6U= +sha256-zO0mNouwY+thgefl2zB/Lk8pIZGh0QowC8klXll2qOc= From 9a7301d9a0963ea753bf0ce20b0ee61e0341d710 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 00:15:24 +0200 Subject: [PATCH 50/73] test(app): remove unintended replica cache case --- .../src/runtime/replica-cache/index.test.ts | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index f97b63ef72..8149032f38 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -25,10 +25,6 @@ class MemoryStorage implements ReplicaCacheStorage { this.writes += 1; this.values.set(key, value); } - - async removeItem(key: string): Promise<void> { - this.values.delete(key); - } } function workspace( @@ -230,38 +226,6 @@ describe("ReplicaCache", () => { }); }); - it("clears a cache containing a timeline row that fails its schema", async () => { - const storage = new MemoryStorage(); - const writer = new ReplicaCache(storage); - writer.setHosts([SERVER_ID]); - seedSession(); - await writer.flush(); - - const payload = JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null") as { - hosts: Array<{ timeline: { items: unknown[] } | null }>; - }; - const timeline = payload.hosts[0]?.timeline; - if (!timeline) throw new Error("Expected cached timeline"); - timeline.items = [ - { - kind: "todo_list", - id: "legacy-todo", - timestamp: { __paseoDate: "2026-08-11T08:02:00.000Z" }, - provider: "codex", - items: [{ text: "Ship it", completed: false }], - }, - ]; - storage.values.set("@paseo:replica-cache", JSON.stringify(payload)); - useSessionStore.getState().clearSession(SERVER_ID); - - const reader = new ReplicaCache(storage); - reader.setHosts([SERVER_ID]); - await reader.restore(); - - expect(storage.values.has("@paseo:replica-cache")).toBe(false); - expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined(); - }); - it("persists only the focused agent view with a short timeline tail", async () => { const storage = new MemoryStorage(); const cache = new ReplicaCache(storage); From c190fed27cb62f2f422f462e8c157abd1da870e7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 12:02:15 +0200 Subject: [PATCH 51/73] Keep new worktrees clean when setup changes are uncommitted (#3311) * fix(worktrees): preserve selected base config New worktrees were overwriting the selected ref's paseo.json with source-checkout bytes, which could create an accidental revert in an otherwise fresh workspace. Keep committed base config authoritative and warn when locally saved setup changes still need to be committed. * fix(worktrees): seed config exclusively * fix(projects): refresh config status on focus --- docs/development.md | 4 +- .../app/e2e/browser/projects-settings.spec.ts | 12 +++ .../e2e/support/helpers/project-settings.ts | 19 ++++ packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/ko.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 2 + .../src/screens/project-settings-screen.tsx | 27 ++++++ packages/protocol/src/messages.ts | 2 + .../tests/validation/ws-outbound.test.ts | 28 ++++++ .../src/server/paseo-worktree-service.test.ts | 33 +++++++ .../src/server/paseo-worktree-service.ts | 4 +- .../project-config-session.test.ts | 64 ++++++++++++ .../project-config/project-config-session.ts | 22 +++++ .../worktree-setup-commit-status.test.ts | 97 +++++++++++++++++++ .../worktree-setup-commit-status.ts | 54 +++++++++++ .../server/src/utils/worktree.posix.test.ts | 96 +++++++++++------- packages/server/src/utils/worktree.ts | 28 +++--- 23 files changed, 460 insertions(+), 55 deletions(-) create mode 100644 packages/server/src/server/session/project-config/worktree-setup-commit-status.test.ts create mode 100644 packages/server/src/server/session/project-config/worktree-setup-commit-status.ts diff --git a/docs/development.md b/docs/development.md index 244db268ee..b763e83c82 100644 --- a/docs/development.md +++ b/docs/development.md @@ -297,9 +297,7 @@ another remote fails closed until the worktree records an explicit local target. the optional field and retain the previous local-first behavior; older worktree metadata without the exact ref also resolves through its stored branch name. -Worktrees inherit committed Git state. Before lifecycle setup, Paseo copies the source checkout's -`paseo.json` over the worktree copy so saved Project Settings apply without a commit. Other -uncommitted source-checkout changes are not copied. +Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied. ## paseo.json service scripts diff --git a/packages/app/e2e/browser/projects-settings.spec.ts b/packages/app/e2e/browser/projects-settings.spec.ts index a6a68a3ba7..52954a3290 100644 --- a/packages/app/e2e/browser/projects-settings.spec.ts +++ b/packages/app/e2e/browser/projects-settings.spec.ts @@ -9,12 +9,14 @@ import { clickReloadProjectSettings, clickRetryProjectSettingsSave, clickSaveProjectSettings, + commitPaseoConfig, corruptPaseoConfig, editWorktreeSetup, expectEmptyScriptList, expectProjectHostContextHidden, expectNoEditableTarget, expectNoProjectSettingsError, + expectNoUncommittedSetupWarning, expectProjectEditFailed, expectProjectEditName, expectProjectEditSaved, @@ -27,6 +29,7 @@ import { expectSaveButtonDisabled, expectScriptRowCount, expectWriteFailedCalloutActions, + expectUncommittedSetupWarning, fillProjectIconUrl, fillProjectName, installDaemonConnectionGate, @@ -216,9 +219,16 @@ test.describe("Projects settings", () => { test("user edits worktree setup from the projects page", async ({ page, editableProject }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); + await expectNoUncommittedSetupWarning(page); await editWorktreeSetup(page, updatedSetup); await clickSaveProjectSettings(page); await expectProjectConfigSaved(editableProject); + await expectUncommittedSetupWarning(page); + + commitPaseoConfig(editableProject.path); + await returnToProjectsList(page); + await openProjectSettings(page, editableProject.name); + await expectNoUncommittedSetupWarning(page); }); test("project navigation stays inside the selected host", async ({ page, editableProject }) => { @@ -439,5 +449,7 @@ test.describe("Projects settings — error UX", () => { await expectScriptRowCount(page, 0); await expectEmptyScriptList(page); + await clickSaveProjectSettings(page); + await expectNoUncommittedSetupWarning(page); }); }); diff --git a/packages/app/e2e/support/helpers/project-settings.ts b/packages/app/e2e/support/helpers/project-settings.ts index 703a0fa83b..8b3b23faaf 100644 --- a/packages/app/e2e/support/helpers/project-settings.ts +++ b/packages/app/e2e/support/helpers/project-settings.ts @@ -1,4 +1,5 @@ import { chmod, readFile, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; import path from "node:path"; import { expect, type Page } from "@playwright/test"; import type { WebSocketRoute } from "@playwright/test"; @@ -177,6 +178,19 @@ export async function expectSaveButtonDisabled(page: Page): Promise<void> { await expect(page.getByTestId("save-button")).toBeDisabled(); } +export async function expectUncommittedSetupWarning(page: Page): Promise<void> { + const warning = page.getByRole("alert").filter({ hasText: "Commit paseo.json changes" }); + await expect(warning).toContainText("Commit paseo.json changes"); + await expect(warning).toContainText( + "New worktrees use the setup script from the base branch you select.", + ); +} + +export async function expectNoUncommittedSetupWarning(page: Page): Promise<void> { + const warning = page.getByRole("alert").filter({ hasText: "Commit paseo.json changes" }); + await expect(warning).toHaveCount(0); +} + // --- Form-state assertions --- export async function expectProjectSettingsFormVisible(page: Page): Promise<void> { @@ -249,6 +263,11 @@ export async function restorePaseoConfig( await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n"); } +export function commitPaseoConfig(repoPath: string): void { + execFileSync("git", ["add", "paseo.json"], { cwd: repoPath }); + execFileSync("git", ["commit", "-m", "Update project config"], { cwd: repoPath }); +} + // The daemon writes atomically via a temp file + rename, so blocking writes requires // removing write permission from the *directory*, not just the file. export async function blockPaseoConfigWrites(repoPath: string): Promise<void> { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b42e61b666..f72bcb1a36 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2405,6 +2405,9 @@ export const ar: TranslationResources = { docsTooltip: "راجع المستندات لمزيد من التفاصيل ومتغيرات البيئة المتاحة لهذه الأوامر", setup: "يثبت", setupAccessibility: "أوامر إعداد شجرة العمل", + uncommittedTitle: "ثبّت تغييرات paseo.json", + uncommittedDescription: + "تستخدم أشجار العمل الجديدة نص الإعداد البرمجي من الفرع الأساسي الذي تحدده.", teardown: "هدم", teardownAccessibility: "أوامر هدم شجرة العمل", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index af43bec2eb..83ff3c8b36 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2419,6 +2419,9 @@ export const en = { "See docs for more details and the environment variables available to these commands", setup: "Setup", setupAccessibility: "Worktree setup commands", + uncommittedTitle: "Commit paseo.json changes", + uncommittedDescription: + "New worktrees use the setup script from the base branch you select.", teardown: "Teardown", teardownAccessibility: "Worktree teardown commands", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 60e14d81e6..4d38d6e705 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2460,6 +2460,9 @@ export const es: TranslationResources = { "Consulte los documentos para obtener más detalles y las variables de entorno disponibles para estos comandos.", setup: "Configuración", setupAccessibility: "Comandos de configuración del árbol de trabajo", + uncommittedTitle: "Confirma los cambios de paseo.json", + uncommittedDescription: + "Los árboles de trabajo nuevos usan el script de configuración de la rama base que selecciones.", teardown: "Demoler", teardownAccessibility: "Comandos de desmontaje del árbol de trabajo", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 471de64823..44c61c65e3 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2466,6 +2466,9 @@ export const fr: TranslationResources = { "Voir la documentation pour plus de détails et les variables d'environnement disponibles pour ces commandes", setup: "Installation", setupAccessibility: "Commandes de configuration de Worktree", + uncommittedTitle: "Validez les modifications de paseo.json", + uncommittedDescription: + "Les nouveaux worktrees utilisent le script de configuration de la branche de base sélectionnée.", teardown: "Démolir", teardownAccessibility: "Commandes de démontage de Worktree", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 57331e14a5..6d2eaace67 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2430,6 +2430,9 @@ export const ja: TranslationResources = { "これらのコマンドで使用可能な詳細と環境変数についてはドキュメントを参照してください", setup: "セットアップ", setupAccessibility: "ワークツリーセットアップコマンド", + uncommittedTitle: "paseo.json の変更をコミットしてください", + uncommittedDescription: + "新しいワークツリーでは、選択したベースブランチのセットアップスクリプトが使われます。", teardown: "削除時", teardownAccessibility: "ワークツリー削除時のコマンド", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 6be87e7b94..f7e76a6c50 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2422,6 +2422,8 @@ export const ko: TranslationResources = { docsTooltip: "자세한 내용과 이 명령에 사용할 수 있는 환경 변수는 문서를 참조하세요", setup: "설정", setupAccessibility: "워크트리 설정 명령", + uncommittedTitle: "paseo.json 변경 사항을 커밋하세요", + uncommittedDescription: "새 워크트리는 선택한 기본 브랜치의 설정 스크립트를 사용합니다.", teardown: "정리", teardownAccessibility: "워크트리 정리 명령", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 7c05f5e00b..5006b22519 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2445,6 +2445,9 @@ export const ptBR: TranslationResources = { "Veja a documentação para mais detalhes e as variáveis de ambiente disponíveis para estes comandos", setup: "Configuração", setupAccessibility: "Comandos de configuração do worktree", + uncommittedTitle: "Faça commit das alterações no paseo.json", + uncommittedDescription: + "Novos worktrees usam o script de configuração do branch base selecionado.", teardown: "Desmontagem", teardownAccessibility: "Comandos de desmontagem do worktree", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index e68c7e4e78..0bd5ccbbe5 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2448,6 +2448,9 @@ export const ru: TranslationResources = { "Дополнительную информацию и переменные среды, доступные для этих команд, см. в документации.", setup: "Настраивать", setupAccessibility: "Команды настройки рабочего дерева", + uncommittedTitle: "Закоммитьте изменения paseo.json", + uncommittedDescription: + "Новые рабочие деревья используют сценарий настройки из выбранной базовой ветки.", teardown: "Срывать", teardownAccessibility: "Команды разрушения рабочего дерева", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 9c70d7b701..fffc9dc6a6 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2377,6 +2377,8 @@ export const zhCN: TranslationResources = { docsTooltip: "查看命令可用的环境变量和更多细节", setup: "Setup", setupAccessibility: "Worktree setup 命令", + uncommittedTitle: "提交 paseo.json 更改", + uncommittedDescription: "新工作树使用所选基础分支中的设置脚本。", teardown: "Teardown", teardownAccessibility: "Worktree teardown 命令", }, diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index fd9ff32403..5d9c4fba21 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -3,6 +3,7 @@ import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Pressable, Text, TextInput, View } from "react-native"; import { router } from "expo-router"; +import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet } from "react-native-unistyles"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, MoreVertical, Pencil, Plus } from "lucide-react-native"; @@ -200,6 +201,12 @@ function ProjectSettingsBody({ queryFn: () => client.readProjectConfig(selectedHost.repoRoot), retry: false, }); + const refetchProjectConfig = readQuery.refetch; + useFocusEffect( + useCallback(() => { + void refetchProjectConfig(); + }, [refetchProjectConfig]), + ); const data = readQuery.data; const supportsCustomIcon = useHostFeature(selectedHost.serverId, "projectCustomIcon"); @@ -240,6 +247,8 @@ function ProjectSettingsBody({ ); const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null; const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null; + const hasUncommittedWorktreeSetupChanges = + data?.ok === true && data.hasUncommittedWorktreeSetupChanges === true; const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null; const handleReload = useCallback(() => { @@ -290,6 +299,7 @@ function ProjectSettingsBody({ readQuery, loadedConfig, loadedRevision, + hasUncommittedWorktreeSetupChanges, readError, selectedHost, queryKey, @@ -305,6 +315,7 @@ interface RenderContentInput { readQuery: ReturnType<typeof useQuery<ReadProjectConfigData>>; loadedConfig: PaseoConfigRaw | null; loadedRevision: PaseoConfigRevision | null; + hasUncommittedWorktreeSetupChanges: boolean; readError: ProjectConfigRpcError | null; selectedHost: ProjectHostEntry; queryKey: readonly [string, string, string]; @@ -317,6 +328,7 @@ function renderContent({ readQuery, loadedConfig, loadedRevision, + hasUncommittedWorktreeSetupChanges, readError, selectedHost, queryKey, @@ -358,6 +370,7 @@ function renderContent({ key={formKey} baseConfig={loadedConfig} revision={loadedRevision} + hasUncommittedWorktreeSetupChanges={hasUncommittedWorktreeSetupChanges} repoRoot={selectedHost.repoRoot} queryKey={queryKey} client={client} @@ -438,6 +451,7 @@ function errorToDetail(error: unknown): string | null { interface ProjectConfigFormProps { baseConfig: PaseoConfigRaw; revision: PaseoConfigRevision | null; + hasUncommittedWorktreeSetupChanges: boolean; repoRoot: string; queryKey: readonly [string, string, string]; client: DaemonClient; @@ -447,6 +461,7 @@ interface ProjectConfigFormProps { function ProjectConfigForm({ baseConfig, revision, + hasUncommittedWorktreeSetupChanges, repoRoot, queryKey, client, @@ -479,6 +494,11 @@ function ProjectConfigForm({ revision: result.revision, requestId: "local-cache", repoRoot, + ...(result.hasUncommittedWorktreeSetupChanges === undefined + ? {} + : { + hasUncommittedWorktreeSetupChanges: result.hasUncommittedWorktreeSetupChanges, + }), }); setWriteError(null); queryClient.invalidateQueries({ queryKey: ["projects"] }); @@ -660,6 +680,13 @@ function ProjectConfigForm({ testID="worktree-setup-section" trailing={setupDocsLink} > + {hasUncommittedWorktreeSetupChanges ? ( + <Alert + variant="warning" + title={t("settings.project.worktree.uncommittedTitle")} + description={t("settings.project.worktree.uncommittedDescription")} + /> + ) : null} <SettingsTextAreaCard testID="worktree-setup-input" accessibilityLabel={t("settings.project.worktree.setupAccessibility")} diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 7ec0a93ff9..1243b9adcd 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -4219,6 +4219,7 @@ export const ReadProjectConfigResponseMessageSchema = z.object({ ok: z.literal(true), config: PaseoConfigRawSchema.nullable(), revision: PaseoConfigRevisionSchema.nullable(), + hasUncommittedWorktreeSetupChanges: z.boolean().optional(), }), z.object({ requestId: z.string(), @@ -4240,6 +4241,7 @@ export const WriteProjectConfigResponseMessageSchema = z.object({ ok: z.literal(true), config: PaseoConfigRawSchema, revision: PaseoConfigRevisionSchema, + hasUncommittedWorktreeSetupChanges: z.boolean().optional(), }), z.object({ requestId: z.string(), diff --git a/packages/protocol/tests/validation/ws-outbound.test.ts b/packages/protocol/tests/validation/ws-outbound.test.ts index 3aede5cb2b..e2e3e3170a 100644 --- a/packages/protocol/tests/validation/ws-outbound.test.ts +++ b/packages/protocol/tests/validation/ws-outbound.test.ts @@ -138,6 +138,34 @@ const SourceSchema = z.object({ ); }); + it("accepts project config responses with and without setup commit status", () => { + const payload = { + requestId: "project-config-read", + repoRoot: "/repo", + ok: true, + config: null, + revision: null, + }; + const envelope = ( + responsePayload: typeof payload & { + hasUncommittedWorktreeSetupChanges?: boolean; + }, + ) => ({ + type: "session", + message: { + type: "read_project_config_response", + payload: responsePayload, + }, + }); + + expect(GeneratedWSOutboundMessageSchema.safeParse(envelope(payload)).success).toBe(true); + expect( + GeneratedWSOutboundMessageSchema.safeParse( + envelope({ ...payload, hasUncommittedWorktreeSetupChanges: true }), + ).success, + ).toBe(true); + }); + it("accepts a compact provider snapshot envelope", () => { const envelope = { type: "session", diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 614820dd4f..c40f710d38 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -259,6 +259,39 @@ test("seeds an uncommitted exact-project config into the mapped worktree directo expect(existsSync(path.join(result.worktree.worktreePath, "paseo.json"))).toBe(false); }); +test("does not overwrite a committed exact-project config with source checkout edits", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const sourceDir = path.join(repoDir, "packages", "app"); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(path.join(sourceDir, "package.json"), "{}\n"); + const committedConfig = JSON.stringify({ worktree: { setup: ["npm ci"] } }); + writeFileSync(path.join(sourceDir, "paseo.json"), committedConfig); + commitAll(repoDir, "add subproject config"); + writeFileSync( + path.join(sourceDir, "paseo.json"), + JSON.stringify({ worktree: { setup: ["npm install"] } }), + ); + + const result = await createPaseoWorktree( + { + cwd: sourceDir, + worktreeSlug: "preserve-nested-config", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + createDeps(), + ); + + expect(readFileSync(path.join(result.workspace.cwd, "paseo.json"), "utf8")).toBe(committedConfig); + expect( + execFileSync("git", ["status", "--porcelain"], { + cwd: result.worktree.worktreePath, + encoding: "utf8", + }), + ).toBe(""); +}); + test("removes a new worktree when its ref does not contain the selected project directory", async () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 7e72275603..b32123cb37 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -13,7 +13,7 @@ import { import { mapWorkspaceRelativeCwdToWorktree, rollbackCreatedPaseoWorktree, - copySourcePaseoConfigFile, + seedPaseoConfigFile, validateBranchSlug, type WorktreeConfig, } from "../utils/worktree.js"; @@ -85,7 +85,7 @@ async function createPaseoWorktreeWithPriority( } if (createdWorktree.created) { - await copySourcePaseoConfigFile({ + await seedPaseoConfigFile({ sourceCwd: workspaceCwdPlan.inputCwd, targetCwd: workspaceCwd, }); diff --git a/packages/server/src/server/session/project-config/project-config-session.test.ts b/packages/server/src/server/session/project-config/project-config-session.test.ts index 0faa95404a..6e93dff805 100644 --- a/packages/server/src/server/session/project-config/project-config-session.test.ts +++ b/packages/server/src/server/session/project-config/project-config-session.test.ts @@ -1,9 +1,11 @@ import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { execFileSync } from "node:child_process"; import { afterEach, describe, expect, test } from "vitest"; import pino from "pino"; import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js"; +import { statPaseoConfigPath } from "../../../utils/paseo-config-file.js"; import type { PersistedProjectRecord } from "../../workspace-registry.js"; import type { SessionOutboundMessage } from "../../messages.js"; @@ -45,6 +47,38 @@ function makeSubsystem(records: PersistedProjectRecord[]) { } describe("ProjectConfigSession", () => { + test("read reports when the saved worktree setup differs from HEAD", async () => { + const repoRoot = makeRoot(); + execFileSync("git", ["init", "-b", "main"], { cwd: repoRoot }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoRoot }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot }); + writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } })); + execFileSync("git", ["add", "paseo.json"], { cwd: repoRoot }); + execFileSync("git", ["commit", "-m", "add config"], { cwd: repoRoot }); + writeFileSync( + join(repoRoot, "paseo.json"), + JSON.stringify({ worktree: { setup: "npm install" } }), + ); + const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]); + + await subsystem.handleReadProjectConfigRequest({ + type: "read_project_config_request", + requestId: "read-uncommitted-setup", + repoRoot, + }); + + expect(emitted).toEqual([ + { + type: "read_project_config_response", + payload: expect.objectContaining({ + requestId: "read-uncommitted-setup", + ok: true, + hasUncommittedWorktreeSetupChanges: true, + }), + }, + ]); + }); + test("read resolves a known root despite a trailing slash and returns the raw config + revision", async () => { const repoRoot = makeRoot(); writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } })); @@ -179,6 +213,36 @@ describe("ProjectConfigSession", () => { ]); }); + test("write recomputes the worktree setup commit status", async () => { + const repoRoot = makeRoot(); + execFileSync("git", ["init", "-b", "main"], { cwd: repoRoot }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoRoot }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot }); + writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } })); + execFileSync("git", ["add", "paseo.json"], { cwd: repoRoot }); + execFileSync("git", ["commit", "-m", "add config"], { cwd: repoRoot }); + const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]); + + await subsystem.handleWriteProjectConfigRequest({ + type: "write_project_config_request", + requestId: "write-uncommitted-setup", + repoRoot, + config: { worktree: { setup: "npm install" } }, + expectedRevision: statPaseoConfigPath(repoRoot), + }); + + expect(emitted).toEqual([ + { + type: "write_project_config_response", + payload: expect.objectContaining({ + requestId: "write-uncommitted-setup", + ok: true, + hasUncommittedWorktreeSetupChanges: true, + }), + }, + ]); + }); + test("write rejects a stale revision and an unknown root with their inline domain failures", async () => { const staleRoot = makeRoot(); writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); diff --git a/packages/server/src/server/session/project-config/project-config-session.ts b/packages/server/src/server/session/project-config/project-config-session.ts index ae62bd3d2b..6056d08826 100644 --- a/packages/server/src/server/session/project-config/project-config-session.ts +++ b/packages/server/src/server/session/project-config/project-config-session.ts @@ -8,6 +8,7 @@ import { writePaseoConfigForEdit, type ProjectConfigRpcError, } from "../../../utils/paseo-config-file.js"; +import { hasUncommittedWorktreeSetupChanges } from "./worktree-setup-commit-status.js"; export interface ProjectConfigSessionHost { emit(msg: SessionOutboundMessage): void; @@ -63,6 +64,8 @@ export class ProjectConfigSession { ); } + const setupCommitStatus = await this.readSetupCommitStatus(repoRoot, result.config); + this.host.emit({ type: "read_project_config_response", payload: { @@ -71,6 +74,9 @@ export class ProjectConfigSession { ok: true, config: result.config, revision: result.revision, + ...(setupCommitStatus === null + ? {} + : { hasUncommittedWorktreeSetupChanges: setupCommitStatus }), }, }); } @@ -106,6 +112,7 @@ export class ProjectConfigSession { { repoRoot, requestId: msg.requestId, outcome: "written" }, "Project config written", ); + const setupCommitStatus = await this.readSetupCommitStatus(repoRoot, result.config); this.host.emit({ type: "write_project_config_response", payload: { @@ -114,10 +121,25 @@ export class ProjectConfigSession { ok: true, config: result.config, revision: result.revision, + ...(setupCommitStatus === null + ? {} + : { hasUncommittedWorktreeSetupChanges: setupCommitStatus }), }, }); } + private async readSetupCommitStatus( + repoRoot: string, + config: Parameters<typeof hasUncommittedWorktreeSetupChanges>[0]["currentConfig"], + ): Promise<boolean | null> { + try { + return await hasUncommittedWorktreeSetupChanges({ repoRoot, currentConfig: config }); + } catch (error) { + this.logger.debug({ repoRoot, error }, "Could not inspect committed worktree setup"); + return null; + } + } + private emitProjectConfigReadFailure( msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>, error: ProjectConfigRpcError, diff --git a/packages/server/src/server/session/project-config/worktree-setup-commit-status.test.ts b/packages/server/src/server/session/project-config/worktree-setup-commit-status.test.ts new file mode 100644 index 0000000000..73c54014af --- /dev/null +++ b/packages/server/src/server/session/project-config/worktree-setup-commit-status.test.ts @@ -0,0 +1,97 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import type { PaseoConfigRaw } from "@getpaseo/protocol/paseo-config-schema"; +import { hasUncommittedWorktreeSetupChanges } from "./worktree-setup-commit-status.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeGitRepo(config?: PaseoConfigRaw): string { + const repoRoot = realpathSync(mkdtempSync(join(tmpdir(), "worktree-setup-status-test-"))); + tempDirs.push(repoRoot); + execFileSync("git", ["init", "-b", "main"], { cwd: repoRoot }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: repoRoot }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot }); + writeFileSync(join(repoRoot, "README.md"), "test\n"); + if (config) writeConfig(repoRoot, config); + execFileSync("git", ["add", "."], { cwd: repoRoot }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: repoRoot }); + return repoRoot; +} + +function writeConfig(repoRoot: string, config: PaseoConfigRaw): void { + writeFileSync(join(repoRoot, "paseo.json"), `${JSON.stringify(config, null, 2)}\n`); +} + +describe("worktree setup commit status", () => { + test("reports matching committed setup as clean", async () => { + const repoRoot = makeGitRepo({ worktree: { setup: "npm ci" } }); + await expect( + hasUncommittedWorktreeSetupChanges({ + repoRoot, + currentConfig: { worktree: { setup: "npm ci" } }, + }), + ).resolves.toBe(false); + }); + + test("reports modified and staged setup as uncommitted", async () => { + const repoRoot = makeGitRepo({ worktree: { setup: "npm ci" } }); + const currentConfig = { worktree: { setup: "npm install" } }; + writeConfig(repoRoot, currentConfig); + await expect(hasUncommittedWorktreeSetupChanges({ repoRoot, currentConfig })).resolves.toBe( + true, + ); + + execFileSync("git", ["add", "paseo.json"], { cwd: repoRoot }); + await expect(hasUncommittedWorktreeSetupChanges({ repoRoot, currentConfig })).resolves.toBe( + true, + ); + }); + + test("reports an untracked config with setup as uncommitted", async () => { + const repoRoot = makeGitRepo(); + const currentConfig = { worktree: { setup: "npm ci" } }; + writeConfig(repoRoot, currentConfig); + await expect(hasUncommittedWorktreeSetupChanges({ repoRoot, currentConfig })).resolves.toBe( + true, + ); + }); + + test("ignores representation-only and unrelated config changes", async () => { + const repoRoot = makeGitRepo({ + worktree: { setup: "npm ci", teardown: "echo old" }, + scripts: { dev: { command: "npm run dev" } }, + }); + const currentConfig = { + worktree: { setup: ["npm ci"], teardown: "echo new" }, + scripts: { dev: { command: "npm run start" } }, + metadataGeneration: { branchName: { instructions: "Use fix/" } }, + }; + writeConfig(repoRoot, currentConfig); + await expect(hasUncommittedWorktreeSetupChanges({ repoRoot, currentConfig })).resolves.toBe( + false, + ); + }); + + test("resolves paseo.json relative to a nested project root", async () => { + const repoRoot = makeGitRepo(); + const projectRoot = join(repoRoot, "packages", "app"); + mkdirSync(projectRoot, { recursive: true }); + writeConfig(projectRoot, { worktree: { setup: "npm ci" } }); + execFileSync("git", ["add", "packages/app/paseo.json"], { cwd: repoRoot }); + execFileSync("git", ["commit", "-m", "add nested config"], { cwd: repoRoot }); + const currentConfig = { worktree: { setup: "npm install" } }; + writeConfig(projectRoot, currentConfig); + await expect( + hasUncommittedWorktreeSetupChanges({ repoRoot: projectRoot, currentConfig }), + ).resolves.toBe(true); + }); +}); diff --git a/packages/server/src/server/session/project-config/worktree-setup-commit-status.ts b/packages/server/src/server/session/project-config/worktree-setup-commit-status.ts new file mode 100644 index 0000000000..dca1eca8e0 --- /dev/null +++ b/packages/server/src/server/session/project-config/worktree-setup-commit-status.ts @@ -0,0 +1,54 @@ +import { + PaseoConfigRawSchema, + normalizeLifecycleCommands, + type PaseoConfigRaw, +} from "@getpaseo/protocol/paseo-config-schema"; +import { READ_ONLY_GIT_ENV } from "../../checkout-git-utils.js"; +import { runGitCommand } from "../../../utils/run-git-command.js"; + +export async function hasUncommittedWorktreeSetupChanges(input: { + repoRoot: string; + currentConfig: PaseoConfigRaw | null; +}): Promise<boolean> { + const gitPath = await resolveConfigGitPath(input.repoRoot); + const committedConfig = await readCommittedConfig(input.repoRoot, gitPath); + const currentSetup = normalizeLifecycleCommands(input.currentConfig?.worktree?.setup); + const committedSetup = normalizeLifecycleCommands(committedConfig?.worktree?.setup); + return !stringArraysEqual(currentSetup, committedSetup); +} + +async function resolveConfigGitPath(repoRoot: string): Promise<string> { + const { stdout } = await runGitCommand(["rev-parse", "--show-prefix"], { + cwd: repoRoot, + envOverlay: READ_ONLY_GIT_ENV, + }); + return `${stdout.trim()}paseo.json`; +} + +async function readCommittedConfig( + repoRoot: string, + gitPath: string, +): Promise<PaseoConfigRaw | null> { + await runGitCommand(["rev-parse", "--verify", "HEAD"], { + cwd: repoRoot, + envOverlay: READ_ONLY_GIT_ENV, + }); + const { stdout: trackedPath } = await runGitCommand( + ["ls-tree", "--name-only", "HEAD", "--", gitPath], + { + cwd: repoRoot, + envOverlay: READ_ONLY_GIT_ENV, + }, + ); + if (trackedPath.trim().length === 0) return null; + + const { stdout } = await runGitCommand(["show", `HEAD:${gitPath}`], { + cwd: repoRoot, + envOverlay: READ_ONLY_GIT_ENV, + }); + return PaseoConfigRawSchema.parse(JSON.parse(stdout)); +} + +function stringArraysEqual(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 73bd6c01f8..36e1642f7d 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -41,8 +41,8 @@ import { readFileSync, chmodSync, lstatSync, + readlinkSync, symlinkSync, - unlinkSync, } from "fs"; import { delimiter, dirname, join } from "path"; import { tmpdir } from "os"; @@ -1191,73 +1191,95 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); - it("runs setup from the edited source config when the selected ref already has paseo.json", async () => { + it("keeps a new worktree clean when its upstream ref has a newer paseo.json", async () => { + const remoteDir = join(tempDir, "remote.git"); + const updaterDir = join(tempDir, "updater"); writeFileSync( join(repoDir, "paseo.json"), - JSON.stringify({ worktree: { setup: 'echo "committed" > committed-setup.log' } }), + JSON.stringify({ worktree: { setup: "echo old" } }), ); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json"], { cwd: repoDir, }); - + execFileSync("git", ["clone", "--bare", repoDir, remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + execFileSync("git", ["clone", remoteDir, updaterDir]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: updaterDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: updaterDir }); writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ worktree: { setup: 'echo "edited" > edited-setup.log' } }), + join(updaterDir, "paseo.json"), + JSON.stringify({ worktree: { setup: "echo new" } }), ); + execFileSync("git", ["add", "paseo.json"], { cwd: updaterDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "update paseo.json"], { + cwd: updaterDir, + }); + execFileSync("git", ["push", "origin", "main"], { cwd: updaterDir }); + execFileSync("git", ["fetch", "origin"], { cwd: repoDir }); const result = await createLegacyWorktreeForTest({ cwd: repoDir, - worktreeSlug: "edited-config", - source: { kind: "branch-off", baseBranch: "main", branchName: "feature/edited-config" }, - runSetup: true, + worktreeSlug: "upstream-config", + source: { + kind: "branch-off", + baseBranch: "origin/main", + branchName: "feature/upstream-config", + }, + runSetup: false, paseoHome, }); const worktreeConfigPath = join(result.worktreePath, "paseo.json"); expect(JSON.parse(readFileSync(worktreeConfigPath, "utf8"))).toEqual({ - worktree: { setup: 'echo "edited" > edited-setup.log' }, + worktree: { setup: "echo new" }, }); - expect(readFileSync(join(result.worktreePath, "edited-setup.log"), "utf8")).toBe("edited\n"); - expect(existsSync(join(result.worktreePath, "committed-setup.log"))).toBe(false); + expect( + execFileSync("git", ["status", "--porcelain"], { + cwd: result.worktreePath, + encoding: "utf8", + }), + ).toBe(""); }); - it("replaces a selected ref's paseo.json symlink without writing through it", async () => { - const sourceConfigPath = join(repoDir, "paseo.json"); - const externalConfigPath = join(tempDir, "external-paseo.json"); - const committedConfig = JSON.stringify({ - worktree: { setup: 'echo "committed" > committed-setup.log' }, - }); - const editedConfig = JSON.stringify({ - worktree: { setup: 'echo "edited" > edited-setup.log' }, - }); - writeFileSync(externalConfigPath, committedConfig); - symlinkSync(externalConfigPath, sourceConfigPath); + it("preserves a dangling paseo.json symlink from the selected ref", async () => { + const externalConfigPath = join(tempDir, "outside-paseo.json"); + execFileSync("git", ["checkout", "-b", "symlink-config"], { cwd: repoDir }); + symlinkSync(externalConfigPath, join(repoDir, "paseo.json")); execFileSync("git", ["add", "paseo.json"], { cwd: repoDir }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add config link"], { - cwd: repoDir, - }); - unlinkSync(sourceConfigPath); - writeFileSync(sourceConfigPath, editedConfig); + execFileSync( + "git", + ["-c", "commit.gpgsign=false", "commit", "-m", "add paseo.json symlink"], + { cwd: repoDir }, + ); + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ worktree: { setup: "echo source" } }), + ); const result = await createLegacyWorktreeForTest({ cwd: repoDir, - worktreeSlug: "replace-config-link", + worktreeSlug: "symlink-config", source: { kind: "branch-off", - baseBranch: "main", - branchName: "feature/replace-config-link", + baseBranch: "symlink-config", + branchName: "feature/symlink-config", }, - runSetup: true, + runSetup: false, paseoHome, }); const worktreeConfigPath = join(result.worktreePath, "paseo.json"); - expect(readFileSync(externalConfigPath, "utf8")).toBe(committedConfig); - expect(lstatSync(worktreeConfigPath).isFile()).toBe(true); - expect(readFileSync(worktreeConfigPath, "utf8")).toBe(editedConfig); - expect(readFileSync(join(result.worktreePath, "edited-setup.log"), "utf8")).toBe("edited\n"); - expect(existsSync(join(result.worktreePath, "committed-setup.log"))).toBe(false); + expect(lstatSync(worktreeConfigPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(worktreeConfigPath)).toBe(externalConfigPath); + expect(existsSync(externalConfigPath)).toBe(false); + expect( + execFileSync("git", ["status", "--porcelain"], { + cwd: result.worktreePath, + encoding: "utf8", + }), + ).toBe(""); }); it("creates a worktree without error when no paseo.json exists in the main repo", async () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 5add0f76ee..e3cb98aba9 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,7 +1,14 @@ import { execFile } from "child_process"; import { promisify } from "util"; -import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs"; -import { readFile, rm, stat } from "fs/promises"; +import { + constants as fsConstants, + existsSync, + mkdirSync, + realpathSync, + rmSync, + statSync, +} from "fs"; +import { copyFile, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; @@ -33,7 +40,6 @@ import { import { runGitCommand } from "./run-git-command.js"; import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; -import { writeFileAtomic } from "../server/atomic-file.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -794,20 +800,16 @@ export async function runWorktreeTeardownCommands(options: { return results; } -export async function copySourcePaseoConfigFile(options: { +export async function seedPaseoConfigFile(options: { sourceCwd: string; targetCwd: string; }): Promise<void> { const sourceConfigPath = join(options.sourceCwd, "paseo.json"); const targetConfigPath = join(options.targetCwd, "paseo.json"); - let sourceConfig: Buffer; - try { - sourceConfig = await readFile(sourceConfigPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; - } - await writeFileAtomic(targetConfigPath, sourceConfig); + await copyFile(sourceConfigPath, targetConfigPath, fsConstants.COPYFILE_EXCL).catch((error) => { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST" && code !== "ENOENT") throw error; + }); } /** @@ -1257,7 +1259,7 @@ export const createWorktree = async ({ : {}), }); - await copySourcePaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); + await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); if (runSetup) { await runWorktreeSetupCommands({ From 36312352db88b2b5c4024465c40ff97fe48f67f0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 12:02:39 +0200 Subject: [PATCH 52/73] fix(app): validate persisted client state (#3289) * fix(app): validate persisted client state Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate. * fix(app): preserve validated legacy state * fix(app): preserve validated preferences Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider. * fix(app): migrate legacy agent preferences Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields. * fix(app): preserve drafts with legacy reviews Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft. --- ...ent-timeline-pagination-old-daemon.spec.ts | 2 +- .../command-center-agent-controls.spec.ts | 9 +- ...w-workspace-codex-mode-preferences.spec.ts | 17 +- .../new-workspace-mode-cycle-safety.spec.ts | 14 +- .../app/e2e/browser/sessions-empty.spec.ts | 2 +- .../browser/workspace-model-restart.spec.ts | 9 +- .../worktree-restore-after-restart.spec.ts | 2 +- packages/app/e2e/support/fixtures.ts | 2 +- .../app/e2e/support/helpers/archive-tab.ts | 2 +- .../e2e/support/helpers/daemon-registry.ts | 26 +- .../e2e/support/helpers/relay-deployment.ts | 2 +- packages/app/e2e/support/helpers/settings.ts | 2 +- .../app/e2e/support/helpers/startup-dsl.ts | 6 +- .../agent-profiles/migration/index.test.ts | 4 + .../app/src/agent-profiles/migration/index.ts | 39 +- .../src/contexts/sidebar-callout-context.tsx | 11 +- .../contexts/sidebar-callout-state.test.ts | 7 - .../app/src/contexts/sidebar-callout-state.ts | 17 +- .../preferences.test.ts | 30 ++ .../create-agent-preferences/preferences.ts | 78 ++- .../src/create-agent-preferences/storage.ts | 29 +- .../test-utils/fake-preference-storage.ts | 2 +- .../app/src/data/provider-snapshot-cache.ts | 36 +- .../app/src/desktop/browser/store/index.ts | 6 +- .../app/src/desktop/browser/store/state.ts | 68 +-- packages/app/src/git/use-actions.tsx | 6 +- .../hooks/use-changes-preferences/fakes.ts | 3 + .../hooks/use-changes-preferences/storage.ts | 29 +- .../hooks/use-keyboard-shortcut-overrides.ts | 11 +- .../app/src/hooks/use-preferred-editor.ts | 8 +- packages/app/src/hooks/use-settings/fakes.ts | 3 + .../src/hooks/use-settings/storage.test.ts | 17 +- .../app/src/hooks/use-settings/storage.ts | 90 +++- packages/app/src/hosts/appearance.ts | 24 +- .../internal/subscriptions.ts | 19 +- packages/app/src/review/state.ts | 65 +-- packages/app/src/review/store.test.ts | 51 +- packages/app/src/review/store.ts | 6 +- packages/app/src/runtime/host-runtime.test.ts | 3 + packages/app/src/runtime/host-runtime.ts | 93 ++-- .../src/runtime/replica-cache/index.test.ts | 13 +- .../app/src/runtime/replica-cache/index.ts | 504 ++++++++++++++---- .../storage/validated-persist-storage.test.ts | 65 +++ .../src/storage/validated-persist-storage.ts | 43 ++ .../app/src/storage/validated-storage.test.ts | 51 ++ packages/app/src/storage/validated-storage.ts | 47 ++ packages/app/src/stores/draft-store/index.ts | 19 +- .../src/stores/draft-store/migration.test.ts | 88 ++- .../app/src/stores/draft-store/migration.ts | 201 ++++--- .../app/src/stores/draft-store/persistence.ts | 23 +- packages/app/src/stores/draft-store/state.ts | 166 +++--- .../stores/last-workspace-selection.test.ts | 9 +- .../src/stores/last-workspace-selection.ts | 23 +- .../index.ts | 1 + packages/app/src/stores/panel-store/index.ts | 9 +- packages/app/src/stores/panel-store/state.ts | 40 +- .../sidebar-collapsed-sections-store/index.ts | 14 +- .../state.test.ts | 4 +- .../sidebar-collapsed-sections-store/state.ts | 45 +- .../app/src/stores/sidebar-order-store.ts | 20 +- packages/app/src/stores/sidebar-view-store.ts | 47 +- .../src/stores/workspace-layout-store.test.ts | 19 + .../app/src/stores/workspace-layout-store.ts | 94 +++- packages/app/src/types/host-connection.ts | 112 ++-- packages/app/src/utils/client-id.test.ts | 3 + packages/app/src/utils/client-id.ts | 16 +- packages/app/src/workspace-pins/store.ts | 27 +- .../workspace-service-routes/store.test.ts | 5 +- .../app/src/workspace-service-routes/store.ts | 43 +- 69 files changed, 1801 insertions(+), 800 deletions(-) create mode 100644 packages/app/src/storage/validated-persist-storage.test.ts create mode 100644 packages/app/src/storage/validated-persist-storage.ts create mode 100644 packages/app/src/storage/validated-storage.test.ts create mode 100644 packages/app/src/storage/validated-storage.ts diff --git a/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts b/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts index 16e2c7eaba..6ec68a2b1e 100644 --- a/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts @@ -66,7 +66,7 @@ test("does not repeat an assistant block when the current app paginates a publis localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); }, - { seededHost: host, preferences: buildCreateAgentPreferences(serverId) }, + { seededHost: host, preferences: buildCreateAgentPreferences() }, ); const history = await holdOlderHistoryPages(page, agent, daemon.port); diff --git a/packages/app/e2e/browser/command-center-agent-controls.spec.ts b/packages/app/e2e/browser/command-center-agent-controls.spec.ts index 3021a242ed..08597285fc 100644 --- a/packages/app/e2e/browser/command-center-agent-controls.spec.ts +++ b/packages/app/e2e/browser/command-center-agent-controls.spec.ts @@ -1,4 +1,5 @@ import { test } from "../support/fixtures"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center"; import { applyCommandCenterAgentControls, @@ -12,17 +13,15 @@ import { import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { getServerId } from "../support/helpers/server-id"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; async function seedMockDraftPreferences(page: import("@playwright/test").Page): Promise<void> { await page.addInitScript( - ({ preferencesKey, serverId }) => { + ({ preferencesKey }) => { localStorage.setItem( preferencesKey, JSON.stringify({ - serverId, provider: "mock", providerPreferences: { mock: { @@ -30,10 +29,10 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page): mode: "load-test", }, }, - }), + } satisfies FormPreferences), ); }, - { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId: getServerId() }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY }, ); } diff --git a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts index 05f636007b..ddd415ed97 100644 --- a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "../support/fixtures"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { gotoAppShell } from "../support/helpers/app"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { openAgentRoute } from "../support/helpers/mock-agent"; @@ -10,7 +11,6 @@ import { import { expectNoTruncation } from "../support/helpers/no-truncation"; import { escapeRegex } from "../support/helpers/regex"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { getServerId } from "../support/helpers/server-id"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; @@ -49,13 +49,12 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | return maybeEnvelope.message as Record<string, unknown>; } -async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise<void> { +async function seedCodexDefaultPermissionPreferences(page: Page): Promise<void> { await page.addInitScript( - ({ preferencesKey, serverId: seededServerId }) => { + ({ preferencesKey }) => { localStorage.setItem( preferencesKey, JSON.stringify({ - serverId: seededServerId, provider: "codex", providerPreferences: { codex: { @@ -69,10 +68,10 @@ async function seedCodexDefaultPermissionPreferences(page: Page, serverId: strin model: "ten-second-stream", }, }, - }), + } satisfies FormPreferences), ); }, - { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY }, ); } @@ -155,10 +154,9 @@ test.describe("New workspace Codex mode preferences", () => { test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({ page, }) => { - const serverId = getServerId(); const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" }); const createAgentRecorder = await recordAndBlockCreateAgentRequests(page); - await seedCodexDefaultPermissionPreferences(page, serverId); + await seedCodexDefaultPermissionPreferences(page); try { await gotoAppShell(page); @@ -194,9 +192,8 @@ test.describe("New workspace Codex mode preferences", () => { }); test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => { - const serverId = getServerId(); const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" }); - await seedCodexDefaultPermissionPreferences(page, serverId); + await seedCodexDefaultPermissionPreferences(page); try { const agent = await seeded.client.createAgent({ diff --git a/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts b/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts index 61a58fee88..2b6ab5d53d 100644 --- a/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts +++ b/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "../support/fixtures"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { openAgentRoute } from "../support/helpers/mock-agent"; import { @@ -6,7 +7,6 @@ import { selectNewWorkspaceProject, } from "../support/helpers/new-workspace"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { getServerId } from "../support/helpers/server-id"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; @@ -62,13 +62,12 @@ async function recordSetAgentModeRequests(page: Page): Promise<{ }; } -async function seedCodexDefaultPreferences(page: Page, serverId: string): Promise<void> { +async function seedCodexDefaultPreferences(page: Page): Promise<void> { await page.addInitScript( - ({ preferencesKey, serverId: seededServerId }) => { + ({ preferencesKey }) => { localStorage.setItem( preferencesKey, JSON.stringify({ - serverId: seededServerId, provider: "codex", providerPreferences: { codex: { @@ -78,10 +77,10 @@ async function seedCodexDefaultPreferences(page: Page, serverId: string): Promis }, mock: { model: "ten-second-stream" }, }, - }), + } satisfies FormPreferences), ); }, - { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY }, ); } @@ -104,9 +103,8 @@ test.describe("New Workspace mode cycle safety", () => { // control and silently change that (possibly running) agent's mode — e.g. into a // permissive/bypass mode. See use-keyboard-action-handler.ts. test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => { - const serverId = getServerId(); const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" }); - await seedCodexDefaultPreferences(page, serverId); + await seedCodexDefaultPreferences(page); const modeRequests = await recordSetAgentModeRequests(page); try { diff --git a/packages/app/e2e/browser/sessions-empty.spec.ts b/packages/app/e2e/browser/sessions-empty.spec.ts index 05884b45a6..32769996e4 100644 --- a/packages/app/e2e/browser/sessions-empty.spec.ts +++ b/packages/app/e2e/browser/sessions-empty.spec.ts @@ -31,7 +31,7 @@ test("Sessions shows an empty placeholder when the host has no history", async ( localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); }, - { seededHost: host, preferences: buildCreateAgentPreferences(serverId) }, + { seededHost: host, preferences: buildCreateAgentPreferences() }, ); await gotoAppShell(page); diff --git a/packages/app/e2e/browser/workspace-model-restart.spec.ts b/packages/app/e2e/browser/workspace-model-restart.spec.ts index c77b0bb1ec..54cbb0c0e8 100644 --- a/packages/app/e2e/browser/workspace-model-restart.spec.ts +++ b/packages/app/e2e/browser/workspace-model-restart.spec.ts @@ -7,6 +7,7 @@ import path from "node:path"; import net from "node:net"; import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { metroTest as test } from "../support/fixtures"; import { buildSeededHost } from "../support/helpers/daemon-registry"; import { loadDaemonClientConstructor } from "../support/helpers/daemon-client-loader"; @@ -348,12 +349,14 @@ async function seedBrowserForDaemon(page: Page, input: { serverId: string; port: { daemon: host, preferences: { - serverId: input.serverId, provider: "codex", providerPreferences: { - codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" }, + codex: { + model: "gpt-5.4-mini", + thinkingByModel: { "gpt-5.4-mini": "low" }, + }, }, - }, + } satisfies FormPreferences, }, ); } diff --git a/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts b/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts index 4cf294d69d..c4d2ccfffe 100644 --- a/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts +++ b/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts @@ -75,7 +75,7 @@ test.describe("Worktree restore after daemon restart", () => { label: "restart daemon", nowIso, }), - preferences: buildCreateAgentPreferences(serverId), + preferences: buildCreateAgentPreferences(), }, ); } diff --git a/packages/app/e2e/support/fixtures.ts b/packages/app/e2e/support/fixtures.ts index e8352a32e2..723f048081 100644 --- a/packages/app/e2e/support/fixtures.ts +++ b/packages/app/e2e/support/fixtures.ts @@ -132,7 +132,7 @@ const test = metroTest.extend< endpoint: `127.0.0.1:${daemonPort}`, nowIso, }); - const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId); + const createAgentPreferences = buildCreateAgentPreferences(); await page.addInitScript( ({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => { diff --git a/packages/app/e2e/support/helpers/archive-tab.ts b/packages/app/e2e/support/helpers/archive-tab.ts index 201bb189ab..c33414412a 100644 --- a/packages/app/e2e/support/helpers/archive-tab.ts +++ b/packages/app/e2e/support/helpers/archive-tab.ts @@ -26,7 +26,7 @@ function buildSeededStoragePayload() { endpoint: `127.0.0.1:${getE2EDaemonPort()}`, nowIso, }), - preferences: buildCreateAgentPreferences(getServerId()), + preferences: buildCreateAgentPreferences(), }; } diff --git a/packages/app/e2e/support/helpers/daemon-registry.ts b/packages/app/e2e/support/helpers/daemon-registry.ts index 6ef72ddc26..f3b63a7518 100644 --- a/packages/app/e2e/support/helpers/daemon-registry.ts +++ b/packages/app/e2e/support/helpers/daemon-registry.ts @@ -1,14 +1,23 @@ +import type { + FormPreferences, + ProviderPreferences, +} from "../../../src/create-agent-preferences/preferences"; + export const TEST_HOST_LABEL = "localhost"; export const TEST_PROVIDER_PREFERENCES = { claude: { model: "haiku" }, - codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" }, -} as const; + codex: { model: "gpt-5.4-mini", thinkingByModel: { "gpt-5.4-mini": "low" } }, +} satisfies Record<string, ProviderPreferences>; -export function buildDirectTcpConnection(endpoint: string) { +export function buildDirectTcpConnection(endpoint: string): { + id: string; + type: "directTcp"; + endpoint: string; +} { return { id: `direct:${endpoint}`, - type: "directTcp" as const, + type: "directTcp", endpoint, }; } @@ -33,12 +42,11 @@ export function buildSeededHost(input: { export const TEST_MOCK_PROVIDER_PREFERENCES = { ...TEST_PROVIDER_PREFERENCES, mock: { model: "ten-second-stream" }, -} as const; +} satisfies Record<string, ProviderPreferences>; -export function buildCreateAgentPreferences(serverId: string) { +export function buildCreateAgentPreferences() { return { - serverId, - provider: "mock" as const, + provider: "mock", providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES, - }; + } satisfies FormPreferences; } diff --git a/packages/app/e2e/support/helpers/relay-deployment.ts b/packages/app/e2e/support/helpers/relay-deployment.ts index 19e480d486..3d727e0804 100644 --- a/packages/app/e2e/support/helpers/relay-deployment.ts +++ b/packages/app/e2e/support/helpers/relay-deployment.ts @@ -60,7 +60,7 @@ export async function connectDaemonWebAppOnlyThroughRelay( localStorage.setItem("@paseo:daemon-registry", JSON.stringify([storedHost])); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); }, - { storedHost: host, preferences: buildCreateAgentPreferences(offer.serverId) }, + { storedHost: host, preferences: buildCreateAgentPreferences() }, ); const relaySocketOpened = new Promise<void>((resolve) => { diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index d87db35ef9..2af795375b 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -132,7 +132,7 @@ export async function seedSavedSettingsHosts( if (!firstHost) { throw new Error("Expected at least one settings host fixture."); } - const preferences = buildCreateAgentPreferences(firstHost.serverId); + const preferences = buildCreateAgentPreferences(); await page.evaluate( ({ keys, storedRegistry, storedPreferences }) => { diff --git a/packages/app/e2e/support/helpers/startup-dsl.ts b/packages/app/e2e/support/helpers/startup-dsl.ts index 96ad96f3c6..fe78109fe5 100644 --- a/packages/app/e2e/support/helpers/startup-dsl.ts +++ b/packages/app/e2e/support/helpers/startup-dsl.ts @@ -106,7 +106,7 @@ class StartupScenario { if (!firstHost) { throw new Error("Expected at least one startup test host."); } - const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId); + const createAgentPreferences = buildStoredCreateAgentPreferences(); await this.page.evaluate( ({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => { @@ -245,6 +245,6 @@ function buildStoredHost(input: { return buildSeededHost(input); } -function buildStoredCreateAgentPreferences(serverId: string) { - return buildCreateAgentPreferences(serverId); +function buildStoredCreateAgentPreferences() { + return buildCreateAgentPreferences(); } diff --git a/packages/app/src/agent-profiles/migration/index.test.ts b/packages/app/src/agent-profiles/migration/index.test.ts index cf40001e70..a2034b5d11 100644 --- a/packages/app/src/agent-profiles/migration/index.test.ts +++ b/packages/app/src/agent-profiles/migration/index.test.ts @@ -18,6 +18,10 @@ class MemoryStorage { async setItem(key: string, value: string): Promise<void> { this.values.set(key, value); } + + async removeItem(key: string): Promise<void> { + this.values.delete(key); + } } class FakeProfileHost { diff --git a/packages/app/src/agent-profiles/migration/index.ts b/packages/app/src/agent-profiles/migration/index.ts index 964ef248fd..f160420756 100644 --- a/packages/app/src/agent-profiles/migration/index.ts +++ b/packages/app/src/agent-profiles/migration/index.ts @@ -2,29 +2,19 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { z } from "zod"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { FormPreferencesSchema } from "@/create-agent-preferences/preferences"; +import { readValidatedJson, readValidatedString } from "@/storage/validated-storage"; const PREFERENCES_KEY = "@paseo:create-agent-preferences"; const COMPLETION_KEY_PREFIX = "@paseo:legacy-favorites-to-agent-profiles:v1:"; const CATALOG_LOADING_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000, 4_000] as const; -const legacyPreferencesSchema = z.object({ - favoriteModels: z - .array( - z.object({ - provider: z.string().trim().min(1), - modelId: z.string().trim().min(1), - }), - ) - .optional(), -}); - -type LegacyFavorite = NonNullable< - z.infer<typeof legacyPreferencesSchema>["favoriteModels"] ->[number]; +type LegacyFavorite = NonNullable<z.infer<typeof FormPreferencesSchema>["favoriteModels"]>[number]; export interface LegacyFavoriteMigrationStorage { getItem(key: string): Promise<string | null>; setItem(key: string, value: string): Promise<void>; + removeItem(key: string): Promise<void>; } export interface LegacyFavoriteMigrationHost { @@ -47,18 +37,6 @@ function completionKey(serverId: string): string { return `${COMPLETION_KEY_PREFIX}${serverId}`; } -function parseStoredPreferences(raw: string | null): LegacyFavorite[] { - if (!raw) { - return []; - } - try { - const result = legacyPreferencesSchema.safeParse(JSON.parse(raw)); - return result.success ? (result.data.favoriteModels ?? []) : []; - } catch { - return []; - } -} - function favoriteKey(favorite: Pick<LegacyFavorite, "provider" | "modelId">): string { return `${favorite.provider}\u0000${favorite.modelId}`; } @@ -205,11 +183,16 @@ export class LegacyFavoriteProfileMigration { return; } const markerKey = completionKey(serverId); - if ((await this.storage.getItem(markerKey)) === "1") { + if ((await readValidatedString(this.storage, markerKey, z.literal("1"))) === "1") { return; } - const favorites = parseStoredPreferences(await this.storage.getItem(PREFERENCES_KEY)); + const preferences = await readValidatedJson( + this.storage, + PREFERENCES_KEY, + FormPreferencesSchema, + ); + const favorites = preferences?.favoriteModels ?? []; if (favorites.length === 0) { await this.storage.setItem(markerKey, "1"); return; diff --git a/packages/app/src/contexts/sidebar-callout-context.tsx b/packages/app/src/contexts/sidebar-callout-context.tsx index 2e337aadd8..548a32edc5 100644 --- a/packages/app/src/contexts/sidebar-callout-context.tsx +++ b/packages/app/src/contexts/sidebar-callout-context.tsx @@ -13,9 +13,9 @@ import { useStableEvent } from "@/hooks/use-stable-event"; import { clearSidebarCallouts, createSidebarCalloutState, + DismissedCalloutKeysSchema, dismissSidebarCallout, loadDismissedCalloutKeys, - parseDismissedCalloutKeys, selectActiveSidebarCallout, serializeDismissedCalloutKeys, showSidebarCallout, @@ -24,6 +24,7 @@ import { type SidebarCalloutState, unregisterSidebarCallout, } from "./sidebar-callout-state"; +import { readValidatedJson } from "@/storage/validated-storage"; export type { SidebarCalloutOptions } from "./sidebar-callout-state"; @@ -62,8 +63,12 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) { async function loadDismissedKeys(): Promise<void> { let dismissedKeys: ReadonlySet<string>; try { - const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY); - dismissedKeys = parseDismissedCalloutKeys(value); + const value = await readValidatedJson( + AsyncStorage, + DISMISSED_CALLOUTS_STORAGE_KEY, + DismissedCalloutKeysSchema, + ); + dismissedKeys = new Set(value ?? []); } catch (error) { console.error("[SidebarCallouts] Failed to load dismissed callouts", error); dismissedKeys = stateRef.current.dismissedKeys; diff --git a/packages/app/src/contexts/sidebar-callout-state.test.ts b/packages/app/src/contexts/sidebar-callout-state.test.ts index 9b7d1a13aa..f73468f0b3 100644 --- a/packages/app/src/contexts/sidebar-callout-state.test.ts +++ b/packages/app/src/contexts/sidebar-callout-state.test.ts @@ -4,7 +4,6 @@ import { createSidebarCalloutState, dismissSidebarCallout, loadDismissedCalloutKeys, - parseDismissedCalloutKeys, selectActiveSidebarCallout, serializeDismissedCalloutKeys, showSidebarCallout, @@ -118,12 +117,6 @@ describe("sidebar callout state", () => { expect(selectActiveSidebarCallout(state)?.title).toBe("Update available"); }); - it("parses stored dismissal keys defensively", () => { - expect(parseDismissedCalloutKeys(JSON.stringify(["a", 4, "b"]))).toEqual(new Set(["a", "b"])); - expect(parseDismissedCalloutKeys("{")).toEqual(new Set()); - expect(parseDismissedCalloutKeys(JSON.stringify({ key: "a" }))).toEqual(new Set()); - }); - it("clears visible callouts without dropping dismissal state", () => { let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"])); state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state; diff --git a/packages/app/src/contexts/sidebar-callout-state.ts b/packages/app/src/contexts/sidebar-callout-state.ts index fa1da011ab..0cd83c53fd 100644 --- a/packages/app/src/contexts/sidebar-callout-state.ts +++ b/packages/app/src/contexts/sidebar-callout-state.ts @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { z } from "zod"; import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout"; export interface SidebarCalloutOptions { @@ -44,21 +45,7 @@ export function normalizeDismissalKey(key: string | null | undefined): string | return trimmed ? trimmed : null; } -export function parseDismissedCalloutKeys(value: string | null): Set<string> { - if (!value) { - return new Set(); - } - - try { - const parsed = JSON.parse(value) as unknown; - if (!Array.isArray(parsed)) { - return new Set(); - } - return new Set(parsed.filter((entry): entry is string => typeof entry === "string")); - } catch { - return new Set(); - } -} +export const DismissedCalloutKeysSchema = z.array(z.string()); export function serializeDismissedCalloutKeys(keys: ReadonlySet<string>): string { return JSON.stringify([...keys]); diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts index 7529a80729..cb48416d74 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -137,6 +137,36 @@ describe("create agent preferences", () => { expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({}); }); + it("strips the explicitly supported legacy location fields", () => { + expect( + parseFormPreferences({ + workingDir: "/old/workspace", + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4-mini", + mode: "full-access", + thinkingOptionId: "high", + }, + }, + serverId: "old-host", + }), + ).toEqual({ + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4-mini", + mode: "full-access", + thinkingByModel: { "gpt-5.4-mini": "high" }, + }, + }, + }); + }); + + it("rejects unknown persisted fields outside the explicit legacy shape", () => { + expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({}); + }); + it("persists and reloads the workspace isolation choice", async () => { const storage = new FakeCreateAgentPreferenceStorage(); const preferences = new CreateAgentPreferencesService(storage); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index 7153fa8b4f..710df9efdc 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -1,19 +1,38 @@ import { z } from "zod"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -const providerPreferencesSchema = z.object({ +const featureValuesSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])); + +export interface ProviderPreferences { + model?: string; + mode?: string; + thinkingByModel?: Record<string, string>; + featureValues?: Record<string, unknown>; +} + +export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string }; + +export interface FormPreferences { + provider?: string; + providerPreferences?: Record<string, ProviderPreferences>; + favoriteModels?: Array<{ provider: string; modelId: string }>; + isolation?: "local" | "worktree"; + launchTarget?: LaunchTarget; +} + +const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.strictObject({ model: z.string().optional(), mode: z.string().optional(), thinkingByModel: z.record(z.string(), z.string()).optional(), - featureValues: z.record(z.string(), z.unknown()).optional(), + featureValues: featureValuesSchema.optional(), }); -const launchTargetSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("chat") }), - z.object({ kind: z.literal("terminal"), profileId: z.string() }), +const launchTargetSchema: z.ZodType<LaunchTarget> = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("chat") }), + z.strictObject({ kind: z.literal("terminal"), profileId: z.string() }), ]); -const formPreferencesSchema = z.object({ +export const FormPreferencesSchema = z.strictObject({ provider: z.string().optional(), providerPreferences: z.record(z.string(), providerPreferencesSchema).optional(), // COMPAT(agentProfileFavoriteMigration): favourites were removed in v0.3.2. @@ -21,7 +40,7 @@ const formPreferencesSchema = z.object({ // import it; ordinary preference writes must not erase it first. favoriteModels: z .array( - z.object({ + z.strictObject({ provider: z.string(), modelId: z.string(), }), @@ -31,16 +50,50 @@ const formPreferencesSchema = z.object({ // What the New workspace composer submits to: the chat agent (default) or a // terminal profile. See `@/new-workspace-launch` for resolution/fallback. launchTarget: launchTargetSchema.optional(), +}) satisfies z.ZodType<FormPreferences>; + +const LegacyProviderPreferencesSchema = z.strictObject({ + model: z.string().optional(), + mode: z.string().optional(), + thinkingOptionId: z.string().optional(), }); -export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>; -export type FormPreferences = z.infer<typeof formPreferencesSchema>; -export type LaunchTarget = z.infer<typeof launchTargetSchema>; +const LegacyFormPreferencesSchema = z + .strictObject({ + workingDir: z.string().optional(), + provider: z.string().optional(), + serverId: z.string().optional(), + providerPreferences: z.record(z.string(), LegacyProviderPreferencesSchema).optional(), + }) + .transform(({ provider, providerPreferences }): FormPreferences => { + const migratedProviderPreferences: Record<string, ProviderPreferences> = {}; + for (const [providerId, legacy] of Object.entries(providerPreferences ?? {})) { + const model = legacy.model; + migratedProviderPreferences[providerId] = { + ...(model !== undefined ? { model } : {}), + ...(legacy.mode !== undefined ? { mode: legacy.mode } : {}), + ...(model !== undefined && legacy.thinkingOptionId !== undefined + ? { thinkingByModel: { [model]: legacy.thinkingOptionId } } + : {}), + }; + } + return { + ...(provider !== undefined ? { provider } : {}), + ...(providerPreferences !== undefined + ? { providerPreferences: migratedProviderPreferences } + : {}), + }; + }); + +export const StoredFormPreferencesSchema: z.ZodType<FormPreferences> = z.union([ + FormPreferencesSchema, + LegacyFormPreferencesSchema, +]); export const DEFAULT_FORM_PREFERENCES: FormPreferences = {}; export function parseFormPreferences(value: unknown): FormPreferences { - const result = formPreferencesSchema.safeParse(value); + const result = StoredFormPreferencesSchema.safeParse(value); return result.success ? result.data : DEFAULT_FORM_PREFERENCES; } @@ -115,6 +168,7 @@ export function mergeCreateAgentSelectionPreferences(args: { const modelId = args.modelId?.trim() ?? ""; const modeId = args.modeId?.trim() ?? ""; const thinkingOptionId = args.thinkingOptionId?.trim() ?? ""; + const featureValues = featureValuesSchema.safeParse(args.featureValues); return mergeProviderPreferences({ preferences: args.preferences, @@ -123,7 +177,7 @@ export function mergeCreateAgentSelectionPreferences(args: { model: modelId || undefined, mode: modeId || undefined, ...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}), - ...(args.featureValues ? { featureValues: args.featureValues } : {}), + ...(featureValues.success ? { featureValues: featureValues.data } : {}), }, }); } diff --git a/packages/app/src/create-agent-preferences/storage.ts b/packages/app/src/create-agent-preferences/storage.ts index 2dd2311423..951c9efcf0 100644 --- a/packages/app/src/create-agent-preferences/storage.ts +++ b/packages/app/src/create-agent-preferences/storage.ts @@ -1,5 +1,10 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import type { FormPreferences } from "./preferences"; +import { readValidatedJson } from "@/storage/validated-storage"; +import { + FormPreferencesSchema, + StoredFormPreferencesSchema, + type FormPreferences, +} from "./preferences"; export const CREATE_AGENT_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences"; @@ -10,19 +15,19 @@ export interface CreateAgentPreferenceStorage { export class AsyncStorageCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage { async read(): Promise<unknown> { - const stored = await AsyncStorage.getItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY); - if (!stored) { - return null; - } - - try { - return JSON.parse(stored); - } catch { - return null; - } + return readValidatedJson( + AsyncStorage, + CREATE_AGENT_PREFERENCES_STORAGE_KEY, + StoredFormPreferencesSchema, + ); } async write(preferences: FormPreferences): Promise<void> { - await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences)); + const result = FormPreferencesSchema.safeParse(preferences); + if (!result.success) { + await AsyncStorage.removeItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY); + return; + } + await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(result.data)); } } diff --git a/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts b/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts index 6b0fcf245a..84d7cc53df 100644 --- a/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts +++ b/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts @@ -73,5 +73,5 @@ export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceSt } function clone<T>(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; + return structuredClone(value); } diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index d252613d19..ed4823a39c 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -6,6 +6,7 @@ import { type CompactProviderSnapshot, } from "@getpaseo/protocol/provider-snapshot-codec"; import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages"; +import { z } from "zod"; const CACHE_VERSION = 1; const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1"; @@ -44,6 +45,13 @@ interface ProviderSnapshotIndex { entries: ProviderSnapshotIndexEntry[]; } +const StoredProviderSnapshotSchema: z.ZodType<StoredProviderSnapshot> = z.strictObject({ + version: z.literal(CACHE_VERSION), + hash: z.string(), + generatedAt: z.string().datetime({ offset: true }), + compactSnapshot: CompactProviderSnapshotSchema, +}); + export interface CachedProviderSnapshot extends StoredProviderSnapshot { entries: ProviderSnapshotEntry[]; } @@ -81,22 +89,8 @@ function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIn function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null { const parsed: unknown = JSON.parse(value); - if (typeof parsed !== "object" || parsed === null) return null; - const version = Reflect.get(parsed, "version"); - const hash = Reflect.get(parsed, "hash"); - const generatedAt = Reflect.get(parsed, "generatedAt"); - const compactSnapshot = CompactProviderSnapshotSchema.safeParse( - Reflect.get(parsed, "compactSnapshot"), - ); - if ( - version !== CACHE_VERSION || - typeof hash !== "string" || - typeof generatedAt !== "string" || - !compactSnapshot.success - ) { - return null; - } - return { version, hash, generatedAt, compactSnapshot: compactSnapshot.data }; + const result = StoredProviderSnapshotSchema.safeParse(parsed); + return result.success ? result.data : null; } export function createProviderSnapshotCache( @@ -173,12 +167,18 @@ export function createProviderSnapshotCache( }): Promise<void> { const currentIndex = await ensureCacheIndex(); const key = cacheKey(input.serverId, input.cwd); - const value = JSON.stringify({ + const stored = StoredProviderSnapshotSchema.safeParse({ version: CACHE_VERSION, hash: input.hash, generatedAt: input.generatedAt, compactSnapshot: input.compactSnapshot, - } satisfies StoredProviderSnapshot); + }); + if (!stored.success) { + await storage.removeItem(key); + await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key)); + return; + } + const value = JSON.stringify(stored.data); const incomingBytes = storedBytes(key, value); const canStoreIncoming = incomingBytes <= maxBytes; const retainedEntries = currentIndex.entries.filter((entry) => entry.key !== key); diff --git a/packages/app/src/desktop/browser/store/index.ts b/packages/app/src/desktop/browser/store/index.ts index 7ff21c733a..16558eaa0d 100644 --- a/packages/app/src/desktop/browser/store/index.ts +++ b/packages/app/src/desktop/browser/store/index.ts @@ -1,9 +1,11 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import { applyBrowserPatch, + BrowserIndexStateSchema, type BrowserIndexState, type BrowserRecord, type BrowserRecordPatch, @@ -74,7 +76,7 @@ export const useBrowserStore = create<BrowserStoreState>()( }), { name: "workspace-browser-store", - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, BrowserIndexStateSchema), partialize: (state) => sanitizeBrowsersForPersist(state), merge: (persistedState, currentState) => ({ ...currentState, diff --git a/packages/app/src/desktop/browser/store/state.ts b/packages/app/src/desktop/browser/store/state.ts index 3909a7aa31..47d0de2455 100644 --- a/packages/app/src/desktop/browser/store/state.ts +++ b/packages/app/src/desktop/browser/store/state.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export type BrowserViewport = | { mode: "responsive" } | { mode: "fixed"; width: number; height: number }; @@ -23,6 +25,32 @@ export interface BrowserIndexState { browsersById: Record<string, BrowserRecord>; } +const BrowserViewportSchema = z.discriminatedUnion("mode", [ + z.strictObject({ mode: z.literal("responsive") }), + z.strictObject({ + mode: z.literal("fixed"), + width: z.number().positive(), + height: z.number().positive(), + }), +]); + +const BrowserRecordSchema = z.strictObject({ + browserId: z.string(), + url: z.string(), + title: z.string(), + isLoading: z.boolean(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + faviconUrl: z.string().nullable(), + lastError: z.string().nullable(), + viewport: BrowserViewportSchema.optional().default(RESPONSIVE_BROWSER_VIEWPORT), + createdAt: z.number(), +}); + +export const BrowserIndexStateSchema: z.ZodType<BrowserIndexState> = z.strictObject({ + browsersById: z.record(z.string(), BrowserRecordSchema), +}); + export function createFixedBrowserViewport(width: number, height: number): BrowserViewport { return { mode: "fixed", @@ -32,19 +60,10 @@ export function createFixedBrowserViewport(width: number, height: number): Brows } export function normalizeBrowserViewport(value: unknown): BrowserViewport { - if ( - value && - typeof value === "object" && - (value as { mode?: unknown }).mode === "fixed" && - typeof (value as { width?: unknown }).width === "number" && - typeof (value as { height?: unknown }).height === "number" - ) { - return createFixedBrowserViewport( - (value as { width: number }).width, - (value as { height: number }).height, - ); - } - return RESPONSIVE_BROWSER_VIEWPORT; + const result = BrowserViewportSchema.safeParse(value); + return result.success && result.data.mode === "fixed" + ? createFixedBrowserViewport(result.data.width, result.data.height) + : RESPONSIVE_BROWSER_VIEWPORT; } function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): boolean { @@ -56,27 +75,8 @@ function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): b } export function normalizeBrowserIndexState(value: unknown): BrowserIndexState { - if (!value || typeof value !== "object") { - return { browsersById: {} }; - } - const browsersById = (value as { browsersById?: unknown }).browsersById; - if (!browsersById || typeof browsersById !== "object") { - return { browsersById: {} }; - } - return { - browsersById: Object.fromEntries( - Object.entries(browsersById).map(([browserId, record]) => { - const browser = record as BrowserRecord & { viewport?: unknown }; - return [ - browserId, - { - ...browser, - viewport: normalizeBrowserViewport(browser.viewport), - } satisfies BrowserRecord, - ]; - }), - ), - }; + const result = BrowserIndexStateSchema.safeParse(value); + return result.success ? result.data : { browsersById: {} }; } export function trimNonEmpty(value: string | null | undefined): string | null { diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index 63f6c7425f..05e65a21a5 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useMemo, type ReactElement } from "re import { Info } from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import { z } from "zod"; import { useTranslation } from "react-i18next"; import type { Theme } from "@/styles/theme"; import { getForgePresentation, type Forge } from "@/git/forge"; @@ -29,6 +30,7 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; +import { readValidatedString } from "@/storage/validated-storage"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -361,10 +363,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use } let isActive = true; setShipDefault("pr"); - AsyncStorage.getItem(shipDefaultStorageKey) + readValidatedString(AsyncStorage, shipDefaultStorageKey, z.enum(["pr", "merge"])) .then((value) => { if (!isActive) return; - if (value === "pr" || value === "merge") { + if (value) { setShipDefault(value); return; } diff --git a/packages/app/src/hooks/use-changes-preferences/fakes.ts b/packages/app/src/hooks/use-changes-preferences/fakes.ts index 96e7a84907..62f194a5d1 100644 --- a/packages/app/src/hooks/use-changes-preferences/fakes.ts +++ b/packages/app/src/hooks/use-changes-preferences/fakes.ts @@ -16,5 +16,8 @@ export function createInMemoryKeyValueStorage( async setItem(key, value) { entries.set(key, value); }, + async removeItem(key) { + entries.delete(key); + }, }; } diff --git a/packages/app/src/hooks/use-changes-preferences/storage.ts b/packages/app/src/hooks/use-changes-preferences/storage.ts index 3ceaadeadd..05a0038dcd 100644 --- a/packages/app/src/hooks/use-changes-preferences/storage.ts +++ b/packages/app/src/hooks/use-changes-preferences/storage.ts @@ -1,11 +1,12 @@ import type { QueryClient } from "@tanstack/react-query"; import { z } from "zod"; +import { readValidatedJson, readValidatedString } from "@/storage/validated-storage"; export const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences"; export const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines"; export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"]; -const changesPreferencesSchema = z.object({ +const changesPreferencesSchema = z.strictObject({ layout: z.enum(["unified", "split"]).optional(), viewMode: z.enum(["flat", "tree"]).optional(), wrapLines: z.boolean().optional(), @@ -32,28 +33,28 @@ export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = { export interface KeyValueStorage { getItem(key: string): Promise<string | null>; setItem(key: string, value: string): Promise<void>; + removeItem(key: string): Promise<void>; } async function loadLegacyWrapLinesPreference(storage: KeyValueStorage): Promise<boolean | null> { - const legacyValue = await storage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY); - if (legacyValue === "true") { - return true; - } - if (legacyValue === "false") { - return false; - } - return null; + const legacyValue = await readValidatedString( + storage, + LEGACY_WRAP_LINES_STORAGE_KEY, + z.enum(["true", "false"]), + ); + return legacyValue === null ? null : legacyValue === "true"; } export async function loadChangesPreferencesFromStorage( storage: KeyValueStorage, ): Promise<ChangesPreferences> { - const stored = await storage.getItem(CHANGES_PREFERENCES_STORAGE_KEY); + const stored = await readValidatedJson( + storage, + CHANGES_PREFERENCES_STORAGE_KEY, + changesPreferencesSchema, + ); if (stored) { - const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored)); - if (parsed.success) { - return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data }; - } + return { ...DEFAULT_CHANGES_PREFERENCES, ...stored }; } const legacyWrapLines = await loadLegacyWrapLinesPreference(storage); diff --git a/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts b/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts index 874b28122c..620e76efd0 100644 --- a/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts +++ b/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts @@ -1,15 +1,18 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; import type { ShortcutOverrides } from "@/keyboard/keyboard-shortcuts"; import { createShortcutOverrideStore, type ShortcutOverrideStore, } from "@/keyboard/shortcut-override-store"; +import { readValidatedJson } from "@/storage/validated-storage"; const STORAGE_KEY = "@paseo:keyboard-shortcut-overrides"; const QUERY_KEY = ["keyboard-shortcut-overrides"]; const EMPTY_OVERRIDES: ShortcutOverrides = {}; +const ShortcutOverridesSchema = z.record(z.string(), z.string().nullable()); export interface UseKeyboardShortcutOverridesReturn { overrides: ShortcutOverrides; @@ -76,10 +79,10 @@ function getStore(queryClient: QueryClient): ShortcutOverrideStore { async function loadOverridesFromStorage(): Promise<ShortcutOverrides> { try { - const stored = await AsyncStorage.getItem(STORAGE_KEY); - if (stored) { - return JSON.parse(stored) as ShortcutOverrides; - } + return ( + (await readValidatedJson(AsyncStorage, STORAGE_KEY, ShortcutOverridesSchema)) ?? + EMPTY_OVERRIDES + ); } catch (err) { console.error("[KeyboardShortcutOverrides] Failed to load overrides:", err); } diff --git a/packages/app/src/hooks/use-preferred-editor.ts b/packages/app/src/hooks/use-preferred-editor.ts index 480fd3d562..b216185182 100644 --- a/packages/app/src/hooks/use-preferred-editor.ts +++ b/packages/app/src/hooks/use-preferred-editor.ts @@ -1,6 +1,8 @@ import { useCallback } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; +import { readValidatedString } from "@/storage/validated-storage"; type EditorTargetId = string; @@ -8,11 +10,7 @@ const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor"; const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"]; async function loadPreferredEditor(): Promise<EditorTargetId | null> { - const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY); - if (!stored) { - return null; - } - return stored.trim() || null; + return readValidatedString(AsyncStorage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1)); } export function resolvePreferredEditorId( diff --git a/packages/app/src/hooks/use-settings/fakes.ts b/packages/app/src/hooks/use-settings/fakes.ts index 0e7ce025d8..691c3e9c18 100644 --- a/packages/app/src/hooks/use-settings/fakes.ts +++ b/packages/app/src/hooks/use-settings/fakes.ts @@ -17,6 +17,9 @@ export function createInMemoryKeyValueStorage( async setItem(key, value) { entries.set(key, value); }, + async removeItem(key) { + entries.delete(key); + }, }; } diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index b421c27cb8..87fdd300f1 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -15,6 +15,7 @@ import { type SettingsDeps, } from "./storage"; import { createFakeDesktopBridge, createInMemoryKeyValueStorage } from "./fakes"; +import { THEME_OPTIONS } from "@/styles/theme"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; @@ -42,6 +43,18 @@ describe("loadAppSettingsFromStorage", () => { expect(result.theme).toBe("auto"); }); + it.each(THEME_OPTIONS)("loads the persisted $name theme", async ({ name }) => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ theme: name }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.theme).toBe(name); + }); + it("seeds storage with the client defaults when nothing is persisted", async () => { const deps = makeDeps(); @@ -382,14 +395,14 @@ describe("appearance settings", () => { expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); }); - it("maps an unrecognized tool call detail level to overview", async () => { + it("clears settings with an unrecognized tool call detail level", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }), }), }); - expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); + expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed"); }); it("migrates a switched-off checks row item to the hidden checks display", async () => { diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 3b78310fa7..0b7a733de0 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -14,6 +14,8 @@ import { type SidebarRowItems, } from "@/components/sidebar/display-preferences/row-items"; import { THEME_OPTIONS, type ThemePreference } from "@/styles/theme"; +import { z } from "zod"; +import { readValidatedJson } from "@/storage/validated-storage"; export const APP_SETTINGS_KEY = "@paseo:app-settings"; export const APP_SETTINGS_QUERY_KEY = ["app-settings"]; @@ -28,6 +30,7 @@ export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none"; export type ToolCallDetailLevel = "overview" | "detailed"; const VALID_THEMES = new Set<string>(THEME_OPTIONS.map((option) => option.name)); +const ThemePreferenceSchema = z.enum(THEME_OPTIONS.map((option) => option.name)); const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]); const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]); const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([ @@ -74,14 +77,45 @@ export interface Settings extends AppSettings { releaseChannel: ReleaseChannel; } -/** - * `sidebarRowItems` is widened back to `unknown` because it is still read for a value the - * current shape no longer has — see `isChecksHiddenByLegacyRowItem`. - */ -type StoredAppSettings = Partial<Omit<AppSettings, "sidebarRowItems">> & { - compactToolCalls?: unknown; - sidebarRowItems?: unknown; -}; +const SidebarRowItemsSchema = z.strictObject({ + host: z.boolean().optional(), + changeRequest: z.boolean().optional(), + services: z.boolean().optional(), + checks: z.boolean().optional(), + scripts: z.boolean().optional(), +}); + +const StoredAppSettingsSchema = z.strictObject({ + theme: ThemePreferenceSchema.optional(), + language: z + .enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"]) + .optional(), + sendBehavior: z.enum(["interrupt", "queue"]).optional(), + serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).optional(), + terminalScrollbackLines: z.union([z.number(), z.string()]).optional(), + useLegacyTerminalRenderer: z.boolean().optional(), + uiFontFamily: z.string().optional(), + monoFontFamily: z.string().optional(), + uiFontSize: z.union([z.number(), z.string()]).optional(), + codeFontSize: z.union([z.number(), z.string()]).optional(), + syntaxTheme: z.string().refine(isSyntaxThemeId).optional(), + workspaceTitleSource: z.enum(["title", "branch"]).optional(), + sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).optional(), + sidebarRowItems: SidebarRowItemsSchema.optional(), + sidebarChecksDisplay: z.enum(["iconAndText", "icon", "none"]).optional(), + autoExpandReasoning: z.boolean().optional(), + toolCallDetailLevel: z.enum(["overview", "detailed"]).optional(), + compactToolCalls: z.boolean().optional(), + chatOutlineEnabled: z.boolean().optional(), + vimKeybindings: z.boolean().optional(), + // COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key. + manageBuiltInDaemon: z.boolean().optional(), + releaseChannel: z.enum(["stable", "beta"]).optional(), +}); + +const LegacyRendererSettingsSchema = StoredAppSettingsSchema; + +type StoredAppSettings = z.infer<typeof StoredAppSettingsSchema>; export const DEFAULT_CLIENT_SETTINGS: AppSettings = { theme: "auto", @@ -114,6 +148,7 @@ export const DEFAULT_APP_SETTINGS: Settings = { export interface KeyValueStorage { getItem(key: string): Promise<string | null>; setItem(key: string, value: string): Promise<void>; + removeItem(key: string): Promise<void>; } export interface DesktopSettingsBridge { @@ -146,17 +181,20 @@ export async function saveAppSettings(input: { export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<AppSettings> { try { - const stored = await deps.storage.getItem(APP_SETTINGS_KEY); + const stored = await readValidatedJson(deps.storage, APP_SETTINGS_KEY, StoredAppSettingsSchema); if (stored) { - return normalizeAppSettings(JSON.parse(stored)); + return normalizeAppSettings(stored); } - const legacyStored = await deps.storage.getItem(LEGACY_SETTINGS_KEY); + const legacyStored = await readValidatedJson( + deps.storage, + LEGACY_SETTINGS_KEY, + LegacyRendererSettingsSchema, + ); if (legacyStored) { - const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>; const next = { ...DEFAULT_CLIENT_SETTINGS, - ...pickAppSettingsFromLegacy(legacyParsed), + ...pickAppSettingsFromLegacy(legacyStored), } satisfies AppSettings; await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); return next; @@ -197,11 +235,11 @@ export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Setti } export function normalizeAppSettings(value: unknown): AppSettings { - const stored = - typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as StoredAppSettings) - : {}; - return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(stored) }; + const result = StoredAppSettingsSchema.safeParse(value); + return { + ...DEFAULT_CLIENT_SETTINGS, + ...pickAppSettings(result.success ? result.data : {}), + }; } function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null { @@ -334,7 +372,9 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> { return result; } -function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> { +function pickAppSettingsFromLegacy( + legacy: z.infer<typeof LegacyRendererSettingsSchema>, +): Partial<AppSettings> { const result: Partial<AppSettings> = {}; if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") { result.theme = legacy.theme; @@ -420,15 +460,11 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P async function loadRendererSettingsPayload( storage: KeyValueStorage, -): Promise<Record<string, unknown> | null> { - const current = await storage.getItem(APP_SETTINGS_KEY); +): Promise<z.infer<typeof LegacyRendererSettingsSchema> | null> { + const current = await readValidatedJson(storage, APP_SETTINGS_KEY, LegacyRendererSettingsSchema); if (current) { - return JSON.parse(current) as Record<string, unknown>; + return current; } - const legacy = await storage.getItem(LEGACY_SETTINGS_KEY); - if (!legacy) { - return null; - } - return JSON.parse(legacy) as Record<string, unknown>; + return readValidatedJson(storage, LEGACY_SETTINGS_KEY, LegacyRendererSettingsSchema); } diff --git a/packages/app/src/hosts/appearance.ts b/packages/app/src/hosts/appearance.ts index 8f4fc80a2a..e51f4410dc 100644 --- a/packages/app/src/hosts/appearance.ts +++ b/packages/app/src/hosts/appearance.ts @@ -1,5 +1,6 @@ import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors"; import type { HostProfile } from "@/types/host-connection"; +import { z } from "zod"; export type HostColor = "none" | IdentityColorName; @@ -19,27 +20,18 @@ export interface HostAppearance { badgeDisplay: HostBadgeDisplay | null; } +export const HostAppearanceSchema: z.ZodType<HostAppearance> = z.strictObject({ + color: z.enum(["none", ...IDENTITY_COLOR_NAMES]), + badgeDisplay: z.enum(["name", "icon", "hidden"]).nullable(), +}); + export function defaultHostAppearance(): HostAppearance { return { color: "none", badgeDisplay: null }; } -function isHostColor(value: unknown): value is HostColor { - return HOST_COLORS.some((color) => color === value); -} - -function isHostBadgeDisplay(value: unknown): value is HostBadgeDisplay { - return HOST_BADGE_DISPLAYS.some((display) => display === value); -} - export function normalizeStoredHostAppearance(value: unknown): HostAppearance { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return defaultHostAppearance(); - } - const record = value as Record<string, unknown>; - return { - color: isHostColor(record.color) ? record.color : "none", - badgeDisplay: isHostBadgeDisplay(record.badgeDisplay) ? record.badgeDisplay : null, - }; + const result = HostAppearanceSchema.safeParse(value); + return result.success ? result.data : defaultHostAppearance(); } export function resolveHostBadgeDisplay(input: { diff --git a/packages/app/src/push-notifications/internal/subscriptions.ts b/packages/app/src/push-notifications/internal/subscriptions.ts index 7a29347674..26df575ebc 100644 --- a/packages/app/src/push-notifications/internal/subscriptions.ts +++ b/packages/app/src/push-notifications/internal/subscriptions.ts @@ -2,23 +2,22 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import { Platform } from "react-native"; +import { z } from "zod"; +import { readValidatedString } from "@/storage/validated-storage"; import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./types"; const STORAGE_PREFIX = "@paseo:expo-push-token:"; +const ExpoPushTokenSchema = z.string().trim().min(1); function storageKey(serverId: string): string { return `${STORAGE_PREFIX}${serverId}`; } function getExpoProjectId(): string | null { - const constants = Constants as unknown as { - easConfig?: { projectId?: unknown }; - expoConfig?: { extra?: { eas?: { projectId?: unknown } } }; - }; - const fromEas = constants.easConfig?.projectId; - if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim(); - const fromExtra = constants.expoConfig?.extra?.eas?.projectId; - return typeof fromExtra === "string" && fromExtra.trim() ? fromExtra.trim() : null; + const fromEas = ExpoPushTokenSchema.safeParse(Constants.easConfig?.projectId); + if (fromEas.success) return fromEas.data; + const fromExtra = ExpoPushTokenSchema.safeParse(Constants.expoConfig?.extra?.eas?.projectId); + return fromExtra.success ? fromExtra.data : null; } async function ensurePushPermission(): Promise<boolean> { @@ -31,7 +30,7 @@ async function ensurePushPermission(): Promise<boolean> { async function resolveToken(serverId: string): Promise<string | null> { const key = storageKey(serverId); - const cached = await AsyncStorage.getItem(key); + const cached = await readValidatedString(AsyncStorage, key, ExpoPushTokenSchema); if (!(await ensurePushPermission())) { await AsyncStorage.removeItem(key); return null; @@ -87,7 +86,7 @@ export function startSubscription(input: StartPushNotificationsInput): () => voi export async function revokeSubscription(input: RevokePushNotificationsInput): Promise<void> { const key = storageKey(input.serverId); - const token = await AsyncStorage.getItem(key); + const token = await readValidatedString(AsyncStorage, key, ExpoPushTokenSchema); if ( token && input.client?.isConnected && diff --git a/packages/app/src/review/state.ts b/packages/app/src/review/state.ts index dff34ff038..1b5057a648 100644 --- a/packages/app/src/review/state.ts +++ b/packages/app/src/review/state.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export type ReviewDraftMode = "uncommitted" | "base"; export type ReviewDraftSide = "old" | "new"; @@ -30,8 +32,27 @@ export interface ReviewDraftStoreState { // Only drafts are persisted; diffModeOverrides is intentionally excluded. export interface SerializedReviewDraftState { drafts: Record<string, ReviewDraftComment[]>; + activeModesByScope?: Record<string, ReviewDraftMode>; } +const IsoDateTimeSchema = z.string().datetime({ offset: true }); +export const ReviewDraftCommentSchema: z.ZodType<ReviewDraftComment> = z.strictObject({ + id: z.string(), + filePath: z.string(), + side: z.enum(["old", "new"]), + lineNumber: z.number().int().positive(), + body: z.string(), + createdAt: IsoDateTimeSchema, + updatedAt: IsoDateTimeSchema, +}); + +export const SerializedReviewDraftStateSchema: z.ZodType<SerializedReviewDraftState> = + z.strictObject({ + drafts: z.record(z.string(), z.array(ReviewDraftCommentSchema)), + // COMPAT(reviewDraftModes): v1 persisted this field; v2 discards it during migration. + activeModesByScope: z.record(z.string(), z.enum(["uncommitted", "base"])).optional(), + }); + export function setDiffModeOverrideInState( state: ReviewDraftStoreState, input: { scopeKey: string; override: DiffModeOverride }, @@ -160,45 +181,11 @@ export function serializeReviewDraftState( } export function normalizePersistedState(state: unknown): ReviewDraftStoreState { - if (!state || typeof state !== "object") { - return { drafts: {}, diffModeOverrides: {} }; - } - // activeModesByScope may be present in old persisted JSON — tolerate and ignore it. - const persisted = state as { drafts?: unknown }; - const drafts = persisted.drafts; - if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) { - return { drafts: {}, diffModeOverrides: {} }; - } - - const normalized: Record<string, ReviewDraftComment[]> = {}; - for (const [key, value] of Object.entries(drafts)) { - if (!Array.isArray(value)) { - continue; - } - normalized[key] = value.filter((comment): comment is ReviewDraftComment => - isReviewDraftComment(comment), - ); - } - - return { drafts: normalized, diffModeOverrides: {} }; -} - -export function isReviewDraftComment(value: unknown): value is ReviewDraftComment { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record<string, unknown>; - return ( - typeof record.id === "string" && - typeof record.filePath === "string" && - (record.side === "old" || record.side === "new") && - typeof record.lineNumber === "number" && - Number.isInteger(record.lineNumber) && - record.lineNumber > 0 && - typeof record.body === "string" && - typeof record.createdAt === "string" && - typeof record.updatedAt === "string" - ); + const result = SerializedReviewDraftStateSchema.safeParse(state); + return { + drafts: result.success ? result.data.drafts : {}, + diffModeOverrides: {}, + }; } function applyCommentUpdates( diff --git a/packages/app/src/review/store.test.ts b/packages/app/src/review/store.test.ts index 62e68f0152..8c8dea9463 100644 --- a/packages/app/src/review/store.test.ts +++ b/packages/app/src/review/store.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import type { StateStorage } from "zustand/middleware"; import type { ParsedDiffFile } from "@/git/use-diff-query"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import { buildReviewAttachmentSnapshot, buildReviewDraftKey, @@ -16,6 +18,7 @@ import { type ReviewDraftComment, type ReviewDraftStoreState, serializeReviewDraftState, + SerializedReviewDraftStateSchema, setDiffModeOverrideInState, updateCommentInState, } from "./state"; @@ -69,6 +72,20 @@ function makeFile(): ParsedDiffFile { }; } +function createMemoryStorage(): StateStorage & { values: Map<string, string> } { + const values = new Map<string, string>(); + return { + values, + getItem: async (key) => values.get(key) ?? null, + setItem: async (key, value) => { + values.set(key, value); + }, + removeItem: async (key) => { + values.delete(key); + }, + }; +} + describe("buildReviewDraftKey", () => { it("scopes by server, workspace-or-cwd, diff mode, base ref, and whitespace mode", () => { const base = buildReviewDraftKey({ @@ -122,7 +139,27 @@ describe("buildReviewDraftKey", () => { }); describe("normalizePersistedState", () => { - it("filters invalid draft comments and ignores activeModesByScope from old persisted JSON", () => { + it("keeps v1 review comments for migration while dropping the legacy mode field", async () => { + const backing = createMemoryStorage(); + const legacyState = { + drafts: { "review:key": [makeComment()] }, + activeModesByScope: { "review:scope": "base" }, + }; + backing.values.set( + "@paseo:review-draft-store", + JSON.stringify({ state: legacyState, version: 1 }), + ); + const storage = createValidatedPersistStorage(backing, SerializedReviewDraftStateSchema); + + const stored = await storage.getItem("@paseo:review-draft-store"); + const normalized = normalizePersistedState(stored?.state); + + expect(normalized.drafts["review:key"]).toEqual([makeComment()]); + expect(normalized.diffModeOverrides).toEqual({}); + expect(backing.values.has("@paseo:review-draft-store")).toBe(true); + }); + + it("rejects the complete payload when any draft comment or field is invalid", () => { const normalized = normalizePersistedState({ drafts: { "review:key": [ @@ -146,17 +183,7 @@ describe("normalizePersistedState", () => { }); expect(normalized.diffModeOverrides).toEqual({}); - expect(normalized.drafts["review:key"]).toEqual([ - { - id: "comment-1", - filePath: "src/example.ts", - side: "new", - lineNumber: 41, - body: "Keep me.", - createdAt: "2026-04-21T00:00:00.000Z", - updatedAt: "2026-04-21T00:00:00.000Z", - }, - ]); + expect(normalized.drafts).toEqual({}); }); it("returns empty state for null, non-object, or malformed inputs", () => { diff --git a/packages/app/src/review/store.ts b/packages/app/src/review/store.ts index d1cc5e16b3..3f9d7c55ea 100644 --- a/packages/app/src/review/store.ts +++ b/packages/app/src/review/store.ts @@ -1,7 +1,7 @@ import { useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; import type { ComposerAttachment } from "@/attachments/types"; import type { ParsedDiffFile } from "@/git/use-diff-query"; import { @@ -17,12 +17,14 @@ import { type ReviewDraftSide, type ReviewDraftStoreState, serializeReviewDraftState, + SerializedReviewDraftStateSchema, setDiffModeOverrideInState, updateCommentInState, } from "@/review/state"; import { generateMessageId } from "@/types/stream"; import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type { DiffModeOverride, @@ -168,7 +170,7 @@ export const useReviewDraftStore = create<ReviewDraftStore>()( { name: "@paseo:review-draft-store", version: STORE_VERSION, - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, SerializedReviewDraftStateSchema), partialize: (state) => serializeReviewDraftState(state), migrate: async (state) => normalizePersistedState(state), }, diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 1fe16b72c0..9fc711d683 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -445,6 +445,9 @@ function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): H setItem: async (key, value) => { values.set(key, value); }, + removeItem: async (key) => { + values.delete(key); + }, }; } diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 5bd205074d..eff24ee328 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -3,6 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import equal from "fast-deep-equal/es6"; import { DaemonClient, + type DaemonClientConfig, type ConnectionState, type FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; @@ -11,6 +12,7 @@ import { normalizeStoredHostProfile, upsertHostConnectionInProfiles, registryHasConnection, + StoredHostRegistrySchema, type HostConnection, type HostProfile, } from "@/types/host-connection"; @@ -28,6 +30,8 @@ import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { isWeb } from "@/constants/platform"; import { connectToDaemon } from "@/utils/test-daemon-connection"; import { getOrCreateClientId } from "@/utils/client-id"; +import { z } from "zod"; +import { readValidatedJson, readValidatedString } from "@/storage/validated-storage"; import { selectBestConnection, type ConnectionCandidate, @@ -163,6 +167,7 @@ export interface HostRuntimeControllerDeps { export interface HostRuntimeStorage { getItem: (key: string) => Promise<string | null>; setItem: (key: string, value: string) => Promise<void>; + removeItem: (key: string) => Promise<void>; } export interface HostRuntimeStartOptions { @@ -483,12 +488,12 @@ function createDefaultDeps(): HostRuntimeControllerDeps { const base = { suppressSendErrors: true, clientId, - clientType: "mobile" as const, + clientType: "mobile", appVersion: resolveAppVersion() ?? undefined, runtimeGeneration, capabilities: appCapabilities, trace: nativePerformanceTrace, - }; + } satisfies Omit<DaemonClientConfig, "url">; if (connection.type === "directSocket" || connection.type === "directPipe") { return new DaemonClient({ ...base, @@ -1288,9 +1293,10 @@ export interface InitialDaemonConnectionHint { useTls?: boolean; } -function isInitialConnectionHintRecord(value: unknown): value is Record<string, unknown> { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} +const InitialDaemonConnectionHintSchema: z.ZodType<InitialDaemonConnectionHint> = z.object({ + listen: z.string().trim().min(1), + useTls: z.boolean().optional().default(false), +}); export function readInitialDaemonConnectionHint(input?: { isWebRuntime?: boolean; @@ -1299,18 +1305,10 @@ export function readInitialDaemonConnectionHint(input?: { if (!isWebRuntime || typeof globalThis === "undefined") { return null; } - const value = (globalThis as Record<string, unknown>)[INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY]; - if (!isInitialConnectionHintRecord(value)) { - return null; - } - const listen = typeof value.listen === "string" ? value.listen.trim() : ""; - if (!listen) { - return null; - } - return { - listen, - useTls: value.useTls === true, - }; + const result = InitialDaemonConnectionHintSchema.safeParse( + Reflect.get(globalThis, INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY), + ); + return result.success ? result.data : null; } function readConfiguredLocalDaemonOverride(): string | null { @@ -1419,7 +1417,7 @@ export class HostRuntimeStore { let isE2E: string | null = null; try { - isE2E = await this.storage.getItem(E2E_STORAGE_KEY); + isE2E = await readValidatedString(this.storage, E2E_STORAGE_KEY, z.string().min(1)); } catch { return; } @@ -1452,17 +1450,25 @@ export class HostRuntimeStore { let shouldPersistHosts = false; let profiles: HostProfile[] = []; try { - const stored = await this.storage.getItem(REGISTRY_STORAGE_KEY); + const stored = await readValidatedJson( + this.storage, + REGISTRY_STORAGE_KEY, + StoredHostRegistrySchema, + ); if (stored) { - const parsed = JSON.parse(stored) as unknown; - if (Array.isArray(parsed)) { - const normalizedProfiles = parsed - .map((entry) => normalizeStoredHostProfile(entry)) - .filter((entry): entry is HostProfile => entry !== null); - profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); - if (profiles.length !== normalizedProfiles.length) { - shouldPersistHosts = true; + const normalizedProfiles: HostProfile[] = []; + for (const entry of stored) { + const profile = normalizeStoredHostProfile(entry); + if (!profile) { + await this.storage.removeItem(REGISTRY_STORAGE_KEY); + normalizedProfiles.length = 0; + break; } + normalizedProfiles.push(profile); + } + profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); + if (profiles.length !== normalizedProfiles.length) { + shouldPersistHosts = true; } } this.hosts = profiles; @@ -1922,7 +1928,11 @@ export class HostRuntimeStore { void this.persistHosts().catch((error) => console.error("[HostRuntime] Failed to persist host registry", error), ); - return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile; + const profile = next.find((daemon) => daemon.serverId === input.serverId); + if (!profile) { + throw new Error(`Host ${input.serverId} was not inserted`); + } + return profile; } private setHostsAndSync( @@ -2321,25 +2331,19 @@ export class HostRuntimeStore { let singletonHostRuntimeStore: HostRuntimeStore | null = null; const HOST_RUNTIME_STORE_GLOBAL_KEY = "__paseoHostRuntimeStore"; -type HostRuntimeGlobal = typeof globalThis & { - [HOST_RUNTIME_STORE_GLOBAL_KEY]?: HostRuntimeStore; -}; - export function getHostRuntimeStore(): HostRuntimeStore { if (singletonHostRuntimeStore) { return singletonHostRuntimeStore; } - const runtimeGlobal = globalThis as HostRuntimeGlobal; - if (runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY]) { - singletonHostRuntimeStore = runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] ?? null; - if (singletonHostRuntimeStore) { - return singletonHostRuntimeStore; - } + const existing = Reflect.get(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY); + if (existing instanceof HostRuntimeStore) { + singletonHostRuntimeStore = existing; + return existing; } singletonHostRuntimeStore = new HostRuntimeStore(); - runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] = singletonHostRuntimeStore; + Reflect.set(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY, singletonHostRuntimeStore); return singletonHostRuntimeStore; } @@ -2396,12 +2400,11 @@ export function useHostRuntimeConnectionStatuses( return useMemo(() => { // The aggregate version is the reactivity trigger; re-read snapshots on every host tick. void version; - return new Map( - serverIds.map( - (serverId) => - [serverId, store.getSnapshot(serverId)?.connectionStatus ?? "connecting"] as const, - ), - ); + const entries: Array<[string, HostRuntimeConnectionStatus]> = serverIds.map((serverId) => [ + serverId, + store.getSnapshot(serverId)?.connectionStatus ?? "connecting", + ]); + return new Map(entries); }, [serverIds, store, version]); } diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 8149032f38..6066aed514 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -25,6 +25,10 @@ class MemoryStorage implements ReplicaCacheStorage { this.writes += 1; this.values.set(key, value); } + + async removeItem(key: string): Promise<void> { + this.values.delete(key); + } } function workspace( @@ -272,7 +276,7 @@ describe("ReplicaCache", () => { version: number; hosts: Array<{ timeline: Record<string, unknown> | null }>; }; - expect(persisted.version).toBe(4); + expect(persisted.version).toBe(5); expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]); }); @@ -331,12 +335,12 @@ describe("ReplicaCache", () => { expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]); }); - it("rejects version 3 cache data and overwrites it on flush", async () => { + it("rejects and clears version 4 cache data before overwriting it on flush", async () => { const storage = new MemoryStorage(); storage.values.set( "@paseo:replica-cache", JSON.stringify({ - version: 3, + version: 4, hosts: [ { serverId: SERVER_ID, @@ -357,11 +361,12 @@ describe("ReplicaCache", () => { cache.setHosts([SERVER_ID]); await cache.restore(); + expect(storage.values.has("@paseo:replica-cache")).toBe(false); await cache.flush(); expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined(); expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({ - version: 4, + version: 5, hosts: [], }); }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index c773327bb6..7459b526e3 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,19 +1,14 @@ import { Buffer } from "buffer"; import { z } from "zod"; -import { - AgentSnapshotPayloadSchema, - ProjectPlacementPayloadSchema, - WorkspaceDescriptorPayloadSchema, - WorkspaceProjectDescriptorPayloadSchema, - type AgentSnapshotPayload, - type WorkspaceDescriptorPayload, -} from "@getpaseo/protocol/messages"; +import { AgentStatusSchema } from "@getpaseo/protocol/messages"; +import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest"; import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, selectAgentTimelineState, useSessionStore, type Agent, + type AgentTimelineState, type SessionReplica, type SessionState, type ProjectDescriptor, @@ -23,39 +18,217 @@ import { isUnreconciledLocalUserMessage, type StreamItem } from "@/types/stream" import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; const STORAGE_KEY = "@paseo:replica-cache"; -const CACHE_VERSION = 4; +const CACHE_VERSION = 5; const PERSIST_DELAY_MS = 750; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 1024 * 1024; -const DATE_TAG = "__paseoDate"; - -const StoredAgentSchema = z.object({ - snapshot: AgentSnapshotPayloadSchema, - projectPlacement: ProjectPlacementPayloadSchema.nullable(), - lastActivityAt: z.string(), +const IsoDateSchema = z.iso.datetime(); +const TimelinePositionSchema = z.strictObject({ + epoch: z.string(), + seq: z.number().int().nonnegative(), }); -const StoredTimelineSchema = z.object({ +const TimelineItemBaseShape = { + id: z.string(), + timelineCursor: TimelinePositionSchema.optional(), + timestamp: IsoDateSchema, +}; + +const TodoEntrySchema = z.strictObject({ + text: z.string(), + completed: z.boolean(), + id: z.string().optional(), + status: z.enum(["pending", "in_progress", "completed"]).optional(), + activeForm: z.string().optional(), +}); + +const TaskActivitySchema = z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("created"), count: z.number().int().nonnegative() }), + z.strictObject({ + type: z.enum(["added", "started", "completed", "reopened"]), + task: z.string(), + }), +]); + +const StoredTimelineItemSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("user_message"), + clientMessageId: z.string().optional(), + messageId: z.string().optional(), + text: z.string(), + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("assistant_message"), + messageId: z.string().optional(), + text: z.string(), + blockGroupId: z.string().optional(), + blockIndex: z.number().int().nonnegative().optional(), + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("thought"), + text: z.string(), + status: z.enum(["loading", "ready"]), + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("todo_list"), + provider: AgentProviderSchema, + items: z.array(TodoEntrySchema), + activity: TaskActivitySchema, + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("activity_log"), + activityType: z.enum(["system", "info", "success", "error"]), + message: z.string(), + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("compaction"), + status: z.enum(["loading", "completed"]), + trigger: z.enum(["auto", "manual"]).optional(), + preTokens: z.number().nonnegative().optional(), + }), +]); + +const AgentCapabilitiesSchema = z.strictObject({ + supportsStreaming: z.boolean(), + supportsSessionPersistence: z.boolean(), + supportsSessionListing: z.boolean().optional(), + supportsDynamicModes: z.boolean(), + supportsMcpServers: z.boolean(), + supportsReasoningStream: z.boolean(), + supportsToolInvocations: z.boolean(), + supportsRewindConversation: z.boolean().optional(), + supportsRewindFiles: z.boolean().optional(), + supportsRewindBoth: z.boolean().optional(), +}); + +const StoredAgentSnapshotSchema = z.strictObject({ + id: z.string(), + provider: AgentProviderSchema, + cwd: z.string(), + workspaceId: z.string().optional(), + model: z.string().nullable(), + thinkingOptionId: z.string().nullable().optional(), + createdAt: IsoDateSchema, + updatedAt: IsoDateSchema, + lastUserMessageAt: IsoDateSchema.nullable(), + status: AgentStatusSchema, + activeTurn: z + .strictObject({ + turnId: z.string(), + startedAt: IsoDateSchema.nullable(), + }) + .nullable() + .optional(), + capabilities: AgentCapabilitiesSchema, + currentModeId: z.string().nullable(), + availableModes: z.array(z.never()).max(0), + pendingPermissions: z.array(z.never()).max(0), + persistence: z.null(), + lastError: z.string().optional(), + title: z.string().nullable(), + labels: z.record(z.string(), z.string()), + requiresAttention: z.boolean().optional(), + attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), + attentionTimestamp: IsoDateSchema.nullable().optional(), + archivedAt: IsoDateSchema.nullable().optional(), +}); + +const StoredAgentSchema = z.strictObject({ + snapshot: StoredAgentSnapshotSchema, + projectPlacement: z.null(), + lastActivityAt: IsoDateSchema, +}); + +const WorkspaceScriptSchema = z.strictObject({ + scriptName: z.string(), + type: z.enum(["script", "service"]), + hostname: z.string(), + port: z.number().int().positive().nullable(), + localProxyUrl: z.string().nullable().optional(), + publicProxyUrl: z.string().nullable().optional(), + proxyUrl: z.string().nullable(), + lifecycle: z.enum(["running", "stopped"]), + health: z.enum(["healthy", "unhealthy"]).nullable(), + exitCode: z.number().nullable(), + terminalId: z.string().nullable(), +}); + +const WorkspaceGitRuntimeSchema = z + .strictObject({ + currentBranch: z.string().nullable().optional(), + remoteUrl: z.string().nullable().optional(), + isPaseoOwnedWorktree: z.boolean().optional(), + isDirty: z.boolean().nullable().optional(), + aheadBehind: z.strictObject({ ahead: z.number(), behind: z.number() }).nullable().optional(), + aheadOfOrigin: z.number().nullable().optional(), + behindOfOrigin: z.number().nullable().optional(), + }) + .nullable() + .optional(); + +const StoredWorkspaceSchema = z.strictObject({ + id: z.string(), + projectId: z.string(), + projectDisplayName: z.string(), + projectCustomName: z.string().nullable(), + projectCustomIconRevision: z.string().nullable(), + projectRootPath: z.string(), + workspaceDirectory: z.string(), + worktreeSlug: z.string().optional(), + projectKind: z.enum(["git", "non_git", "directory"]), + workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]), + name: z.string(), + title: z.string().nullable(), + pinnedAt: z.string().nullable(), + status: z.enum(["needs_input", "failed", "running", "attention", "done"]), + statusEnteredAt: IsoDateSchema.nullable(), + activityAt: z.null(), + archivingAt: z.string().nullable(), + diffStat: z.strictObject({ additions: z.number(), deletions: z.number() }).nullable(), + scripts: z.array(WorkspaceScriptSchema), + gitRuntime: WorkspaceGitRuntimeSchema, + forge: z.string().optional(), +}); + +const StoredProjectSchema = z.strictObject({ + projectId: z.string(), + projectKey: z.string().optional(), + projectDisplayName: z.string(), + projectCustomName: z.string().nullable(), + projectRootPath: z.string(), + projectKind: z.enum(["git", "non_git", "directory"]), +}); + +const StoredTimelineSchema = z.strictObject({ agentId: z.string(), - items: z.unknown(), + items: z.array(StoredTimelineItemSchema), }); -const StoredHostSchema = z.object({ +const StoredHostSchema = z.strictObject({ serverId: z.string(), agents: z.array(StoredAgentSchema), - workspaces: z.array(WorkspaceDescriptorPayloadSchema), - projects: z.array(WorkspaceProjectDescriptorPayloadSchema).optional().default([]), - emptyProjects: z.array(WorkspaceProjectDescriptorPayloadSchema), + workspaces: z.array(StoredWorkspaceSchema), + projects: z.array(StoredProjectSchema), + emptyProjects: z.array(StoredProjectSchema), timeline: StoredTimelineSchema.nullable(), }); -const StoredCacheSchema = z.object({ +const StoredCacheSchema = z.strictObject({ version: z.literal(CACHE_VERSION), hosts: z.array(StoredHostSchema), }); type StoredAgent = z.infer<typeof StoredAgentSchema>; type StoredHost = z.infer<typeof StoredHostSchema>; +type StoredTimelineItem = z.infer<typeof StoredTimelineItemSchema>; +type StoredWorkspace = z.infer<typeof StoredWorkspaceSchema>; +type StoredProject = z.infer<typeof StoredProjectSchema>; interface ReplicaInput { agent: Agent | undefined; @@ -67,104 +240,177 @@ interface ReplicaInput { export interface ReplicaCacheStorage { getItem: (key: string) => Promise<string | null>; setItem: (key: string, value: string) => Promise<void>; + removeItem: (key: string) => Promise<void>; } interface ReplicaCacheOptions { maxBytes?: number; } -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function hasString(value: Record<string, unknown>, key: string): boolean { - return typeof value[key] === "string"; -} - -function isStreamItem(value: unknown): value is StreamItem { - if (!isRecord(value) || !hasString(value, "id") || !(value.timestamp instanceof Date)) { - return false; - } - switch (value.kind) { - case "user_message": - case "assistant_message": - return hasString(value, "text"); - case "thought": - return hasString(value, "text") && (value.status === "loading" || value.status === "ready"); - case "tool_call": - return isRecord(value.payload) && isRecord(value.payload.data); - case "todo_list": - return hasString(value, "provider") && Array.isArray(value.items); - case "activity_log": - return hasString(value, "message") && hasString(value, "activityType"); - case "compaction": - return value.status === "loading" || value.status === "completed"; - default: - return false; - } -} - -function encodeDates(value: unknown): unknown { - if (value instanceof Date) { - return { [DATE_TAG]: value.toISOString() }; - } - if (Array.isArray(value)) { - return value.map(encodeDates); - } - if (!isRecord(value)) { - return value; - } - return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, encodeDates(entry)])); -} - -function decodeDates(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(decodeDates); - } - if (!isRecord(value)) { - return value; - } - if (Object.keys(value).length === 1 && typeof value[DATE_TAG] === "string") { - const date = new Date(value[DATE_TAG]); - return Number.isNaN(date.getTime()) ? value : date; - } - return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, decodeDates(entry)])); -} - function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["timeline"] { if (!stored) { return null; } - const decoded = decodeDates(stored.items); - if (!Array.isArray(decoded) || !decoded.every(isStreamItem)) { - return null; - } return { agentId: stored.agentId, - items: decoded, + items: stored.items.map(deserializeTimelineItem), }; } +function timelineBase(item: StreamItem) { + return { + id: item.id, + ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + timestamp: item.timestamp.toISOString(), + }; +} + +function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { + const base = timelineBase(item); + switch (item.kind) { + case "user_message": + return { + ...base, + kind: item.kind, + ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + }; + case "assistant_message": + return { + ...base, + kind: item.kind, + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + ...(item.blockGroupId ? { blockGroupId: item.blockGroupId } : {}), + ...(item.blockIndex !== undefined ? { blockIndex: item.blockIndex } : {}), + }; + case "thought": + return { ...base, kind: item.kind, text: item.text, status: item.status }; + case "todo_list": + return { + ...base, + kind: item.kind, + provider: item.provider, + items: item.items, + activity: item.activity, + }; + case "activity_log": + return { + ...base, + kind: item.kind, + activityType: item.activityType, + message: item.message, + }; + case "compaction": + return { + ...base, + kind: item.kind, + status: item.status, + ...(item.trigger ? { trigger: item.trigger } : {}), + ...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}), + }; + case "tool_call": + return null; + } +} + +function deserializeTimelineItem(item: StoredTimelineItem): StreamItem { + const base = { + id: item.id, + ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + timestamp: new Date(item.timestamp), + }; + switch (item.kind) { + case "user_message": + return { + ...base, + kind: item.kind, + ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + }; + case "assistant_message": + return { + ...base, + kind: item.kind, + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + ...(item.blockGroupId ? { blockGroupId: item.blockGroupId } : {}), + ...(item.blockIndex !== undefined ? { blockIndex: item.blockIndex } : {}), + }; + case "thought": + return { ...base, kind: item.kind, text: item.text, status: item.status }; + case "todo_list": + return { + ...base, + kind: item.kind, + provider: item.provider, + items: item.items, + activity: item.activity, + }; + case "activity_log": + return { + ...base, + kind: item.kind, + activityType: item.activityType, + message: item.message, + }; + case "compaction": + return { + ...base, + kind: item.kind, + status: item.status, + ...(item.trigger ? { trigger: item.trigger } : {}), + ...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}), + }; + } +} + function serializeAgent(agent: Agent): StoredAgent { - const snapshot: AgentSnapshotPayload = { + const snapshot = { id: agent.id, provider: agent.provider, cwd: agent.cwd, ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), model: agent.model, - ...(agent.features ? { features: agent.features } : {}), thinkingOptionId: agent.thinkingOptionId ?? null, createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, status: agent.status, - capabilities: agent.capabilities, + ...(agent.activeTurn?.turnId + ? { + activeTurn: { + turnId: agent.activeTurn.turnId, + startedAt: agent.activeTurn.startedAt?.toISOString() ?? null, + }, + } + : {}), + capabilities: { + supportsStreaming: agent.capabilities.supportsStreaming, + supportsSessionPersistence: agent.capabilities.supportsSessionPersistence, + ...(agent.capabilities.supportsSessionListing !== undefined + ? { supportsSessionListing: agent.capabilities.supportsSessionListing } + : {}), + supportsDynamicModes: agent.capabilities.supportsDynamicModes, + supportsMcpServers: agent.capabilities.supportsMcpServers, + supportsReasoningStream: agent.capabilities.supportsReasoningStream, + supportsToolInvocations: agent.capabilities.supportsToolInvocations, + ...(agent.capabilities.supportsRewindConversation !== undefined + ? { supportsRewindConversation: agent.capabilities.supportsRewindConversation } + : {}), + ...(agent.capabilities.supportsRewindFiles !== undefined + ? { supportsRewindFiles: agent.capabilities.supportsRewindFiles } + : {}), + ...(agent.capabilities.supportsRewindBoth !== undefined + ? { supportsRewindBoth: agent.capabilities.supportsRewindBoth } + : {}), + }, currentModeId: agent.currentModeId, - availableModes: agent.availableModes, + availableModes: [], pendingPermissions: [], - persistence: agent.persistence, - ...(agent.runtimeInfo ? { runtimeInfo: agent.runtimeInfo } : {}), - ...(agent.lastUsage ? { lastUsage: agent.lastUsage } : {}), + persistence: null, ...(agent.lastError ? { lastError: agent.lastError } : {}), title: agent.title, labels: agent.labels, @@ -175,7 +421,7 @@ function serializeAgent(agent: Agent): StoredAgent { }; return { snapshot, - projectPlacement: agent.projectPlacement ?? null, + projectPlacement: null, lastActivityAt: agent.lastActivityAt.toISOString(), }; } @@ -188,7 +434,7 @@ function deserializeAgent(serverId: string, stored: StoredAgent): Agent { }; } -function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptorPayload { +function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace { return { id: workspace.id, projectId: workspace.projectId, @@ -208,15 +454,25 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor activityAt: null, archivingAt: workspace.archivingAt, diffStat: workspace.diffStat, - scripts: workspace.scripts, + scripts: workspace.scripts.map((script) => ({ + scriptName: script.scriptName, + type: script.type, + hostname: script.hostname, + port: script.port, + ...(script.localProxyUrl !== undefined ? { localProxyUrl: script.localProxyUrl } : {}), + ...(script.publicProxyUrl !== undefined ? { publicProxyUrl: script.publicProxyUrl } : {}), + proxyUrl: script.proxyUrl, + lifecycle: script.lifecycle, + health: script.health, + exitCode: script.exitCode, + terminalId: script.terminalId, + })), gitRuntime: workspace.gitRuntime, - githubRuntime: workspace.githubRuntime, forge: workspace.forge, - project: workspace.project, }; } -function serializeProject(project: ProjectDescriptor) { +function serializeProject(project: ProjectDescriptor): StoredProject { return { projectId: project.projectId, ...(project.projectKey ? { projectKey: project.projectKey } : {}), @@ -244,9 +500,9 @@ function selectReplicaInput(session: SessionState, agentId: string | null): Repl (candidate) => candidate.workspaceDirectory === agent.cwd, )) : undefined; - const timeline = agentId + const timeline: AgentTimelineState = agentId ? selectAgentTimelineState(session, agentId) - : { status: "cold" as const }; + : { status: "cold" }; return { agent, workspace, @@ -256,9 +512,10 @@ function selectReplicaInput(session: SessionState, agentId: string | null): Repl } function serializeHost(serverId: string, input: ReplicaInput): StoredHost { - const items = input.timelineItems?.filter( - (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), - ); + const items = input.timelineItems + ?.filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item)) + .map(serializeTimelineItem) + .filter((item) => item !== null); return { serverId, agents: input.agent ? [serializeAgent(input.agent)] : [], @@ -269,7 +526,7 @@ function serializeHost(serverId: string, input: ReplicaInput): StoredHost { input.agent && items ? { agentId: input.agent.id, - items: encodeDates(items.slice(-MAX_TIMELINE_ITEMS)), + items: items.slice(-MAX_TIMELINE_ITEMS), } : null, }; @@ -360,10 +617,14 @@ export class ReplicaCache { try { parsed = JSON.parse(raw); } catch { + await this.clearInvalidCache(); return; } const cache = StoredCacheSchema.safeParse(parsed); - if (!cache.success) return; + if (!cache.success) { + await this.clearInvalidCache(); + return; + } for (const host of cache.data.hosts) { if (!this.activeServerIds.has(host.serverId)) { this.needsPersist = true; @@ -372,7 +633,13 @@ export class ReplicaCache { this.storedHosts.set(host.serverId, host); if (host.timeline) this.lastFocusedAgentIds.set(host.serverId, host.timeline.agentId); } - if (this.buildBoundedPayload().evicted) this.needsPersist = true; + const bounded = this.buildBoundedPayload(); + if (!bounded) { + await this.clearInvalidCache(); + this.storedHosts.clear(); + return; + } + if (bounded.evicted) this.needsPersist = true; for (const host of this.storedHosts.values()) { useSessionStore.getState().restoreSessionReplica(host.serverId, deserializeHost(host)); const session = useSessionStore.getState().sessions[host.serverId]; @@ -427,11 +694,15 @@ export class ReplicaCache { this.persistTimer = null; } this.captureSessions(); - const { payload } = this.buildBoundedPayload(); + const bounded = this.buildBoundedPayload(); this.needsPersist = false; + if (!bounded) { + await this.clearInvalidCache(); + return; + } const write = this.writeQueue .catch(() => undefined) - .then(() => this.storage.setItem(STORAGE_KEY, payload)); + .then(() => this.storage.setItem(STORAGE_KEY, bounded.payload)); this.writeQueue = write; await write.catch(() => undefined); } @@ -461,24 +732,35 @@ export class ReplicaCache { return true; } - private buildBoundedPayload(): { payload: string; evicted: boolean } { + private buildBoundedPayload(): { payload: string; evicted: boolean } | null { let evicted = false; let payload = this.serialize(); + if (payload === null) return null; while (Buffer.byteLength(payload, "utf8") > this.maxBytes && this.storedHosts.size > 0) { const oldestServerId = this.storedHosts.keys().next().value; if (oldestServerId === undefined) break; this.storedHosts.delete(oldestServerId); evicted = true; payload = this.serialize(); + if (payload === null) return null; } return { payload, evicted }; } - private serialize(): string { - return JSON.stringify({ + private serialize(): string | null { + const cache = StoredCacheSchema.safeParse({ version: CACHE_VERSION, hosts: Array.from(this.storedHosts.values()), }); + return cache.success ? JSON.stringify(cache.data) : null; + } + + private async clearInvalidCache(): Promise<void> { + try { + await this.storage.removeItem(STORAGE_KEY); + } catch { + // A storage failure must not turn invalid cached data into application state. + } } private schedulePersist(): void { diff --git a/packages/app/src/storage/validated-persist-storage.test.ts b/packages/app/src/storage/validated-persist-storage.test.ts new file mode 100644 index 0000000000..7255086e37 --- /dev/null +++ b/packages/app/src/storage/validated-persist-storage.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import type { StateStorage } from "zustand/middleware"; +import { createValidatedPersistStorage } from "./validated-persist-storage"; + +class MemoryStorage implements StateStorage { + readonly values = new Map<string, string>(); + + getItem(name: string): string | null { + return this.values.get(name) ?? null; + } + + setItem(name: string, value: string): void { + this.values.set(name, value); + } + + removeItem(name: string): void { + this.values.delete(name); + } +} + +const StateSchema = z.strictObject({ enabled: z.boolean() }); + +describe("createValidatedPersistStorage", () => { + it.each([ + ["malformed JSON", "{"], + ["invalid state", JSON.stringify({ state: { enabled: "yes" }, version: 1 })], + ["unknown state fields", JSON.stringify({ state: { enabled: true, extra: true }, version: 1 })], + [ + "unknown envelope fields", + JSON.stringify({ state: { enabled: true }, version: 1, extra: true }), + ], + ])("clears %s", async (_label, stored) => { + const backing = new MemoryStorage(); + backing.values.set("settings", stored); + const storage = createValidatedPersistStorage(backing, StateSchema); + + await expect(storage.getItem("settings")).resolves.toBeNull(); + expect(backing.values.has("settings")).toBe(false); + }); + + it("returns schema-validated state", async () => { + const backing = new MemoryStorage(); + backing.values.set("settings", JSON.stringify({ state: { enabled: true }, version: 1 })); + const storage = createValidatedPersistStorage(backing, StateSchema); + + await expect(storage.getItem("settings")).resolves.toEqual({ + state: { enabled: true }, + version: 1, + }); + }); + + it("clears a value rejected by the schema before writing", async () => { + const backing = new MemoryStorage(); + backing.values.set("settings", JSON.stringify({ state: { count: 1 } })); + const storage = createValidatedPersistStorage( + backing, + z.strictObject({ count: z.number().finite() }), + ); + + await storage.setItem("settings", { state: { count: Number.NaN } }); + + expect(backing.values.has("settings")).toBe(false); + }); +}); diff --git a/packages/app/src/storage/validated-persist-storage.ts b/packages/app/src/storage/validated-persist-storage.ts new file mode 100644 index 0000000000..07459f8642 --- /dev/null +++ b/packages/app/src/storage/validated-persist-storage.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import type { PersistStorage, StateStorage } from "zustand/middleware"; + +export function createValidatedPersistStorage<State>( + backingStorage: StateStorage, + stateSchema: z.ZodType<State>, +): PersistStorage<State> { + const envelopeSchema = z.strictObject({ + state: stateSchema, + version: z.number().int().nonnegative().optional(), + }); + + return { + getItem: async (name) => { + const raw = await backingStorage.getItem(name); + if (raw === null) return null; + + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + await backingStorage.removeItem(name); + return null; + } + + const result = envelopeSchema.safeParse(decoded); + if (!result.success) { + await backingStorage.removeItem(name); + return null; + } + return result.data; + }, + setItem: async (name, value) => { + const result = envelopeSchema.safeParse(value); + if (!result.success) { + await backingStorage.removeItem(name); + return; + } + await backingStorage.setItem(name, JSON.stringify(result.data)); + }, + removeItem: (name) => backingStorage.removeItem(name), + }; +} diff --git a/packages/app/src/storage/validated-storage.test.ts b/packages/app/src/storage/validated-storage.test.ts new file mode 100644 index 0000000000..8ebd7339db --- /dev/null +++ b/packages/app/src/storage/validated-storage.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { readValidatedJson, readValidatedString } from "./validated-storage"; + +class MemoryStorage { + readonly values = new Map<string, string>(); + + async getItem(key: string): Promise<string | null> { + return this.values.get(key) ?? null; + } + + async removeItem(key: string): Promise<void> { + this.values.delete(key); + } +} + +describe("validated storage reads", () => { + it.each([ + ["malformed JSON", "{"], + ["wrong field type", JSON.stringify({ enabled: "yes" })], + ["unknown field", JSON.stringify({ enabled: true, surprise: true })], + ])("clears invalid JSON payloads: %s", async (_label, raw) => { + const storage = new MemoryStorage(); + storage.values.set("settings", raw); + + await expect( + readValidatedJson(storage, "settings", z.strictObject({ enabled: z.boolean() })), + ).resolves.toBeNull(); + expect(storage.values.has("settings")).toBe(false); + }); + + it("clears invalid scalar values", async () => { + const storage = new MemoryStorage(); + storage.values.set("channel", "nightly"); + + await expect( + readValidatedString(storage, "channel", z.enum(["stable", "beta"])), + ).resolves.toBeNull(); + expect(storage.values.has("channel")).toBe(false); + }); + + it("returns only schema-validated values", async () => { + const storage = new MemoryStorage(); + storage.values.set("settings", JSON.stringify({ enabled: true })); + + await expect( + readValidatedJson(storage, "settings", z.strictObject({ enabled: z.boolean() })), + ).resolves.toEqual({ enabled: true }); + expect(storage.values.has("settings")).toBe(true); + }); +}); diff --git a/packages/app/src/storage/validated-storage.ts b/packages/app/src/storage/validated-storage.ts new file mode 100644 index 0000000000..b47c92e493 --- /dev/null +++ b/packages/app/src/storage/validated-storage.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +export interface ValidatedStorage { + getItem(key: string): Promise<string | null>; + removeItem(key: string): Promise<void>; +} + +async function clearInvalidValue(storage: ValidatedStorage, key: string): Promise<void> { + try { + await storage.removeItem(key); + } catch { + // Invalid persisted data must never cross the boundary, even when cleanup fails. + } +} + +export async function readValidatedString<Value>( + storage: ValidatedStorage, + key: string, + schema: z.ZodType<Value>, +): Promise<Value | null> { + const raw = await storage.getItem(key); + if (raw === null) return null; + const result = schema.safeParse(raw); + if (result.success) return result.data; + await clearInvalidValue(storage, key); + return null; +} + +export async function readValidatedJson<Value>( + storage: ValidatedStorage, + key: string, + schema: z.ZodType<Value>, +): Promise<Value | null> { + const raw = await storage.getItem(key); + if (raw === null) return null; + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + await clearInvalidValue(storage, key); + return null; + } + const result = schema.safeParse(decoded); + if (result.success) return result.data; + await clearInvalidValue(storage, key); + return null; +} diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 5285170415..4b89ee98c0 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types"; import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file"; @@ -27,8 +27,14 @@ import { type DraftRecord, type DraftStoreState, } from "./state"; -import { migrateDraftInput, migratePersistedState, type MigrateLegacyImages } from "./migration"; +import { + migrateDraftInput, + migratePersistedState, + type MigrateLegacyImages, + PersistedDraftStoreSchema, +} from "./migration"; import { createDraftPersistStorage } from "./persistence"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type { DraftInput, DraftLifecycleState } from "./state"; @@ -58,7 +64,7 @@ type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions; let gcScheduled = false; const draftPersistStorage = createDraftPersistStorage( - createJSONStorage<DraftStoreState>(() => AsyncStorage), + createValidatedPersistStorage(AsyncStorage, PersistedDraftStoreSchema), ); export function flushDraftPersistStorage(): Promise<void> { @@ -414,12 +420,11 @@ export const useDraftStore = create<DraftStore>()( version: DRAFT_STORE_VERSION, storage: draftPersistStorage, partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }), - migrate: (persistedState) => { - return migratePersistedState(persistedState, { + migrate: (state) => + migratePersistedState(state, { migrateLegacyImages, nowMs: Date.now(), - }); - }, + }), onRehydrateStorage: () => { return () => { void migrateAllLegacyDrafts(); diff --git a/packages/app/src/stores/draft-store/migration.test.ts b/packages/app/src/stores/draft-store/migration.test.ts index 6ef20097fa..218e74c4e0 100644 --- a/packages/app/src/stores/draft-store/migration.test.ts +++ b/packages/app/src/stores/draft-store/migration.test.ts @@ -1,11 +1,31 @@ import { describe, expect, it } from "vitest"; +import type { StateStorage } from "zustand/middleware"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; -import { migratePersistedState, type MigrateLegacyImages } from "./migration"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; +import { + migratePersistedState, + type MigrateLegacyImages, + PersistedDraftStoreSchema, +} from "./migration"; import { isAttachmentMetadata, type DraftRecord } from "./state"; const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) => images.filter(isAttachmentMetadata); +function createMemoryStorage(): StateStorage & { values: Map<string, string> } { + const values = new Map<string, string>(); + return { + values, + getItem: async (key) => values.get(key) ?? null, + setItem: async (key, value) => { + values.set(key, value); + }, + removeItem: async (key) => { + values.delete(key); + }, + }; +} + function activeDraft( text: string, updatedAt: number, @@ -96,6 +116,30 @@ function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "revie } describe("draft-store migration", () => { + it("keeps a supported legacy draft for migration", async () => { + const backing = createMemoryStorage(); + const legacyState = { + drafts: { + "draft:unsent": { + text: "do not lose this", + attachments: [], + }, + }, + createModalDraft: null, + }; + backing.values.set("paseo-drafts", JSON.stringify({ state: legacyState, version: 4 })); + const storage = createValidatedPersistStorage(backing, PersistedDraftStoreSchema); + + const stored = await storage.getItem("paseo-drafts"); + const migrated = await migratePersistedState(stored?.state, { + migrateLegacyImages: passThroughMigrateLegacyImages, + nowMs: 1, + }); + + expect(migrated.drafts["draft:unsent"]?.input.text).toBe("do not lose this"); + expect(backing.values.has("paseo-drafts")).toBe(true); + }); + it("promotes the newest legacy New Workspace draft into the singleton surface", async () => { const forkDraft = activeDraft("fork context", 1700000000003); const agentDraft = activeDraft("agent prompt", 1700000000004); @@ -242,25 +286,35 @@ describe("draft-store migration", () => { }); }); - it("rejects workspace review attachments from migrated draft attachments", async () => { - const migrated = await migratePersistedState( - { - drafts: { - "agent:server:agent": { - input: { - text: "hello", - attachments: [workspaceReviewAttachment()], - }, - lifecycle: "active", - updatedAt: 1700000000001, - version: 2, + it("drops a legacy workspace review attachment without deleting its draft", async () => { + const backing = createMemoryStorage(); + const persistedState = { + drafts: { + "agent:server:agent": { + input: { + text: "hello", + attachments: [workspaceReviewAttachment()], }, + lifecycle: "active", + updatedAt: 1700000000001, + version: 2, }, - createModalDraft: null, }, - { migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 }, - ); + createModalDraft: null, + }; + backing.values.set("paseo-drafts", JSON.stringify({ state: persistedState, version: 4 })); + const storage = createValidatedPersistStorage(backing, PersistedDraftStoreSchema); - expect(migrated.drafts["agent:server:agent"]?.input.attachments).toEqual([]); + const stored = await storage.getItem("paseo-drafts"); + const migrated = await migratePersistedState(stored?.state, { + migrateLegacyImages: passThroughMigrateLegacyImages, + nowMs: 1700000000002, + }); + + expect(migrated.drafts["agent:server:agent"]?.input).toEqual({ + text: "hello", + attachments: [], + }); + expect(backing.values.has("paseo-drafts")).toBe(true); }); }); diff --git a/packages/app/src/stores/draft-store/migration.ts b/packages/app/src/stores/draft-store/migration.ts index fc21087ca2..7d1756fc75 100644 --- a/packages/app/src/stores/draft-store/migration.ts +++ b/packages/app/src/stores/draft-store/migration.ts @@ -1,11 +1,12 @@ import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; import { isLegacyNewWorkspaceDraftKey, NEW_WORKSPACE_DRAFT_KEY } from "@/stores/draft-keys"; +import { z } from "zod"; import { - isAttachmentMetadata, - isLegacyDraftImage, - isUserComposerAttachment, + AttachmentMetadataSchema, + LegacyDraftImageSchema, normalizeAttachmentMetadata, normalizeComposerAttachment, + UserComposerAttachmentSchema, type CanonicalDraftInput, type DraftLifecycleState, type DraftRecord, @@ -13,28 +14,108 @@ import { type PersistedDraftImage, } from "./state"; +const LegacyPullRequestItemSchema = z.strictObject({ + kind: z.literal("pr"), + forge: z.string().optional(), + number: z.number(), + title: z.string(), + url: z.string(), + state: z.string(), + body: z.string().nullable(), + labels: z.array(z.string()), + projectPath: z.string().optional(), + baseRefName: z.string().nullable().optional(), + headRefName: z.string().nullable().optional(), + updatedAt: z.string().optional(), +}); +const LegacyGithubPrAttachmentSchema = z.strictObject({ + kind: z.literal("github_pr"), + item: LegacyPullRequestItemSchema, + owner: z.literal("new-workspace-picker").optional(), +}); +const LegacyReviewAttachmentContextLineSchema = z.strictObject({ + oldLineNumber: z.number().int().positive().nullable(), + newLineNumber: z.number().int().positive().nullable(), + type: z.enum(["add", "remove", "context"]), + content: z.string(), +}); +const LegacyReviewAttachmentSchema = z.strictObject({ + kind: z.literal("review"), + reviewDraftKey: z.string(), + commentCount: z.number().int().nonnegative(), + attachment: z.strictObject({ + type: z.literal("review"), + mimeType: z.literal("application/paseo-review"), + cwd: z.string(), + mode: z.enum(["uncommitted", "base"]), + baseRef: z.string().nullable().optional(), + comments: z.array( + z.strictObject({ + filePath: z.string(), + side: z.enum(["old", "new"]), + lineNumber: z.number().int().positive(), + body: z.string(), + context: z.strictObject({ + hunkHeader: z.string(), + targetLine: LegacyReviewAttachmentContextLineSchema, + lines: z.array(LegacyReviewAttachmentContextLineSchema), + }), + }), + ), + }), +}); +const PersistedComposerAttachmentSchema = z.union([ + UserComposerAttachmentSchema, + LegacyGithubPrAttachmentSchema, + LegacyReviewAttachmentSchema, +]); +type PersistedComposerAttachment = z.infer<typeof PersistedComposerAttachmentSchema>; +const LegacyAttachmentMetadataSchema = AttachmentMetadataSchema.extend({ + previewUri: z.string(), +}); +type PersistedImage = PersistedDraftImage | z.infer<typeof LegacyAttachmentMetadataSchema>; + +const RawDraftInputSchema = z.strictObject({ + text: z.string().optional(), + attachments: z.array(PersistedComposerAttachmentSchema).optional(), + images: z + .array( + z.union([AttachmentMetadataSchema, LegacyAttachmentMetadataSchema, LegacyDraftImageSchema]), + ) + .optional(), + cwd: z.string().optional(), +}); +const DraftLifecycleSchema = z.enum(["active", "abandoned", "sent"]); +const NestedDraftRecordSchema = z.strictObject({ + input: RawDraftInputSchema, + lifecycle: DraftLifecycleSchema.optional(), + updatedAt: z.number().optional(), + version: z.number().int().positive().optional(), +}); +const FlatDraftRecordSchema = RawDraftInputSchema.extend({ + lifecycle: DraftLifecycleSchema.optional(), + updatedAt: z.number().optional(), + version: z.number().int().positive().optional(), +}); +const PersistedDraftRecordSchema = z.union([NestedDraftRecordSchema, FlatDraftRecordSchema]); +export const PersistedDraftStoreSchema = z.strictObject({ + drafts: z.record(z.string(), PersistedDraftRecordSchema).optional(), + createModalDraft: PersistedDraftRecordSchema.nullable().optional(), +}); +type PersistedDraftRecord = z.infer<typeof PersistedDraftRecordSchema>; + export type MigrateLegacyImages = ( images: readonly PersistedDraftImage[], ) => Promise<AttachmentMetadata[]>; -function normalizePersistedImage(value: unknown): PersistedDraftImage | null { - if (isAttachmentMetadata(value)) { +function normalizePersistedImage(value: PersistedImage): PersistedDraftImage { + if ("id" in value) { return normalizeAttachmentMetadata(value); } - if (isLegacyDraftImage(value)) { - return { - uri: value.uri, - ...(value.mimeType ? { mimeType: value.mimeType } : {}), - }; - } - return null; -} - -function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null { - if (!isUserComposerAttachment(value)) { - return null; - } - return normalizeComposerAttachment(value); + return { + uri: value.uri, + ...(value.mimeType ? { mimeType: value.mimeType } : {}), + }; } function legacyImagesToAttachments( @@ -46,24 +127,36 @@ function legacyImagesToAttachments( })); } +function normalizePersistedComposerAttachment( + attachment: PersistedComposerAttachment, +): UserComposerAttachment | null { + if (attachment.kind === "review") { + return null; + } + if (attachment.kind === "github_pr" && attachment.item.kind === "pr") { + return { + kind: "github_pr", + item: { ...attachment.item, kind: "change_request" }, + ...(attachment.owner ? { owner: attachment.owner } : {}), + }; + } + const result = UserComposerAttachmentSchema.safeParse(attachment); + if (!result.success) { + throw new Error("Persisted composer attachment failed validation after migration"); + } + return normalizeComposerAttachment(result.data); +} + export async function migrateDraftInput( input: { rawInput: unknown }, ports: { migrateLegacyImages: MigrateLegacyImages }, ): Promise<CanonicalDraftInput> { - const rawInput = - input.rawInput && typeof input.rawInput === "object" - ? (input.rawInput as Record<string, unknown>) - : {}; - const attachments = Array.isArray(rawInput.attachments) - ? rawInput.attachments - .map((entry) => normalizePersistedComposerAttachment(entry)) - .filter((entry): entry is UserComposerAttachment => entry !== null) - : []; - const legacyImages = Array.isArray(rawInput.images) - ? rawInput.images - .map((entry) => normalizePersistedImage(entry)) - .filter((entry): entry is PersistedDraftImage => entry !== null) - : []; + const result = RawDraftInputSchema.safeParse(input.rawInput); + const rawInput = result.success ? result.data : {}; + const attachments = (rawInput.attachments ?? []) + .map(normalizePersistedComposerAttachment) + .filter((attachment): attachment is UserComposerAttachment => attachment !== null); + const legacyImages = (rawInput.images ?? []).map(normalizePersistedImage); const migratedImages = await ports.migrateLegacyImages(legacyImages); return { @@ -72,30 +165,29 @@ export async function migrateDraftInput( }; } -function resolvePersistedLifecycle(lifecycle: unknown): DraftLifecycleState { - if (lifecycle === "sent" || lifecycle === "abandoned") { - return lifecycle as DraftLifecycleState; - } - return "active"; +function resolvePersistedLifecycle( + lifecycle: DraftLifecycleState | undefined, +): DraftLifecycleState { + return lifecycle ?? "active"; } -function extractRawInput(record: Record<string, unknown>): unknown { - if ("input" in record && record.input && typeof record.input === "object") { +function extractRawInput(record: PersistedDraftRecord): z.infer<typeof RawDraftInputSchema> { + if ("input" in record) { return record.input; } return record; } async function buildMigratedDraftRecord( - parsed: Record<string, unknown>, + parsed: PersistedDraftRecord, ports: { migrateLegacyImages: MigrateLegacyImages }, nowMs: number, ): Promise<DraftRecord> { return { input: await migrateDraftInput({ rawInput: extractRawInput(parsed) }, ports), lifecycle: resolvePersistedLifecycle(parsed.lifecycle), - updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : nowMs, - version: typeof parsed.version === "number" ? parsed.version : 1, + updatedAt: parsed.updatedAt ?? nowMs, + version: parsed.version ?? 1, }; } @@ -143,30 +235,17 @@ export async function migratePersistedState( state: unknown, ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number }, ): Promise<DraftStoreState> { - const input = (state ?? {}) as { - drafts?: Record<string, unknown>; - createModalDraft?: unknown; - }; + const result = PersistedDraftStoreSchema.safeParse(state); + const input = result.success ? result.data : {}; const nextDrafts: Record<string, DraftRecord> = {}; for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) { - if (!rawRecord || typeof rawRecord !== "object") { - continue; - } - nextDrafts[draftKey] = await buildMigratedDraftRecord( - rawRecord as Record<string, unknown>, - ports, - ports.nowMs, - ); + nextDrafts[draftKey] = await buildMigratedDraftRecord(rawRecord, ports, ports.nowMs); } let createModalDraft: DraftRecord | null = null; - if (input.createModalDraft && typeof input.createModalDraft === "object") { - createModalDraft = await buildMigratedDraftRecord( - input.createModalDraft as Record<string, unknown>, - ports, - ports.nowMs, - ); + if (input.createModalDraft) { + createModalDraft = await buildMigratedDraftRecord(input.createModalDraft, ports, ports.nowMs); } return { diff --git a/packages/app/src/stores/draft-store/persistence.ts b/packages/app/src/stores/draft-store/persistence.ts index ab7c374c5a..f56b671307 100644 --- a/packages/app/src/stores/draft-store/persistence.ts +++ b/packages/app/src/stores/draft-store/persistence.ts @@ -1,7 +1,6 @@ import type { PersistStorage } from "zustand/middleware"; export const DRAFT_PERSIST_INTERVAL_MS = 200; - export interface PersistenceScheduler { now: () => number; schedule: (callback: () => void, delayMs: number) => unknown; @@ -12,10 +11,28 @@ export interface DraftPersistStorage<T> extends PersistStorage<T> { flush: () => Promise<void>; } +let nextSystemTimerId = 0; +const systemTimers = new Map<number, ReturnType<typeof setTimeout>>(); const systemScheduler: PersistenceScheduler = { now: Date.now, - schedule: (callback, delayMs) => setTimeout(callback, delayMs), - cancel: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>), + schedule: (callback, delayMs) => { + const id = nextSystemTimerId++; + systemTimers.set( + id, + setTimeout(() => { + systemTimers.delete(id); + callback(); + }, delayMs), + ); + return id; + }, + cancel: (handle) => { + if (typeof handle !== "number") return; + const timer = systemTimers.get(handle); + if (timer === undefined) return; + clearTimeout(timer); + systemTimers.delete(handle); + }, }; export function createDraftPersistStorage<T>( diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts index a2314ec3a1..ee53825ed4 100644 --- a/packages/app/src/stores/draft-store/state.ts +++ b/packages/app/src/stores/draft-store/state.ts @@ -3,12 +3,7 @@ import { type AttachmentMetadata, type UserComposerAttachment, } from "@/attachments/types"; -import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file"; -import { - ForgeSearchItemSchema, - GitHubSearchItemSchema, - UploadedFileAttachmentSchema, -} from "@getpaseo/protocol/messages"; +import { z } from "zod"; export const DRAFT_STORE_VERSION = 5; export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; @@ -41,26 +36,95 @@ export interface DraftStoreState { createModalDraft: DraftRecord | null; } +export const AttachmentMetadataSchema = z.strictObject({ + id: z.string(), + mimeType: z.string(), + storageType: z.enum(["web-indexeddb", "desktop-file", "native-file"]), + storageKey: z.string(), + fileName: z.string().nullable().optional(), + byteSize: z.number().nullable().optional(), + createdAt: z.number(), +}) satisfies z.ZodType<AttachmentMetadata>; +export const LegacyDraftImageSchema: z.ZodType<LegacyDraftImage> = z.strictObject({ + uri: z.string(), + mimeType: z.string().optional(), +}); +const UploadedFileSchema = z.strictObject({ + type: z.literal("uploaded_file"), + id: z.string(), + fileName: z.string(), + mimeType: z.string(), + size: z.number().int().nonnegative(), + path: z.string(), +}); +const ForgeItemFields = { + forge: z.string().optional(), + number: z.number(), + title: z.string(), + url: z.string(), + state: z.string(), + body: z.string().nullable(), + labels: z.array(z.string()), + projectPath: z.string().optional(), + baseRefName: z.string().nullable().optional(), + headRefName: z.string().nullable().optional(), + updatedAt: z.string().optional(), +}; +const IssueItemSchema = z.strictObject({ kind: z.literal("issue"), ...ForgeItemFields }); +const ChangeRequestItemSchema = z.strictObject({ + kind: z.literal("change_request"), + ...ForgeItemFields, +}); +export const UserComposerAttachmentSchema: z.ZodType<UserComposerAttachment> = z.discriminatedUnion( + "kind", + [ + z.strictObject({ kind: z.literal("image"), metadata: AttachmentMetadataSchema }), + z.strictObject({ kind: z.literal("file"), attachment: UploadedFileSchema }), + z.strictObject({ + kind: z.literal("workspace_file"), + path: z.string(), + selection: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("whole_file") }), + z.strictObject({ + kind: z.literal("line_range"), + startLine: z.number().int().positive(), + endLine: z.number().int().positive(), + }), + ]), + }), + z.strictObject({ kind: z.literal("forge_issue"), item: IssueItemSchema }), + z.strictObject({ kind: z.literal("forge_change_request"), item: ChangeRequestItemSchema }), + z.strictObject({ kind: z.literal("github_issue"), item: IssueItemSchema }), + z.strictObject({ + kind: z.literal("github_pr"), + item: ChangeRequestItemSchema, + owner: z.literal(NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER).optional(), + }), + ], +); +export const CanonicalDraftInputSchema = z.strictObject({ + text: z.string(), + attachments: z.array(UserComposerAttachmentSchema), + // COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09. + cwd: z.string().optional(), +}); +const DraftRecordSchema: z.ZodType<DraftRecord> = z.strictObject({ + input: CanonicalDraftInputSchema, + lifecycle: z.enum(["active", "abandoned", "sent"]), + updatedAt: z.number(), + version: z.number().int().positive(), +}); +export const DraftStoreStateSchema: z.ZodType<DraftStoreState> = z.strictObject({ + drafts: z.record(z.string(), DraftRecordSchema), + createModalDraft: DraftRecordSchema.nullable(), +}); + export function isAttachmentMetadata(value: unknown): value is AttachmentMetadata { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record<string, unknown>; - return ( - typeof record.id === "string" && - typeof record.mimeType === "string" && - typeof record.storageType === "string" && - typeof record.storageKey === "string" && - typeof record.createdAt === "number" - ); + return AttachmentMetadataSchema.safeParse(value).success; } export function isLegacyDraftImage(value: unknown): value is LegacyDraftImage { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record<string, unknown>; - return typeof record.uri === "string"; + return LegacyDraftImageSchema.safeParse(value).success; } export function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata { @@ -80,39 +144,7 @@ export function normalizeAttachmentMetadata(image: AttachmentMetadata): Attachme } export function isUserComposerAttachment(value: unknown): value is UserComposerAttachment { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record<string, unknown>; - if (record.kind === "image") { - const metadata = record.metadata; - return isAttachmentMetadata(metadata); - } - if (record.kind === "workspace_file") { - return isWorkspaceFileComposerAttachment(value); - } - if (record.kind === "file") { - return UploadedFileAttachmentSchema.safeParse(record.attachment).success; - } - if ( - record.kind !== "forge_issue" && - record.kind !== "forge_change_request" && - record.kind !== "github_issue" && - record.kind !== "github_pr" - ) { - return false; - } - if ( - record.kind === "github_pr" && - record.owner !== undefined && - record.owner !== NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER - ) { - return false; - } - return ( - ForgeSearchItemSchema.safeParse(record.item).success || - GitHubSearchItemSchema.safeParse(record.item).success - ); + return UserComposerAttachmentSchema.safeParse(value).success; } export function normalizeComposerAttachment( @@ -131,33 +163,11 @@ export function normalizeComposerAttachment( selection: attachment.selection, }; } - if (attachment.kind === "github_pr") { - const item = - (attachment.item as { kind: string }).kind === "pr" - ? { ...attachment.item, kind: "change_request" as const } - : attachment.item; - return { - kind: "github_pr", - item, - ...(attachment.owner === NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER - ? { owner: attachment.owner } - : {}), - }; - } return attachment; } export function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput { - if (!value || typeof value !== "object") { - return false; - } - const input = value as Record<string, unknown>; - // COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09. - return ( - typeof input.text === "string" && - Array.isArray(input.attachments) && - input.attachments.every(isUserComposerAttachment) - ); + return CanonicalDraftInputSchema.safeParse(value).success; } export function toDraftInputIfReady( diff --git a/packages/app/src/stores/last-workspace-selection.test.ts b/packages/app/src/stores/last-workspace-selection.test.ts index 06f4dceecf..e5fc979be3 100644 --- a/packages/app/src/stores/last-workspace-selection.test.ts +++ b/packages/app/src/stores/last-workspace-selection.test.ts @@ -20,12 +20,19 @@ class DelayedWorkspaceSelectionStorage implements LastWorkspaceSelectionStorage this.saved = value; } + async clear(): Promise<void> { + this.saved = null; + } + finishHydrationWith(selection: ActiveWorkspaceSelection | null) { this.finishRead(selection ? JSON.stringify(selection) : null); } getSavedSelection(): ActiveWorkspaceSelection | null { - return this.saved ? (JSON.parse(this.saved) as ActiveWorkspaceSelection) : null; + if (!this.saved) return null; + const parsed: unknown = JSON.parse(this.saved); + if (!parsed || typeof parsed !== "object") return null; + return this.saved ? JSON.parse(this.saved) : null; } } diff --git a/packages/app/src/stores/last-workspace-selection.ts b/packages/app/src/stores/last-workspace-selection.ts index f7bd3bc2e3..6e689e0a48 100644 --- a/packages/app/src/stores/last-workspace-selection.ts +++ b/packages/app/src/stores/last-workspace-selection.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export interface ActiveWorkspaceSelection { serverId: string; workspaceId: string; @@ -8,19 +10,17 @@ export const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route- export interface LastWorkspaceSelectionStorage { read(): Promise<string | null>; write(value: string): Promise<void>; + clear(): Promise<void>; } +const ActiveWorkspaceSelectionSchema: z.ZodType<ActiveWorkspaceSelection> = z.strictObject({ + serverId: z.string().trim().min(1), + workspaceId: z.string().trim().min(1), +}); + function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null { - if (!input || typeof input !== "object" || Array.isArray(input)) { - return null; - } - const record = input as Record<string, unknown>; - const serverId = typeof record.serverId === "string" ? record.serverId.trim() : ""; - const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId.trim() : ""; - if (!serverId || !workspaceId) { - return null; - } - return { serverId, workspaceId }; + const result = ActiveWorkspaceSelectionSchema.safeParse(input); + return result.success ? result.data : null; } function parseStoredWorkspaceSelection(stored: string | null): ActiveWorkspaceSelection | null { @@ -75,6 +75,9 @@ export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectio .then((stored) => { if (revision === hydrationRevision) { selection = parseStoredWorkspaceSelection(stored); + if (stored !== null && selection === null) { + void storage.clear().catch(() => {}); + } } return undefined; }) diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index 6cc8191936..9adcd6c6ba 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -25,6 +25,7 @@ export type { NavigateToWorkspaceInput } from "./navigation"; const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = { read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY), write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value), + clear: () => AsyncStorage.removeItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY), }; const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore( diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index baac870f41..6695ee5f4d 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { persist, createJSONStorage } from "zustand/middleware"; +import { persist } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { buildExplorerCheckoutKey, @@ -24,6 +24,7 @@ import { MIN_EXPLORER_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, migratePanelState, + PanelPersistedStateSchema, selectIsAgentListOpen, selectIsFileExplorerOpen, setMobilePanelTarget, @@ -37,6 +38,7 @@ import { type SortOption, } from "./state"; import { isWeb } from "@/constants/platform"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type { ExplorerTab } from "../explorer-tab-memory"; export type { ExplorerCheckoutContext } from "../explorer-checkout-context"; export type { @@ -311,9 +313,8 @@ export const usePanelStore = create<PanelState>()( { name: "panel-state", version: 12, - storage: createJSONStorage(() => AsyncStorage), - migrate: (persistedState, version) => - migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState, + storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema), + migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }), partialize: (state) => ({ desktop: state.desktop, explorerTab: state.explorerTab, diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts index a7e13288b7..611c133b5e 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -5,6 +5,7 @@ import { type ExplorerTab, } from "../explorer-tab-memory"; import { type ExplorerCheckoutContext } from "../explorer-checkout-context"; +import { z } from "zod"; export type MobilePanelView = "agent" | "agent-list" | "file-explorer"; @@ -161,7 +162,37 @@ export function buildToggleFileExplorerPatch( return { desktop: { ...state.desktop, fileExplorerOpen: false } }; } -type MigratablePanelState = Record<string, unknown>; +const ExplorerTabSchema = z.enum(["changes", "files", "pr"]); +const DesktopSidebarStorageSchema = z.strictObject({ + agentListOpen: z.boolean().optional(), + fileExplorerOpen: z.boolean().optional(), + focusModeEnabled: z.boolean().optional(), + zoomed: z.boolean().optional(), + focused: z.boolean().optional(), +}); + +export const PanelPersistedStateSchema = z.strictObject({ + mobileView: z.enum(["agent", "agent-list", "file-explorer"]).optional(), + mobilePanel: z + .strictObject({ + target: z.enum(["agent", "agent-list", "file-explorer"]), + revision: z.number().int().nonnegative(), + }) + .optional(), + desktop: DesktopSidebarStorageSchema.optional(), + explorerTab: ExplorerTabSchema.optional(), + explorerTabByCheckout: z.record(z.string(), ExplorerTabSchema).optional(), + expandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(), + diffExpandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(), + diffCollapsedFoldersByWorkspace: z.record(z.string(), z.array(z.string())).optional(), + sidebarWidth: z.number().optional(), + explorerWidth: z.number().optional(), + explorerSortOption: z.enum(["name", "modified", "size"]).optional(), + explorerShowHiddenFiles: z.boolean().optional(), + explorerFilesSplitRatio: z.number().optional(), +}); + +type MigratablePanelState = z.infer<typeof PanelPersistedStateSchema>; function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): void { if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) { @@ -193,7 +224,7 @@ function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: state.explorerTabByCheckout = {}; return; } - const entries = Object.entries(state.explorerTabByCheckout as Record<string, unknown>); + const entries = Object.entries(state.explorerTabByCheckout); const next: Record<string, ExplorerTab> = {}; for (const [key, value] of entries) { if (!isExplorerTab(value)) { @@ -205,7 +236,7 @@ function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: } function migratePanelDesktopFocusMode(state: MigratablePanelState): void { - const desktop = state.desktop as Record<string, unknown> | undefined; + const desktop = state.desktop; if (!desktop) { return; } @@ -227,7 +258,8 @@ export function migratePanelState( version: number, options: { isWeb: boolean }, ): MigratablePanelState { - const state = (persistedState ?? {}) as MigratablePanelState; + const result = PanelPersistedStateSchema.safeParse(persistedState); + const state: MigratablePanelState = result.success ? result.data : {}; const { isWeb } = options; if (version < 2) { diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts index abda2aac76..c4fb6239af 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts @@ -1,9 +1,12 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import { type CollapsedProjectsState, + type PersistedCollapsedProjects, mergePersistedCollapsedProjects, + PersistedCollapsedProjectsSchema, serializeCollapsedProjects, setProjectCollapsed, togglePinnedCollapsed, @@ -19,7 +22,7 @@ interface SidebarCollapsedSectionsState extends CollapsedProjectsState { } export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>()( - persist( + persist<SidebarCollapsedSectionsState, [], [], PersistedCollapsedProjects>( (set) => ({ collapsedProjectKeys: new Set(), collapsedStatusGroupKeys: new Set(), @@ -34,13 +37,10 @@ export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsS }), { name: "sidebar-collapsed-sections", - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, PersistedCollapsedProjectsSchema), partialize: (state) => serializeCollapsedProjects(state), merge: (persistedState, currentState) => - mergePersistedCollapsedProjects( - persistedState as { collapsedProjectKeys?: unknown } | undefined, - currentState, - ), + mergePersistedCollapsedProjects(persistedState, currentState), }, ), ); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts index 051d7cc7cc..f2d9258b6d 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts @@ -52,13 +52,13 @@ describe("sidebar collapsed projects transitions", () => { expect(restored.collapsedPinned).toBe(true); }); - it("restores collapsed project keys from persisted preferences", () => { + it("rejects the complete value when a persisted project key is invalid", () => { const restored = mergePersistedCollapsedProjects( { collapsedProjectKeys: ["project-a", "project-b", 42] }, emptyState(), ); - expect(Array.from(restored.collapsedProjectKeys)).toEqual(["project-a", "project-b"]); + expect(Array.from(restored.collapsedProjectKeys)).toEqual([]); expect(Array.from(restored.collapsedStatusGroupKeys)).toEqual([]); }); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts index 3a03ab0ac2..4d84205e1a 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export interface CollapsedProjectsState { collapsedProjectKeys: Set<string>; collapsedStatusGroupKeys: Set<string>; @@ -5,11 +7,18 @@ export interface CollapsedProjectsState { } export interface PersistedCollapsedProjects { - collapsedProjectKeys?: unknown; - collapsedStatusGroupKeys?: unknown; - collapsedPinned?: unknown; + collapsedProjectKeys?: string[]; + collapsedStatusGroupKeys?: string[]; + collapsedPinned?: boolean; } +export const PersistedCollapsedProjectsSchema: z.ZodType<PersistedCollapsedProjects> = + z.strictObject({ + collapsedProjectKeys: z.array(z.string()).optional(), + collapsedStatusGroupKeys: z.array(z.string()).optional(), + collapsedPinned: z.boolean().optional(), + }); + export function togglePinnedCollapsed(state: CollapsedProjectsState): CollapsedProjectsState { return { ...state, collapsedPinned: !state.collapsedPinned }; } @@ -67,22 +76,21 @@ export function serializeCollapsedProjects(state: CollapsedProjectsState): { } export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState>( - persisted: PersistedCollapsedProjects | undefined, + persistedValue: unknown, current: S, ): S { - if ( - !persisted?.collapsedProjectKeys && - !persisted?.collapsedStatusGroupKeys && - persisted?.collapsedPinned === undefined - ) { + const result = PersistedCollapsedProjectsSchema.safeParse(persistedValue); + if (!result.success) { return current; } - const restoredProjects = deserializeCollapsedKeys(persisted.collapsedProjectKeys); - const restoredStatusGroups = deserializeCollapsedKeys(persisted.collapsedStatusGroupKeys); - const restoredPinned = - typeof persisted.collapsedPinned === "boolean" - ? persisted.collapsedPinned - : current.collapsedPinned; + const persisted = result.data; + const restoredProjects = deserializeCollapsedKeys( + persisted.collapsedProjectKeys ?? Array.from(current.collapsedProjectKeys), + ); + const restoredStatusGroups = deserializeCollapsedKeys( + persisted.collapsedStatusGroupKeys ?? Array.from(current.collapsedStatusGroupKeys), + ); + const restoredPinned = persisted.collapsedPinned ?? current.collapsedPinned; if ( areSetsEqual(current.collapsedProjectKeys, restoredProjects) && areSetsEqual(current.collapsedStatusGroupKeys, restoredStatusGroups) && @@ -98,11 +106,8 @@ export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState }; } -function deserializeCollapsedKeys(value: unknown): Set<string> { - if (!Array.isArray(value)) { - return new Set(); - } - return new Set(value.filter((key): key is string => typeof key === "string")); +function deserializeCollapsedKeys(value: string[]): Set<string> { + return new Set(value); } function areSetsEqual(left: Set<string>, right: Set<string>): boolean { diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 307e432c0a..bd6bdbbf30 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -1,6 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { z } from "zod"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; interface SidebarOrderStoreState { projectOrder: string[]; @@ -18,6 +20,14 @@ interface SidebarOrderPersistedState { workspaceOrderByServerAndProject?: Record<string, string[]>; } +const StringArrayRecordSchema = z.record(z.string(), z.array(z.string())); +const SidebarOrderPersistedStateSchema = z.strictObject({ + projectOrder: z.array(z.string()).optional(), + workspaceOrderByProject: StringArrayRecordSchema.optional(), + projectOrderByServerId: StringArrayRecordSchema.optional(), + workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(), +}); + interface SidebarWorkspaceOrderScope { serverId: string; projectViewKey: string; @@ -71,11 +81,11 @@ export function migrateSidebarOrderState(persistedState: unknown): { projectOrder: string[]; workspaceOrderByProject: Record<string, string[]>; } { - const state = persistedState as SidebarOrderPersistedState | undefined; - - if (!state) { + const result = SidebarOrderPersistedStateSchema.safeParse(persistedState); + if (!result.success) { return { projectOrder: [], workspaceOrderByProject: {} }; } + const state: SidebarOrderPersistedState = result.data; const projectOrder = normalizeKeys(state.projectOrder ?? []); const seenProjects = new Set(projectOrder); @@ -135,7 +145,7 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()( }), { name: "sidebar-project-workspace-order", - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, SidebarOrderPersistedStateSchema), partialize: (state) => ({ projectOrder: state.projectOrder, workspaceOrderByProject: state.workspaceOrderByProject, diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index 52d2198d98..e8d77b0a08 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -1,6 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; +import { persist, type StateStorage } from "zustand/middleware"; +import { z } from "zod"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type SidebarGroupMode = "project" | "status"; @@ -23,51 +25,55 @@ interface SidebarViewPersistedState { hostFilters: string[]; } -function isSidebarGroupMode(value: unknown): value is SidebarGroupMode { - return value === "project" || value === "status"; -} +const SidebarGroupModeSchema = z.enum(["project", "status"]); +const SidebarViewPersistedStateSchema = z.strictObject({ + groupMode: SidebarGroupModeSchema.optional(), + hostFilters: z.array(z.string()).optional(), + hostFilter: z.string().nullable().optional(), + groupModeByServerId: z.record(z.string(), SidebarGroupModeSchema).optional(), +}); -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +type SidebarViewStorageState = z.infer<typeof SidebarViewPersistedStateSchema>; -function readLegacyGroupMode(persistedState: Record<string, unknown>): SidebarGroupMode | null { +function readLegacyGroupMode(persistedState: SidebarViewStorageState): SidebarGroupMode | null { const groupModeByServerId = persistedState.groupModeByServerId; - if (!isRecord(groupModeByServerId)) { + if (!groupModeByServerId) { return null; } - const modes = Object.values(groupModeByServerId).filter(isSidebarGroupMode); + const modes = Object.values(groupModeByServerId); if (modes.length === 0) return null; return modes.includes("status") ? "status" : "project"; } // Reads the host filter from any persisted shape: the current `hostFilters` array, or the // pre-v2 single `hostFilter` string (null/absent meant "all hosts"). -function readHostFilters(persistedState: Record<string, unknown>): string[] { +function readHostFilters(persistedState: SidebarViewStorageState): string[] { const hostFilters = persistedState.hostFilters; - if (Array.isArray(hostFilters)) { - return hostFilters.filter((value): value is string => typeof value === "string"); + if (hostFilters) { + return hostFilters; } // COMPAT(sidebarHostFilters): added in v0.1.102, remove after 2026-12-30 once pre-v2 persisted // sidebar state (a single `hostFilter` string) has aged out. const legacyHostFilter = persistedState.hostFilter; - return typeof legacyHostFilter === "string" ? [legacyHostFilter] : []; + return legacyHostFilter ? [legacyHostFilter] : []; } export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState { - if (!isRecord(persistedState)) { + const result = SidebarViewPersistedStateSchema.safeParse(persistedState); + if (!result.success) { return { groupMode: "project", hostFilters: [] }; } + const state = result.data; - const legacyGroupMode = readLegacyGroupMode(persistedState); + const legacyGroupMode = readLegacyGroupMode(state); if (legacyGroupMode) { return { groupMode: legacyGroupMode, hostFilters: [] }; } return { - groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project", - hostFilters: readHostFilters(persistedState), + groupMode: state.groupMode ?? "project", + hostFilters: readHostFilters(state), }; } @@ -116,7 +122,10 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()( { name: SIDEBAR_VIEW_STORAGE_KEY, version: SIDEBAR_VIEW_STORE_VERSION, - storage: createJSONStorage(createSidebarViewStorage), + storage: createValidatedPersistStorage( + createSidebarViewStorage(), + SidebarViewPersistedStateSchema, + ), partialize: (state) => ({ groupMode: state.groupMode, hostFilters: state.hostFilters, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 24d162eae4..4951d28687 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -15,6 +15,7 @@ vi.mock("@react-native-async-storage/async-storage", () => { }; }); +import AsyncStorage from "@react-native-async-storage/async-storage"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model"; import { collectAllPanes, @@ -316,6 +317,24 @@ describe("workspace-layout-store actions", () => { }); }); + it("keeps layouts written before split sizes were persisted", async () => { + const legacyLayout = createDefaultLayout(); + await AsyncStorage.setItem( + "workspace-layout-state", + JSON.stringify({ + state: { layoutByWorkspace: { legacy: legacyLayout } }, + version: 1, + }), + ); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + + await restored.persist.rehydrate(); + + expect(restored.getState().layoutByWorkspace.legacy).toEqual(legacyLayout); + expect(restored.getState().splitSizesByWorkspace).toEqual({}); + await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull(); + }); + it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => { useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); const workspaceKey = createWorkspaceKey(); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index dee6c58693..8631756a6c 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -1,7 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect, useState } from "react"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { z } from "zod"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { defaultWorkspaceLayoutIds, @@ -42,6 +43,7 @@ import { type WorkspaceLayout, } from "@/stores/workspace-layout-actions"; import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export { collectAllPanes, @@ -123,6 +125,83 @@ interface WorkspaceFocusRestorationState { const MAX_TREE_DEPTH = 4; +const WorkspaceDraftTabSetupStorageSchema = z.strictObject({ + provider: z.string(), + cwd: z.string(), + modeId: z.string().nullable(), + model: z.string().nullable(), + thinkingOptionId: z.string().nullable(), + featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])), +}); +const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("draft"), + draftId: z.string(), + setup: WorkspaceDraftTabSetupStorageSchema.optional(), + }), + z.strictObject({ kind: z.literal("agent"), agentId: z.string() }), + z.strictObject({ + kind: z.literal("provider_subagent"), + parentAgentId: z.string(), + subagentId: z.string(), + }), + z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }), + z.strictObject({ kind: z.literal("browser"), browserId: z.string() }), + z.strictObject({ + kind: z.literal("file"), + path: z.string(), + lineStart: z.number().int().positive().optional(), + lineEnd: z.number().int().positive().optional(), + }), + z.strictObject({ + kind: z.literal("working_diff"), + focusPath: z.string().optional(), + focusRequestId: z.number().optional(), + // COMPAT(workingDiffTarget): accepted from pre-canonical tab ids; normalization removes them. + mode: z.enum(["uncommitted", "base"]).optional(), + baseRef: z.string().nullable().optional(), + ignoreWhitespace: z.boolean().optional(), + }), + z.strictObject({ kind: z.literal("setup"), workspaceId: z.string() }), + z.strictObject({ kind: z.literal("commit_diff"), sha: z.string() }), +]); +const WorkspaceTabStorageSchema = z.strictObject({ + tabId: z.string(), + target: WorkspaceTabTargetStorageSchema, + createdAt: z.number(), +}); +const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() => + z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("pane"), + pane: z.strictObject({ + id: z.string(), + tabIds: z.array(z.string()), + focusedTabId: z.string().nullable(), + tabs: z.array(WorkspaceTabStorageSchema).optional(), + }), + }), + z.strictObject({ + kind: z.literal("group"), + group: z.strictObject({ + id: z.string(), + direction: z.enum(["horizontal", "vertical"]), + children: z.array(SplitNodeStorageSchema), + sizes: z.array(z.number()), + }), + }), + ]), +); +const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject({ + root: SplitNodeStorageSchema, + focusedPaneId: z.string().nullable(), + parentTabIdByTabId: z.record(z.string(), z.string()).optional(), +}); +const WorkspaceLayoutPersistedStateSchema = z.strictObject({ + layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema), + splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(), +}); + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -942,7 +1021,7 @@ export function createWorkspaceLayoutStore( { name: "workspace-layout-state", version: 1, - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema), partialize: (state) => { const layoutByWorkspace: Record<string, WorkspaceLayout> = {}; for (const key in state.layoutByWorkspace) { @@ -957,6 +1036,17 @@ export function createWorkspaceLayoutStore( splitSizesByWorkspace: state.splitSizesByWorkspace, }; }, + merge: (persistedState, currentState) => { + const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState); + if (!result.success) { + return currentState; + } + return { + ...currentState, + layoutByWorkspace: result.data.layoutByWorkspace, + splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {}, + }; + }, }, ), ); diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 421f5868c0..2105312e88 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -9,8 +9,9 @@ import { import { type HostAppearance, defaultHostAppearance, - normalizeStoredHostAppearance, + HostAppearanceSchema, } from "@/hosts/appearance"; +import { z } from "zod"; export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection }; @@ -293,54 +294,74 @@ export function connectionFromListen(listen: string): HostConnection | null { } } -function isPlainRecord(value: unknown): value is Record<string, unknown> { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} +const StoredHostConnectionSchema = z.discriminatedUnion("type", [ + z.strictObject({ + id: z.string().optional(), + type: z.literal("directTcp"), + endpoint: z.string(), + useTls: z.boolean().optional(), + password: z.string().optional(), + }), + z.strictObject({ + id: z.string().optional(), + type: z.literal("directSocket"), + path: z.string(), + }), + z.strictObject({ + id: z.string().optional(), + type: z.literal("directPipe"), + path: z.string(), + }), + z.strictObject({ + id: z.string().optional(), + type: z.literal("relay"), + relayEndpoint: z.string(), + useTls: z.boolean().optional(), + daemonPublicKeyB64: z.string(), + }), +]); +const StoredHostProfileSchema = z.strictObject({ + serverId: z.string().trim().min(1), + label: z.string().optional(), + appearance: HostAppearanceSchema.optional(), + lifecycle: z.strictObject({}).optional(), + connections: z.array(StoredHostConnectionSchema).min(1), + preferredConnectionId: z.string().nullable().optional(), + createdAt: z.string().datetime({ offset: true }).optional(), + updatedAt: z.string().datetime({ offset: true }).optional(), +}); +export const StoredHostRegistrySchema = z.array(StoredHostProfileSchema); +type StoredHostConnection = z.infer<typeof StoredHostConnectionSchema>; -function toObjectRecord(value: unknown): Record<string, unknown> | undefined { - return isPlainRecord(value) ? value : undefined; -} - -function normalizeStoredConnection(connection: unknown): HostConnection | null { - const record = toObjectRecord(connection); - if (!record) { - return null; - } - const type = record.type; - if (type === "directTcp") { +function normalizeStoredConnection(connection: StoredHostConnection): HostConnection | null { + if (connection.type === "directTcp") { try { - const endpoint = normalizeLoopbackToLocalhost( - normalizeHostPort(typeof record.endpoint === "string" ? record.endpoint : ""), - ); + const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint)); return DirectTcpHostConnectionSchema.parse({ id: `direct:${endpoint}`, type: "directTcp", endpoint, - useTls: record.useTls, - ...(typeof record.password === "string" ? { password: record.password } : {}), + useTls: connection.useTls, + ...(connection.password !== undefined ? { password: connection.password } : {}), }); } catch { return null; } } - if (type === "directSocket") { - const path = (typeof record.path === "string" ? record.path : "").trim(); + if (connection.type === "directSocket") { + const path = connection.path.trim(); return path ? { id: `socket:${path}`, type: "directSocket", path } : null; } - if (type === "directPipe") { - const path = (typeof record.path === "string" ? record.path : "").trim(); + if (connection.type === "directPipe") { + const path = connection.path.trim(); return path ? { id: `pipe:${path}`, type: "directPipe", path } : null; } - if (type === "relay") { + if (connection.type === "relay") { try { - const relayEndpoint = normalizeHostPort( - typeof record.relayEndpoint === "string" ? record.relayEndpoint : "", - ); - const daemonPublicKeyB64 = ( - typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : "" - ).trim(); + const relayEndpoint = normalizeHostPort(connection.relayEndpoint); + const daemonPublicKeyB64 = connection.daemonPublicKeyB64.trim(); if (!daemonPublicKeyB64) return null; - const useTls = typeof record.useTls === "boolean" ? record.useTls : undefined; + const useTls = connection.useTls; return { id: useTls === true ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, type: "relay", @@ -357,17 +378,14 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null { } export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { - const record = toObjectRecord(entry); - if (!record) { - return null; - } - const serverId = typeof record.serverId === "string" ? record.serverId.trim() : ""; - if (!serverId) { + const result = StoredHostProfileSchema.safeParse(entry); + if (!result.success) { return null; } + const record = result.data; + const serverId = record.serverId; - const rawConnections = Array.isArray(record.connections) ? record.connections : []; - const connections = rawConnections + const connections = record.connections .map((connection) => normalizeStoredConnection(connection)) .filter((connection): connection is HostConnection => connection !== null); if (connections.length === 0) { @@ -375,12 +393,10 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { } const now = new Date().toISOString(); - const label = normalizeHostLabel( - typeof record.label === "string" ? record.label : null, - serverId, - ); + const label = normalizeHostLabel(record.label, serverId); const preferredConnectionId = - typeof record.preferredConnectionId === "string" && + record.preferredConnectionId !== null && + record.preferredConnectionId !== undefined && connections.some((connection) => connection.id === record.preferredConnectionId) ? record.preferredConnectionId : (connections[0]?.id ?? null); @@ -388,12 +404,12 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { return { serverId, label, - appearance: normalizeStoredHostAppearance(record.appearance), + appearance: record.appearance ?? defaultHostAppearance(), lifecycle: defaultLifecycle(), connections, preferredConnectionId, - createdAt: typeof record.createdAt === "string" ? record.createdAt : now, - updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : now, + createdAt: record.createdAt ?? now, + updatedAt: record.updatedAt ?? now, }; } diff --git a/packages/app/src/utils/client-id.test.ts b/packages/app/src/utils/client-id.test.ts index 647ea48b0e..7184ab5547 100644 --- a/packages/app/src/utils/client-id.test.ts +++ b/packages/app/src/utils/client-id.test.ts @@ -18,6 +18,9 @@ function inMemoryStorage(initial: Record<string, string> = {}): InMemoryStorage storage.setCallCount += 1; items.set(key, value); }, + async removeItem(key) { + items.delete(key); + }, }; return storage; } diff --git a/packages/app/src/utils/client-id.ts b/packages/app/src/utils/client-id.ts index 52397e91bb..d721110980 100644 --- a/packages/app/src/utils/client-id.ts +++ b/packages/app/src/utils/client-id.ts @@ -1,23 +1,20 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import { z } from "zod"; +import { readValidatedString } from "@/storage/validated-storage"; const CLIENT_ID_STORAGE_KEY = "@paseo:client-id-v1"; export interface ClientIdStorage { getItem(key: string): Promise<string | null>; setItem(key: string, value: string): Promise<void>; + removeItem(key: string): Promise<void>; } export interface ClientIdResolver { getOrCreate(): Promise<string>; } -function normalizeStoredClientId(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} +const ClientIdSchema = z.string().trim().min(1); export function createClientIdResolver(deps: { storage: ClientIdStorage; @@ -38,8 +35,7 @@ export function createClientIdResolver(deps: { } inFlight = (async () => { - const stored = await deps.storage.getItem(storageKey); - const existing = normalizeStoredClientId(stored); + const existing = await readValidatedString(deps.storage, storageKey, ClientIdSchema); if (existing) { cached = existing; return existing; @@ -61,7 +57,7 @@ export function createClientIdResolver(deps: { } function generateUuidFromGlobalCrypto(): string { - const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined; + const cryptoObj = globalThis.crypto; if (cryptoObj && typeof cryptoObj.randomUUID === "function") { return cryptoObj.randomUUID().replace(/-/g, ""); } diff --git a/packages/app/src/workspace-pins/store.ts b/packages/app/src/workspace-pins/store.ts index 8fd3178a91..45a49c4712 100644 --- a/packages/app/src/workspace-pins/store.ts +++ b/packages/app/src/workspace-pins/store.ts @@ -1,7 +1,9 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { z } from "zod"; import { isTargetPinned, togglePinnedTarget, type PinnedTabTarget } from "@/workspace-pins/target"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; interface PinnedTargetsState { pinned: PinnedTabTarget[]; @@ -10,6 +12,15 @@ interface PinnedTargetsState { } const DEFAULT_PINNED_TARGETS: PinnedTabTarget[] = [{ kind: "terminal" }, { kind: "browser" }]; +const PinnedTabTargetSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("draft") }), + z.strictObject({ kind: z.literal("terminal") }), + z.strictObject({ kind: z.literal("browser") }), + z.strictObject({ kind: z.literal("profile"), profileId: z.string() }), +]); +const PinnedTargetsPersistedStateSchema = z.strictObject({ + pinned: z.array(PinnedTabTargetSchema), +}); function applyDefaultPinnedTargets(pinned: PinnedTabTarget[]): PinnedTabTarget[] { const next = [...DEFAULT_PINNED_TARGETS]; @@ -22,7 +33,7 @@ function applyDefaultPinnedTargets(pinned: PinnedTabTarget[]): PinnedTabTarget[] } export const usePinnedTargetsStore = create<PinnedTargetsState>()( - persist( + persist<PinnedTargetsState, [], [], z.infer<typeof PinnedTargetsPersistedStateSchema>>( (set, get) => ({ pinned: [], toggle: (target) => set((state) => ({ pinned: togglePinnedTarget(state.pinned, target) })), @@ -32,21 +43,21 @@ export const usePinnedTargetsStore = create<PinnedTargetsState>()( name: "pinned-tab-targets", version: 1, merge: (persistedState, currentState) => { - const persisted = persistedState as Partial<PinnedTargetsState> | null; + const result = PinnedTargetsPersistedStateSchema.safeParse(persistedState); return { ...currentState, - ...persisted, - pinned: persisted?.pinned ?? applyDefaultPinnedTargets([]), + pinned: result.success ? result.data.pinned : applyDefaultPinnedTargets([]), }; }, - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, PinnedTargetsPersistedStateSchema), partialize: (state) => ({ pinned: state.pinned }), migrate: (persistedState, version) => { + const result = PinnedTargetsPersistedStateSchema.safeParse(persistedState); + const pinned = result.success ? result.data.pinned : []; if (version === 0) { - const pinned = (persistedState as { pinned?: PinnedTabTarget[] } | null)?.pinned ?? []; return { pinned: applyDefaultPinnedTargets(pinned) }; } - return persistedState as PinnedTargetsState; + return { pinned }; }, }, ), diff --git a/packages/app/src/workspace-service-routes/store.test.ts b/packages/app/src/workspace-service-routes/store.test.ts index ba2bab1f2c..719c4f9acc 100644 --- a/packages/app/src/workspace-service-routes/store.test.ts +++ b/packages/app/src/workspace-service-routes/store.test.ts @@ -32,7 +32,7 @@ describe("workspace service route preferences", () => { expect(restored.getState().byServerId).toEqual({ desktop: "direct", devbox: "public" }); }); - it("drops invalid route kinds from persisted storage", async () => { + it("clears the complete persisted value when one route kind is invalid", async () => { const storage = createMemoryStorage({ "workspace-service-route-preferences": JSON.stringify({ state: { byServerId: { desktop: "direct", broken: "unknown" } }, @@ -42,6 +42,7 @@ describe("workspace service route preferences", () => { const store = createWorkspaceServiceRoutePreferencesStore(storage); await store.persist.rehydrate(); - expect(store.getState().byServerId).toEqual({ desktop: "direct" }); + expect(store.getState().byServerId).toEqual({}); + expect(storage.values.has("workspace-service-route-preferences")).toBe(false); }); }); diff --git a/packages/app/src/workspace-service-routes/store.ts b/packages/app/src/workspace-service-routes/store.ts index 15479f08e9..bf061effc4 100644 --- a/packages/app/src/workspace-service-routes/store.ts +++ b/packages/app/src/workspace-service-routes/store.ts @@ -1,6 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; +import { persist, type StateStorage } from "zustand/middleware"; +import { z } from "zod"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import type { WorkspaceScriptLinkKind } from "@/utils/workspace-script-links"; interface WorkspaceServiceRoutePreferencesState { @@ -8,25 +10,19 @@ interface WorkspaceServiceRoutePreferencesState { setPreferredRoute: (serverId: string, kind: WorkspaceScriptLinkKind) => void; } -function isWorkspaceScriptLinkKind(value: unknown): value is WorkspaceScriptLinkKind { - return value === "public" || value === "paseo" || value === "direct"; -} - -function sanitizePreferences(value: unknown): Record<string, WorkspaceScriptLinkKind> { - if (!value || typeof value !== "object") return {}; - const byServerId = (value as { byServerId?: unknown }).byServerId; - if (!byServerId || typeof byServerId !== "object") return {}; - - const result: Record<string, WorkspaceScriptLinkKind> = {}; - for (const [serverId, kind] of Object.entries(byServerId)) { - if (isWorkspaceScriptLinkKind(kind)) result[serverId] = kind; - } - return result; -} +const WorkspaceScriptLinkKindSchema = z.enum(["public", "paseo", "direct"]); +const WorkspaceServiceRoutePreferencesSchema = z.strictObject({ + byServerId: z.record(z.string(), WorkspaceScriptLinkKindSchema), +}); export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorage) { return create<WorkspaceServiceRoutePreferencesState>()( - persist( + persist< + WorkspaceServiceRoutePreferencesState, + [], + [], + z.infer<typeof WorkspaceServiceRoutePreferencesSchema> + >( (set) => ({ byServerId: {}, setPreferredRoute: (serverId, kind) => @@ -35,12 +31,15 @@ export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorag { name: "workspace-service-route-preferences", version: 1, - storage: createJSONStorage(() => storage), + storage: createValidatedPersistStorage(storage, WorkspaceServiceRoutePreferencesSchema), partialize: (state) => ({ byServerId: state.byServerId }), - merge: (persistedState, currentState) => ({ - ...currentState, - byServerId: sanitizePreferences(persistedState), - }), + merge: (persistedState, currentState) => { + const result = WorkspaceServiceRoutePreferencesSchema.safeParse(persistedState); + return { + ...currentState, + byServerId: result.success ? result.data.byServerId : {}, + }; + }, }, ), ); From 9ca6f24315db187ef046582cf408f24ef1125bad Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 13:40:31 +0200 Subject: [PATCH 53/73] Let Claude apply its native MCP timeouts (#3315) * fix(claude): preserve native MCP timeouts Paseo forced Claude MCP startup and tool waits to ten minutes, turning unavailable servers into long silent first turns. Let Claude apply its own defaults while preserving explicit user environment overrides. * test(claude): isolate MCP timeout regression Exercise timeout precedence through the existing provider runtime environment input without mutating process-wide test state. * test(workspace-git): await refresh boundaries Synchronize fetch observation tests with initial snapshot and fetch completion before advancing fake timers. --- .../agent/providers/claude/agent.env.test.ts | 8 ++++ .../server/agent/providers/claude/agent.ts | 9 +--- .../workspace-git-service.observation.test.ts | 42 ++++++++++++------- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.env.test.ts b/packages/server/src/server/agent/providers/claude/agent.env.test.ts index 018894af65..642e562f61 100644 --- a/packages/server/src/server/agent/providers/claude/agent.env.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.env.test.ts @@ -68,6 +68,12 @@ describe("Claude SDK env", () => { logger: createTestLogger(), queryFactory, resolveBinary: async () => "/test/claude/bin", + runtimeSettings: { + env: { + MCP_TIMEOUT: "claude-startup-timeout", + MCP_TOOL_TIMEOUT: "claude-tool-timeout", + }, + }, }); const session = await client.createSession( { @@ -82,6 +88,8 @@ describe("Claude SDK env", () => { expect(result.sessionId).toBe("managed-agent-env-session"); expect(capturedEnv?.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID); expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG); + expect(capturedEnv?.MCP_TIMEOUT).toBe("claude-startup-timeout"); + expect(capturedEnv?.MCP_TOOL_TIMEOUT).toBe("claude-tool-timeout"); } finally { await session.close(); } diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index fcd6b46946..c02697c8dc 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -3075,14 +3075,7 @@ class ClaudeAgentSession implements AgentSession { return createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings, - overlays: [ - { - // Increase MCP timeouts for long-running tool calls (10 minutes) - MCP_TIMEOUT: "600000", - MCP_TOOL_TIMEOUT: "600000", - }, - this.launchEnv, - ], + overlays: [this.launchEnv], }); } diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 61d90f212a..165435a1f8 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -503,6 +503,15 @@ describe("WorkspaceGitService checkout observation", () => { ...current, upstreamStatus: facts.upstreamStatus, })); + const runGitFetch = vi.fn(async (_cwd, observer) => { + observer?.onRefSnapshot("before"); + await releaseFetch.promise; + observer?.onRefSnapshot("after"); + return { + changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }], + error: null, + }; + }); const service = createService(watcher, { getCheckoutSnapshotFacts, getCheckoutRefDerivedState, @@ -515,22 +524,16 @@ describe("WorkspaceGitService checkout observation", () => { }), ), hasOriginRemote: vi.fn(async () => true), - runGitFetch: vi.fn(async (_cwd, observer) => { - observer?.onRefSnapshot("before"); - await releaseFetch.promise; - observer?.onRefSnapshot("after"); - return { - changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }], - error: null, - }; - }), + runGitFetch, }); const listener = vi.fn(); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener); + await service.getSnapshot(REPO_CWD); await vi.waitFor(() => { + expect(runGitFetch).toHaveBeenCalledTimes(1); expect(service.getMetrics().fetchInFlightCount).toBe(1); - expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); listener.mockClear(); watcher.records @@ -539,7 +542,9 @@ describe("WorkspaceGitService checkout observation", () => { { path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "create" }, ]); releaseFetch.resolve(); - await flushPromises(); + await vi.waitFor(() => { + expect(service.getMetrics().fetchInFlightCount).toBe(0); + }); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1); @@ -798,6 +803,10 @@ describe("WorkspaceGitService checkout observation", () => { ...current, upstreamStatus: facts.upstreamStatus, })); + const runGitFetch = vi.fn(async () => ({ + changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }], + error: null, + })); const service = createService(watcher, { getCheckoutSnapshotFacts, getCheckoutRefDerivedState, @@ -810,13 +819,16 @@ describe("WorkspaceGitService checkout observation", () => { }), ), hasOriginRemote: vi.fn(async () => true), - runGitFetch: vi.fn(async () => ({ - changes: [{ kind: "moved" as const, ref: "origin/main", beforeOid: "a", afterOid: "b" }], - error: null, - })), + runGitFetch, }); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await service.getSnapshot(REPO_CWD); + await vi.waitFor(() => { + expect(runGitFetch).toHaveBeenCalledTimes(1); + expect(service.getMetrics().fetchInFlightCount).toBe(0); + }); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { expect(getCheckoutRefDerivedState).toHaveBeenCalledTimes(1); From d0d60a4052ea5d067debb0413cbda2810e0a1467 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 14:00:37 +0200 Subject: [PATCH 54/73] Disconnect daemons locally when Hub is unreachable (#3321) * fix(hub): make daemon disconnect unilateral Local relationship removal must not depend on reaching the Hub. Fence execution authority, make one bounded courtesy revocation attempt, then clear local state so status and reconnect eligibility agree immediately. * fix(hub): preserve disconnect intent across crashes Persist the terminal disconnect marker only while the bounded Hub notification is in flight. Startup removes that marker, so a crash cannot restore the old relationship or create a revocation retry loop. --- docs/hub.md | 10 +- .../cli/src/commands/hub/commands.test.ts | 33 ++++- packages/cli/src/commands/hub/disconnect.ts | 4 +- packages/cli/src/commands/hub/logout.ts | 15 ++- .../hub/relationship-controller.test.ts | 115 ++++++++++++++---- .../src/server/hub/relationship-controller.ts | 62 +++++----- .../hub/test-utils/relationship-harness.ts | 28 +++++ 7 files changed, 195 insertions(+), 72 deletions(-) diff --git a/docs/hub.md b/docs/hub.md index 336b6600cc..de13853218 100644 --- a/docs/hub.md +++ b/docs/hub.md @@ -95,11 +95,11 @@ Hub authentication rejection or close code `4403` permanently revokes the local daemon deletes its credential, stops reconnecting, and retains only the relationship ID, Hub origin, scopes, and a sanitized reason for status reporting. -`paseo hub disconnect` disables socket reconnect before requesting remote revocation. If the Hub is -offline, the daemon persists `disconnecting` and retries revocation across daemon restarts without -opening a Hub socket. This also covers an enrollment whose request may have succeeded but whose -response was lost. `--force` removes local authority immediately and warns that remote revocation may -still be pending. +`paseo hub disconnect` disables socket reconnect and execution authority before making one bounded +remote revocation request. The daemon then removes the local relationship whether the request +succeeds or fails. A failed request returns a warning that server-side revocation may remain pending. +`--force` skips the remote request. Legacy persisted `disconnecting` records are removed on startup; +the daemon does not retry revocation in the background. `paseo hub logout` removes only the active human CLI credential and preserves credentials for other origins. Interactive logout inspects and optionally disconnects a same-origin daemon before deleting the login; a failed requested disconnect preserves the login. JSON and noninteractive logout never prompt or disconnect implicitly. diff --git a/packages/cli/src/commands/hub/commands.test.ts b/packages/cli/src/commands/hub/commands.test.ts index 2815982a24..a68f1ab2d7 100644 --- a/packages/cli/src/commands/hub/commands.test.ts +++ b/packages/cli/src/commands/hub/commands.test.ts @@ -344,7 +344,6 @@ describe("Hub commands", () => { daemon.beforeDisconnect = () => { assert.equal(credentials.active()?.credential, "human-secret"); }; - const result = await runHubLogout( { json: true, disconnectDaemon: true, force: true }, { @@ -359,9 +358,31 @@ describe("Hub commands", () => { assert.equal(daemon.disconnects, 1); assert.deepEqual(daemon.disconnectForces, [true]); assert.equal(result.data.daemonDisconnected, true); + assert.equal(result.data.warning, undefined); }); - it("disconnect reports the current Hub and preserves it in the final result", async () => { + it("logout reports a daemon disconnect warning", async () => { + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "human-secret" }); + const daemon = new FakeDaemon("https://hub.test"); + daemon.disconnectWarning = "Hub could not be reached; cleanup may remain pending."; + + const result = await runHubLogout( + { json: true, disconnectDaemon: true }, + { + credentials, + daemon: new FakeDaemonConnection(daemon), + isInteractive: () => false, + confirmDisconnect: async () => false, + reporter: quietReporter, + }, + ); + + assert.equal(result.data.daemonDisconnected, true); + assert.equal(result.data.warning, "Hub could not be reached; cleanup may remain pending."); + }); + + it("disconnect reports clean local status without a ghost Hub origin", async () => { const daemon = new FakeDaemon("https://hub.test"); const progress: string[] = []; @@ -374,7 +395,7 @@ describe("Hub commands", () => { ); assert.deepEqual(progress, ["Disconnecting this daemon from https://hub.test"]); - assert.equal(result.data[0]?.hub, "https://hub.test"); + assert.equal(result.data[0]?.hub, null); }); it("preserves login when an accepted daemon disconnect fails", async () => { @@ -448,6 +469,7 @@ class FakeDaemon implements HubDaemonClient { disconnects = 0; readonly disconnectForces: boolean[] = []; disconnectError: Error | null = null; + disconnectWarning: string | null = null; beforeDisconnect: (() => void) | null = null; constructor(private readonly origin: string) {} @@ -466,7 +488,10 @@ class FakeDaemon implements HubDaemonClient { this.disconnectForces.push(force); this.beforeDisconnect?.(); if (this.disconnectError !== null) throw this.disconnectError; - return { status: hubStatus("not_connected", null) }; + return { + status: hubStatus("not_connected", null), + ...(this.disconnectWarning ? { warning: this.disconnectWarning } : {}), + }; } async close() {} diff --git a/packages/cli/src/commands/hub/disconnect.ts b/packages/cli/src/commands/hub/disconnect.ts index 5cbe0e4291..867201376f 100644 --- a/packages/cli/src/commands/hub/disconnect.ts +++ b/packages/cli/src/commands/hub/disconnect.ts @@ -31,7 +31,7 @@ export function runHubDisconnect( ); } const response = await client.disconnectHub(options.force ?? false); - return hubStatusResult(response.status, response.warning, current.hubOrigin); + return hubStatusResult(response.status, response.warning); }); } @@ -42,7 +42,7 @@ export function addHubDisconnectCommand( addJsonAndDaemonHostOptions( parent .command("disconnect") - .option("--force", "Remove local authority even if the Hub is offline"), + .option("--force", "Remove local authority without notifying the Hub"), ).action( withOutput(async (...args) => { const options = args.at(-2) as HubDisconnectOptions; diff --git a/packages/cli/src/commands/hub/logout.ts b/packages/cli/src/commands/hub/logout.ts index e4ea721376..28f0837021 100644 --- a/packages/cli/src/commands/hub/logout.ts +++ b/packages/cli/src/commands/hub/logout.ts @@ -11,6 +11,7 @@ interface HubLogoutResult { origin: string | null; status: "logged_out" | "not_logged_in"; daemonDisconnected: boolean; + warning?: string; } const schema: OutputSchema<HubLogoutResult> = { @@ -19,6 +20,7 @@ const schema: OutputSchema<HubLogoutResult> = { { header: "HUB", field: "origin" }, { header: "STATUS", field: "status" }, { header: "DAEMON DISCONNECTED", field: "daemonDisconnected" }, + { header: "WARNING", field: "warning" }, ], }; @@ -70,11 +72,16 @@ export async function runHubLogout( options, `Disconnecting this daemon from ${active.origin}`, ); - await withHubDaemon(dependencies.daemon, options.host, async (daemon) => { - await daemon.disconnectHub(options.force ?? false); + const disconnect = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => { + return daemon.disconnectHub(options.force ?? false); }); dependencies.credentials.logoutActive(); - return logoutResult({ origin: active.origin, status: "logged_out", daemonDisconnected: true }); + return logoutResult({ + origin: active.origin, + status: "logged_out", + daemonDisconnected: disconnect.status.state === "not_connected", + ...(disconnect.warning ? { warning: disconnect.warning } : {}), + }); } export function addHubLogoutCommand(parent: Command, dependencies: HubLogoutDependencies): void { @@ -83,7 +90,7 @@ export function addHubLogoutCommand(parent: Command, dependencies: HubLogoutDepe .command("logout") .description("Remove the active stored Hub CLI login") .option("--disconnect-daemon", "Also disconnect a daemon related to the same Hub") - .option("--force", "Remove daemon authority even if Hub is offline"), + .option("--force", "Remove daemon authority without notifying the Hub"), ).action( withOutput(async (...args) => { const options = args.at(-2) as HubLogoutOptions; diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index c7ffbd8338..9f83a5a6a9 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -344,16 +344,18 @@ describe("Hub relationship", () => { const credential = relationship.relationshipFile()?.credential?.secret; relationship.loseEnrollmentResponse(); await connecting.result; - relationship.failRevocations(2); + relationship.failRevocations(1); const disconnected = await relationship.disconnect(); - expect(disconnected.state).toBe("disconnecting"); - expect(relationship.relationshipFile()?.state).toBe("disconnecting"); + const reconnected = await relationship.beginConnect("fresh-token").result; - await relationship.restartDaemon(); - await relationship.retry(); - - expect(relationship.revocationAttempts()).toBe(3); + expect(disconnected).toMatchObject({ + state: "not_connected", + hub: null, + warning: expect.stringContaining("server-side revocation may remain pending"), + }); + expect(reconnected.state).toBe("connecting"); + expect(relationship.revocationAttempts()).toBe(1); expect(relationship.latestRevocation()).toEqual( expect.objectContaining({ daemonId: enrollment.daemonId, @@ -361,7 +363,10 @@ describe("Hub relationship", () => { credential, }), ); - expect(relationship.relationshipFile()).toBeNull(); + expect(relationship.pendingRelationshipRetries()).toBe(0); + expect(relationship.loggableValues(disconnected)).toContain( + "Failed to notify Hub before removing local relationship", + ); }); test("disconnect revokes only after an in-flight enrollment settles", async () => { @@ -371,7 +376,8 @@ describe("Hub relationship", () => { const enrollment = await relationship.enrollmentBegins(); const disconnecting = relationship.beginDisconnect(); - await relationship.relationshipStateBecomes("disconnecting"); + await relationship.connectionStateBecomes("disconnecting"); + expect(relationship.relationshipFile()?.state).toBe("disconnecting"); expect(relationship.revocationAttempts()).toBe(0); relationship.completeEnrollment(); @@ -385,6 +391,40 @@ describe("Hub relationship", () => { expect(relationship.relationshipFile()).toBeNull(); }); + test("disconnect persists terminal intent only while remote notification is in flight", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + relationship.holdRevocation(); + + const disconnecting = relationship.beginDisconnect(); + await relationship.relationshipStateBecomes("disconnecting"); + + expect(await relationship.status()).toMatchObject({ state: "disconnecting" }); + expect(relationship.pendingRelationshipRetries()).toBe(0); + + relationship.completeRevocation(); + await expect(disconnecting.result).resolves.toMatchObject({ state: "not_connected" }); + expect(relationship.relationshipFile()).toBeNull(); + }); + + test("force disconnect does not wait for or notify an in-flight enrollment", async () => { + relationship = await HubRelationshipHarness.start(); + relationship.holdEnrollment(); + const connecting = relationship.beginConnect("one-time-token"); + await relationship.enrollmentBegins(); + + const disconnected = await relationship.disconnect(true); + + expect(disconnected).toMatchObject({ state: "not_connected", hub: null }); + expect(relationship.revocationAttempts()).toBe(0); + expect(relationship.relationshipFile()).toBeNull(); + + relationship.completeEnrollment(); + await connecting.result; + expect(relationship.socketAttempts()).toBe(0); + expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null }); + }); + test("daemon restart reconnects the same durable relationship", async () => { relationship = await HubRelationshipHarness.start(); await relationship.beginConnect().result; @@ -729,48 +769,73 @@ describe("Hub relationship", () => { expect(loggable).not.toContain(enrollment.idempotencyKey); }); - test("offline disconnect retries across runtime and restart without opening a socket", async () => { + test("offline disconnect removes local authority after one notify attempt", async () => { relationship = await HubRelationshipHarness.start(); await relationship.beginConnect().result; relationship.connectLatestSocket(); relationship.failRevocations(3); - const disconnecting = await relationship.disconnect(); - await relationship.retry(); + const disconnected = await relationship.disconnect(); await relationship.restartDaemon(); - await relationship.retry(); - expect(disconnecting.state).toBe("disconnecting"); - expect(relationship.revocationAttempts()).toBe(4); + expect(disconnected).toMatchObject({ + state: "not_connected", + hub: null, + warning: expect.stringContaining("server-side revocation may remain pending"), + }); + expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null }); + expect(relationship.revocationAttempts()).toBe(1); expect(relationship.socketAttempts()).toBe(1); + expect(relationship.pendingRelationshipRetries()).toBe(0); expect(relationship.relationshipFile()).toBeNull(); }); - test("successful disconnect clears a transient revocation error", async () => { + test("successful disconnect notifies the Hub and returns clean local status", async () => { relationship = await HubRelationshipHarness.start(); await relationship.beginConnect().result; - relationship.failRevocations(1); - const disconnecting = await relationship.disconnect(); - await relationship.retry(); + const disconnected = await relationship.disconnect(); - expect(disconnecting).toMatchObject({ state: "disconnecting", error: expect.any(String) }); + expect(disconnected).toMatchObject({ state: "not_connected", hub: null, error: null }); + expect(disconnected).not.toHaveProperty("warning"); + expect(relationship.revocationAttempts()).toBe(1); expect(await relationship.status()).toMatchObject({ state: "not_connected", error: null }); }); - test("force disconnect removes local authority and reports the remote warning", async () => { + test("force disconnect removes local authority without notifying the Hub", async () => { relationship = await HubRelationshipHarness.start(); await relationship.beginConnect().result; - relationship.failRevocations(1); - await relationship.disconnect(); const forced = await relationship.disconnect(true); - expect(forced.state).toBe("not_connected"); - expect(forced.warning).toContain("remote revocation"); + expect(forced).toMatchObject({ state: "not_connected", hub: null }); + expect(forced).not.toHaveProperty("warning"); + expect(relationship.revocationAttempts()).toBe(0); + expect(relationship.loggableValues(forced)).not.toContain( + "Failed to notify Hub before removing local relationship", + ); expect(relationship.relationshipFile()).toBeNull(); }); + test("startup removes a legacy persisted disconnecting relationship", async () => { + relationship = await HubRelationshipHarness.start(); + await relationship.beginConnect().result; + const active = relationship.relationshipFile(); + await relationship.corruptRelationshipFile( + JSON.stringify({ ...active, state: "disconnecting" }), + ); + + await relationship.startStoppedDaemon(); + + expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null }); + expect(relationship.relationshipFile()).toBeNull(); + expect(relationship.revocationAttempts()).toBe(0); + expect(relationship.pendingRelationshipRetries()).toBe(0); + expect(relationship.loggableValues(await relationship.status())).toContain( + "Removed legacy disconnecting Hub relationship during startup", + ); + }); + test("disconnect leaves no intent artifacts and shutdown closes the owned agent", async () => { relationship = await HubRelationshipHarness.start(); await relationship.beginConnect().result; diff --git a/packages/server/src/server/hub/relationship-controller.ts b/packages/server/src/server/hub/relationship-controller.ts index 2c213aae66..8ce6f8050b 100644 --- a/packages/server/src/server/hub/relationship-controller.ts +++ b/packages/server/src/server/hub/relationship-controller.ts @@ -197,11 +197,19 @@ export class HubRelationshipController implements HubRelationshipManagement { this.clock = options.clock ?? systemClock; this.retryPolicy = options.retryPolicy ?? new BoundedExponentialHubRetryPolicy(); this.record = this.load(); + if (this.record?.state === "disconnecting") { + // COMPAT(hubUnilateralDisconnect): added in v0.4.0, remove after 2027-02-13 once legacy records have aged out. + this.options.logger.warn( + { daemonId: this.record.relationship.daemonId }, + "Removed legacy disconnecting Hub relationship during startup", + ); + rmSync(this.filePath, { force: true }); + this.record = null; + } if (this.record?.state === "revoked") { this.state = "revoked"; this.lastError = this.record.reason ?? null; - } else if (this.record?.state === "disconnecting") this.state = "disconnecting"; - else if (this.record) this.state = "connecting"; + } else if (this.record) this.state = "connecting"; } async start(): Promise<void> { @@ -218,7 +226,6 @@ export class HubRelationshipController implements HubRelationshipManagement { ); } } - if (this.record?.state === "disconnecting") await this.tryRevocation(this.record); } async stop(): Promise<void> { @@ -291,14 +298,6 @@ export class HubRelationshipController implements HubRelationshipManagement { await pendingCreateCleanup; return { status: this.status() }; } - if (input.force) { - this.remove(); - await pendingCreateCleanup; - return { - status: this.status(), - warning: "Local Hub credential removed; remote revocation may remain pending.", - }; - } const disconnecting: DisconnectingRecord = { version: 1, state: "disconnecting", @@ -309,12 +308,29 @@ export class HubRelationshipController implements HubRelationshipManagement { this.persist(disconnecting); this.record = disconnecting; this.state = "disconnecting"; - if (waitForEnrollment) { + if (waitForEnrollment && !input.force) { await Promise.all(this.inFlightEnrollments); } - await this.tryRevocation(disconnecting); + let warning: string | undefined; + if (!input.force) { + try { + await this.options.remote.revoke({ + daemonId: disconnecting.relationship.daemonId, + hubOrigin: disconnecting.relationship.hubOrigin, + credential: disconnecting.credential.secret, + }); + } catch (error) { + this.options.logger.warn( + { err: error, daemonId: disconnecting.relationship.daemonId }, + "Failed to notify Hub before removing local relationship", + ); + warning = + "Hub could not be reached; local relationship removed, but server-side revocation may remain pending."; + } + } + this.remove(); await pendingCreateCleanup; - return { status: this.status() }; + return { status: this.status(), ...(warning ? { warning } : {}) }; } private async tryEnrollment(pending: PendingRecord, enrollmentGeneration: number): Promise<void> { @@ -457,24 +473,6 @@ export class HubRelationshipController implements HubRelationshipManagement { }); } - private async tryRevocation(record: DisconnectingRecord): Promise<void> { - const generation = this.generation; - try { - await this.options.remote.revoke({ - daemonId: record.relationship.daemonId, - hubOrigin: record.relationship.hubOrigin, - credential: record.credential.secret, - }); - if (generation !== this.generation) return; - this.remove(); - } catch (error) { - if (generation !== this.generation) return; - this.lastError = error instanceof Error ? error.message : String(error); - this.state = "disconnecting"; - this.schedule(() => void this.tryRevocation(record)); - } - } - private schedule(task: () => void): void { this.retry?.cancel(); const generation = this.generation; diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index a74ebe0681..ac34086703 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -284,6 +284,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote { private enrollmentRejection: 401 | 403 | null = null; private enrollmentScopes = ["hub.execution.*"]; private revokeFailures = 0; + private revocationGate: Deferred<void> | null = null; private readonly relationships = new Set<string>(); readonly enrollmentSnapshots: RelationshipInvocationSnapshot[] = []; readonly socketSnapshots: RelationshipInvocationSnapshot[] = []; @@ -353,8 +354,19 @@ class InMemoryHubRelationships implements HubRelationshipRemote { this.revokeFailures = count; } + holdRevocation(): void { + this.revocationGate = deferred<void>(); + } + + completeRevocation(): void { + if (!this.revocationGate) throw new Error("No revocation is waiting"); + this.revocationGate.resolve(); + this.revocationGate = null; + } + async revoke(input: HubRevocation): Promise<void> { this.revocations.push({ ...input }); + await this.revocationGate?.promise; if (this.revokeFailures > 0) { this.revokeFailures--; throw new Error("Hub is offline"); @@ -510,6 +522,14 @@ export class HubRelationshipHarness { } } + async connectionStateBecomes(expected: string): Promise<void> { + for (let attempt = 0; attempt < 100; attempt++) { + if ((await this.status()).state === expected) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Hub connection state did not become ${expected}`); + } + async manageRelationshipFromExternalSocket(): Promise<SessionOutboundMessage[]> { const address = Object.values(networkInterfaces()) .flat() @@ -596,6 +616,14 @@ export class HubRelationshipHarness { this.remote.failRevocations(count); } + holdRevocation(): void { + this.remote.holdRevocation(); + } + + completeRevocation(): void { + this.remote.completeRevocation(); + } + failProviderPromptStart(prompt = "Create through the Hub"): void { this.promptsToFail.add(prompt); } From 20bccd31a387b115339b54dfcc041ba1bebdb5ae Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 15:08:46 +0200 Subject: [PATCH 55/73] Make provider catalog refresh deadlines configurable (#3322) * fix(providers): make catalog refresh deadlines configurable Provider startup can legitimately exceed a fixed short timeout when catalog discovery has substantial initialization work. Give each provider refresh one configurable deadline covering availability and the complete catalog probe, and cancel named child operations before allowing retries so timed-out work cannot accumulate in the background. * fix(opencode): abort catalog server acquisition A provider refresh could expire while the helper server was still starting because acquisition only tracked the activity without observing its abort signal. Reject acquisition before transferring a server reference, while leaving the manager-owned startup reusable by the next caller. * test(file-observer): allow native burst recovery --- docs/custom-providers.md | 16 +- docs/providers.md | 7 + .../src/server/agent/agent-sdk-types.ts | 18 +- .../server/agent/provider-refresh-deadline.ts | 80 +++++++ .../src/server/agent/provider-registry.ts | 45 ++-- .../agent/provider-snapshot-manager.test.ts | 201 +++++++++++++++++- .../server/agent/provider-snapshot-manager.ts | 45 ++-- .../src/server/agent/providers/acp-agent.ts | 133 +++++++----- .../server/agent/providers/claude/agent.ts | 26 ++- .../agent/providers/codex-app-server-agent.ts | 98 ++++++--- .../agent/providers/diagnostic-utils.ts | 6 +- .../generic-acp-agent.diagnostic.test.ts | 14 +- .../agent/providers/mock-slow-provider.ts | 20 +- .../src/server/agent/providers/omp/agent.ts | 46 ++-- .../server/agent/providers/omp/cli-runtime.ts | 9 +- .../src/server/agent/providers/omp/runtime.ts | 3 +- .../providers/omp/test-utils/fake-omp.ts | 2 +- ...opencode-agent.list-models-timeout.test.ts | 104 +++++---- .../server/agent/providers/opencode-agent.ts | 73 +++++-- .../providers/opencode-server-manager.test.ts | 19 ++ .../providers/opencode/server-manager.ts | 33 ++- .../test-utils/test-opencode-harness.ts | 20 +- .../src/server/agent/providers/pi/agent.ts | 40 +++- .../server/agent/providers/pi/cli-runtime.ts | 4 + .../src/server/agent/providers/pi/runtime.ts | 1 + packages/server/src/server/bootstrap.ts | 2 + packages/server/src/server/config.test.ts | 15 +- packages/server/src/server/config.ts | 1 + .../src/server/file-observer/index.test.ts | 4 +- .../src/server/persisted-config.test.ts | 14 ++ .../server/src/server/persisted-config.ts | 1 + packages/server/src/utils/spawn.ts | 2 + .../public/schemas/paseo.config.v1.json | 5 + 33 files changed, 862 insertions(+), 245 deletions(-) create mode 100644 packages/server/src/server/agent/provider-refresh-deadline.ts diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 17bd1ceeb6..acf8826573 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -2,7 +2,7 @@ Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider. -All provider configuration lives under `agents.providers` in config.json: +Provider definitions live under `agents.providers` in config.json: ```json { @@ -17,6 +17,20 @@ All provider configuration lives under `agents.providers` in config.json: Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`). +Each provider catalog refresh waits up to 60 seconds. If a provider loads many plugins or a large +agent catalog during startup, raise the limit in milliseconds: + +```json +{ + "agents": { + "catalogRefreshTimeoutMs": 180000 + } +} +``` + +The limit applies independently to every provider refresh and covers availability plus the entire +catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent. + --- ## Table of Contents diff --git a/docs/providers.md b/docs/providers.md index b76aa3d89d..4e199e3b0b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -105,6 +105,13 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory. +`ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the +availability check and covers that check plus the complete catalog probe. Providers that make +multiple catalog requests must not apply this deadline separately to each request. The manager +aborts the shared refresh signal at the deadline. Providers name active catalog operations and +finish subprocess, server, or session cleanup before rejecting. Timeout errors list the operations +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. diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index cea5a9f1bd..686c1b24aa 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -668,15 +668,19 @@ export type FetchCatalogOptions = | { scope: "global"; force: boolean; - timeoutMs?: number; } | { scope: "workspace"; cwd: string; force: boolean; - timeoutMs?: number; }; +export interface ProviderRefreshContext { + readonly signal: AbortSignal; + /** Track an upstream operation so timeout errors identify the work still pending. */ + runActivity<T>(name: string, operation: () => Promise<T>): Promise<T>; +} + export interface ProviderCatalog { models: AgentModelDefinition[]; modes: AgentMode[]; @@ -686,6 +690,7 @@ export interface ProviderCatalog { export interface ResolveAgentDefaultModeInput { config: AgentSessionConfig; env?: Record<string, string>; + signal?: AbortSignal; } export interface AgentClient { @@ -707,8 +712,13 @@ export interface AgentClient { * process, separate upstream calls, static modes, or private helpers; callers * outside the provider do not get separate runtime model/mode probes. * The registry is responsible for merging configured model overrides. + * ProviderSnapshotManager supplies a shared context. Providers must pass its + * signal downstream and finish resource cleanup before rejecting on abort. */ - fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>; + fetchCatalog( + options: FetchCatalogOptions, + context?: ProviderRefreshContext, + ): Promise<ProviderCatalog>; /** Apply provider-owned defaults to a model supplied through provider configuration. */ resolveConfiguredModel?(model: AgentModelDefinition): AgentModelDefinition; resolveDefaultModeId?(input: ResolveAgentDefaultModeInput): Promise<string | undefined>; @@ -727,7 +737,7 @@ export interface AgentClient { * Check if this provider is available (CLI binary is installed). * Returns true if available, false otherwise. */ - isAvailable(): Promise<boolean>; + isAvailable(signal?: AbortSignal): Promise<boolean>; getDiagnostic?(): Promise<{ diagnostic: string }>; /** * Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close(). diff --git a/packages/server/src/server/agent/provider-refresh-deadline.ts b/packages/server/src/server/agent/provider-refresh-deadline.ts new file mode 100644 index 0000000000..12528cfe81 --- /dev/null +++ b/packages/server/src/server/agent/provider-refresh-deadline.ts @@ -0,0 +1,80 @@ +import type { ProviderRefreshContext } from "./agent-sdk-types.js"; + +interface RunProviderRefreshOptions<T> { + label: string; + timeoutMs: number; + operation: (context: ProviderRefreshContext) => Promise<T>; +} + +export function runProviderRefreshActivity<T>( + context: ProviderRefreshContext | undefined, + name: string, + operation: () => Promise<T>, +): Promise<T> { + return context ? context.runActivity(name, operation) : operation(); +} + +export async function raceProviderRefreshAbort<T>( + signal: AbortSignal | undefined, + operation: Promise<T>, +): Promise<T> { + if (!signal) return await operation; + signal.throwIfAborted(); + let handleAbort: (() => void) | undefined; + const aborted = new Promise<never>((_resolve, reject) => { + handleAbort = () => reject(signal.reason); + signal.addEventListener("abort", handleAbort, { once: true }); + }); + try { + return await Promise.race([operation, aborted]); + } finally { + if (handleAbort) signal.removeEventListener("abort", handleAbort); + } +} + +export async function runProviderRefreshWithDeadline<T>( + options: RunProviderRefreshOptions<T>, +): Promise<T> { + const controller = new AbortController(); + const activityCounts = new Map<string, number>(); + let timeoutError: Error | undefined; + + const context: ProviderRefreshContext = { + signal: controller.signal, + async runActivity(name, operation) { + controller.signal.throwIfAborted(); + activityCounts.set(name, (activityCounts.get(name) ?? 0) + 1); + try { + const result = await operation(); + controller.signal.throwIfAborted(); + return result; + } finally { + const remaining = (activityCounts.get(name) ?? 1) - 1; + if (remaining === 0) { + activityCounts.delete(name); + } else { + activityCounts.set(name, remaining); + } + } + }, + }; + + const timer = setTimeout(() => { + const pending = Array.from(activityCounts.keys()); + const suffix = pending.length > 0 ? `; pending: ${pending.join(", ")}` : ""; + timeoutError = new Error( + `Timed out refreshing ${options.label} after ${options.timeoutMs}ms${suffix}`, + ); + controller.abort(timeoutError); + }, options.timeoutMs); + + try { + const result = await options.operation(context); + if (timeoutError) throw timeoutError; + return result; + } catch (error) { + throw timeoutError ?? error; + } finally { + clearTimeout(timer); + } +} diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index b673e2bd75..58e45d37c7 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -13,6 +13,7 @@ import type { AgentSession, AgentStreamEvent, FetchCatalogOptions, + ProviderRefreshContext, ProviderCatalog, ResolveAgentCreateConfigInput, ResolveAgentCreateConfigResult, @@ -24,6 +25,7 @@ import { resolveDefaultAgentCreateConfig, } from "./create-agent-mode.js"; import { normalizeAgentModelDefinition } from "./agent-sdk-types.js"; +import { runProviderRefreshActivity } from "./provider-refresh-deadline.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js"; import type { @@ -92,7 +94,11 @@ export interface ProviderDefinition extends AgentProviderDefinition { * Single catalog discovery call used by ProviderSnapshotManager. Should spawn * at most one provider runtime process and return both models and modes. */ - fetchCatalog: (options: FetchCatalogOptions, client?: AgentClient) => Promise<ProviderCatalog>; + fetchCatalog: ( + options: FetchCatalogOptions, + client?: AgentClient, + context?: ProviderRefreshContext, + ) => Promise<ProviderCatalog>; } export interface BuildProviderRegistryOptions { @@ -507,8 +513,8 @@ function wrapClientProvider( options, ), ), - fetchCatalog: async (options) => { - const catalog = await inner.fetchCatalog(options); + fetchCatalog: async (options, context) => { + const catalog = await inner.fetchCatalog(options, context); return { ...catalog, models: mergeModels(provider, profileModels, additionalModels, catalog.models, { @@ -518,10 +524,11 @@ function wrapClientProvider( }; }, resolveDefaultModeId: inner.resolveDefaultModeId - ? async ({ config, env }: ResolveAgentDefaultModeInput) => + ? async ({ config, env, signal }: ResolveAgentDefaultModeInput) => await inner.resolveDefaultModeId?.({ config: { ...config, provider: inner.provider }, env, + signal, }) : undefined, resolveCreateConfig: inner.resolveCreateConfig?.bind(inner), @@ -561,7 +568,7 @@ function wrapClientProvider( }; } : undefined, - isAvailable: () => inner.isAvailable(), + isAvailable: (signal) => inner.isAvailable(signal), getDiagnostic: inner.getDiagnostic?.bind(inner), }; } @@ -621,7 +628,11 @@ function createRegistryEntry( resolveCreateConfig: modelClient.resolveCreateConfig ?? resolveDefaultAgentCreateConfig, isCreateConfigUnattended: modelClient.isCreateConfigUnattended ?? isDefaultAgentCreateConfigUnattended, - fetchCatalog: async (options: FetchCatalogOptions, client?: AgentClient) => { + fetchCatalog: async ( + options: FetchCatalogOptions, + client?: AgentClient, + context?: ProviderRefreshContext, + ) => { const catalogClient = client ?? modelClient; if (hasReplacementModels) { // Replacement models skip runtime model discovery, but additionalModels @@ -629,23 +640,29 @@ function createRegistryEntry( // the single catalog API; otherwise use static/empty modes with no runtime. const models = mergeModelAdditions(provider, replacementModels, additionalModels); if (hasStaticModes) { - const defaultModeId = await catalogClient.resolveDefaultModeId?.({ - config: { - provider, - cwd: options.scope === "workspace" ? options.cwd : process.cwd(), - }, - }); + const defaultModeId = await runProviderRefreshActivity( + context, + "default-mode", + async () => + await catalogClient.resolveDefaultModeId?.({ + config: { + provider, + cwd: options.scope === "workspace" ? options.cwd : process.cwd(), + }, + signal: context?.signal, + }), + ); return { models, modes: decorateModes(resolved.definition.modes), defaultModeId, }; } - const catalog = await catalogClient.fetchCatalog(options); + const catalog = await catalogClient.fetchCatalog(options, context); return { ...catalog, models, modes: decorateModes(catalog.modes) }; } - const catalog = await catalogClient.fetchCatalog(options); + const catalog = await catalogClient.fetchCatalog(options, context); return { ...catalog, models: mergeModels(provider, profileModels, additionalModels, catalog.models, { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index b2c0062783..ed67290b07 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -9,6 +9,7 @@ import type { AgentModelDefinition, AgentProvider, FetchCatalogOptions, + ProviderRefreshContext, ResolveAgentCreateConfigInput, } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; @@ -68,6 +69,41 @@ async function withEnv(key: string, value: string, run: () => Promise<void>): Pr } } +function waitUntilAborted(signal?: AbortSignal): Promise<boolean> { + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); +} + +function waitForDelay(delayMs: number): Promise<void> { + return new Promise((finish) => setTimeout(finish, delayMs)); +} + +function waitForAbortWithCleanup( + signal: AbortSignal, + cleanupState: { cleanedUp: boolean }, +): Promise<void> { + return new Promise((_resolve, reject) => { + const handleAbort = () => { + cleanupState.cleanedUp = true; + reject(signal.reason); + }; + signal.addEventListener("abort", handleAbort, { once: true }); + }); +} + +async function runTestCatalogActivities( + context: ProviderRefreshContext, + cleanupState: { cleanedUp: boolean }, +): Promise<void> { + const waitForAgents = () => waitForDelay(50); + const waitForProviders = () => waitForAbortWithCleanup(context.signal, cleanupState); + await Promise.all([ + context.runActivity("app.agents", waitForAgents), + context.runActivity("provider.list", waitForProviders), + ]); +} + describe("ProviderSnapshotManager public surface", () => { test("validates complete Hub agent configurations through the current provider contract", async () => { const manager = new ProviderSnapshotManager({ @@ -380,7 +416,7 @@ describe("ProviderSnapshotManager public surface", () => { test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => { // never-resolving isAvailable forces the timeout path - const isAvailable = vi.fn(() => new Promise<boolean>(() => {})); + const isAvailable = vi.fn(waitUntilAborted); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), refreshTimeoutMs: 1, @@ -406,9 +442,158 @@ describe("ProviderSnapshotManager public surface", () => { } }); + test("one refresh timeout covers availability and catalog discovery", async () => { + vi.useFakeTimers(); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + refreshTimeoutMs: 100, + extraClients: { + codex: createExtraClient("codex", { + isAvailable: () => + new Promise((settle) => { + setTimeout(() => settle(true), 60); + }), + fetchCatalog: () => + new Promise((settle) => { + setTimeout(() => settle({ models: [], modes: [] }), 60); + }), + }), + }, + }); + + try { + const entryPromise = manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + + await vi.advanceTimersByTimeAsync(120); + + await expect(entryPromise).resolves.toMatchObject({ + provider: "codex", + status: "error", + error: "Timed out refreshing Codex after 100ms", + }); + } finally { + manager.destroy(); + vi.useRealTimers(); + } + }); + + test("timeout names pending catalog activities, aborts them, and waits for cleanup", async () => { + vi.useFakeTimers(); + let attempt = 0; + const cleanupState = { cleanedUp: false }; + const fetchCatalog = vi.fn( + async (_options: FetchCatalogOptions, context?: ProviderRefreshContext) => { + attempt += 1; + if (attempt > 1) { + expect(cleanupState.cleanedUp).toBe(true); + return { models: [], modes: [] }; + } + + if (!context) throw new Error("missing refresh context"); + await runTestCatalogActivities(context, cleanupState); + return { models: [], modes: [] }; + }, + ); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + refreshTimeoutMs: 100, + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog, + }), + }, + }); + + try { + const first = manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + await vi.advanceTimersByTimeAsync(100); + await expect(first).resolves.toMatchObject({ + status: "error", + error: "Timed out refreshing Codex after 100ms; pending: provider.list", + }); + expect(cleanupState.cleanedUp).toBe(true); + + await manager.refreshSnapshotForCwd({ cwd: "/tmp/project", providers: ["codex"] }); + expect(fetchCatalog).toHaveBeenCalledTimes(2); + expect( + manager.getSnapshot("/tmp/project").find((entry) => entry.provider === "codex"), + ).toMatchObject({ provider: "codex", status: "ready" }); + } finally { + manager.destroy(); + vi.useRealTimers(); + } + }); + + test("each provider refresh gets its own timeout", async () => { + vi.useFakeTimers(); + const neverReturnsCatalog = (_options: FetchCatalogOptions, context?: ProviderRefreshContext) => + new Promise<never>((_resolve, reject) => { + context?.signal.addEventListener("abort", () => reject(context.signal.reason), { + once: true, + }); + }); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + refreshTimeoutMs: 100, + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: neverReturnsCatalog, + }), + claude: createExtraClient("claude", { + isAvailable: async () => true, + fetchCatalog: neverReturnsCatalog, + }), + }, + }); + + try { + const codexPromise = manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + await vi.advanceTimersByTimeAsync(50); + + const claudePromise = manager.getProvider({ + cwd: "/tmp/project", + provider: "claude", + wait: true, + }); + await vi.advanceTimersByTimeAsync(50); + + await expect(codexPromise).resolves.toMatchObject({ + provider: "codex", + status: "error", + }); + await expect( + manager.getProvider({ cwd: "/tmp/project", provider: "claude", wait: false }), + ).resolves.toMatchObject({ provider: "claude", status: "loading" }); + + await vi.advanceTimersByTimeAsync(50); + + await expect(claudePromise).resolves.toMatchObject({ + provider: "claude", + status: "error", + }); + } finally { + manager.destroy(); + vi.useRealTimers(); + } + }); + test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is honored when no option is given", async () => { vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); - const isAvailable = vi.fn(() => new Promise<boolean>(() => {})); + const isAvailable = vi.fn(waitUntilAborted); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), providerOverrides: { @@ -435,7 +620,7 @@ describe("ProviderSnapshotManager public surface", () => { test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is ignored when option is provided", async () => { vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); - const isAvailable = vi.fn(() => new Promise<boolean>(() => {})); + const isAvailable = vi.fn(waitUntilAborted); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), refreshTimeoutMs: 5, @@ -708,7 +893,10 @@ describe("ProviderSnapshotManager public surface", () => { extraClients: { codex: createExtraClient("codex", { isAvailable: async () => true, - fetchCatalog: async () => new Promise(() => {}), + fetchCatalog: async (_options, context) => { + await context?.runActivity("model/list", () => waitUntilAborted(context.signal)); + return { models: [], modes: [] }; + }, getDiagnostic: async () => { diagnosticStarted = true; return { diagnostic: "codex diagnostics available" }; @@ -751,9 +939,10 @@ describe("ProviderSnapshotManager public surface", () => { extraClients: { codex: createExtraClient("codex", { isAvailable: async () => true, - fetchCatalog: async () => { + fetchCatalog: async (_options, context) => { snapshotStarted = true; - return new Promise(() => {}); + await context?.runActivity("model/list", () => waitUntilAborted(context.signal)); + return { models: [], modes: [] }; }, getDiagnostic: async () => { diagnosticStarted = true; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 7b9033bf24..60f7850a6e 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -16,6 +16,10 @@ import { type FetchCatalogOptions, type ProviderSnapshotEntry, } from "./agent-sdk-types.js"; +import { + raceProviderRefreshAbort, + runProviderRefreshWithDeadline, +} from "./provider-refresh-deadline.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js"; @@ -42,6 +46,7 @@ import { } from "./agent-configuration-validator.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 60_000; +const MAX_REFRESH_TIMEOUT_MS = 2_147_483_647; const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000; const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS"; export const GLOBAL_PROVIDER_SNAPSHOT_KEY = "paseo:global"; @@ -50,14 +55,19 @@ export const GLOBAL_PROVIDER_SNAPSHOT_KEY = "paseo:global"; // `copilot --acp` invocation, OpenCode workspace probes with many MCP servers). // Allow operators to bump the ceiling via env var without rebuilding. function resolveRefreshTimeoutMs(option: number | undefined): number { - if (typeof option === "number" && Number.isFinite(option) && option > 0) { + if ( + typeof option === "number" && + Number.isSafeInteger(option) && + option > 0 && + option <= MAX_REFRESH_TIMEOUT_MS + ) { return option; } const fromEnv = process.env[REFRESH_TIMEOUT_ENV_VAR]; if (fromEnv) { // Number() handles scientific notation (e.g. "6e4") which parseInt would silently truncate. const parsed = Number(fromEnv); - if (Number.isFinite(parsed) && parsed > 0) { + if (Number.isSafeInteger(parsed) && parsed > 0 && parsed <= MAX_REFRESH_TIMEOUT_MS) { return parsed; } } @@ -515,7 +525,7 @@ export class ProviderSnapshotManager { client.resolveCreateConfig?.bind(client) ?? definition.resolveCreateConfig, isCreateConfigUnattended: client.isCreateConfigUnattended?.bind(client) ?? definition.isCreateConfigUnattended, - fetchCatalog: client.fetchCatalog.bind(client), + fetchCatalog: (options, _client, context) => client.fetchCatalog(options, context), }; } @@ -834,23 +844,26 @@ export class ProviderSnapshotManager { } const client = this.ensureClient(provider, definition); - const available = await withTimeout( - client.isAvailable(), - this.refreshTimeoutMs, - `Timed out checking ${definition.label} availability after ${this.refreshTimeoutMs}ms`, - ); - if (!available) { + const catalog = await runProviderRefreshWithDeadline({ + label: definition.label, + timeoutMs: this.refreshTimeoutMs, + operation: async (context) => { + const available = await context.runActivity("availability", () => + raceProviderRefreshAbort(context.signal, client.isAvailable(context.signal)), + ); + if (!available) { + return null; + } + + const catalogOptions = createFetchCatalogOptions(catalogScope, force); + return await definition.fetchCatalog(catalogOptions, client, context); + }, + }); + if (!catalog) { setEntry({ ...base, status: "unavailable", enabled: true }); return; } - const catalogOptions = createFetchCatalogOptions(catalogScope, force); - const catalog = await withTimeout( - definition.fetchCatalog({ ...catalogOptions, timeoutMs: this.refreshTimeoutMs }, client), - this.refreshTimeoutMs, - `Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`, - ); - setEntry({ ...base, defaultModeId: diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 2296aa3960..1a43ed5538 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -85,6 +85,7 @@ import { type AgentTimelineItem, type AgentUsage, type FetchCatalogOptions, + type ProviderRefreshContext, type ImportableProviderSession, type ImportProviderSessionContext, type ImportProviderSessionInput, @@ -96,6 +97,10 @@ import { type ToolCallDetail, type ToolCallTimelineItem, } from "../agent-sdk-types.js"; +import { + raceProviderRefreshAbort, + runProviderRefreshActivity, +} from "../provider-refresh-deadline.js"; import { isDefaultAgentCreateConfigUnattended, resolveDefaultAgentCreateConfig, @@ -267,7 +272,6 @@ export function buildACPClientCapabilities( // sign-in URL in the browser) when probing an ACP agent for models/modes. // NO_BROWSER is honored by Gemini CLI; other ACP agents ignore it. const PROBE_ENV: Record<string, string> = { NO_BROWSER: "true" }; -const ACP_CATALOG_TIMEOUT_MS = 60_000; const ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS = 20_000; function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } { @@ -934,66 +938,83 @@ export class ACPAgentClient implements AgentClient { return session; } - async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> { + async fetchCatalog( + options: FetchCatalogOptions, + context?: ProviderRefreshContext, + ): Promise<ProviderCatalog> { const cwd = options.scope === "global" ? homedir() : options.cwd; - const timeoutMs = options.timeoutMs ?? ACP_CATALOG_TIMEOUT_MS; let probe: UninitializedACPProcess | null = null; - try { - const catalogProbe = (async () => { - const initializedProbe = await this.spawnProcess(PROBE_ENV, { - initializeTimeoutMs: timeoutMs, - onSpawned: (spawned) => { - probe = spawned; - }, - }); - probe = initializedProbe; - const response = await this.runACPRequest(() => - initializedProbe.connection.newSession({ - cwd, - mcpServers: [], - }), - ); - const transformed = this.transformSessionResponse(response); - const derivedModels = deriveModelDefinitionsFromACP( - this.provider, - transformed.models, - transformed.configOptions, - ); - const models = this.catalogModelResolver - ? await this.catalogModelResolver({ - connection: initializedProbe.connection, - sessionId: response.sessionId, - models: derivedModels, - configOptions: transformed.configOptions, - runRequest: (request) => this.runACPRequest(request), - transformConfigOptions: (configOptions) => - this.configOptionsTransformer - ? this.configOptionsTransformer(configOptions) - : configOptions, - logger: this.logger, - provider: this.provider, - }) - : derivedModels; - const modeInfo = deriveModesFromACP( - this.defaultModes, - transformed.modes, - transformed.configOptions, - ); - return { - models: this.modelTransformer ? this.modelTransformer(models) : models, - modes: modeInfo.modes, - }; - })(); + let closePromise: Promise<void> | null = null; + const closeProbe = (): Promise<void> => { + if (!probe) return Promise.resolve(); + closePromise ??= this.closeProbe(probe); + return closePromise; + }; + const handleAbort = () => void closeProbe().catch(() => undefined); + context?.signal.addEventListener("abort", handleAbort, { once: true }); - return await withTimeout( - catalogProbe, - timeoutMs, - `ACP catalog probe timed out after ${timeoutMs}ms`, + try { + const initializedProbe = await runProviderRefreshActivity(context, "initialize", () => + raceProviderRefreshAbort( + context?.signal, + this.spawnProcess(PROBE_ENV, { + onSpawned: (spawned) => { + probe = spawned; + if (context?.signal.aborted) void closeProbe().catch(() => undefined); + }, + }), + ), ); + probe = initializedProbe; + const response = await runProviderRefreshActivity(context, "session/new", () => + raceProviderRefreshAbort( + context?.signal, + this.runACPRequest(() => + initializedProbe.connection.newSession({ + cwd, + mcpServers: [], + }), + ), + ), + ); + const transformed = this.transformSessionResponse(response); + const derivedModels = deriveModelDefinitionsFromACP( + this.provider, + transformed.models, + transformed.configOptions, + ); + const models = this.catalogModelResolver + ? await runProviderRefreshActivity(context, "catalog.resolve", () => + raceProviderRefreshAbort( + context?.signal, + this.catalogModelResolver?.({ + connection: initializedProbe.connection, + sessionId: response.sessionId, + models: derivedModels, + configOptions: transformed.configOptions, + runRequest: (request) => this.runACPRequest(request), + transformConfigOptions: (configOptions) => + this.configOptionsTransformer + ? this.configOptionsTransformer(configOptions) + : configOptions, + logger: this.logger, + provider: this.provider, + }) ?? Promise.resolve(derivedModels), + ), + ) + : derivedModels; + const modeInfo = deriveModesFromACP( + this.defaultModes, + transformed.modes, + transformed.configOptions, + ); + return { + models: this.modelTransformer ? this.modelTransformer(models) : models, + modes: modeInfo.modes, + }; } finally { - if (probe) { - await this.closeProbe(probe); - } + context?.signal.removeEventListener("abort", handleAbort); + await closeProbe(); } } diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index c02697c8dc..22873f4075 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -121,9 +121,11 @@ import { type ListImportableSessionsOptions, type McpServerConfig, type ProviderCatalog, + type ProviderRefreshContext, type ResolveAgentDefaultModeInput, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; +import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js"; import { checkProviderLaunchAvailable, createProviderEnv, @@ -392,7 +394,7 @@ interface ClaudeAgentClientOptions { runtimeSettings?: ProviderRuntimeSettings; queryFactory?: ClaudeQueryFactory; resolveBinary?: () => Promise<string>; - resolveVersion?: () => Promise<string>; + resolveVersion?: (signal?: AbortSignal) => Promise<string>; configDir?: string; } @@ -1476,7 +1478,7 @@ export class ClaudeAgentClient implements AgentClient { private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise<string>; - private readonly resolveVersion: () => Promise<string>; + private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>; private readonly configDir?: string; constructor(options: ClaudeAgentClientOptions) { @@ -1486,7 +1488,8 @@ export class ClaudeAgentClient implements AgentClient { this.queryFactory = options.queryFactory; this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings)); this.resolveVersion = - options.resolveVersion ?? (() => resolveClaudeCodeVersion(this.runtimeSettings)); + options.resolveVersion ?? + ((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal)); this.configDir = options.configDir; } @@ -1540,18 +1543,21 @@ export class ClaudeAgentClient implements AgentClient { }); } - async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> { + async fetchCatalog( + _options: FetchCatalogOptions, + context?: ProviderRefreshContext, + ): Promise<ProviderCatalog> { // Claude exposes a global catalog here; cwd/force are intentionally irrelevant. let claudeCodeVersion: string | undefined; try { - claudeCodeVersion = await this.resolveVersion(); + claudeCodeVersion = await runProviderRefreshActivity(context, "version", () => + this.resolveVersion(context?.signal), + ); } catch (error) { this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog"); } - const models = await getClaudeModelsWithSettings( - this.logger, - this.configDir, - claudeCodeVersion, + const models = await runProviderRefreshActivity(context, "settings", () => + getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion), ); const modes = detectIneligibleAutoModeTransport( createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }), @@ -1680,6 +1686,7 @@ async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): P export async function resolveClaudeCodeVersion( runtimeSettings?: ProviderRuntimeSettings, + signal?: AbortSignal, ): Promise<string> { const launch = await resolveProviderLaunch({ commandConfig: runtimeSettings?.command, @@ -1693,6 +1700,7 @@ export async function resolveClaudeCodeVersion( const { stdout, stderr } = await execCommand(executable, [...launch.args, "--version"], { ...createProviderEnvSpec({ runtimeSettings }), timeout: 5_000, + signal, }); const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`); if (!version) { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 9680d62a0f..1a51434305 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -33,8 +33,11 @@ import { type ImportProviderSessionInput, type ListImportableSessionsOptions, type ProviderCatalog, + type ProviderRefreshContext, + type ResolveAgentDefaultModeInput, } from "../agent-sdk-types.js"; import { importSessionFromPersistence } from "../provider-session-import.js"; +import { runProviderRefreshActivity } from "../provider-refresh-deadline.js"; import type { Logger } from "pino"; import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process"; @@ -6611,31 +6614,33 @@ export class CodexAppServerAgentClient implements AgentClient { return this.goalsEnabledPromise; } - private resolveAutoReviewEnabled(): Promise<boolean> { + private resolveAutoReviewEnabled(signal?: AbortSignal): Promise<boolean> { + if (signal) return this.probeAutoReviewEnabled(signal); if (!this.autoReviewEnabledPromise) { - this.autoReviewEnabledPromise = (async () => { - try { - const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); - const versionOutput = await resolveBinaryVersion(launchPrefix.command); - const enabled = codexVersionAtLeast(versionOutput, CODEX_AUTO_REVIEW_MIN_VERSION); - this.logger.trace( - { - provider: CODEX_PROVIDER, - versionOutput, - enabled, - }, - "provider.codex.config.auto_review_resolved", - ); - return enabled; - } catch (error) { - this.logger.warn({ err: error }, "Failed to probe codex version for auto-review gate"); - return false; - } - })(); + this.autoReviewEnabledPromise = this.probeAutoReviewEnabled(); } return this.autoReviewEnabledPromise; } + private async probeAutoReviewEnabled(signal?: AbortSignal): Promise<boolean> { + try { + const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); + signal?.throwIfAborted(); + const versionOutput = await resolveBinaryVersion(launchPrefix.command, signal); + signal?.throwIfAborted(); + const enabled = codexVersionAtLeast(versionOutput, CODEX_AUTO_REVIEW_MIN_VERSION); + this.logger.trace( + { provider: CODEX_PROVIDER, versionOutput, enabled }, + "provider.codex.config.auto_review_resolved", + ); + return enabled; + } catch (error) { + if (signal?.aborted) throw signal.reason; + this.logger.warn({ err: error }, "Failed to probe codex version for auto-review gate"); + return false; + } + } + private async spawnAppServer( launchEnv?: Record<string, string>, options?: { goalsEnabled?: boolean; agentId?: string }, @@ -6787,10 +6792,15 @@ export class CodexAppServerAgentClient implements AgentClient { }); } - async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> { + async fetchCatalog( + _options: FetchCatalogOptions, + context?: ProviderRefreshContext, + ): Promise<ProviderCatalog> { const [models, autoReviewEnabled] = await Promise.all([ - this.fetchModelsFromAppServer(), - this.resolveAutoReviewEnabled(), + this.fetchModelsFromAppServer(context), + runProviderRefreshActivity(context, "version", () => + this.resolveAutoReviewEnabled(context?.signal), + ), ]); return { models, @@ -6801,23 +6811,46 @@ export class CodexAppServerAgentClient implements AgentClient { }; } - async resolveDefaultModeId(): Promise<string> { - return (await this.resolveAutoReviewEnabled()) ? "auto-review" : DEFAULT_CODEX_MODE_ID; + async resolveDefaultModeId(input: ResolveAgentDefaultModeInput): Promise<string> { + return (await this.resolveAutoReviewEnabled(input.signal)) + ? "auto-review" + : DEFAULT_CODEX_MODE_ID; } - private async fetchModelsFromAppServer(): Promise<AgentModelDefinition[]> { + private async fetchModelsFromAppServer( + context?: ProviderRefreshContext, + ): Promise<AgentModelDefinition[]> { // Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored. - const child = await this.spawnAppServer(); - const client = new CodexAppServerClient(child, this.logger); + let client: CodexAppServerClient | undefined; + let disposePromise: Promise<void> | undefined; + const dispose = () => { + if (!client) return Promise.resolve(); + disposePromise ??= client.dispose(); + return disposePromise; + }; + const handleAbort = () => void dispose().catch(() => undefined); + context?.signal.addEventListener("abort", handleAbort, { once: true }); try { - await client.request("initialize", buildCodexAppServerInitializeParams()); + await runProviderRefreshActivity(context, "app-server.start", async () => { + const child = await this.spawnAppServer(); + client = new CodexAppServerClient(child, this.logger); + if (context?.signal.aborted) await dispose(); + }); + if (!client) throw new Error("Codex app-server did not start"); + await runProviderRefreshActivity(context, "initialize", () => + client!.request("initialize", buildCodexAppServerInitializeParams()), + ); client.notify("initialized", {}); - const rawResponse = await client.request("model/list", {}); + const rawResponse = await runProviderRefreshActivity(context, "model/list", () => + client!.request("model/list", {}), + ); const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse); const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : []; - const configuredDefaults = await readCodexConfiguredDefaults(client, this.logger); + const configuredDefaults = await runProviderRefreshActivity(context, "config/read", () => + readCodexConfiguredDefaults(client!, this.logger), + ); const configuredDefaultModelId = configuredDefaults.model; const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId; const hasConfiguredDefaultModel = @@ -6832,7 +6865,8 @@ export class CodexAppServerAgentClient implements AgentClient { }), ); } finally { - await client.dispose(); + context?.signal.removeEventListener("abort", handleAbort); + await dispose(); } } diff --git a/packages/server/src/server/agent/providers/diagnostic-utils.ts b/packages/server/src/server/agent/providers/diagnostic-utils.ts index 0cf2b44b02..670bd80755 100644 --- a/packages/server/src/server/agent/providers/diagnostic-utils.ts +++ b/packages/server/src/server/agent/providers/diagnostic-utils.ts @@ -135,11 +135,15 @@ export function toDiagnosticErrorMessage(error: unknown): string { return formatNonErrorDiagnostic(error); } -export async function resolveBinaryVersion(binaryPath: string): Promise<string> { +export async function resolveBinaryVersion( + binaryPath: string, + signal?: AbortSignal, +): Promise<string> { try { const { stdout } = await execCommand(binaryPath, ["--version"], { ...createProviderEnvSpec(), timeout: 5_000, + signal, }); return stdout.trim() || "unknown"; } catch (error) { diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts index 785838bb64..5bac343fdc 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { runProviderRefreshWithDeadline } from "../provider-refresh-deadline.js"; import { buildVersionProbeCommand, GenericACPAgentClient } from "./generic-acp-agent.js"; const TEST_ACP_TIMEOUT_MS = 1_000; @@ -91,8 +92,15 @@ describe("GenericACPAgentClient diagnostics", () => { }); await expect( - client.fetchCatalog({ cwd: tmpdir(), force: true, timeoutMs: TEST_ACP_TIMEOUT_MS }), - ).rejects.toThrow(`ACP catalog probe timed out after ${TEST_ACP_TIMEOUT_MS}ms`); + runProviderRefreshWithDeadline({ + label: "Grok", + timeoutMs: TEST_ACP_TIMEOUT_MS, + operation: (context) => + client.fetchCatalog({ scope: "workspace", cwd: tmpdir(), force: true }, context), + }), + ).rejects.toThrow( + `Timed out refreshing Grok after ${TEST_ACP_TIMEOUT_MS}ms; pending: session/new`, + ); const pid = Number(await readFile(pidPath, "utf8")); await expectProcessExit(pid); @@ -116,7 +124,7 @@ describe("GenericACPAgentClient diagnostics", () => { }, }); - await client.fetchCatalog({ cwd: testDir, force: true, timeoutMs: TEST_ACP_TIMEOUT_MS }); + await client.fetchCatalog({ scope: "workspace", cwd: testDir, force: true }); const session = await client.createSession({ provider: "acp", cwd: testDir }); await session.close(); diff --git a/packages/server/src/server/agent/providers/mock-slow-provider.ts b/packages/server/src/server/agent/providers/mock-slow-provider.ts index ef2ad42df7..19817d29c6 100644 --- a/packages/server/src/server/agent/providers/mock-slow-provider.ts +++ b/packages/server/src/server/agent/providers/mock-slow-provider.ts @@ -7,6 +7,7 @@ import type { AgentSession, AgentSessionConfig, FetchCatalogOptions, + ProviderRefreshContext, ProviderCatalog, } from "../agent-sdk-types.js"; @@ -24,10 +25,6 @@ const CAPABILITIES: AgentCapabilityFlags = { supportsRewindBoth: false, }; -function neverResolves<T>(): Promise<T> { - return new Promise<T>(() => {}); -} - export class MockSlowProviderClient implements AgentClient { readonly provider: AgentProvider = MOCK_SLOW_PROVIDER_ID; readonly capabilities = CAPABILITIES; @@ -36,8 +33,19 @@ export class MockSlowProviderClient implements AgentClient { return process.env.PASEO_ENABLE_MOCK_SLOW === "true"; } - async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> { - return neverResolves<ProviderCatalog>(); + async fetchCatalog( + _options: FetchCatalogOptions, + context?: ProviderRefreshContext, + ): Promise<ProviderCatalog> { + return await (context?.runActivity( + "mock.catalog", + () => + new Promise<ProviderCatalog>((_resolve, reject) => { + context.signal.addEventListener("abort", () => reject(context.signal.reason), { + once: true, + }); + }), + ) ?? new Promise<ProviderCatalog>(() => {})); } async getDiagnostic(): Promise<{ diagnostic: string }> { diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index f3f6c7f972..db996cf701 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -33,10 +33,12 @@ import { type ImportProviderSessionInput, type ListImportableSessionsOptions, type ProviderCatalog, + type ProviderRefreshContext, type ToolCallDetail, } from "../../agent-sdk-types.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; +import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js"; import { runProviderTurn } from "../provider-runner.js"; import { checkProviderLaunchAvailable, @@ -108,7 +110,6 @@ import { import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js"; const OMP_PROVIDER = "omp"; -const OMP_CATALOG_REQUEST_TIMEOUT_MS = 120_000; const QUESTION_RESPONSE_HEADER = "Response"; const QUESTION_COMMENT_HEADER = "Comment"; const OMP_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response..."; @@ -2304,23 +2305,44 @@ export class OmpAgentClient implements AgentClient { } } - async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> { + async fetchCatalog( + options: FetchCatalogOptions, + context?: ProviderRefreshContext, + ): Promise<ProviderCatalog> { const launchMode = this.resolveLaunchMode(undefined); - const runtimeSession = await this.runtime.startSession({ - cwd: options.scope === "global" ? homedir() : options.cwd, - protocolMode: "rpc-ui", - modeId: launchMode.modeId, - extraArgs: launchMode.extraArgs, - }); + let runtimeSession: OmpRuntimeSession | undefined; + let closePromise: Promise<void> | undefined; + const closeSession = () => { + if (!runtimeSession) return Promise.resolve(); + closePromise ??= runtimeSession.close(); + return closePromise; + }; + const handleAbort = () => void closeSession().catch(() => undefined); + context?.signal.addEventListener("abort", handleAbort, { once: true }); try { + await runProviderRefreshActivity(context, "runtime.start", async () => { + runtimeSession = await this.runtime.startSession({ + cwd: options.scope === "global" ? homedir() : options.cwd, + protocolMode: "rpc-ui", + modeId: launchMode.modeId, + extraArgs: launchMode.extraArgs, + signal: context?.signal, + }); + if (context?.signal.aborted) await closeSession(); + }); + if (!runtimeSession) throw new Error("OMP catalog runtime did not start"); + const catalogSession = runtimeSession; const models = transformOmpModels( - (await runtimeSession.getAvailableModels(OMP_CATALOG_REQUEST_TIMEOUT_MS)).map((model) => - mapOmpModel(model, this.provider), - ), + ( + await runProviderRefreshActivity(context, "get_available_models", () => + catalogSession.getAvailableModels(null), + ) + ).map((model) => mapOmpModel(model, this.provider)), ); return { models, modes: [...OMP_MODES] }; } finally { - await runtimeSession.close(); + context?.signal.removeEventListener("abort", handleAbort); + await closeSession(); } } diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.ts index 22acf58ce6..97e01c937a 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -88,13 +88,18 @@ export class OmpCliRuntime implements OmpRuntime { ...(spawn ? { spawn: () => spawn(launch) } : {}), }; const process = new JsonlRpcProcess(processOptions); + const handleAbort = () => void process.close(input.signal?.reason).catch(() => undefined); + input.signal?.addEventListener("abort", handleAbort, { once: true }); try { await negotiateOmpProtocolV2(process, this.options.logger); + input.signal?.throwIfAborted(); return new OmpCliRuntimeSession(process, this.commandsRpcName); } catch (error) { const startupError = error instanceof Error ? error : new Error(String(error)); await process.close(startupError); throw startupError; + } finally { + input.signal?.removeEventListener("abort", handleAbort); } } } @@ -216,7 +221,7 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { return data.messages ?? []; } - async getAvailableModels(timeoutMs?: number): Promise<OmpModel[]> { + async getAvailableModels(timeoutMs?: number | null): Promise<OmpModel[]> { const data = OmpModelsResultSchema.parse( await this.request({ type: "get_available_models" }, timeoutMs), ); @@ -337,7 +342,7 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { await this.process.close(new Error("OMP RPC session is closed")); } - private request(command: OmpRpcCommand, timeoutMs?: number): Promise<unknown> { + private request(command: OmpRpcCommand, timeoutMs?: number | null): Promise<unknown> { return this.process.request(OmpRpcCommandSchema.parse(command), timeoutMs); } diff --git a/packages/server/src/server/agent/providers/omp/runtime.ts b/packages/server/src/server/agent/providers/omp/runtime.ts index 3a8d90c61c..dc8001e3ec 100644 --- a/packages/server/src/server/agent/providers/omp/runtime.ts +++ b/packages/server/src/server/agent/providers/omp/runtime.ts @@ -30,6 +30,7 @@ export interface OmpRuntimeLaunch { export interface OmpStartSessionInput { cwd: string; + signal?: AbortSignal; env?: Record<string, string>; protocolMode?: "rpc" | "rpc-ui"; model?: string; @@ -52,7 +53,7 @@ export interface OmpRuntimeSession { abort(): Promise<void>; getState(): Promise<OmpSessionState>; getMessages(): Promise<OmpAgentMessage[]>; - getAvailableModels(timeoutMs?: number): Promise<OmpModel[]>; + getAvailableModels(timeoutMs?: number | null): Promise<OmpModel[]>; setModel(provider: string, modelId: string): Promise<OmpModel>; setThinkingLevel(level: OmpThinkingLevel): Promise<void>; getSessionStats(): Promise<OmpSessionStats>; diff --git a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts index 6d46c50f1d..0b17fe8c5b 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts @@ -274,7 +274,7 @@ export class FakeOmpSession implements OmpRuntimeSession { return this.messages; } - async getAvailableModels(_timeoutMs?: number): Promise<OmpModel[]> { + async getAvailableModels(_timeoutMs?: number | null): Promise<OmpModel[]> { return this.models; } diff --git a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts index ffa85ce38b..a3aed018bc 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { runProviderRefreshWithDeadline } from "../provider-refresh-deadline.js"; import { TestOpenCodeClient, TestOpenCodeHarness, @@ -11,32 +12,15 @@ afterEach(() => { vi.useRealTimers(); }); -test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => { +test("the catalog deadline aborts provider.list and releases the server", async () => { vi.useFakeTimers(); const runtime = new TestOpenCodeHarness(); const openCodeClient = new TestOpenCodeClient(); - openCodeClient.providerListImplementation = () => - new Promise((resolve) => { - setTimeout(() => { - resolve({ - data: { - connected: ["zai"], - all: [ - { - id: "zai", - name: "Z.AI", - models: { - "glm-5.1": { - name: "GLM 5.1", - limit: { context: 128_000 }, - }, - }, - }, - ], - }, - }); - }, 15_000); + openCodeClient.providerListImplementation = (_parameters, options) => + new Promise((_resolve, reject) => { + const signal = (options as { signal: AbortSignal }).signal; + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); runtime.enqueueClient(openCodeClient); @@ -44,24 +28,72 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se serverManager: runtime, createClient: runtime.createClient, }); - const modelsPromise = client.fetchCatalog({ - scope: "workspace", - cwd: "/tmp/opencode-models", - force: false, + const modelsPromise = runProviderRefreshWithDeadline({ + label: "OpenCode", + timeoutMs: 100, + operation: (context) => + client.fetchCatalog( + { scope: "workspace", cwd: "/tmp/opencode-models", force: false }, + context, + ), }); + const rejection = expect(modelsPromise).rejects.toThrow( + "Timed out refreshing OpenCode after 100ms; pending: provider.list", + ); - await vi.advanceTimersByTimeAsync(15_000); + await vi.advanceTimersByTimeAsync(100); - await expect(modelsPromise).resolves.toMatchObject({ - models: [ - { - provider: "opencode", - id: "zai/glm-5.1", - label: "GLM 5.1", - }, - ], - }); + await rejection; expect(openCodeClient.calls.providerList).toHaveLength(1); + expect(openCodeClient.calls.providerListOptions[0]).toMatchObject({ + signal: expect.objectContaining({ aborted: true }), + }); + expect(runtime.acquisitions).toEqual([{ kind: "current", releaseCount: 1 }]); +}); + +test("the catalog deadline aborts app.agents and releases the server", async () => { + vi.useFakeTimers(); + + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.providerListResponse = { + data: { + connected: ["openai"], + all: [{ id: "openai", name: "OpenAI", models: {} }], + }, + }; + openCodeClient.appAgentsImplementation = (_parameters, options) => + new Promise((_resolve, reject) => { + const signal = (options as { signal: AbortSignal }).signal; + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + runtime.enqueueClient(openCodeClient); + + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const catalogPromise = runProviderRefreshWithDeadline({ + label: "OpenCode", + timeoutMs: 100, + operation: (context) => + client.fetchCatalog( + { scope: "workspace", cwd: "/tmp/opencode-agents", force: false }, + context, + ), + }); + const rejection = expect(catalogPromise).rejects.toThrow( + "Timed out refreshing OpenCode after 100ms; pending: app.agents", + ); + + await vi.advanceTimersByTimeAsync(100); + + await rejection; + expect(openCodeClient.calls.appAgents).toHaveLength(1); + expect(openCodeClient.calls.appAgentsOptions[0]).toMatchObject({ + signal: expect.objectContaining({ aborted: true }), + }); + expect(runtime.acquisitions).toEqual([{ kind: "current", releaseCount: 1 }]); }); test("uses a new server for explicit catalog refresh", async () => { diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 7dbe5d3b73..d2eb6730fe 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -41,6 +41,7 @@ import { type AgentTimelineItem, type AgentUsage, type FetchCatalogOptions, + type ProviderRefreshContext, type ImportableProviderSession, type ImportProviderSessionContext, type ImportProviderSessionInput, @@ -53,6 +54,10 @@ import { type ToolCallTimelineItem, } from "../agent-sdk-types.js"; import { importSessionFromPersistence } from "../provider-session-import.js"; +import { + raceProviderRefreshAbort, + runProviderRefreshActivity, +} from "../provider-refresh-deadline.js"; import { isDefaultAgentCreateConfigUnattended, resolveDefaultAgentCreateConfig, @@ -68,6 +73,7 @@ import { execCommand } from "../../../utils/spawn.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { OpenCodeServerManager, + type OpenCodeServerAcquisition, type OpenCodeServerManagerLike, } from "./opencode/server-manager.js"; import { resolveOpenCodeHomeDir } from "./opencode/paths.js"; @@ -297,9 +303,9 @@ type OpenCodeMcpConfig = }; const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const; -const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000; const OPENCODE_METADATA_CONCURRENCY = 4; const openCodeMetadataLimit = pLimit(OPENCODE_METADATA_CONCURRENCY); + const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [ { name: "compact", @@ -1422,14 +1428,22 @@ export class OpenCodeAgentClient implements AgentClient { } } - async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> { - const acquisition = options.force - ? await this.serverManager.acquireNew() - : await this.serverManager.acquireCurrent(); - const { url } = acquisition.server; - const isGlobalCatalog = options.scope === "global"; - + async fetchCatalog( + options: FetchCatalogOptions, + context?: ProviderRefreshContext, + ): Promise<ProviderCatalog> { + let acquisition: OpenCodeServerAcquisition | undefined; try { + await runProviderRefreshActivity(context, "server.acquire", async () => { + acquisition = options.force + ? await this.serverManager.acquireNew(context?.signal) + : await this.serverManager.acquireCurrent(context?.signal); + }); + if (!acquisition) throw new Error("OpenCode server acquisition did not complete"); + context?.signal.throwIfAborted(); + const { url } = acquisition.server; + const isGlobalCatalog = options.scope === "global"; + // OpenCode treats the catalog directory as a workspace. The global catalog // is not a project, so use the neutral OpenCode home instead of user home. const directory = isGlobalCatalog ? this.resolveHomeDir() : options.cwd; @@ -1444,12 +1458,12 @@ export class OpenCodeAgentClient implements AgentClient { const client = this.createOpenCodeClient({ baseUrl: url, directory }); const [models, modes] = await Promise.all([ - this.fetchModelsFromClient(client, directory), - this.fetchModesFromClient(client, directory), + this.fetchModelsFromClient(client, directory, context), + this.fetchModesFromClient(client, directory, context), ]); return { models, modes }; } finally { - await acquisition.release(); + await acquisition?.release(); } } @@ -1637,12 +1651,18 @@ export class OpenCodeAgentClient implements AgentClient { private async fetchModelsFromClient( client: OpencodeClient, directory: string, + context?: ProviderRefreshContext, ): Promise<AgentModelDefinition[]> { - const response = await openCodeMetadataLimit(() => - withTimeout( - client.provider.list({ directory }), - OPENCODE_PROVIDER_LIST_TIMEOUT_MS, - `OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`, + const response = await runProviderRefreshActivity(context, "provider.list", () => + raceProviderRefreshAbort( + context?.signal, + openCodeMetadataLimit(() => { + context?.signal.throwIfAborted(); + return client.provider.list( + { directory }, + context ? { signal: context.signal } : undefined, + ); + }), ), ); @@ -1669,7 +1689,7 @@ export class OpenCodeAgentClient implements AgentClient { } const models: AgentModelDefinition[] = []; - this.modelContextWindows.clear(); + const contextWindows = new Map<string, number>(); for (const provider of providers.all) { if (!isAccessible(provider)) { continue; @@ -1679,7 +1699,7 @@ export class OpenCodeAgentClient implements AgentClient { const definition = buildOpenCodeModelDefinition(provider, modelId, model); const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model); if (contextWindowMaxTokens !== undefined) { - this.modelContextWindows.set( + contextWindows.set( buildOpenCodeModelLookupKey(provider.id, modelId), contextWindowMaxTokens, ); @@ -1688,18 +1708,25 @@ export class OpenCodeAgentClient implements AgentClient { } } + context?.signal.throwIfAborted(); + this.modelContextWindows.clear(); + for (const [key, value] of contextWindows) this.modelContextWindows.set(key, value); + return models; } private async fetchModesFromClient( client: OpencodeClient, directory: string, + context?: ProviderRefreshContext, ): Promise<AgentMode[]> { - const response = await openCodeMetadataLimit(() => - withTimeout( - client.app.agents({ directory }), - 10_000, - "OpenCode app.agents timed out after 10s", + const response = await runProviderRefreshActivity(context, "app.agents", () => + raceProviderRefreshAbort( + context?.signal, + openCodeMetadataLimit(() => { + context?.signal.throwIfAborted(); + return client.app.agents({ directory }, context ? { signal: context.signal } : undefined); + }), ), ); diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index 38f698b473..d6a1d5578a 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -138,6 +138,25 @@ describe("OpenCodeServerManager generations", () => { expect(await runtime.managedProcesses.list()).toEqual([]); }); + test("aborted acquisition transfers no reference and leaves startup reusable", async () => { + const { manager, runtime } = createTestManager([4477], { autoAnnounce: false }); + const controller = new AbortController(); + + const abortedAcquisition = manager.acquireCurrent(controller.signal); + await runtime.settle(); + controller.abort(new Error("catalog refresh expired")); + + await expect(abortedAcquisition).rejects.toThrow("catalog refresh expired"); + runtime.processForPort(4477).announceListening(); + + const nextAcquisition = await manager.acquireCurrent(); + expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4477"); + expect(runtime.launchedPorts).toEqual([4477]); + + await nextAcquisition.release(); + expect(runtime.terminatedPorts).toEqual([4477]); + }); + test("shutdown kills a server that is still starting", async () => { const { manager, runtime } = createTestManager([4472], { autoAnnounce: false }); diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index 2baa07bbd3..0c850514da 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -25,8 +25,8 @@ export interface OpenCodeServerAcquisition { } export interface OpenCodeServerManagerLike { - acquireCurrent(): Promise<OpenCodeServerAcquisition>; - acquireNew(): Promise<OpenCodeServerAcquisition>; + acquireCurrent(signal?: AbortSignal): Promise<OpenCodeServerAcquisition>; + acquireNew(signal?: AbortSignal): Promise<OpenCodeServerAcquisition>; acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>; acquireExisting(url: string): OpenCodeServerAcquisition | null; shutdown(): Promise<void>; @@ -137,13 +137,17 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { process.on("SIGINT", cleanup); } - async acquireCurrent(): Promise<OpenCodeServerAcquisition> { - const server = await this.getCurrentServer(); + async acquireCurrent(signal?: AbortSignal): Promise<OpenCodeServerAcquisition> { + signal?.throwIfAborted(); + const server = await waitForServerAcquisition(this.getCurrentServer(), signal); + signal?.throwIfAborted(); return this.acquireServer(server); } - async acquireNew(): Promise<OpenCodeServerAcquisition> { - const server = await this.getNewServer(); + async acquireNew(signal?: AbortSignal): Promise<OpenCodeServerAcquisition> { + signal?.throwIfAborted(); + const server = await waitForServerAcquisition(this.getNewServer(), signal); + signal?.throwIfAborted(); return this.acquireServer(server); } @@ -533,6 +537,23 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { } } +async function waitForServerAcquisition<T>( + operation: Promise<T>, + signal: AbortSignal | undefined, +): Promise<T> { + if (!signal) return await operation; + let handleAbort: (() => void) | undefined; + const aborted = new Promise<never>((_resolve, reject) => { + handleAbort = () => reject(signal.reason); + signal.addEventListener("abort", handleAbort, { once: true }); + }); + try { + return await Promise.race([operation, aborted]); + } finally { + if (handleAbort) signal.removeEventListener("abort", handleAbort); + } +} + async function resolveOpenCodeBinary(): Promise<string> { const found = await findExecutable("opencode"); if (!found) { diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index 7433532f0f..f17ba9da6a 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -71,6 +71,7 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike { export class TestOpenCodeClient { readonly calls = { appAgents: [] as unknown[], + appAgentsOptions: [] as unknown[], commandList: [] as unknown[], eventSubscribe: [] as unknown[], experimentalSessionList: [] as unknown[], @@ -79,6 +80,7 @@ export class TestOpenCodeClient { mcpConnect: [] as unknown[], permissionReply: [] as unknown[], providerList: [] as unknown[], + providerListOptions: [] as unknown[], questionReject: [] as unknown[], questionReply: [] as unknown[], sessionAbort: [] as unknown[], @@ -95,6 +97,9 @@ export class TestOpenCodeClient { }; appAgentsResponse: OpenCodeResponse = { data: [] }; + appAgentsImplementation: + | ((parameters: unknown, options: unknown) => Promise<OpenCodeResponse>) + | null = null; commandListResponse: OpenCodeResponse = { data: [] }; eventStream: AsyncIterable<unknown>; experimentalSessionListResponse: OpenCodeResponse = { data: [] }; @@ -102,7 +107,9 @@ export class TestOpenCodeClient { mcpConnectResponse: OpenCodeResponse = {}; permissionReplyResponse: OpenCodeResponse = {}; providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } }; - providerListImplementation: (() => Promise<OpenCodeResponse>) | null = null; + providerListImplementation: + | ((parameters: unknown, options: unknown) => Promise<OpenCodeResponse>) + | null = null; globalEventImplementation: | ((options: unknown) => Promise<{ stream: AsyncIterable<unknown> }>) | null = null; @@ -141,8 +148,12 @@ export class TestOpenCodeClient { asSdkClient(): OpencodeClient { return { app: { - agents: async (parameters: unknown) => { + agents: async (parameters: unknown, options: unknown) => { this.calls.appAgents.push(parameters); + this.calls.appAgentsOptions.push(options); + if (this.appAgentsImplementation) { + return await this.appAgentsImplementation(parameters, options); + } return this.appAgentsResponse; }, }, @@ -195,10 +206,11 @@ export class TestOpenCodeClient { }, }, provider: { - list: async (parameters: unknown) => { + list: async (parameters: unknown, options: unknown) => { this.calls.providerList.push(parameters); + this.calls.providerListOptions.push(options); return this.providerListImplementation - ? await this.providerListImplementation() + ? await this.providerListImplementation(parameters, options) : this.providerListResponse; }, }, diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 04adc29b92..82bd0a8232 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -36,9 +36,11 @@ import { type ImportProviderSessionInput, type ListImportableSessionsOptions, type ProviderCatalog, + type ProviderRefreshContext, type ToolCallDetail, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; +import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js"; import { runProviderTurn } from "../provider-runner.js"; import { checkProviderLaunchAvailable, @@ -88,7 +90,6 @@ import { const PI_PROVIDER = "pi"; const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium"; const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi"; -const PI_CATALOG_REQUEST_TIMEOUT_MS = 120_000; const PASEO_PI_TREE_EXTENSION_COMMAND = "paseo_tree"; const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries"; const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE"; @@ -2474,19 +2475,40 @@ export class PiRpcAgentClient implements AgentClient { } } - async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> { - const runtimeSession = await this.runtime.startSession({ - cwd: options.scope === "global" ? homedir() : options.cwd, - }); + async fetchCatalog( + options: FetchCatalogOptions, + context?: ProviderRefreshContext, + ): Promise<ProviderCatalog> { + let runtimeSession: PiRuntimeSession | undefined; + let closePromise: Promise<void> | undefined; + const closeSession = () => { + if (!runtimeSession) return Promise.resolve(); + closePromise ??= runtimeSession.close(); + return closePromise; + }; + const handleAbort = () => void closeSession().catch(() => undefined); + context?.signal.addEventListener("abort", handleAbort, { once: true }); try { + await runProviderRefreshActivity(context, "runtime.start", async () => { + runtimeSession = await this.runtime.startSession({ + cwd: options.scope === "global" ? homedir() : options.cwd, + signal: context?.signal, + }); + if (context?.signal.aborted) await closeSession(); + }); + if (!runtimeSession) throw new Error("Pi catalog runtime did not start"); + const catalogSession = runtimeSession; const models = transformPiModels( - (await runtimeSession.getAvailableModels(PI_CATALOG_REQUEST_TIMEOUT_MS)).map((model) => - mapPiModel(model, PI_PROVIDER), - ), + ( + await runProviderRefreshActivity(context, "get_available_models", () => + catalogSession.getAvailableModels(null), + ) + ).map((model) => mapPiModel(model, PI_PROVIDER)), ); return { models, modes: [] }; } finally { - await runtimeSession.close(); + context?.signal.removeEventListener("abort", handleAbort); + await closeSession(); } } diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 66cff0daf6..5432b913ea 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -78,6 +78,10 @@ export class PiCliRuntime implements PiRuntime { ...(spawn ? { spawn: () => spawn(launch) } : {}), }; const process = new JsonlRpcProcess(processOptions); + if (input.signal?.aborted) { + await process.close(input.signal.reason); + input.signal.throwIfAborted(); + } return new PiCliRuntimeSession(process, this.commandsRpcName); } } diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index 2d4800c144..7d29fc159f 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -26,6 +26,7 @@ export interface PiRuntimeLaunch { export interface PiStartSessionInput { cwd: string; + signal?: AbortSignal; env?: Record<string, string>; protocolMode?: "rpc" | "rpc-ui"; model?: string; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index fad665b102..e270216744 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -427,6 +427,7 @@ export interface PaseoDaemonConfig { dictationFinalTimeoutMs?: number; downloadTokenTtlMs?: number; agentProviderSettings?: AgentProviderRuntimeSettingsMap; + providerCatalogRefreshTimeoutMs?: number; metadataGeneration?: { providers?: Array<{ provider: string; @@ -820,6 +821,7 @@ export async function createPaseoDaemon( const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" }); const providerSnapshotManager = new ProviderSnapshotManager({ logger: providerSnapshotLogger, + refreshTimeoutMs: config.providerCatalogRefreshTimeoutMs, runtimeSettings: config.agentProviderSettings, providerOverrides: config.providerOverrides, workspaceGitService, diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index f9d4929c6b..5cfe41ec4d 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -26,6 +26,19 @@ describe("server config", () => { expect(standaloneConfig.desktopManaged).toBe(false); }); + test("loads the provider catalog refresh timeout", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-provider-timeout-")); + roots.push(paseoHome); + await writeFile( + path.join(paseoHome, "config.json"), + JSON.stringify({ agents: { catalogRefreshTimeoutMs: 180_000 } }), + ); + + const config = loadConfig(paseoHome, { env: {} }); + + expect(config.providerCatalogRefreshTimeoutMs).toBe(180_000); + }); + test("resolves bundled web UI path from source-tree modules", () => { const root = path.parse(process.cwd()).root; expect( diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 2930d1f0b2..9419b5b156 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -555,6 +555,7 @@ export function loadConfig( voiceLlmProviderExplicit: voiceLlm.providerExplicit, voiceLlmModel: voiceLlm.model, agentProviderSettings: extractAgentProviderSettings(providerOverrides), + providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs, metadataGeneration: persisted.agents?.metadataGeneration, providerOverrides, log: resolveLogConfigFromEnv(env, persisted), diff --git a/packages/server/src/server/file-observer/index.test.ts b/packages/server/src/server/file-observer/index.test.ts index 1123023211..0427cd823b 100644 --- a/packages/server/src/server/file-observer/index.test.ts +++ b/packages/server/src/server/file-observer/index.test.ts @@ -237,7 +237,7 @@ test("observes a thousand concurrent writes and remains healthy after delete and await Promise.all(paths.map((path, index) => writeFile(path, `${index}`))); await expect - .poll(() => paths.filter((path) => !observed.has(path)), { timeout: 10_000 }) + .poll(() => paths.filter((path) => !observed.has(path)), { timeout: 60_000 }) .toEqual([]); const removedPaths = paths.slice(0, 100); @@ -258,7 +258,7 @@ test("observes a thousand concurrent writes and remains healthy after delete and await writeFile(sentinel, "alive"); await expect.poll(() => observed.has(sentinel)).toBe(true); await subscription.unsubscribe(); -}, 30_000); +}, 90_000); test("unsubscribe cancels reconciliation queued by a write burst", async () => { const root = await createRoot(); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 140bc0f40e..8ea1c5c25d 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -251,6 +251,20 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { ], }); }); + + test("accepts a custom provider catalog refresh timeout", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { catalogRefreshTimeoutMs: 180_000 }, + }); + + expect(parsed.agents?.catalogRefreshTimeoutMs).toBe(180_000); + }); + + test("rejects provider catalog refresh timeouts that overflow Node timers", () => { + expect(() => + PersistedConfigSchema.parse({ agents: { catalogRefreshTimeoutMs: 2_147_483_648 } }), + ).toThrow(); + }); }); describe("provider overrides (new format)", () => { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 9a4dbd11b0..e83b02603d 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -308,6 +308,7 @@ export const PersistedConfigSchema = z agents: z .object({ providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), + catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(), metadataGeneration: AgentMetadataGenerationSchema.optional(), }) .strict() diff --git a/packages/server/src/utils/spawn.ts b/packages/server/src/utils/spawn.ts index ac27572c24..8fac115f00 100644 --- a/packages/server/src/utils/spawn.ts +++ b/packages/server/src/utils/spawn.ts @@ -27,6 +27,7 @@ interface ExecCommandOptions extends ExternalEnvOptions { timeout?: number; maxBuffer?: number; shell?: boolean | string; + signal?: AbortSignal; } interface ExecCommandResult { @@ -77,6 +78,7 @@ export function spawnProcess( ...spawnOptions, env: childEnv, shell, + signal: options?.signal, windowsHide: true, }); } diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 033b496130..c8dc40a75c 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -335,6 +335,11 @@ }, "additionalProperties": false } + }, + "catalogRefreshTimeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647 } }, "additionalProperties": false From 1a6054138d895223fc913fe5aefdf0c1de36f484 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 16:08:23 +0200 Subject: [PATCH 56/73] Stop idle workspace Git refreshes from starving daemon requests (#3323) * fix(server): trust healthy workspace git watchers Quiet observed workspaces were periodically scheduling full Git refreshes, saturating the daemon-wide process pool as workspace count grew. Keep only cheap observation re-ensure work, refresh ignore paths from watcher events, and detect silent metadata watchers with a one-shot canary before accepting the subscription. * fix(server): refresh watcher ignores from git metadata Repository exclude and Git configuration changes also affect the standard ignore set. Route those metadata events back to linked working-tree observations so their excluded directory lists stay current without periodic Git polling. * test(server): drive ignore updates through watcher events The integration contract now exercises the production event-driven ignore refresh instead of waiting for the removed periodic audit. * fix(server): normalize git metadata paths on windows File observation returns platform-native relative separators. Normalize those paths before matching Git metadata names so repository exclusion changes refresh working-tree ignores on Windows too. * fix(server): trail concurrent ignore refreshes Remember ignore-source events that arrive during an in-flight Git query and recompute once more before considering the working-tree observer current. --- docs/file-observation.md | 2 + .../server/watcher-liveness-canary.test.ts | 33 ++++ .../src/server/watcher-liveness-canary.ts | 66 +++++++ ...kspace-create-git-fanout.local.e2e.test.ts | 34 ++-- ...it-service.observation.integration.test.ts | 3 +- .../workspace-git-service.observation.test.ts | 147 +++++++++++++- .../workspace-git-service.primitive.test.ts | 95 ++-------- .../src/server/workspace-git-service.test.ts | 5 + .../src/server/workspace-git-service.ts | 179 ++++++++++-------- 9 files changed, 381 insertions(+), 183 deletions(-) create mode 100644 packages/server/src/server/watcher-liveness-canary.test.ts create mode 100644 packages/server/src/server/watcher-liveness-canary.ts diff --git a/docs/file-observation.md b/docs/file-observation.md index 1418422eaa..f246372f8a 100644 --- a/docs/file-observation.md +++ b/docs/file-observation.md @@ -16,4 +16,6 @@ Read aggregate health from the owning observer service. Runtime metrics include Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module. +Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer. + The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak. diff --git a/packages/server/src/server/watcher-liveness-canary.test.ts b/packages/server/src/server/watcher-liveness-canary.test.ts new file mode 100644 index 0000000000..4da1481408 --- /dev/null +++ b/packages/server/src/server/watcher-liveness-canary.test.ts @@ -0,0 +1,33 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js"; + +const cleanupPaths: string[] = []; + +afterEach(async () => { + await Promise.all( + cleanupPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +test("requires the canary event to round-trip through the watcher callback", async () => { + const watchRoot = await mkdtemp(join(tmpdir(), "paseo-watcher-canary-")); + cleanupPaths.push(watchRoot); + const canary = createWatcherLivenessCanary(watchRoot, { timeoutMs: 1_000 }); + + const verification = canary.verify(); + const canaryPath = canary.path; + expect(canary.filterEvents([{ path: canaryPath, type: "create" }])).toEqual([]); + + await expect(verification).resolves.toBeUndefined(); +}); + +test("rejects when the watcher never reports the canary", async () => { + const watchRoot = await mkdtemp(join(tmpdir(), "paseo-watcher-canary-")); + cleanupPaths.push(watchRoot); + const canary = createWatcherLivenessCanary(watchRoot, { timeoutMs: 10 }); + + await expect(canary.verify()).rejects.toThrow("did not report its liveness canary"); +}); diff --git a/packages/server/src/server/watcher-liveness-canary.ts b/packages/server/src/server/watcher-liveness-canary.ts new file mode 100644 index 0000000000..a366f0229a --- /dev/null +++ b/packages/server/src/server/watcher-liveness-canary.ts @@ -0,0 +1,66 @@ +import { randomUUID } from "node:crypto"; +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { FileChange } from "./file-observer/index.js"; + +export const WATCHER_LIVENESS_CANARY_TIMEOUT_MS = 10_000; + +export interface WatcherLivenessCanary { + readonly path: string; + filterEvents(events: FileChange[]): FileChange[]; + verify(signal?: AbortSignal): Promise<void>; +} + +export function createWatcherLivenessCanary( + watchRoot: string, + options: { timeoutMs?: number } = {}, +): WatcherLivenessCanary { + const canaryPath = join(watchRoot, `.paseo-watcher-canary-${randomUUID()}`); + const timeoutMs = options.timeoutMs ?? WATCHER_LIVENESS_CANARY_TIMEOUT_MS; + let reportCanary!: () => void; + const reported = new Promise<void>((resolve) => { + reportCanary = resolve; + }); + + return { + path: canaryPath, + filterEvents(events) { + const filtered = events.filter((event) => event.path !== canaryPath); + if (filtered.length !== events.length) { + reportCanary(); + } + return filtered; + }, + async verify(signal) { + await writeFile(canaryPath, "paseo watcher liveness canary\n", { flag: "wx" }); + let timeout: NodeJS.Timeout | null = null; + let removeAbortListener = () => {}; + try { + const timeoutPromise = new Promise<never>((_resolve, reject) => { + timeout = setTimeout(() => { + reject( + new Error( + `Watcher for ${watchRoot} did not report its liveness canary within ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + }); + const abortPromise = new Promise<never>((_resolve, reject) => { + if (!signal) return; + const rejectForAbort = () => reject(signal.reason); + if (signal.aborted) { + rejectForAbort(); + return; + } + signal.addEventListener("abort", rejectForAbort, { once: true }); + removeAbortListener = () => signal.removeEventListener("abort", rejectForAbort); + }); + await Promise.race([reported, timeoutPromise, abortPromise]); + } finally { + if (timeout) clearTimeout(timeout); + removeAbortListener(); + await rm(canaryPath, { force: true }); + } + }, + }; +} diff --git a/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts b/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts index 7037d7f177..82f580ef1b 100644 --- a/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts +++ b/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts @@ -153,6 +153,18 @@ async function startObservedFixture(siblingCount: number): Promise<ReturnType<ty return fixture; } +test("quiet observed workspaces spawn no git commands beyond the old self-heal interval", async () => { + await startObservedFixture(2); + await settleGitCommands(); + + startGitCommandMetrics(); + await new Promise((resolve) => setTimeout(resolve, 61_000)); + await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 5_000 }); + const idle = stopGitCommandMetrics(); + + expect(idle.submissions).toEqual([]); +}, 90_000); + interface GitRuntimeExpectation { currentBranch?: string; remoteUrl?: string; @@ -377,21 +389,21 @@ test.each([1, 2, 10])( const creation = stopGitCommandMetrics(); expect(response?.workspace?.name).toBe("created-during-measurement"); - expect(creation.submitted).toBe(90); expect(countGitOperations(creation.submissions)).toEqual({ branch: 4, - config: 19, - diff: 2, - "for-each-ref": 6, - "ls-files": 3, - "merge-base": 2, - "rev-list": 2, - "rev-parse": 34, - "show-ref": 6, - status: 6, + config: 22, + diff: 3, + "for-each-ref": 7, + "ls-files": 4, + "merge-base": 3, + "rev-list": 3, + "rev-parse": 38, + "show-ref": 7, + status: 7, "symbolic-ref": 4, - worktree: 2, + worktree: 1, }); + expect(creation.submitted).toBe(103); const existingWorktrees = new Set(fixture.siblingWorktrees.map((cwd) => realpathSync(cwd))); expect( creation.submissions.filter((command) => existingWorktrees.has(realpathSync(command.cwd))), diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 95d490e914..eb54262b7b 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -180,7 +180,6 @@ test("recursive observation updates tracked state and prunes ignored storms", as getCheckoutWorktreeState, getCheckoutDiff, runGitCommand, - getWorkspaceGitSelfHealPhaseMs: () => 7_000, } as never, }); const diffManager = new CheckoutDiffManager({ @@ -320,7 +319,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as editedDuringIgnoreUpdate = true; writeFileSync(newlyTrackedPath, "first\nsecond\n"); }; - writeFileSync(path.join(repoDir, ".git", "index"), "force-added\n"); + writeFileSync(path.join(repoDir, ".gitignore"), "cache/\n"); await vi.waitFor( () => { expect(editedDuringIgnoreUpdate).toBe(true); diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 165435a1f8..e863ebb4a0 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -191,6 +191,11 @@ function createService( exitCode: 0, signal: null, })), + createWatcherLivenessCanary: vi.fn(() => ({ + path: "", + filterEvents: (events) => events, + verify: vi.fn(async () => {}), + })), ...overrides, } as never, }); @@ -1583,6 +1588,47 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); + test("repository watcher canary timeout closes the subscription and starts fallback polling", async () => { + const watcher = createWatcherHarness(); + const logger = createLogger(); + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); + const verifyCanary = vi.fn(async () => { + throw new Error("watcher did not report its liveness canary"); + }); + const service = createService( + watcher, + { + getCheckoutStatus, + createWatcherLivenessCanary: vi.fn(() => ({ + path: path.join(GIT_DIR, "paseo", ".watcher-canary-timeout"), + filterEvents: (events: WatchEvent[]) => events, + verify: verifyCanary, + })), + }, + logger, + ); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + + await vi.waitFor(() => { + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + const repositoryWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[0]; + expect(repositoryWatcher?.unsubscribe).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ repoGitRoot: GIT_DIR }), + "Failed to start repository metadata watcher; using degraded polling", + ); + + const statusCallsBeforePoll = getCheckoutStatus.mock.calls.length; + await vi.advanceTimersByTimeAsync(5_000); + await vi.waitFor(() => { + expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsBeforePoll); + }); + + subscription.unsubscribe(); + service.dispose(); + }); + test("setup-time watcher error defers recovery until subscribe settles", async () => { const watcher = createWatcherHarness(); const openedSubscription = createDeferred<{ @@ -1862,7 +1908,7 @@ describe("WorkspaceGitService checkout observation", () => { expect(checkoutWatcher?.ignore).toContain(path.join(REPO_CWD, "node_modules")); ignoredDirectories = "node_modules/\nbuild/\n"; - await vi.advanceTimersByTimeAsync(1_000); + checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]); await vi.waitFor(() => { expect(runGitCommand).toHaveBeenCalledTimes(3); expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledTimes(1); @@ -1918,7 +1964,7 @@ describe("WorkspaceGitService checkout observation", () => { const originalWatcher = watcher.records.find((record) => record.directory === REPO_CWD); ignoredDirectories = "node_modules/\n"; - await vi.advanceTimersByTimeAsync(1_000); + originalWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]); await vi.waitFor(() => { expect(originalWatcher?.updateIgnore).toHaveBeenCalledTimes(1); expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); @@ -1940,6 +1986,93 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); + test("repository exclude changes update working-tree filtering", async () => { + const watcher = createWatcherHarness(); + let ignoredDirectories = "node_modules/\n"; + const runGitCommand = vi.fn(async (args: string[]) => ({ + stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })); + const service = createService(watcher, { runGitCommand }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + + await vi.waitFor(() => { + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0]; + const repositoryWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[0]; + ignoredDirectories = "node_modules/\nbuild/\n"; + + repositoryWatcher?.callback(null, [ + { path: path.join(GIT_DIR, "info", "exclude"), type: "update" }, + ]); + await vi.waitFor(() => { + expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledTimes(1); + }); + expect(checkoutWatcher?.ignore).toContain(path.join(REPO_CWD, "build")); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("ignore events during a refresh trigger a trailing recomputation", async () => { + const watcher = createWatcherHarness(); + const blockedRefresh = createDeferred<{ + stdout: string; + stderr: string; + truncated: boolean; + exitCode: number; + signal: null; + }>(); + let lsFilesCallCount = 0; + const runGitCommand = vi.fn(async (args: string[]) => { + if (args[0] === "rev-parse") { + return { + stdout: `${REPO_CWD}\n`, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }; + } + lsFilesCallCount += 1; + if (lsFilesCallCount === 2) return blockedRefresh.promise; + return { + stdout: lsFilesCallCount === 1 ? "node_modules/\n" : "node_modules/\nbuild/\n", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }; + }); + const service = createService(watcher, { runGitCommand }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + + await vi.waitFor(() => { + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0]; + checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]); + await vi.waitFor(() => expect(lsFilesCallCount).toBe(2)); + checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]); + blockedRefresh.resolve({ + stdout: "node_modules/\n", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }); + + await vi.waitFor(() => expect(lsFilesCallCount).toBe(3)); + expect(checkoutWatcher?.ignore).toContain(path.join(REPO_CWD, "build")); + + subscription.unsubscribe(); + service.dispose(); + }); + test("ignore watcher update failure enters polling with a distinct reason", async () => { const watcher = createWatcherHarness(); const logger = createLogger(); @@ -1971,7 +2104,7 @@ describe("WorkspaceGitService checkout observation", () => { checkoutWatcher?.updateIgnore.mockRejectedValueOnce(new Error("update failed")); ignoredDirectories = "node_modules/\n"; - await vi.advanceTimersByTimeAsync(1_000); + checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]); await vi.waitFor(() => { expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ reason: "watcher_update_failed" }), @@ -1993,7 +2126,7 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); - test("a missed watcher event converges through the deterministic audit", async () => { + test("a working-tree watcher event refreshes summary and diff subscribers", async () => { const watcher = createWatcherHarness(); let isDirty = false; let diffStat = { additions: 0, deletions: 0 }; @@ -2010,7 +2143,6 @@ describe("WorkspaceGitService checkout observation", () => { getCheckoutStatus, getCheckoutShortstat, getCheckoutDiff, - getWorkspaceGitSelfHealPhaseMs: () => 10_000, }); const diffManager = new CheckoutDiffManager({ logger: createLogger(), @@ -2033,7 +2165,10 @@ describe("WorkspaceGitService checkout observation", () => { diffStat = { additions: 7, deletions: 3 }; diffFiles = [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }]; - await vi.advanceTimersByTimeAsync(70_000); + getWatcherRecordsForDirectory(watcher, REPO_CWD)[0]?.callback(null, [ + { path: path.join(REPO_CWD, "tracked.txt"), type: "update" }, + ]); + await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { expect(listener).toHaveBeenLastCalledWith( expect.objectContaining({ diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 017ed831df..4cccaa6e55 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -370,6 +370,11 @@ function buildDefaultServiceDeps() { signal: null, })), getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000), + createWatcherLivenessCanary: vi.fn(() => ({ + path: "", + filterEvents: (events) => events, + verify: vi.fn(async () => {}), + })), now: () => new Date("2026-04-12T00:00:00.000Z"), }; } @@ -867,7 +872,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { service.dispose(); }); - test("self-heal timer refreshes git without refreshing GitHub", async () => { + test("quiet observed workspaces do not refresh git on the observation re-ensure timer", async () => { let nowMs = 0; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); @@ -886,7 +891,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await vi.advanceTimersByTimeAsync(120_000); await flushPromises(); - expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledTimes(1); expect(getPullRequestStatus).toHaveBeenCalledWith( REPO_CWD, @@ -899,50 +904,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { service.dispose(); }); - test("self-heal phase is stable across process restarts", () => { + test("observation re-ensure phase is stable across process restarts", () => { expect(getWorkspaceGitSelfHealPhaseMs("/tmp/repo")).toBe(54_185); expect(getWorkspaceGitSelfHealPhaseMs("/tmp/staggered-repo")).toBe(9_817); }); - test("self-heal staggers workspaces and settles into a 120 second cadence", async () => { - vi.setSystemTime(0); - const otherRepoCwd = resolvePath("/tmp/staggered-repo"); - const refreshes = new Map<string, number[]>(); - const getCheckoutStatus = vi.fn(async (cwd: string) => { - const timestamps = refreshes.get(cwd) ?? []; - timestamps.push(Date.now()); - refreshes.set(cwd, timestamps); - return createCheckoutStatus(cwd, { hasRemote: false, remoteUrl: null }); - }); - const service = createService({ - getCheckoutSnapshotFacts: vi.fn(async (cwd: string) => - createCheckoutFacts(cwd, { remoteUrl: null }), - ), - getCheckoutStatus, - getWorkspaceGitSelfHealPhaseMs: (cwd) => (cwd === REPO_CWD ? 10_000 : 40_000), - now: () => new Date(Date.now()), - }); - const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); - const second = service.registerWorkspace({ cwd: otherRepoCwd }, vi.fn()); - await Promise.all([service.getSnapshot(REPO_CWD), service.getSnapshot(otherRepoCwd)]); - - await vi.advanceTimersByTimeAsync(300_000); - await flushPromises(); - - const firstSelfHeals = refreshes.get(REPO_CWD)?.slice(1) ?? []; - const secondSelfHeals = refreshes.get(otherRepoCwd)?.slice(1) ?? []; - expect(firstSelfHeals).toHaveLength(2); - expect(secondSelfHeals).toHaveLength(2); - expect(firstSelfHeals[0]).not.toBe(secondSelfHeals[0]); - expect(firstSelfHeals[1] - firstSelfHeals[0]).toBe(120_000); - expect(secondSelfHeals[1] - secondSelfHeals[0]).toBe(120_000); - - first.unsubscribe(); - second.unsubscribe(); - service.dispose(); - }); - - test("self-heal retries observation setup even when the git snapshot is still recent", async () => { + test("observation re-ensure retries setup even when the git snapshot is still recent", async () => { let nowMs = 0; const getCheckoutSnapshotFacts = vi .fn<(cwd: string) => Promise<CheckoutSnapshotFacts>>() @@ -1483,7 +1450,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { service.dispose(); }); - test("multiple subscribers on the same target share one self-heal timer", async () => { + test("multiple subscribers on the same target do not duplicate periodic git work", async () => { let nowMs = 0; const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => createCheckoutFacts(cwd, { remoteUrl: null }), @@ -1504,14 +1471,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { await vi.advanceTimersByTimeAsync(120_000); await flushPromises(); - expect(getCheckoutStatus).toHaveBeenCalledTimes(2); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); first.unsubscribe(); second.unsubscribe(); service.dispose(); }); - test("unsubscribe with no remaining subscribers clears the self-heal timer", async () => { + test("unsubscribe with no remaining subscribers clears the observation re-ensure timer", async () => { let nowMs = 0; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const service = createService({ @@ -1531,7 +1498,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { service.dispose(); }); - test("service disposal clears all self-heal timers", async () => { + test("service disposal clears all observation re-ensure timers", async () => { let nowMs = 0; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const service = createService({ @@ -1547,42 +1514,6 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { expect(getCheckoutStatus).toHaveBeenCalledTimes(0); }); - - test("direct getSnapshot returns current snapshot during a self-heal refresh", async () => { - let nowMs = 0; - const selfHealRefresh = createDeferred<CheckoutStatusGit>(); - const getCheckoutStatus = vi - .fn<() => Promise<CheckoutStatusGit>>() - .mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD)) - .mockImplementationOnce(async () => selfHealRefresh.promise); - const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => - createCheckoutFacts(cwd, { remoteUrl: null }), - ); - const service = createService({ - getCheckoutSnapshotFacts, - getCheckoutStatus, - now: () => new Date(nowMs), - }); - const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); - await service.getSnapshot(REPO_CWD); - - nowMs = 120_000; - await vi.advanceTimersByTimeAsync(120_000); - await flushPromises(); - const directRead = service.getSnapshot(REPO_CWD); - await flushPromises(); - - expect(getCheckoutStatus).toHaveBeenCalledTimes(2); - await expect(directRead).resolves.toEqual(createSnapshot(REPO_CWD)); - - selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD)); - await flushPromises(); - - expect(getCheckoutStatus).toHaveBeenCalledTimes(2); - - subscription.unsubscribe(); - service.dispose(); - }); }); describe("WorkspaceGitServiceImpl D2 read methods", () => { diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 914fde1ff7..a1d6b392e7 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -293,6 +293,11 @@ function buildDefaultTestServiceDeps() { signal: null, })), getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000), + createWatcherLivenessCanary: vi.fn(() => ({ + path: "", + filterEvents: (events) => events, + verify: vi.fn(async () => {}), + })), now: () => new Date("2026-04-12T00:00:00.000Z"), }; } diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 33fddb1d8b..6b39ddc1b7 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; import { LRUCache } from "lru-cache"; import pLimit from "p-limit"; import type pino from "pino"; @@ -68,11 +68,12 @@ import { createFileObserver, } from "./file-observer/index.js"; import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js"; +import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000; const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000; const FETCH_METADATA_ECHO_TTL_MS = 5_000; -export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000; +export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000; const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000; const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000; const FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS = 300_000; @@ -108,12 +109,16 @@ function mergeSets<T>( return { merged, added }; } -export function getWorkspaceGitSelfHealPhaseMs(cwd: string): number { +export function getWorkspaceGitObservationReensurePhaseMs(cwd: string): number { return ( - createHash("sha256").update(cwd).digest().readUInt32BE(0) % WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS + createHash("sha256").update(cwd).digest().readUInt32BE(0) % + WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS ); } +// Kept for local diagnostic fixtures that only use the stable phase calculation. +export const getWorkspaceGitSelfHealPhaseMs = getWorkspaceGitObservationReensurePhaseMs; + export interface WorkspaceGitRuntimeSnapshot { cwd: string; git: { @@ -353,7 +358,8 @@ interface WorkspaceGitServiceDependencies { observer: WorkspaceGitFetchObserver, ) => Promise<WorkspaceGitFetchResult>; runGitCommand: typeof runGitCommand; - getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitSelfHealPhaseMs; + getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs; + createWatcherLivenessCanary: typeof createWatcherLivenessCanary; now: () => Date; } @@ -387,15 +393,12 @@ interface WorkspaceGitTarget { workingTreeWatchTarget: WorkingTreeWatchTarget | null; debounceTimer: NodeJS.Timeout | null; pendingDebounceRequest: WorkspaceGitRefreshRequest | null; - selfHealTimer: NodeJS.Timeout | null; + observationReensureTimer: NodeJS.Timeout | null; forgePrStatusPollSubscription: { unsubscribe: () => void } | null; forgePrStatusPollKey: string | null; refreshState: WorkspaceGitRefreshState; latestGit: WorkspaceGitRuntimeSnapshot["git"] | null; latestGitLoadedAtMs: number | null; - latestStructuralRefreshAtMs: number | null; - latestWorktreeRefreshAtMs: number | null; - auditWindowStartedAtMs: number | null; latestForge: WorkspaceGitRuntimeSnapshot["forge"] | null; latestForgeLoadedAtMs: number | null; latestSnapshot: WorkspaceGitRuntimeSnapshot | null; @@ -443,6 +446,7 @@ interface WorkingTreeWatchTarget { subscription: FileObserverSubscription | null; ignoredDirectories: Set<string>; ignoredDirectoriesRefreshPromise: Promise<void> | null; + ignoredDirectoriesRefreshRequested: boolean; aliases: Set<string>; workspaceKeys: Set<string>; fallbackPolling: boolean; @@ -500,7 +504,8 @@ function buildDefaultWorkspaceGitServiceDeps( hasOriginRemote, runGitFetch: fetchWorkspaceGitRemote, runGitCommand, - getWorkspaceGitSelfHealPhaseMs, + getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs, + createWatcherLivenessCanary, now: () => new Date(), }; } @@ -1130,15 +1135,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { workingTreeWatchTarget: null, debounceTimer: null, pendingDebounceRequest: null, - selfHealTimer: null, + observationReensureTimer: null, forgePrStatusPollSubscription: null, forgePrStatusPollKey: null, refreshState: { status: "idle" }, latestGit: null, latestGitLoadedAtMs: null, - latestStructuralRefreshAtMs: null, - latestWorktreeRefreshAtMs: null, - auditWindowStartedAtMs: null, latestForge: null, latestForgeLoadedAtMs: null, latestSnapshot: null, @@ -1295,6 +1297,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { subscription: null, ignoredDirectories, ignoredDirectoriesRefreshPromise: null, + ignoredDirectoriesRefreshRequested: false, aliases: new Set([cwd]), workspaceKeys: new Set(), fallbackPolling: false, @@ -1438,6 +1441,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (watcherErrored) { return; } + if (events.some((event) => basename(event.path) === ".gitignore")) { + void this.refreshWorkingTreeIgnoredDirectories(target); + } if (!this.hasRelevantWorkingTreeEvent(target, events)) { return; } @@ -1629,22 +1635,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (target.closed || target.repoRoot === null) { return Promise.resolve(); } + target.ignoredDirectoriesRefreshRequested = true; if (target.ignoredDirectoriesRefreshPromise) { return target.ignoredDirectoriesRefreshPromise; } - const refreshPromise = this.replaceWorkingTreeIgnoredDirectories(target) - .catch((error) => { - this.logger.warn( - { err: error, cwd: target.cwd }, - "Failed to refresh working tree watcher ignore paths", - ); - }) - .finally(() => { - if (target.ignoredDirectoriesRefreshPromise === refreshPromise) { - target.ignoredDirectoriesRefreshPromise = null; + const refreshPromise = (async () => { + while (!target.closed && target.ignoredDirectoriesRefreshRequested) { + target.ignoredDirectoriesRefreshRequested = false; + try { + await this.replaceWorkingTreeIgnoredDirectories(target); + } catch (error) { + this.logger.warn( + { err: error, cwd: target.cwd }, + "Failed to refresh working tree watcher ignore paths", + ); } - }); + } + })().finally(() => { + if (target.ignoredDirectoriesRefreshPromise === refreshPromise) { + target.ignoredDirectoriesRefreshPromise = null; + } + }); target.ignoredDirectoriesRefreshPromise = refreshPromise; return refreshPromise; } @@ -1822,6 +1834,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { join(target.repoGitRoot, path), ); const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot); + const canary = this.deps.createWatcherLivenessCanary(target.repoGitRoot); + let openedSubscription: FileObserverSubscription | null = null; let watcherErrored = false; let subscribeSettled = false; const markSubscribeSettled = () => { @@ -1834,6 +1848,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const subscription = await this.subscribeWithDeadline( target.repoGitRoot, (error, events) => { + const liveEvents = canary.filterEvents(events); if (error) { if (watcherErrored) { return; @@ -1853,7 +1868,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (watcherErrored) { return; } - const relevantEvents = events.filter( + if (liveEvents.length === 0) { + return; + } + const relevantEvents = liveEvents.filter( (event) => !matchesRepoGitRoot(event.path) && ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)), @@ -1869,6 +1887,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }) : relevantEvents; if (immediateEvents.length > 0) { + this.refreshWorkingTreeIgnoresFromRepoMetadataEvents(target, immediateEvents); const routedRefreshes = this.routeRepoMetadataEvents(target, immediateEvents); this.scheduleRepoMetadataRefresh( target, @@ -1882,6 +1901,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { { ignore }, markSubscribeSettled, ); + openedSubscription = subscription; + await canary.verify(this.disposeController.signal); if (watcherErrored) { await this.unsubscribeWatcherSubscription(subscription, target.repoGitRoot); return false; @@ -1904,6 +1925,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } return true; } catch (error) { + if (openedSubscription) { + await this.unsubscribeWatcherSubscription(openedSubscription, target.repoGitRoot); + } if (watcherErrored) { return false; } @@ -1974,6 +1998,41 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return refreshes; } + private refreshWorkingTreeIgnoresFromRepoMetadataEvents( + target: RepoGitTarget, + events: FileChange[], + ): void { + const workspaceKeys = new Set<string>(); + for (const event of events) { + const commonRelativePath = getRealpathAwareRelativePath( + target.repoGitRoot, + event.path, + )?.replaceAll("\\", "/"); + if (commonRelativePath === "config" || commonRelativePath === "info/exclude") { + for (const workspaceKey of target.workspaceKeys) workspaceKeys.add(workspaceKey); + continue; + } + for (const workspaceKey of target.workspaceKeys) { + const facts = this.workspaceTargets.get(workspaceKey)?.latestFacts; + if ( + facts?.isGit && + facts.absoluteGitDir && + getRealpathAwareRelativePath(facts.absoluteGitDir, event.path)?.replaceAll("\\", "/") === + "config.worktree" + ) { + workspaceKeys.add(workspaceKey); + } + } + } + for (const workspaceKey of workspaceKeys) { + const workspaceTarget = this.workspaceTargets.get(workspaceKey); + const workingTreeTarget = workspaceTarget + ? this.getWorkingTreeWatchTargetForWorkspace(workspaceTarget) + : null; + if (workingTreeTarget) void this.refreshWorkingTreeIgnoredDirectories(workingTreeTarget); + } + } + private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: FileChange): boolean { const relativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path); const effect = classifyGitMetadataPath("common", relativePath ?? ""); @@ -2309,57 +2368,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private startWorkspaceSubscriptionTimers(target: WorkspaceGitTarget): void { - if (!target.selfHealTimer) { - target.auditWindowStartedAtMs = this.deps.now().getTime(); - const runSelfHealTick = () => { + if (!target.observationReensureTimer) { + const reensureObservation = () => { if (!this.isActiveObservedWorkspaceTarget(target)) { - target.selfHealTimer = null; + target.observationReensureTimer = null; return; } - target.selfHealTimer = setTimeout(runSelfHealTick, WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS); + target.observationReensureTimer = setTimeout( + reensureObservation, + WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS, + ); this.scheduleWorkspaceObservationSetup(target); - const auditWindowStartedAtMs = target.auditWindowStartedAtMs; - target.auditWindowStartedAtMs = this.deps.now().getTime(); - if (auditWindowStartedAtMs === null) { - return; - } - const workingTreeTarget = this.getWorkingTreeWatchTargetForWorkspace(target); - if (workingTreeTarget) { - void this.refreshWorkingTreeIgnoredDirectories(workingTreeTarget); - } - const refreshStructure = - target.latestStructuralRefreshAtMs === null || - target.latestStructuralRefreshAtMs < auditWindowStartedAtMs; - const refreshWorktree = - refreshStructure || - target.latestWorktreeRefreshAtMs === null || - target.latestWorktreeRefreshAtMs < auditWindowStartedAtMs; - if (!refreshStructure && !refreshWorktree) { - return; - } - if (refreshWorktree) { - if (workingTreeTarget) { - this.notifyWorkingTreeConsumers(workingTreeTarget); - } - } - this.refreshWorkspaceTarget(target, { - force: false, - refreshStructure, - refreshWorktree, - includeForge: false, - reason: "self-heal-git", - notify: true, - queueIfBusy: true, - movedRemoteRefs: new Set(), - }).catch((error) => { - this.logger.warn( - { err: error, cwd: target.cwd, reason: "self-heal-git" }, - "Failed to run workspace git self-heal refresh", - ); - }); }; - target.selfHealTimer = setTimeout( - runSelfHealTick, + target.observationReensureTimer = setTimeout( + reensureObservation, this.deps.getWorkspaceGitSelfHealPhaseMs(target.cwd), ); } @@ -2891,7 +2913,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }; const loadedAtMs = this.deps.now().getTime(); target.latestGitLoadedAtMs = loadedAtMs; - target.latestWorktreeRefreshAtMs = loadedAtMs; } private async refreshGitSnapshot( @@ -2915,8 +2936,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.latestGit = buildNotGitSnapshot(cwd).git; const loadedAtMs = this.deps.now().getTime(); target.latestGitLoadedAtMs = loadedAtMs; - target.latestStructuralRefreshAtMs = loadedAtMs; - target.latestWorktreeRefreshAtMs = loadedAtMs; target.latestForge = buildForgeUnavailableSnapshot(); target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs; return facts; @@ -2951,10 +2970,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }; const loadedAtMs = this.deps.now().getTime(); target.latestGitLoadedAtMs = loadedAtMs; - target.latestStructuralRefreshAtMs = loadedAtMs; - if (refreshWorktree) { - target.latestWorktreeRefreshAtMs = loadedAtMs; - } if (previousForgePrStatusPollKey !== this.getForgePrStatusPollKey(target)) { target.latestForge = buildForgeUnavailableSnapshot(); @@ -3266,9 +3281,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { clearTimeout(target.debounceTimer); target.debounceTimer = null; } - if (target.selfHealTimer) { - clearTimeout(target.selfHealTimer); - target.selfHealTimer = null; + if (target.observationReensureTimer) { + clearTimeout(target.observationReensureTimer); + target.observationReensureTimer = null; } this.stopForgePrStatusPollForTarget(target); target.listeners.clear(); From 90f3d954151ca7a2ec8d7134464fbcafa61beaac Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 16:24:38 +0200 Subject: [PATCH 57/73] Add guided Hub setup (#3318) * feat(cli): add Hub init wizard Reuse the existing login, daemon enrollment, project listing, and deploy paths so onboarding stays compatible with current Hub servers. Generated triggers require an explicit sender allowlist and existing bundles are never modified. * fix(cli): stop Hub init until daemon is ready A durable Hub relationship can be reconnecting without being ready to execute workflows. Keep enrollment skipped, but stop the wizard until daemon status reports connected. * fix(cli): wait for Hub daemon readiness Both fresh enrollment and reconnect paths can expose a durable relationship before the execution socket is connected. Poll the existing status RPC with a bounded wait before resolving projects or deploying. * fix(cli): make hub init detect resolvable resources --- packages/cli/src/commands/hub/client.test.ts | 35 +- .../cli/src/commands/hub/commands.test.ts | 1 + packages/cli/src/commands/hub/deploy.ts | 20 +- .../cli/src/commands/hub/hub-client/index.ts | 15 + .../hub/hub-client/internal/contracts.ts | 19 + .../hub/hub-client/internal/problem.ts | 19 +- packages/cli/src/commands/hub/index.ts | 2 + .../cli/src/commands/hub/init-plan.test.ts | 159 +++++ packages/cli/src/commands/hub/init-plan.ts | 201 +++++++ packages/cli/src/commands/hub/init.ts | 558 ++++++++++++++++++ 10 files changed, 1019 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/commands/hub/init-plan.test.ts create mode 100644 packages/cli/src/commands/hub/init-plan.ts create mode 100644 packages/cli/src/commands/hub/init.ts diff --git a/packages/cli/src/commands/hub/client.test.ts b/packages/cli/src/commands/hub/client.test.ts index 7cb47ba82b..f4948b3206 100644 --- a/packages/cli/src/commands/hub/client.test.ts +++ b/packages/cli/src/commands/hub/client.test.ts @@ -91,6 +91,35 @@ describe("Hub HTTP client", () => { ); }); + it("reads configuration resources in the Hub's slug vocabulary", async () => { + const requests: Array<{ url: string | undefined; body: string }> = []; + const origin = await startServer( + () => ({ + status: 200, + body: { + daemons: [{ id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", slug: "macbook" }], + github: [ + { + slug: "getpaseo", + accountLogin: "getpaseo", + accountType: "Organization", + repositories: ["getpaseo/paseo"], + }, + ], + discord: [{ slug: "paseo", guildName: "Paseo" }], + slack: [{ slug: "paseo", teamName: "Paseo" }], + }, + }), + requests, + ); + + const resources = await new HubHttpClient().listConfigurationResources(origin, "secret"); + + assert.equal(resources.daemons[0]?.slug, "macbook"); + assert.equal(resources.discord[0]?.slug, "paseo"); + assert.equal(requests[0]?.url, "/api/v1/configuration-resources"); + }); + it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => { const requests: Array<{ url: string | undefined; body: string }> = []; const origin = await startServer( @@ -126,10 +155,14 @@ describe("Hub HTTP client", () => { (error: unknown) => { assert.ok(error instanceof HubCommandError); assert.equal(error.message.includes("operator-secret"), false); + assert.equal(error.message.includes("Correct the canonical"), false); assert.equal(error.details?.includes("operator-secret"), false); assert.equal(error.message.includes("sensitive bundle content"), false); assert.equal(error.details?.includes("sensitive bundle content"), false); - assert.match(error.details ?? "", /\.paseo\/workflows\/answer\.yml\.steps\.work\.agent/u); + assert.equal( + error.details, + ".paseo/workflows/answer.yml: steps.work.agent: unknown named agent [redacted]", + ); return true; }, ); diff --git a/packages/cli/src/commands/hub/commands.test.ts b/packages/cli/src/commands/hub/commands.test.ts index a68f1ab2d7..0857bd1971 100644 --- a/packages/cli/src/commands/hub/commands.test.ts +++ b/packages/cli/src/commands/hub/commands.test.ts @@ -21,6 +21,7 @@ describe("Hub commands", () => { assert.deepEqual(names, [ "login", + "init", "connect", "status", "disconnect", diff --git a/packages/cli/src/commands/hub/deploy.ts b/packages/cli/src/commands/hub/deploy.ts index 7cf4cc5795..d64137879a 100644 --- a/packages/cli/src/commands/hub/deploy.ts +++ b/packages/cli/src/commands/hub/deploy.ts @@ -8,7 +8,7 @@ import { type HubValidationResult, } from "./hub-client/index.js"; import { PrivateHubCredentialStore, type HubCredentialStore } from "./credentials.js"; -import { discoverHubBundle } from "./deploy-bundle.js"; +import { discoverHubBundle, type HubDeployBundle } from "./deploy-bundle.js"; import { processHubReporter, reportHubProgress, type HubReporter } from "./reporter.js"; import { addHubResolutionHelp } from "./help.js"; @@ -20,7 +20,7 @@ export interface HubDeployOptions { json?: boolean; } -interface HubDeployEnvironment { +export interface HubDeployEnvironment { cwd: string; env: Readonly<Record<string, string | undefined>>; credentials?: HubCredentialStore; @@ -76,6 +76,18 @@ export async function runHubDeploy( credentials: new PrivateHubCredentialStore(), hub: new HubHttpClient(), }, +): Promise<SingleResult<HubDeployResult> | SingleResult<HubDryRunResult>> { + const deployInput = await discoverHubBundle({ + cwd: environment.cwd, + ...(options.project === undefined ? {} : { project: options.project }), + }); + return runHubDeployBundle(options, deployInput, environment); +} + +export async function runHubDeployBundle( + options: HubDeployOptions, + deployInput: HubDeployBundle, + environment: HubDeployEnvironment, ): Promise<SingleResult<HubDeployResult> | SingleResult<HubDryRunResult>> { const credentials = environment.credentials ?? new PrivateHubCredentialStore(environment.env); const resolution = { @@ -84,10 +96,6 @@ export async function runHubDeploy( credentials, }; const origin = resolveHubOrigin(resolution); - const deployInput = await discoverHubBundle({ - cwd: environment.cwd, - ...(options.project === undefined ? {} : { project: options.project }), - }); const action = options.dryRun === true ? "Validating" : "Deploying"; reportHubProgress( environment.reporter ?? processHubReporter, diff --git a/packages/cli/src/commands/hub/hub-client/index.ts b/packages/cli/src/commands/hub/hub-client/index.ts index 8bc50b314e..3086d648f2 100644 --- a/packages/cli/src/commands/hub/hub-client/index.ts +++ b/packages/cli/src/commands/hub/hub-client/index.ts @@ -3,6 +3,7 @@ import { HubCommandError } from "../error.js"; import { authorizationPollSchema, authorizationSchema, + configurationResourcesSchema, enrollmentTokenSchema, installResponseSchema, projectsResponseSchema, @@ -10,6 +11,7 @@ import { type CliAuthorization, type CliAuthorizationPoll, type HubInstallResult, + type HubConfigurationResources, type HubProject, type HubValidationResult, } from "./internal/contracts.js"; @@ -19,6 +21,7 @@ export type { CliAuthorization, CliAuthorizationPoll, HubInstallResult, + HubConfigurationResources, HubProject, HubValidationResult, } from "./internal/contracts.js"; @@ -80,6 +83,18 @@ export class HubHttpClient { return response.projects; } + listConfigurationResources(origin: string, apiKey: string): Promise<HubConfigurationResources> { + return requestHub({ + origin, + path: "/api/v1/configuration-resources", + method: "GET", + apiKey, + successStatus: 200, + schema: configurationResourcesSchema, + failureMessage: "Hub configuration resource listing failed", + }); + } + installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult> { return requestHub({ origin: input.origin, diff --git a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts index efbbf27d64..82b7e3ca58 100644 --- a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts +++ b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts @@ -36,6 +36,24 @@ const projectSchema = z export const projectsResponseSchema = z.object({ projects: z.array(projectSchema) }).strict(); +export const configurationResourcesSchema = z + .object({ + daemons: z.array(z.object({ id: z.string().uuid(), slug: z.string().min(1) }).strict()), + github: z.array( + z + .object({ + slug: z.string().min(1), + accountLogin: z.string().min(1), + accountType: z.string().min(1), + repositories: z.array(z.string().min(1)), + }) + .strict(), + ), + discord: z.array(z.object({ slug: z.string().min(1), guildName: z.string().min(1) }).strict()), + slack: z.array(z.object({ slug: z.string().min(1), teamName: z.string().min(1) }).strict()), + }) + .strict(); + export const installResponseSchema = z .object({ projectSlug: z.string().min(1), @@ -56,5 +74,6 @@ export const enrollmentTokenSchema = z export type CliAuthorization = z.infer<typeof authorizationSchema>; export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>; export type HubProject = z.infer<typeof projectSchema>; +export type HubConfigurationResources = z.infer<typeof configurationResourcesSchema>; export type HubInstallResult = z.infer<typeof installResponseSchema>; export type HubValidationResult = z.infer<typeof validationResponseSchema>; diff --git a/packages/cli/src/commands/hub/hub-client/internal/problem.ts b/packages/cli/src/commands/hub/hub-client/internal/problem.ts index 8ed7bdf309..38482953dc 100644 --- a/packages/cli/src/commands/hub/hub-client/internal/problem.ts +++ b/packages/cli/src/commands/hub/hub-client/internal/problem.ts @@ -51,9 +51,14 @@ export async function hubRequestFailure( ); } const title = parsed.data.title ?? `${failureMessage} with HTTP ${response.status}`; - const message = parsed.data.detail === undefined ? title : `${title}: ${parsed.data.detail}`; const details = formatFieldIssues(parsed.data.errors, parsed.data.issues); - const code = response.status === 422 ? "HUB_VALIDATION_FAILED" : "HUB_REQUEST_FAILED"; + const message = + details !== undefined || parsed.data.detail === undefined + ? title + : `${title}: ${parsed.data.detail}`; + let code = "HUB_REQUEST_FAILED"; + if (response.status === 422) code = "HUB_VALIDATION_FAILED"; + if (response.status === 404) code = "HUB_NOT_FOUND"; return new HubCommandError( code, redactSecret(message, apiKey), @@ -82,12 +87,20 @@ function formatFieldIssues( function formatIssuePath(path: z.infer<typeof issuePathSchema> | undefined): string | undefined { if (path === undefined || typeof path === "string") return path; + const [file, ...fieldPath] = path; + if (typeof file === "string" && file.startsWith(".paseo/") && fieldPath.length > 0) { + return `${file}: ${formatPathSegments(fieldPath)}`; + } + return formatPathSegments(path) || undefined; +} + +function formatPathSegments(path: readonly (string | number)[]): string { let formatted = ""; for (const segment of path) { if (typeof segment === "number") formatted += `[${segment}]`; else formatted += formatted.length === 0 ? segment : `.${segment}`; } - return formatted || undefined; + return formatted; } function redactSecret(value: string, secret: string | undefined): string { diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts index 1dac54fc7a..3edd48ef3f 100644 --- a/packages/cli/src/commands/hub/index.ts +++ b/packages/cli/src/commands/hub/index.ts @@ -18,6 +18,7 @@ import { addHubProjectsCommand } from "./projects.js"; import { processHubReporter, type HubReporter } from "./reporter.js"; import { hubStatusResult } from "./status-output.js"; import { addHubResolutionHelp } from "./help.js"; +import { addHubInitCommand } from "./init.js"; interface HubCommandEnvironment { env: Readonly<Record<string, string | undefined>>; @@ -56,6 +57,7 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {}) flow: environment.login, reporter: environment.reporter, }); + addHubInitCommand(hub, environment); addHubConnectCommand(hub, { env: environment.env, credentials: environment.credentials, diff --git a/packages/cli/src/commands/hub/init-plan.test.ts b/packages/cli/src/commands/hub/init-plan.test.ts new file mode 100644 index 0000000000..afc91a690f --- /dev/null +++ b/packages/cli/src/commands/hub/init-plan.test.ts @@ -0,0 +1,159 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import YAML from "yaml"; +import { afterEach, describe, expect, it } from "vitest"; +import { discoverHubBundle } from "./deploy-bundle.js"; +import { runHubDeploy } from "./deploy.js"; +import { + createHubInitScaffold, + planHubInitOpening, + resolveHubInitConnection, + resolveHubInitProjects, + type HubInitProvider, +} from "./init-plan.js"; +import { githubRepositoryFromRemote } from "./init.js"; + +const directories: string[] = []; +const project = (slug: string) => ({ + id: "7e950d84-eec0-4e18-b1f9-9c115bdb31e4", + slug, + name: slug.toUpperCase(), +}); + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +describe("Hub init planning", () => { + it("includes login only when there is no active login", () => { + expect(planHubInitOpening({ loggedIn: false, paseoDirectoryExists: false })).toEqual({ + replaceExisting: false, + steps: ["login", "connect", "project", "scaffold"], + }); + expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: false })).toEqual({ + replaceExisting: false, + steps: ["connect", "project", "scaffold"], + }); + }); + + it("plans a confirmed replacement for an existing .paseo directory", () => { + expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: true })).toEqual({ + replaceExisting: true, + steps: ["connect", "project", "scaffold"], + }); + }); + + it("stops, defaults, or asks according to the project count", () => { + expect(resolveHubInitProjects([])).toEqual({ kind: "none" }); + expect(resolveHubInitProjects([project("one")])).toEqual({ + kind: "selected", + project: project("one"), + }); + expect(resolveHubInitProjects([project("one"), project("two")])).toEqual({ + kind: "choose", + projects: [project("one"), project("two")], + }); + }); + + it("reuses a connected daemon, waits for reconnect, and rejects a different Hub", () => { + const status = { + state: "connected", + daemonId: "daemon-1", + hubOrigin: "https://hub.test", + scopes: ["hub.execution.*"], + connectedAt: null, + lastError: null, + }; + expect(resolveHubInitConnection(status, "https://hub.test")).toEqual({ + kind: "connected", + daemonId: "daemon-1", + }); + expect(resolveHubInitConnection(status, "https://other.test")).toEqual({ + kind: "conflict", + origin: "https://hub.test", + }); + expect( + resolveHubInitConnection({ ...status, state: "reconnecting" }, "https://hub.test"), + ).toEqual({ kind: "pending", state: "reconnecting" }); + expect( + resolveHubInitConnection( + { ...status, state: "not_connected", daemonId: null, hubOrigin: null }, + "https://hub.test", + ), + ).toEqual({ kind: "connect" }); + }); +}); + +describe("Hub init scaffold", () => { + it.each([ + ["github", { repo: "getpaseo/paseo", user: "boudra" }], + ["slack", { workspace: "paseo", user: "boudra" }], + ["discord", { guild: "paseo", user: "boudra" }], + ] satisfies readonly [HubInitProvider, Record<string, string>][])( + "creates a closed %s trigger that the deploy discovery path accepts", + async (provider, providerFilters) => { + const cwd = await temporaryDirectory(); + const scaffold = createHubInitScaffold({ + cwd, + daemonSlug: "build-studio", + provider, + providerFilters, + }); + await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true }); + await writeFile(path.join(cwd, ".paseo", "hub.yml"), scaffold.hub); + await writeFile(path.join(cwd, scaffold.workflowPath), scaffold.workflow); + + const bundle = await discoverHubBundle({ cwd, project: "paseo" }); + expect(bundle.workflowCount).toBe(1); + expect(bundle.files.map((file) => file.path)).toEqual([ + ".paseo/hub.yml", + scaffold.workflowPath, + ]); + const parsed = YAML.parse(scaffold.workflow) as { + filters: { from_users: string[]; channels?: string[] }; + }; + expect(parsed.filters.from_users).toEqual([providerFilters.user]); + expect(parsed.filters.channels).toBeUndefined(); + + let validatedPaths: readonly string[] = []; + const result = await runHubDeploy( + { project: "paseo", hub: "https://hub.test", dryRun: true }, + { + cwd, + env: { PASEO_HUB_API_KEY: "test-key" }, + reporter: { progress() {} }, + hub: { + async validateConfiguration(input) { + validatedPaths = input.files.map((file) => file.path); + for (const file of input.files) YAML.parse(file.content); + return { projectSlug: input.projectSlug, valid: true }; + }, + async installConfiguration() { + throw new Error("dry-run must not install"); + }, + }, + }, + ); + expect(result.data).toMatchObject({ projectSlug: "paseo", valid: true, workflows: 1 }); + expect(validatedPaths).toEqual([".paseo/hub.yml", scaffold.workflowPath]); + }, + ); +}); + +describe("GitHub origin detection", () => { + it.each([ + ["git@github.com:getpaseo/paseo.git", "getpaseo/paseo"], + ["ssh://git@github.com/getpaseo/paseo.git", "getpaseo/paseo"], + ["https://github.com/getpaseo/paseo.git", "getpaseo/paseo"], + ["https://gitlab.com/getpaseo/paseo.git", undefined], + ])("resolves %s", (remote, expected) => { + expect(githubRepositoryFromRemote(remote)).toBe(expected); + }); +}); + +async function temporaryDirectory(): Promise<string> { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-")); + directories.push(directory); + return directory; +} diff --git a/packages/cli/src/commands/hub/init-plan.ts b/packages/cli/src/commands/hub/init-plan.ts new file mode 100644 index 0000000000..88e90de38f --- /dev/null +++ b/packages/cli/src/commands/hub/init-plan.ts @@ -0,0 +1,201 @@ +import path from "node:path"; +import YAML from "yaml"; +import type { HubDeployBundle } from "./deploy-bundle.js"; +import type { HubProject } from "./hub-client/index.js"; +import type { HubStatus } from "./daemon-client.js"; + +export type HubInitProvider = "github" | "slack" | "discord"; + +export type HubInitProjectResolution = + | { kind: "none" } + | { kind: "selected"; project: HubProject } + | { kind: "choose"; projects: readonly HubProject[] }; + +export type HubInitConnectionResolution = + | { kind: "connected"; daemonId: string } + | { kind: "pending"; state: "connecting" | "reconnecting" } + | { kind: "connect" } + | { kind: "conflict"; origin: string }; + +export interface HubInitOpeningPlan { + replaceExisting: boolean; + steps: readonly ("login" | "connect" | "project" | "scaffold")[]; +} + +export interface HubInitScaffoldInput { + cwd: string; + daemonSlug: string; + provider: HubInitProvider; + providerFilters: Readonly<Record<string, string>>; +} + +export interface HubInitScaffold { + hub: string; + workflowPath: string; + workflow: string; + testAction: string; +} + +export function planHubInitOpening(input: { + loggedIn: boolean; + paseoDirectoryExists: boolean; +}): HubInitOpeningPlan { + return { + replaceExisting: input.paseoDirectoryExists, + steps: [...(input.loggedIn ? [] : (["login"] as const)), "connect", "project", "scaffold"], + }; +} + +export function createHubInitBundle( + projectSlug: string, + scaffold: HubInitScaffold, +): HubDeployBundle { + return { + projectSlug, + workflowCount: 1, + files: [ + { path: ".paseo/hub.yml", content: scaffold.hub }, + { path: scaffold.workflowPath, content: scaffold.workflow }, + ], + }; +} + +export function resolveHubInitProjects(projects: readonly HubProject[]): HubInitProjectResolution { + if (projects.length === 0) return { kind: "none" }; + if (projects.length === 1) return { kind: "selected", project: projects[0]! }; + return { kind: "choose", projects }; +} + +export function resolveHubInitConnection( + status: HubStatus, + origin: string, +): HubInitConnectionResolution { + if (status.state === "connected" && status.hubOrigin === origin && status.daemonId !== null) { + return { kind: "connected", daemonId: status.daemonId }; + } + if ( + (status.state === "connecting" || status.state === "reconnecting") && + status.hubOrigin === origin + ) { + return { kind: "pending", state: status.state }; + } + if (status.hubOrigin !== null && status.state !== "not_connected" && status.state !== "revoked") { + return { kind: "conflict", origin: status.hubOrigin }; + } + return { kind: "connect" }; +} + +export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaffold { + const environmentName = input.daemonSlug; + const hub = YAML.stringify( + { + environments: { + [environmentName]: { + kind: "daemon", + daemon: input.daemonSlug, + cwd: path.resolve(input.cwd), + }, + }, + agents: { + codex: { + provider: "codex", + mode: "full-access", + }, + }, + }, + { lineWidth: 0 }, + ); + const provider = providerScaffold(input.provider, input.providerFilters, environmentName); + return { + hub, + workflowPath: `.paseo/workflows/${input.provider}-help.yml`, + workflow: YAML.stringify(provider.workflow, { lineWidth: 0 }), + testAction: provider.testAction, + }; +} + +function providerScaffold( + provider: HubInitProvider, + filters: Readonly<Record<string, string>>, + environment: string, +): { workflow: object; testAction: string } { + if (provider === "github") { + const repo = requireFilter(filters, "repo"); + const user = requireFilter(filters, "user"); + return { + workflow: workflow({ + name: "github-help", + on: "github.issue_comment", + filters: { repo, contains: "@paseo", from_users: [user] }, + environment, + }), + testAction: `Comment \`@paseo have a look\` on ${repo}.`, + }; + } + + const user = requireFilter(filters, "user"); + if (provider === "slack") { + const workspace = requireFilter(filters, "workspace"); + return { + workflow: workflow({ + name: "slack-help", + on: "slack.mention", + filters: { workspace, from_users: [user] }, + environment, + reply: "slack.reply", + }), + testAction: "Mention `@Paseo have a look` in Slack.", + }; + } + + const guild = requireFilter(filters, "guild"); + return { + workflow: workflow({ + name: "discord-help", + on: "discord.mention", + filters: { guild, from_users: [user] }, + environment, + reply: "discord.reply", + }), + testAction: "Mention `@Paseo have a look` in Discord.", + }; +} + +function workflow(input: { + name: string; + on: string; + filters: object; + environment: string; + reply?: "slack.reply" | "discord.reply"; +}): object { + const replyInstruction = input.reply === undefined ? "" : "Answer with hub.reply, then "; + return { + name: input.name, + on: input.on, + max_runtime: "2h", + filters: input.filters, + steps: [ + { + id: "work", + environment: input.environment, + max_runtime: "90m", + idle_timeout: "10m", + agent: "codex", + prompt: [ + { + text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n<user-prompt>\n\${{ paseo.prompt }}\n</user-prompt>\n`, + }, + ], + ...(input.reply === undefined + ? {} + : { allow_outputs: [{ type: input.reply, max: 1, required: true }] }), + }, + ], + }; +} + +function requireFilter(filters: Readonly<Record<string, string>>, name: string): string { + const value = filters[name]?.trim(); + if (!value) throw new Error(`${name} is required for this provider`); + return value; +} diff --git a/packages/cli/src/commands/hub/init.ts b/packages/cli/src/commands/hub/init.ts new file mode 100644 index 0000000000..c86f79b4ec --- /dev/null +++ b/packages/cli/src/commands/hub/init.ts @@ -0,0 +1,558 @@ +import { + cancel, + confirm, + intro, + isCancel, + log, + note, + outro, + select, + spinner, + text, +} from "@clack/prompts"; +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import type { Command } from "commander"; +import { DEFAULT_HUB_ORIGIN, resolveHubCredential } from "./authority.js"; +import type { HubCredentialStore } from "./credentials.js"; +import type { HubDaemonConnection } from "./daemon-client.js"; +import { withHubDaemon } from "./daemon-client.js"; +import { HubCommandError } from "./error.js"; +import type { HubConfigurationResources, HubHttpClient, HubProject } from "./hub-client/index.js"; +import { + createHubInitBundle, + createHubInitScaffold, + planHubInitOpening, + resolveHubInitConnection, + resolveHubInitProjects, + type HubInitProvider, +} from "./init-plan.js"; +import type { CliLoginFlow } from "./login-flow.js"; +import { runHubLogin } from "./login.js"; +import { runHubConnect } from "./connect.js"; +import { runHubDeployBundle } from "./deploy.js"; +import { runHubProjects } from "./projects.js"; +import type { HubReporter } from "./reporter.js"; +import { normalizeHubOrigin } from "./origin.js"; + +const execFileAsync = promisify(execFile); +const DAEMON_READY_TIMEOUT_MS = 60_000; +const DAEMON_READY_POLL_MS = 250; + +interface HubInitEnvironment { + env: Readonly<Record<string, string | undefined>>; + credentials: HubCredentialStore; + hub: HubHttpClient; + login: Pick<CliLoginFlow, "authorize">; + daemon: HubDaemonConnection; + reporter: HubReporter; + cwd(): string; +} + +class HubInitCancelledError extends Error {} + +function initErrorMessage(error: unknown): string { + if (error instanceof HubCommandError && error.details) { + return `${error.message}\n${error.details}`; + } + return error instanceof Error ? error.message : String(error); +} + +export function addHubInitCommand(parent: Command, environment: HubInitEnvironment): void { + parent + .command("init") + .description("Create and optionally deploy a safe starter Hub bundle") + .action(async () => { + try { + await runHubInit(environment); + } catch (error) { + if (error instanceof HubInitCancelledError) { + cancel(error.message); + return; + } + cancel(initErrorMessage(error)); + process.exitCode = 1; + } + }); +} + +export async function runHubInit(environment: HubInitEnvironment): Promise<void> { + requireInteractiveTerminal(); + intro("Set up Paseo Hub"); + + const cwd = environment.cwd(); + const activeLogin = environment.credentials.active(); + const opening = planHubInitOpening({ + loggedIn: activeLogin !== null, + paseoDirectoryExists: await pathExists(path.join(cwd, ".paseo")), + }); + if ( + opening.replaceExisting && + !(await requiredConfirm("Replace the existing .paseo/ Hub bundle?", false)) + ) { + throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged."); + } + + const origin = await ensureLogin(activeLogin?.origin, environment); + const daemonId = await ensureDaemonConnection(origin, environment); + const project = await chooseProject(origin, environment); + const resources = await loadConfigurationResources(origin, environment); + const daemon = resources.daemons.find(({ id }) => id === daemonId); + if (daemon === undefined) { + throw new HubCommandError( + "HUB_DAEMON_RESOURCE_MISSING", + "The connected daemon is not available in this Hub organization. Reconnect it and try again.", + ); + } + log.success(`Connected as ${daemon.slug}`); + const provider = await chooseProvider(); + const providerFilters = await collectProviderFilters(provider, resources, cwd); + const scaffold = createHubInitScaffold({ + cwd, + daemonSlug: daemon.slug, + provider, + providerFilters, + }); + + const bundle = createHubInitBundle(project.slug, scaffold); + + await withSpinner("Validating bundle", async () => { + await runHubDeployBundle({ project: project.slug, hub: origin, dryRun: true }, bundle, { + cwd, + env: environment.env, + credentials: environment.credentials, + hub: environment.hub, + reporter: { progress() {} }, + }); + }); + log.success("Dry run passed"); + await writeScaffold(cwd, scaffold, opening.replaceExisting); + log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`); + + const deploy = await requiredConfirm("Deploy now?", true); + if (deploy) { + await withSpinner("Deploying bundle", async () => { + await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, { + cwd, + env: environment.env, + credentials: environment.credentials, + hub: environment.hub, + reporter: { progress() {} }, + }); + }); + log.success("Deployed"); + } else { + log.message(`Skipped deployment. Run: paseo hub deploy -p ${project.slug}`); + } + + const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString(); + note(`${scaffold.testAction}\nWatch it at ${activityUrl}`, "Test your workflow"); + outro(deploy ? "Hub is ready" : "Hub bundle is ready"); +} + +async function ensureLogin( + activeOrigin: string | undefined, + environment: HubInitEnvironment, +): Promise<string> { + const endpoint = await requiredSelect({ + message: "Hub endpoint", + initialValue: + activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN ? "hosted" : "custom", + options: [ + { value: "hosted", label: "hub.paseo.sh" }, + { value: "custom", label: "Custom endpoint…" }, + ], + }); + const origin = + endpoint === "hosted" + ? DEFAULT_HUB_ORIGIN + : await requiredText({ + message: "Custom Hub URL", + initialValue: + activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN + ? environment.env.PASEO_HUB_URL + : activeOrigin, + validate(value) { + try { + normalizeHubOrigin(value ?? ""); + } catch { + return "Enter a valid Hub URL"; + } + return undefined; + }, + }); + const normalizedOrigin = normalizeHubOrigin(origin); + if (environment.credentials.get(normalizedOrigin) !== null) { + log.success(`Logged in to ${normalizedOrigin}`); + return normalizedOrigin; + } + await runHubLogin( + normalizedOrigin, + {}, + { + env: environment.env, + credentials: environment.credentials, + flow: environment.login, + reporter: environment.reporter, + }, + ); + log.success(`Logged in to ${normalizedOrigin}`); + return normalizedOrigin; +} + +async function ensureDaemonConnection( + origin: string, + environment: HubInitEnvironment, +): Promise<string> { + const status = await withHubDaemon(environment.daemon, undefined, async (daemon) => + daemon.getHubStatus().then((response) => response.status), + ); + const connection = resolveHubInitConnection(status, origin); + if (connection.kind === "connected") { + return connection.daemonId; + } + if (connection.kind === "pending") { + return waitForDaemonReady(origin, environment.daemon); + } + if (connection.kind === "conflict") { + throw new HubCommandError( + "HUB_DAEMON_ALREADY_CONNECTED", + `This daemon is connected to ${connection.origin}. Disconnect it before running Hub init for ${origin}.`, + ); + } + if (!(await requiredConfirm(`Connect this daemon to ${origin}?`, true))) { + throw new HubInitCancelledError("A connected daemon is required to create the bundle."); + } + await runHubConnect( + origin, + {}, + { + env: environment.env, + credentials: environment.credentials, + hub: environment.hub, + daemon: environment.daemon, + reporter: environment.reporter, + }, + ); + return waitForDaemonReady(origin, environment.daemon); +} + +async function waitForDaemonReady( + origin: string, + connection: HubDaemonConnection, +): Promise<string> { + return withSpinner("Waiting for the daemon to connect", async (reporter) => + withHubDaemon(connection, undefined, async (daemon) => { + const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS; + while (true) { + const status = (await daemon.getHubStatus()).status; + const resolution = resolveHubInitConnection(status, origin); + if (resolution.kind === "connected") return resolution.daemonId; + if (resolution.kind === "conflict") { + throw new HubCommandError( + "HUB_DAEMON_ALREADY_CONNECTED", + `This daemon connected to ${resolution.origin} while Hub init was waiting for ${origin}.`, + ); + } + if (resolution.kind === "connect") { + throw new HubCommandError( + "HUB_DAEMON_CONNECTION_LOST", + "The daemon lost its Hub relationship while Hub init was waiting for it.", + ); + } + if (Date.now() >= deadline) { + throw new HubCommandError( + "HUB_DAEMON_CONNECTION_TIMEOUT", + "The daemon did not connect within 60 seconds. Check `paseo hub status`, then run Hub init again.", + ); + } + reporter.progress(`Daemon is ${resolution.state}`); + await delay(DAEMON_READY_POLL_MS); + } + }), + ); +} + +async function chooseProject(origin: string, environment: HubInitEnvironment): Promise<HubProject> { + const result = await runHubProjects( + { hub: origin }, + { + env: environment.env, + credentials: environment.credentials, + hub: environment.hub, + reporter: { progress() {} }, + }, + ); + const resolution = resolveHubInitProjects(result.data.projects); + if (resolution.kind === "none") { + throw new HubInitCancelledError( + `No Hub projects exist yet. Create one at ${new URL("/projects/new", origin).toString()}, then run paseo hub init again.`, + ); + } + if (resolution.kind === "selected") { + return resolution.project; + } + const slug = await requiredSelect({ + message: "Project", + options: resolution.projects.map((project) => ({ + value: project.slug, + label: project.name, + hint: project.slug, + })), + }); + const project = resolution.projects.find((candidate) => candidate.slug === slug); + if (project === undefined) { + throw new HubCommandError( + "HUB_PROJECT_SELECTION_INVALID", + "The selected Hub project is unavailable.", + ); + } + return project; +} + +async function chooseProvider(): Promise<HubInitProvider> { + return requiredSelect({ + message: "Trigger provider", + options: [ + { value: "github", label: "GitHub", hint: "issue or pull request comment" }, + { value: "slack", label: "Slack", hint: "channel mention" }, + { value: "discord", label: "Discord", hint: "channel mention" }, + ], + }); +} + +async function collectProviderFilters( + provider: HubInitProvider, + resources: HubConfigurationResources, + cwd: string, +): Promise<Readonly<Record<string, string>>> { + if (provider === "github") { + const [login, originRemote] = await Promise.all([ + readGhValue(["api", "user", "--jq", ".login"]), + readCommandValue("git", ["remote", "get-url", "origin"], cwd), + ]); + const repo = originRemote === undefined ? undefined : githubRepositoryFromRemote(originRemote); + if (repo === undefined) { + throw new HubCommandError( + "HUB_GITHUB_REPOSITORY_UNDETECTED", + "Could not detect a GitHub repository from the origin remote.", + ); + } + if (!resources.github.some(({ repositories }) => repositories.includes(repo))) { + const connected = resources.github.flatMap(({ repositories }) => repositories); + throw new HubCommandError( + "HUB_GITHUB_REPOSITORY_NOT_CONNECTED", + `${repo} is not connected to this Hub organization. Connected repositories: ${connected.join(", ") || "none"}.`, + ); + } + return { + user: await requiredText({ + message: "Your GitHub username (only this user can trigger the bot)", + initialValue: login, + }), + repo, + }; + } + if (provider === "slack") { + return { + workspace: await chooseConnection( + "Slack workspace", + resources.slack.map(({ slug, teamName }) => ({ slug, label: teamName })), + ), + user: await requiredText({ + message: "Your Slack username (only this user can trigger the bot)", + }), + }; + } + return { + guild: await chooseConnection( + "Discord server", + resources.discord.map(({ slug, guildName }) => ({ slug, label: guildName })), + ), + user: await requiredText({ + message: "Your Discord username (only this user can trigger the bot)", + }), + }; +} + +async function loadConfigurationResources( + origin: string, + environment: HubInitEnvironment, +): Promise<HubConfigurationResources> { + try { + return await withSpinner("Loading Hub connections", () => + environment.hub.listConfigurationResources( + origin, + resolveHubCredential({ + options: { origin }, + env: environment.env, + credentials: environment.credentials, + origin, + }), + ), + ); + } catch (error) { + if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") { + throw new HubCommandError( + "HUB_UPDATE_REQUIRED", + "This Hub does not support guided setup. Update the Hub and try again.", + ); + } + throw error; + } +} + +async function chooseConnection( + message: string, + connections: readonly { slug: string; label: string }[], +): Promise<string> { + if (connections.length === 0) { + throw new HubCommandError( + "HUB_PROVIDER_CONNECTION_REQUIRED", + `No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`, + ); + } + if (connections.length === 1) return connections[0]!.slug; + return requiredSelect({ + message, + options: connections.map(({ slug, label }) => ({ value: slug, label })), + }); +} + +async function writeScaffold( + cwd: string, + scaffold: ReturnType<typeof createHubInitScaffold>, + replaceExisting: boolean, +): Promise<void> { + const root = path.join(cwd, ".paseo"); + const staging = path.join(cwd, `.paseo-init-${randomUUID()}`); + const backup = path.join(cwd, `.paseo-backup-${randomUUID()}`); + let movedExisting = false; + try { + await mkdir(path.join(staging, "workflows"), { recursive: true }); + await writeFile(path.join(staging, "hub.yml"), scaffold.hub, { flag: "wx" }); + await writeFile( + path.join(staging, "workflows", path.basename(scaffold.workflowPath)), + scaffold.workflow, + { + flag: "wx", + }, + ); + if (replaceExisting) { + await rename(root, backup); + movedExisting = true; + } + await rename(staging, root); + if (movedExisting) await rm(backup, { recursive: true, force: true }); + } catch (error) { + await rm(staging, { recursive: true, force: true }); + if (movedExisting) { + await rename(backup, root).catch(() => undefined); + } + throw error; + } +} + +async function pathExists(target: string): Promise<boolean> { + try { + await lstat(target); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + throw error; + } +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error && typeof error.code === "string" + ? error.code + : undefined; +} + +function delay(milliseconds: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function readGhValue(args: readonly string[]): Promise<string | undefined> { + return readCommandValue("gh", args); +} + +async function readCommandValue( + command: string, + args: readonly string[], + cwd?: string, +): Promise<string | undefined> { + try { + const { stdout } = await execFileAsync(command, [...args], { encoding: "utf8", cwd }); + return stdout.trim() || undefined; + } catch { + return undefined; + } +} + +export function githubRepositoryFromRemote(remote: string): string | undefined { + const trimmed = remote.trim().replace(/\.git$/u, ""); + const ssh = /^(?:ssh:\/\/)?git@github\.com[:/]([^/]+\/[^/]+)$/u.exec(trimmed); + if (ssh?.[1] !== undefined) return ssh[1]; + try { + const url = new URL(trimmed); + if (url.hostname !== "github.com") return undefined; + const repository = url.pathname.replace(/^\//u, ""); + return /^[^/]+\/[^/]+$/u.test(repository) ? repository : undefined; + } catch { + return undefined; + } +} + +async function withSpinner<T>( + message: string, + action: (reporter: HubReporter) => Promise<T>, +): Promise<T> { + const progress = spinner(); + progress.start(message); + try { + const result = await action({ progress: (nextMessage) => progress.message(nextMessage) }); + progress.stop(message); + return result; + } catch (error) { + progress.error(message); + throw error; + } +} + +async function requiredText(options: Parameters<typeof text>[0]): Promise<string> { + const answer = await text({ + ...options, + validate(value) { + const input = value ?? ""; + const customError = options.validate?.(input); + if (customError !== undefined) return customError; + return input.trim().length === 0 ? "A value is required" : undefined; + }, + }); + if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); + return answer.trim(); +} + +async function requiredConfirm(message: string, initialValue: boolean): Promise<boolean> { + const answer = await confirm({ message, initialValue }); + if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); + return answer; +} + +async function requiredSelect<T extends string>( + options: Parameters<typeof select<T>>[0], +): Promise<T> { + const answer = await select<T>(options); + if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled."); + return answer; +} + +function requireInteractiveTerminal(): void { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY."); + } +} From 9c195feb9bc6048406e1c71c44dfade9e7e8d905 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 18:48:02 +0200 Subject: [PATCH 58/73] Keep agent profile settings valid across providers (#3331) * fix(app): keep profile modes scoped to providers Applying a profile through sequential field setters allowed a provider change to race mode persistence, poisoning modeless provider preferences with another provider's mode. Apply profiles as one form transition and heal invalid provider-scoped mode preferences when profiles are used. * test(app): cover profile features across providers --- ...agent-profile-modeless-preferences.spec.ts | 225 ++++++++++++++++++ packages/app/e2e/fixtures/fake-pi-rpc.mjs | 38 +++ .../app/e2e/support/helpers/agent-profiles.ts | 7 +- packages/app/src/agent-profiles/index.ts | 1 + .../internal/materialize-profile.test.ts | 32 ++- .../internal/materialize-profile.ts | 13 + .../internal/use-agent-profile-picker.ts | 50 ++-- .../app/src/composer/agent-controls/index.tsx | 36 +-- .../src/composer/agent-controls/mode.test.ts | 2 +- .../src/composer/draft/input-draft-core.ts | 5 +- .../app/src/composer/draft/input-draft.ts | 11 + .../preferences.test.ts | 45 ++++ .../create-agent-preferences/preferences.ts | 46 +++- .../app/src/hooks/use-agent-form-state.ts | 104 +++++--- .../app/src/hooks/use-draft-agent-features.ts | 5 + .../resolve-agent-form.test.ts | 93 ++++---- .../provider-selection/resolve-agent-form.ts | 102 ++++---- 17 files changed, 622 insertions(+), 193 deletions(-) create mode 100644 packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts create mode 100644 packages/app/e2e/fixtures/fake-pi-rpc.mjs diff --git a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts new file mode 100644 index 0000000000..980eaea5d0 --- /dev/null +++ b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts @@ -0,0 +1,225 @@ +import { expect, test, type Page } from "../support/fixtures"; +import path from "node:path"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; +import { + applyProfileFromPicker, + drillIntoProvider, + openModelPicker, + seedAgentProfiles, + seedModelProvider, +} from "../support/helpers/agent-profiles"; +import { gotoAppShell } from "../support/helpers/app"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { + openGlobalNewWorkspaceComposer, + selectNewWorkspaceProject, + submitNewWorkspacePrompt, +} from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; +const MODELESS_PROVIDER = "modeless-profile-e2e"; +const MODELESS_MODEL = "pi-profile-model"; + +type WebSocketMessage = string | Buffer; + +interface CreateAgentRequestMessage { + type: "create_agent_request"; + config?: { + provider?: unknown; + modeId?: unknown; + featureValues?: unknown; + }; +} + +function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null { + const rawMessage = typeof message === "string" ? message : message.toString("utf8"); + try { + const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown }; + return envelope.type === "session" && envelope.message && typeof envelope.message === "object" + ? (envelope.message as Record<string, unknown>) + : null; + } catch { + return null; + } +} + +async function recordAndBlockCreateAgentRequest(page: Page): Promise<{ + waitForRequest(): Promise<CreateAgentRequestMessage>; +}> { + let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null; + const request = new Promise<CreateAgentRequestMessage>((resolve) => { + resolveRequest = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + if (sessionMessage?.type === "create_agent_request") { + resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage); + return; + } + server.send(message); + }); + server.onMessage((message) => ws.send(message)); + }); + + return { waitForRequest: () => request }; +} + +async function seedPoisonedModelessPreference(page: Page): Promise<void> { + await page.addInitScript( + ({ preferencesKey, provider, model }) => { + localStorage.setItem( + preferencesKey, + JSON.stringify({ + provider, + providerPreferences: { + [provider]: { model, mode: "full-access" }, + mock: { model: "ten-second-stream", mode: "load-test" }, + }, + } satisfies FormPreferences), + ); + }, + { + preferencesKey: CREATE_AGENT_PREFERENCES_KEY, + provider: MODELESS_PROVIDER, + model: MODELESS_MODEL, + }, + ); +} + +async function readProviderModePreference(page: Page, provider: string): Promise<unknown> { + return page.evaluate( + ({ preferencesKey, providerId }) => { + const raw = localStorage.getItem(preferencesKey); + if (!raw) return null; + const preferences = JSON.parse(raw) as FormPreferences; + return preferences.providerPreferences?.[providerId]?.mode ?? null; + }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider }, + ); +} + +test.describe("Agent profiles repair modeless provider preferences", () => { + test.describe.configure({ timeout: 240_000 }); + + test("applying a profile heals a stale mode before returning to Pi", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "profile-modeless-preferences-" }); + const profiles = await seedAgentProfiles([ + { + id: "agent_profile_modeless_repair", + name: "Approval work", + provider: "mock", + model: "one-minute-stream", + modeId: "approval-test", + }, + ]); + const provider = await seedModelProvider({ + id: MODELESS_PROVIDER, + label: "Pi profile test", + extends: "pi", + command: [process.execPath, path.resolve("e2e/fixtures/fake-pi-rpc.mjs")], + models: [ + { + id: MODELESS_MODEL, + label: "Pi profile model", + description: "Modeless profile regression model", + }, + ], + }); + await seedPoisonedModelessPreference(page); + const createAgentRecorder = await recordAndBlockCreateAgentRequest(page); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + + await openModelPicker(page); + await applyProfileFromPicker(page, "Approval work"); + + await expect + .poll(() => readProviderModePreference(page, MODELESS_PROVIDER), { timeout: 10_000 }) + .toBeNull(); + await expect + .poll(() => readProviderModePreference(page, "mock"), { timeout: 10_000 }) + .toBe("approval-test"); + + await openModelPicker(page); + await drillIntoProvider(page, MODELESS_PROVIDER); + await page.getByRole("button", { name: /Pi profile model/ }).click(); + + await submitNewWorkspacePrompt(page, "Create a Pi agent after repairing preferences."); + const createAgentRequest = await createAgentRecorder.waitForRequest(); + expect(createAgentRequest).toMatchObject({ + config: { provider: MODELESS_PROVIDER }, + }); + expect(createAgentRequest.config).not.toHaveProperty("modeId"); + } finally { + await workspace.cleanup(); + await provider.restore(); + await profiles.restore(); + } + }); + + test("cross-provider profile features survive immediate workspace creation", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "profile-feature-transition-" }); + const featureProviderId = "profile-feature-e2e"; + const featureModelId = "claude-opus-5"; + const profiles = await seedAgentProfiles([ + { + id: "agent_profile_feature_transition", + name: "Fast profile", + provider: featureProviderId, + model: featureModelId, + modeId: "bypassPermissions", + featureValues: { fast_mode: true }, + }, + ]); + const provider = await seedModelProvider({ + id: featureProviderId, + label: "Profile feature test", + models: [ + { + id: featureModelId, + label: "Profile feature model", + description: "Cross-provider feature regression model", + }, + ], + }); + const createAgentRecorder = await recordAndBlockCreateAgentRequest(page); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + + await openModelPicker(page); + await applyProfileFromPicker(page, "Fast profile"); + await submitNewWorkspacePrompt(page, "Create an agent with profile features."); + + const createAgentRequest = await createAgentRecorder.waitForRequest(); + expect(createAgentRequest).toMatchObject({ + config: { + provider: featureProviderId, + featureValues: { fast_mode: true }, + }, + }); + } finally { + await workspace.cleanup(); + await provider.restore(); + await profiles.restore(); + } + }); +}); diff --git a/packages/app/e2e/fixtures/fake-pi-rpc.mjs b/packages/app/e2e/fixtures/fake-pi-rpc.mjs new file mode 100644 index 0000000000..b6d156ddfc --- /dev/null +++ b/packages/app/e2e/fixtures/fake-pi-rpc.mjs @@ -0,0 +1,38 @@ +import readline from "node:readline"; + +const lines = readline.createInterface({ input: process.stdin }); + +for await (const line of lines) { + const command = JSON.parse(line); + let data = {}; + if (command.type === "get_available_models") { + data = { + models: [ + { + provider: "e2e", + id: "pi-profile-model", + name: "Pi profile model", + }, + ], + }; + } else if (command.type === "get_state") { + data = { + sessionId: "e2e-pi-session", + thinkingLevel: "medium", + isStreaming: false, + isCompacting: false, + messageCount: 0, + pendingMessageCount: 0, + }; + } + + process.stdout.write( + `${JSON.stringify({ + id: command.id, + type: "response", + command: command.type, + success: true, + data, + })}\n`, + ); +} diff --git a/packages/app/e2e/support/helpers/agent-profiles.ts b/packages/app/e2e/support/helpers/agent-profiles.ts index 1f77369eee..59deb88638 100644 --- a/packages/app/e2e/support/helpers/agent-profiles.ts +++ b/packages/app/e2e/support/helpers/agent-profiles.ts @@ -104,21 +104,24 @@ export interface SeededProviderModel { * list is what avoids running anything: replacement models skip catalog * discovery, and Claude's static modes mean the provider never spawns. The * command only has to resolve for the availability probe, hence `node`. + * Providers with dynamic catalogs can pass a small RPC fixture as `command`. */ export async function seedModelProvider(input: { id: string; label: string; models: SeededProviderModel[]; + extends?: "claude" | "pi"; + command?: string[]; }): Promise<HostSeed> { const client = await connectAgentProfilesClient(); await client.patchDaemonConfig({ providers: { [input.id]: { - extends: "claude", + extends: input.extends ?? "claude", label: input.label, description: `${input.label} test provider`, enabled: true, - command: ["node"], + command: input.command ?? ["node"], models: input.models, }, }, diff --git a/packages/app/src/agent-profiles/index.ts b/packages/app/src/agent-profiles/index.ts index a00ad76173..d2ffebe78b 100644 --- a/packages/app/src/agent-profiles/index.ts +++ b/packages/app/src/agent-profiles/index.ts @@ -14,6 +14,7 @@ * here. */ export type { AgentProfile } from "@getpaseo/protocol/messages"; +export type { MaterializedAgentProfile } from "./internal/materialize-profile"; export { useAgentProfiles } from "./internal/use-agent-profiles"; export { useAgentProfilePicker, diff --git a/packages/app/src/agent-profiles/internal/materialize-profile.test.ts b/packages/app/src/agent-profiles/internal/materialize-profile.test.ts index b228c32f37..dd60b6680f 100644 --- a/packages/app/src/agent-profiles/internal/materialize-profile.test.ts +++ b/packages/app/src/agent-profiles/internal/materialize-profile.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { AgentProfile } from "@getpaseo/protocol/messages"; -import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile"; +import { + materializeAgentProfile, + reconcileMaterializedProfileMode, + toAgentConfigApply, +} from "./materialize-profile"; function profile(overrides: Partial<AgentProfile> = {}): AgentProfile { return { @@ -87,3 +91,29 @@ describe("toAgentConfigApply", () => { ).not.toHaveProperty("provider"); }); }); + +describe("reconcileMaterializedProfileMode", () => { + it("waits when the current provider mode catalog is unknown", () => { + expect( + reconcileMaterializedProfileMode(materializeAgentProfile(profile({ modeId: "plan" })), null), + ).toBeNull(); + }); + + it("drops a profile mode removed from the current provider catalog", () => { + expect( + reconcileMaterializedProfileMode( + materializeAgentProfile(profile({ modeId: "removed-mode" })), + ["plan", "default"], + ), + ).toMatchObject({ modeId: "" }); + }); + + it("preserves a profile mode still offered by the provider", () => { + expect( + reconcileMaterializedProfileMode(materializeAgentProfile(profile({ modeId: "plan" })), [ + "plan", + "default", + ]), + ).toMatchObject({ modeId: "plan" }); + }); +}); diff --git a/packages/app/src/agent-profiles/internal/materialize-profile.ts b/packages/app/src/agent-profiles/internal/materialize-profile.ts index bdedf604a5..fc393f5ec2 100644 --- a/packages/app/src/agent-profiles/internal/materialize-profile.ts +++ b/packages/app/src/agent-profiles/internal/materialize-profile.ts @@ -29,6 +29,19 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen }; } +export function reconcileMaterializedProfileMode( + profile: MaterializedAgentProfile, + availableModeIds: readonly string[] | null, +): MaterializedAgentProfile | null { + if (availableModeIds === null) { + return null; + } + if (!profile.modeId || availableModeIds.includes(profile.modeId)) { + return profile; + } + return { ...profile, modeId: "" }; +} + /** * The payload for a running agent. Omitted fields are left alone by the daemon, * so a profile that names no mode does not reset the agent's mode. Provider is diff --git a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts index 38799d2a23..e90b8e8bb4 100644 --- a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts +++ b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts @@ -1,7 +1,5 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import type { AgentProfile } from "@getpaseo/protocol/messages"; import { mergeCreateAgentSelectionPreferences } from "@/create-agent-preferences/preferences"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; @@ -9,21 +7,22 @@ import { useSessionStore } from "@/stores/session-store"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; -import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile"; +import { + materializeAgentProfile, + reconcileMaterializedProfileMode, + toAgentConfigApply, + type MaterializedAgentProfile, +} from "./materialize-profile"; import { buildAgentProfileTags } from "./profile-summary"; import { useAgentProfiles } from "./use-agent-profiles"; -/** The draft composer's own setters. Applying a profile drives them like a user would. */ +/** The draft composer owns profile application as one state transition. */ export interface DraftAgentProfileControls { - selectProvider: (provider: AgentProvider) => void; - selectProviderAndModel: (provider: AgentProvider, modelId: string) => void; - selectMode: (modeId: string) => void; - selectThinkingOption: (thinkingOptionId: string) => void; - setFeature?: (featureId: string, value: unknown) => void; + applyProfile: (profile: MaterializedAgentProfile) => void; } export type AgentProfileApplyTarget = - | { kind: "agent"; agentId: string } + | { kind: "agent"; agentId: string; availableModeIds: readonly string[] | null } | { kind: "draft"; controls: DraftAgentProfileControls }; /** Everything the model picker renders for one profile. It never sees the profile itself. */ @@ -107,8 +106,7 @@ export function useAgentProfilePicker( ); const persistSelection = useCallback( - (profile: AgentProfile) => { - const resolved = materializeAgentProfile(profile); + (resolved: MaterializedAgentProfile) => { void updatePreferences((current) => mergeCreateAgentSelectionPreferences({ preferences: current, @@ -135,35 +133,21 @@ export function useAgentProfilePicker( } const resolved = materializeAgentProfile(profile); - // Persist on both paths, before driving the controls. The draft form drops - // its local feature values when the provider changes, so preferences are - // the only channel that survives a profile that switches provider. - persistSelection(profile); - if (target.kind === "draft") { - const { controls } = target; - if (resolved.modelId) { - controls.selectProviderAndModel(resolved.provider, resolved.modelId); - } else { - controls.selectProvider(resolved.provider); - } - if (resolved.modeId) { - controls.selectMode(resolved.modeId); - } - if (resolved.thinkingOptionId) { - controls.selectThinkingOption(resolved.thinkingOptionId); - } - for (const [featureId, value] of Object.entries(resolved.featureValues)) { - controls.setFeature?.(featureId, value); - } + target.controls.applyProfile(resolved); return; } + const reconciled = reconcileMaterializedProfileMode(resolved, target.availableModeIds); + if (!reconciled) { + return; + } + persistSelection(reconciled); if (!client) { return; } void client - .applyAgentConfig(target.agentId, toAgentConfigApply(resolved)) + .applyAgentConfig(target.agentId, toAgentConfigApply(reconciled)) .then((notice) => showProviderNoticeToast(toast, notice)) .catch((error) => { console.warn("[useAgentProfilePicker] applyAgentConfig failed", error); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index a659b46d1d..5494de61b9 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -81,6 +81,7 @@ import { useAgentProfilePicker, type AgentProfileApplyTarget, type AgentProfilePicker, + type DraftAgentProfileControls, } from "@/agent-profiles"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; @@ -125,7 +126,6 @@ interface ControlledAgentControlsProps { export interface DraftAgentControlsProps { providerDefinitions: AgentProviderDefinition[]; selectedProvider: AgentProvider | null; - onSelectProvider: (provider: AgentProvider) => void; modeOptions: AgentMode[]; selectedMode: string; onSelectMode: (modeId: string) => void; @@ -139,6 +139,7 @@ export interface DraftAgentControlsProps { thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>; selectedThinkingOptionId: string; onSelectThinkingOption: (thinkingOptionId: string) => void; + onApplyAgentProfile: DraftAgentProfileControls["applyProfile"]; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; onDropdownClose?: () => void; @@ -385,6 +386,15 @@ function resolveSnapshotSelectedEntry( return snapshotEntries.find((e) => e.provider === agentProvider) ?? null; } +function resolveSnapshotModeIds( + entry: ReturnType<typeof resolveSnapshotSelectedEntry>, +): string[] | null { + if (entry?.status !== "ready" || !entry.modes) { + return null; + } + return entry.modes.map((mode) => mode.id); +} + function buildAgentProviderDefinitions( agentProvider: string | undefined, snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"], @@ -1550,9 +1560,13 @@ export const AgentControls = memo(function AgentControls({ // A running agent is one provider's process, so only that provider's profiles // can apply to it. const profileProviders = useMemo(() => (agentProvider ? [agentProvider] : []), [agentProvider]); + const profileModeIds = useMemo( + () => resolveSnapshotModeIds(snapshotSelectedEntry), + [snapshotSelectedEntry], + ); const profileTarget = useMemo<AgentProfileApplyTarget>( - () => ({ kind: "agent", agentId }), - [agentId], + () => ({ kind: "agent", agentId, availableModeIds: profileModeIds }), + [agentId, profileModeIds], ); const agentProfiles = useAgentProfilePicker({ serverId, @@ -1692,7 +1706,6 @@ export const AgentControls = memo(function AgentControls({ export function DraftAgentControls({ providerDefinitions, selectedProvider, - onSelectProvider, modeOptions, selectedMode, onSelectMode, @@ -1706,6 +1719,7 @@ export function DraftAgentControls({ thinkingOptions, selectedThinkingOptionId, onSelectThinkingOption, + onApplyAgentProfile, features, onSetFeature, onDropdownClose, @@ -1742,20 +1756,10 @@ export function DraftAgentControls({ () => ({ kind: "draft", controls: { - selectProvider: onSelectProvider, - selectProviderAndModel: onSelectProviderAndModel, - selectMode: onSelectMode, - selectThinkingOption: onSelectThinkingOption, - setFeature: onSetFeature, + applyProfile: onApplyAgentProfile, }, }), - [ - onSelectMode, - onSelectProvider, - onSelectProviderAndModel, - onSelectThinkingOption, - onSetFeature, - ], + [onApplyAgentProfile], ); const agentProfiles = useAgentProfilePicker({ serverId: modelSelectorServerId, diff --git a/packages/app/src/composer/agent-controls/mode.test.ts b/packages/app/src/composer/agent-controls/mode.test.ts index 89c22787b9..0905e3cd4c 100644 --- a/packages/app/src/composer/agent-controls/mode.test.ts +++ b/packages/app/src/composer/agent-controls/mode.test.ts @@ -20,7 +20,6 @@ describe("resolveAgentControlsMode", () => { resolveAgentControlsMode({ providerDefinitions: [], selectedProvider: "codex", - onSelectProvider: () => undefined, modeOptions: [], selectedMode: "", onSelectMode: () => undefined, @@ -34,6 +33,7 @@ describe("resolveAgentControlsMode", () => { thinkingOptions: [], selectedThinkingOptionId: "", onSelectThinkingOption: () => undefined, + onApplyAgentProfile: () => undefined, }), ).toBe("draft"); }); diff --git a/packages/app/src/composer/draft/input-draft-core.ts b/packages/app/src/composer/draft/input-draft-core.ts index ad7ce7b855..9e727433d8 100644 --- a/packages/app/src/composer/draft/input-draft-core.ts +++ b/packages/app/src/composer/draft/input-draft-core.ts @@ -22,13 +22,13 @@ export function buildDraftAgentControls(input: { formState: UseAgentFormStateResult; features?: DraftAgentControlsProps["features"]; onSetFeature?: DraftAgentControlsProps["onSetFeature"]; + onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"]; onDropdownClose?: DraftAgentControlsProps["onDropdownClose"]; }): DraftAgentControlsProps { - const { formState, features, onSetFeature, onDropdownClose } = input; + const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input; return { providerDefinitions: formState.providerDefinitions, selectedProvider: formState.selectedProvider, - onSelectProvider: formState.setProviderFromUser, modeOptions: formState.modeOptions, selectedMode: formState.selectedMode, onSelectMode: formState.setModeFromUser, @@ -42,6 +42,7 @@ export function buildDraftAgentControls(input: { thinkingOptions: formState.availableThinkingOptions, selectedThinkingOptionId: formState.selectedThinkingOptionId, onSelectThinkingOption: formState.setThinkingOptionFromUser, + onApplyAgentProfile, features, onSetFeature, onDropdownClose, diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 995a5382f3..1a68755179 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -190,6 +190,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr features: draftFeatures, featureValues: draftFeatureValues, setFeatureValue: setDraftFeatureValue, + applyProfileFeatureValues, } = useDraftAgentFeatures({ serverId: formState.selectedServerId, provider: formState.selectedProvider, @@ -200,6 +201,14 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr initialFeatureValues: composerOptions?.initialFeatureValues, }); + const applyDraftAgentProfile = useCallback( + (profile: Parameters<typeof formState.applyProfileFromUser>[0]) => { + formState.applyProfileFromUser(profile); + applyProfileFeatureValues(profile.featureValues); + }, + [applyProfileFeatureValues, formState], + ); + const commandDraftConfig = useMemo( () => composerOptions @@ -236,6 +245,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr formState, features: draftFeatures, onSetFeature: setDraftFeatureValue, + onApplyAgentProfile: applyDraftAgentProfile, }), commandDraftConfig, }; @@ -246,6 +256,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr effectiveThinkingOptionId, draftFeatures, draftFeatureValues, + applyDraftAgentProfile, formState, setDraftFeatureValue, workingDir, diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts index cb48416d74..b30b2a66ca 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { CreateAgentPreferencesService } from "./service"; import { + applyAgentProfilePreferences, mergeCreateAgentSelectionPreferences, mergeProviderPreferences, parseFormPreferences, @@ -133,6 +134,50 @@ describe("create agent preferences", () => { }); }); + it("erases a saved mode when a complete selection explicitly has no mode", () => { + expect( + mergeCreateAgentSelectionPreferences({ + preferences: { + provider: "pi", + providerPreferences: { pi: { model: "anthropic/sonnet", mode: "full-access" } }, + }, + provider: "pi", + modelId: "anthropic/sonnet", + modeId: null, + }), + ).toEqual({ + provider: "pi", + providerPreferences: { pi: { model: "anthropic/sonnet" } }, + }); + }); + + it("repairs the previous provider while applying a profile", () => { + expect( + applyAgentProfilePreferences({ + preferences: { + provider: "pi", + providerPreferences: { + pi: { model: "anthropic/sonnet", mode: "full-access" }, + mock: { model: "ten-second-stream", mode: "load-test" }, + }, + }, + previousProvider: "pi", + previousProviderModeIds: [], + provider: "mock", + modelId: "one-minute-stream", + modeId: "approval-test", + thinkingOptionId: "", + featureValues: {}, + }), + ).toEqual({ + provider: "mock", + providerPreferences: { + pi: { model: "anthropic/sonnet" }, + mock: { model: "one-minute-stream", mode: "approval-test", featureValues: {} }, + }, + }); + }); + it("loads invalid stored preferences as empty preferences", () => { expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({}); }); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index 710df9efdc..bdade894e8 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -112,7 +112,7 @@ function mergeDefinedRecord<T>( function applyProviderPreferenceUpdates( existing: ProviderPreferences, - updates: Partial<ProviderPreferences>, + updates: Omit<Partial<ProviderPreferences>, "mode"> & { mode?: string | null }, ): ProviderPreferences { const next: ProviderPreferences = { ...existing }; const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel); @@ -121,7 +121,9 @@ function applyProviderPreferenceUpdates( if (updates.model !== undefined) { next.model = updates.model; } - if (updates.mode !== undefined) { + if (updates.mode === null) { + delete next.mode; + } else if (updates.mode !== undefined) { next.mode = updates.mode; } if (nextThinkingByModel !== undefined) { @@ -137,7 +139,7 @@ function applyProviderPreferenceUpdates( export function mergeProviderPreferences(args: { preferences: FormPreferences; provider: AgentProvider; - updates: Partial<ProviderPreferences>; + updates: Omit<Partial<ProviderPreferences>, "mode"> & { mode?: string | null }; }): FormPreferences { const { preferences, provider, updates } = args; const existingProviderPreferences = preferences.providerPreferences ?? {}; @@ -175,9 +177,45 @@ export function mergeCreateAgentSelectionPreferences(args: { provider: args.provider, updates: { model: modelId || undefined, - mode: modeId || undefined, + mode: args.modeId === undefined ? undefined : modeId || null, ...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}), ...(featureValues.success ? { featureValues: featureValues.data } : {}), }, }); } + +export function applyAgentProfilePreferences(args: { + preferences: FormPreferences; + previousProvider: AgentProvider | null; + previousProviderModeIds: readonly string[]; + provider: AgentProvider; + modelId: string; + modeId: string; + thinkingOptionId: string; + featureValues: Record<string, unknown>; +}): FormPreferences { + let next = args.preferences; + if (args.previousProvider) { + const previousMode = next.providerPreferences?.[args.previousProvider]?.mode; + if (previousMode && !args.previousProviderModeIds.includes(previousMode)) { + next = mergeProviderPreferences({ + preferences: next, + provider: args.previousProvider, + updates: { mode: null }, + }); + } + } + + return mergeProviderPreferences({ + preferences: next, + provider: args.provider, + updates: { + model: args.modelId || undefined, + mode: args.modeId || null, + ...(args.modelId && args.thinkingOptionId + ? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } } + : {}), + featureValues: args.featureValues, + }, + }); +} diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 29e90a6a9f..456ce249c2 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -14,6 +14,7 @@ import { } from "@/provider-selection/provider-selection"; import { filterSelectableModels } from "@/provider-selection/model-catalog"; import { OptimisticFormPreferences } from "@/create-agent-preferences/optimistic-preferences"; +import { applyAgentProfilePreferences } from "@/create-agent-preferences/preferences"; import { useProvidersSnapshot } from "./use-providers-snapshot"; import { useFormPreferences, @@ -37,6 +38,7 @@ import { type FormState, type ProviderModelsByProvider, } from "@/provider-selection/resolve-agent-form"; +import type { MaterializedAgentProfile } from "@/agent-profiles"; export type { FormInitialValues } from "@/provider-selection/resolve-agent-form"; @@ -54,7 +56,6 @@ export interface UseAgentFormStateResult { setSelectedServerId: (value: string | null) => void; setSelectedServerIdFromUser: (value: string | null) => void; selectedProvider: AgentProvider | null; - setProviderFromUser: (provider: AgentProvider) => void; selectedMode: string; setModeFromUser: (modeId: string) => void; selectedModel: string; @@ -80,6 +81,7 @@ export interface UseAgentFormStateResult { refreshProviderModels: (provider?: AgentProvider) => void; refetchProviderModelsIfStale: () => void; setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void; + applyProfileFromUser: (profile: MaterializedAgentProfile) => void; clearProviderSelectionFromUser: () => void; workingDirIsEmpty: boolean; persistFormPreferences: () => Promise<void>; @@ -254,11 +256,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg }), ); - const reducerStateRef = useRef({ form: formState, userModified }); - useEffect(() => { - reducerStateRef.current = { form: formState, userModified }; - }, [formState, userModified]); - useEffect(() => { if (!isVisible) { dispatch({ type: "RESET" }); @@ -423,27 +420,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg dispatch({ type: "SET_SERVER_ID_FROM_USER", value }); }, []); - const setProviderFromUser = useCallback( - (provider: AgentProvider) => { - if (!selectableProviderDefinitionMap.has(provider)) { - return; - } - const providerModels = allProviderModels.get(provider) ?? null; - const providerDef = selectableProviderDefinitionMap.get(provider); - const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider]; - - dispatch({ - type: "SET_PROVIDER_FROM_USER", - provider, - providerModels, - providerDef, - providerPrefs, - }); - void updateCurrentPreferences({ provider }); - }, - [allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences], - ); - const setProviderAndModelFromUser = useCallback( (provider: AgentProvider, modelId: string) => { if (!selectableProviderDefinitionMap.has(provider)) { @@ -480,10 +456,64 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" }); }, []); + const applyProfileFromUser = useCallback( + (profile: MaterializedAgentProfile) => { + const provider = profile.provider as AgentProvider; + if (!selectableProviderDefinitionMap.has(provider)) { + return; + } + + const previousProvider = formState.provider; + const providerDef = selectableProviderDefinitionMap.get(provider); + const providerModels = allProviderModels.get(provider) ?? null; + const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider]; + const action = { + type: "APPLY_PROFILE_FROM_USER" as const, + provider, + modelId: profile.modelId, + modeId: profile.modeId, + thinkingOptionId: profile.thinkingOptionId, + providerDef, + providerModels, + providerPrefs, + }; + const nextState = resolveAgentForm({ form: formState, userModified, resolution }, action); + const previousProviderModeIds = previousProvider + ? (providerDefinitionMap.get(previousProvider)?.modes.map((mode) => mode.id) ?? []) + : []; + + dispatch(action); + void updateCurrentPreferences((current) => { + const { model, modeId, thinkingOptionId } = nextState.form; + return applyAgentProfilePreferences({ + preferences: current, + previousProvider, + previousProviderModeIds, + provider, + modelId: model, + modeId, + thinkingOptionId, + featureValues: profile.featureValues, + }); + }).catch((error) => { + console.warn("[useAgentFormState] persist profile preference failed", error); + }); + }, + [ + allProviderModels, + formState, + providerDefinitionMap, + resolution, + selectableProviderDefinitionMap, + updateCurrentPreferences, + userModified, + ], + ); + const setModeFromUser = useCallback( (modeId: string) => { dispatch({ type: "SET_MODE_FROM_USER", modeId }); - const provider = reducerStateRef.current.form.provider; + const provider = formState.provider; if (provider) { void updateCurrentPreferences((current) => mergeSelectedComposerPreferences({ @@ -496,12 +526,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); } }, - [updateCurrentPreferences], + [formState.provider, updateCurrentPreferences], ); const setModelFromUser = useCallback( (modelId: string) => { - const provider = reducerStateRef.current.form.provider; + const provider = formState.provider; const providerPrefs = provider ? preferenceOverlayRef.current.current().providerPreferences?.[provider] : undefined; @@ -525,13 +555,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); } }, - [availableModels, updateCurrentPreferences], + [availableModels, formState.provider, updateCurrentPreferences], ); const setThinkingOptionFromUser = useCallback( (thinkingOptionId: string) => { dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId }); - const { provider, model: modelId } = reducerStateRef.current.form; + const { provider, model: modelId } = formState; if (provider && modelId) { void updateCurrentPreferences((current) => mergeSelectedComposerPreferences({ @@ -546,7 +576,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); } }, - [updateCurrentPreferences], + [formState, updateCurrentPreferences], ); const setWorkingDir = useCallback((value: string) => { @@ -569,8 +599,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); const refetchProviderModelsIfStale = useCallback(() => { - refetchSnapshotIfStale(reducerStateRef.current.form.provider); - }, [refetchSnapshotIfStale]); + refetchSnapshotIfStale(formState.provider); + }, [formState.provider, refetchSnapshotIfStale]); const persistFormPreferences = useCallback(async () => { if (!formState.provider) { @@ -604,7 +634,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg setSelectedServerId, setSelectedServerIdFromUser, selectedProvider: formState.provider, - setProviderFromUser, selectedMode: formState.modeId, setModeFromUser, selectedModel: formState.model, @@ -630,6 +659,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg refreshProviderModels, refetchProviderModelsIfStale, setProviderAndModelFromUser, + applyProfileFromUser, clearProviderSelectionFromUser, workingDirIsEmpty, persistFormPreferences, @@ -643,7 +673,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg formState.workingDir, setSelectedServerId, setSelectedServerIdFromUser, - setProviderFromUser, setModeFromUser, setModelFromUser, setThinkingOptionFromUser, @@ -665,6 +694,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg refreshProviderModels, refetchProviderModelsIfStale, setProviderAndModelFromUser, + applyProfileFromUser, clearProviderSelectionFromUser, workingDirIsEmpty, persistFormPreferences, diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts index 207ac72e80..9ab23ad6d0 100644 --- a/packages/app/src/hooks/use-draft-agent-features.ts +++ b/packages/app/src/hooks/use-draft-agent-features.ts @@ -145,10 +145,15 @@ export function useDraftAgentFeatures(input: { [provider, updatePreferences], ); + const applyProfileFeatureValues = useCallback((values: Record<string, unknown>) => { + setLocalFeatureValues(values); + }, []); + return { features, featureValues: effectiveFeatureValues, isLoading: featuresQuery.isLoading, setFeatureValue, + applyProfileFeatureValues, }; } diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index f6170394c1..5b6cc2a509 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -47,6 +47,14 @@ const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = { ], }; +const TEST_PI_DEFINITION: AgentProviderDefinition = { + id: "pi", + label: "Pi", + description: "Pi test provider", + defaultModeId: null, + modes: [], +}; + const CODEX_MODELS: AgentModelDefinition[] = [ { provider: "codex", @@ -970,55 +978,6 @@ describe("resolveAgentForm", () => { }); }); - describe("SET_PROVIDER_FROM_USER", () => { - it("switches provider, picks preferred model and mode, marks provider modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { - type: "SET_PROVIDER_FROM_USER", - provider: "codex", - providerModels: CODEX_MODELS, - providerDef: TEST_CODEX_DEFINITION, - providerPrefs: { model: "gpt-5.3-codex", mode: "full-access" }, - }); - - expect(next.form.provider).toBe("codex"); - expect(next.form.model).toBe("gpt-5.3-codex"); - expect(next.form.modeId).toBe("full-access"); - expect(next.userModified.provider).toBe(true); - expect(next.userModified.model).toBe(false); - }); - - it("falls back to provider defaults when no prefs", () => { - const state = makeState(); - const next = resolveAgentForm(state, { - type: "SET_PROVIDER_FROM_USER", - provider: "codex", - providerModels: CODEX_MODELS, - providerDef: TEST_CODEX_DEFINITION, - providerPrefs: undefined, - }); - - expect(next.form.modeId).toBe("auto"); - expect(next.form.model).toBe("gpt-5.3-codex"); - }); - - it("canonicalizes an aliased preferred model and restores its thinking option", () => { - const next = resolveAgentForm(makeState(), { - type: "SET_PROVIDER_FROM_USER", - provider: "codex", - providerModels: ALIASED_CODEX_MODELS, - providerDef: TEST_CODEX_DEFINITION, - providerPrefs: { - model: "gpt-5.3-codex-legacy", - thinkingByModel: { "gpt-5.3-codex-legacy": "low" }, - }, - }); - - expect(next.form.model).toBe("gpt-5.3-codex"); - expect(next.form.thinkingOptionId).toBe("low"); - }); - }); - describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => { it("sets provider, model, and default mode; marks both modified", () => { const state = makeState(); @@ -1090,6 +1049,42 @@ describe("resolveAgentForm", () => { }); }); + describe("APPLY_PROFILE_FROM_USER", () => { + it("drops a stale saved mode for a modeless profile provider", () => { + const next = resolveAgentForm(makeState({ provider: "codex", modeId: "full-access" }), { + type: "APPLY_PROFILE_FROM_USER", + provider: "pi", + modelId: "anthropic/sonnet", + modeId: "", + thinkingOptionId: "", + providerDef: TEST_PI_DEFINITION, + providerModels: [{ provider: "pi", id: "anthropic/sonnet", label: "Sonnet" }], + providerPrefs: { mode: "full-access" }, + }); + + expect(next.form).toMatchObject({ + provider: "pi", + model: "anthropic/sonnet", + modeId: "", + }); + }); + + it("restores thinking for the selected model when the profile omits it", () => { + const next = resolveAgentForm(makeState(), { + type: "APPLY_PROFILE_FROM_USER", + provider: "codex", + modelId: "gpt-5.3-codex", + modeId: "full-access", + thinkingOptionId: "", + providerDef: TEST_CODEX_DEFINITION, + providerModels: CODEX_MODELS, + providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "low" } }, + }); + + expect(next.form.thinkingOptionId).toBe("low"); + }); + }); + describe("SET_MODEL_FROM_USER", () => { it("updates model and resets thinking to model default when thinking is not user-modified", () => { const state = makeState({ provider: "codex", model: "", thinkingOptionId: "" }); diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index 1e2b56bab4..9fa187200e 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -79,13 +79,6 @@ export type AgentFormAction = } | { type: "SET_SERVER_ID"; value: string | null } | { type: "SET_SERVER_ID_FROM_USER"; value: string | null } - | { - type: "SET_PROVIDER_FROM_USER"; - provider: AgentProvider; - providerModels: AgentModelDefinition[] | null; - providerDef: AgentProviderDefinition | undefined; - providerPrefs: ProviderPrefs | undefined; - } | { type: "SET_PROVIDER_AND_MODEL_FROM_USER"; provider: AgentProvider; @@ -94,6 +87,16 @@ export type AgentFormAction = providerModels: AgentModelDefinition[] | null; providerPrefs?: ProviderPrefs | undefined; } + | { + type: "APPLY_PROFILE_FROM_USER"; + provider: AgentProvider; + modelId: string; + modeId: string; + thinkingOptionId: string; + providerDef: AgentProviderDefinition | undefined; + providerModels: AgentModelDefinition[] | null; + providerPrefs?: ProviderPrefs | undefined; + } | { type: "SET_MODE_FROM_USER"; modeId: string } | { type: "SET_MODEL_FROM_USER"; @@ -109,6 +112,7 @@ export type AgentFormAction = | { type: "RESET" }; type CompleteResolutionAction = Extract<AgentFormAction, { type: "COMPLETE_RESOLUTION" }>; +type ApplyProfileAction = Extract<AgentFormAction, { type: "APPLY_PROFILE_FROM_USER" }>; export function normalizeSelectedModelId(modelId: string | null | undefined): string { return typeof modelId === "string" ? modelId.trim() : ""; @@ -484,20 +488,6 @@ export function resolveFormStateFromProviderModels( ); } -function pickNextModelForProvider(input: { - providerModels: AgentModelDefinition[] | null; - providerPrefs: ProviderPrefs | undefined; -}): string { - const { providerModels, providerPrefs } = input; - const preferredModel = normalizeSelectedModelId(providerPrefs?.model); - const defaultModelId = resolveDefaultModelId(providerModels); - if (preferredModel) { - if (!providerModels) return preferredModel; - return resolveCanonicalModelId(providerModels, preferredModel) || defaultModelId; - } - return defaultModelId; -} - function pickNextModeForProvider(input: { providerDef: AgentProviderDefinition | undefined; providerPrefs: ProviderPrefs | undefined; @@ -586,6 +576,45 @@ function completeResolution( return { ...nextState, form: resolved }; } +function applyProfile(state: AgentFormReducerState, action: ApplyProfileAction) { + const preferredModelId = action.modelId || action.providerPrefs?.model || ""; + const normalizedModelId = resolveCanonicalModelId(action.providerModels, preferredModelId); + const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels); + const availableModeIds = new Set(action.providerDef?.modes.map((mode) => mode.id) ?? []); + const preferredModeId = action.modeId || action.providerPrefs?.mode || ""; + const defaultModeId = action.providerDef?.defaultModeId ?? ""; + let nextModeId = ""; + if (availableModeIds.has(preferredModeId)) { + nextModeId = preferredModeId; + } else if (availableModeIds.has(defaultModeId)) { + nextModeId = defaultModeId; + } + const nextThinkingOptionId = + action.thinkingOptionId || + pickNextThinkingOptionForProvider({ + providerModels: action.providerModels, + providerPrefs: action.providerPrefs, + modelId: nextModelId, + }); + return { + ...state, + form: { + ...state.form, + provider: action.provider, + model: nextModelId, + modeId: nextModeId, + thinkingOptionId: nextThinkingOptionId, + }, + userModified: { + ...state.userModified, + provider: true, + model: true, + modeId: true, + thinkingOptionId: true, + }, + }; +} + export function resolveAgentForm( state: AgentFormReducerState, action: AgentFormAction, @@ -611,33 +640,6 @@ export function resolveAgentForm( userModified: { ...state.userModified, serverId: true }, }; - case "SET_PROVIDER_FROM_USER": { - const nextModelId = pickNextModelForProvider({ - providerModels: action.providerModels, - providerPrefs: action.providerPrefs, - }); - const nextModeId = pickNextModeForProvider({ - providerDef: action.providerDef, - providerPrefs: action.providerPrefs, - }); - const nextThinkingOptionId = pickNextThinkingOptionForProvider({ - providerModels: action.providerModels, - providerPrefs: action.providerPrefs, - modelId: nextModelId, - }); - return { - ...state, - form: { - ...state.form, - provider: action.provider, - modeId: nextModeId, - model: nextModelId, - thinkingOptionId: nextThinkingOptionId, - }, - userModified: { ...state.userModified, provider: true }, - }; - } - case "SET_PROVIDER_AND_MODEL_FROM_USER": { const normalizedModelId = resolveCanonicalModelId(action.providerModels, action.modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels); @@ -669,6 +671,10 @@ export function resolveAgentForm( }; } + case "APPLY_PROFILE_FROM_USER": { + return applyProfile(state, action); + } + case "SET_MODE_FROM_USER": return { ...state, From 0f2723074680d810a2be3ff7ee2adc9613dbba89 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 18:47:55 +0200 Subject: [PATCH 59/73] feat(app): unify task list status styling Use one task-row presentation across message and composer lists so running, pending, and completed states keep the same hierarchy and status language. --- packages/app/src/components/message.tsx | 82 +------------ .../app/src/components/task-list-row.test.tsx | 115 ++++++++++++++++++ packages/app/src/components/task-list-row.tsx | 62 ++++++++++ packages/app/src/composer/task-list/index.tsx | 65 ++-------- 4 files changed, 188 insertions(+), 136 deletions(-) create mode 100644 packages/app/src/components/task-list-row.test.tsx create mode 100644 packages/app/src/components/task-list-row.tsx diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 1615c7b73c..8058320f99 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -1,4 +1,5 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { TaskListRow } from "@/components/task-list-row"; import { View, Text, @@ -166,7 +167,6 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [ const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>; const ThemedMicVocal = withUnistyles(MicVocal); -const ThemedTodoCheckIcon = withUnistyles(Check); const ThemedFileSymlinkIcon = withUnistyles(FileSymlink); const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon); const ThemedChevronRightIcon = withUnistyles(ChevronRight); @@ -179,9 +179,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ const mutedForegroundColorMapping = (theme: Theme) => ({ color: theme.colors.mutedForeground, }); -const primaryForegroundColorMapping = (theme: Theme) => ({ - color: theme.colors.primaryForeground, -}); const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = ` @keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} { @@ -2239,12 +2236,6 @@ interface TodoListCardProps { disableOuterSpacing?: boolean; } -interface TodoListItemRowProps { - text: string; - completed: boolean; - status?: TodoEntry["status"]; -} - function taskActivityIcon(activity: TaskActivity) { switch (activity.type) { case "added": @@ -2260,34 +2251,6 @@ function taskActivityIcon(activity: TaskActivity) { } } -function TodoListItemRow({ text, completed, status }: TodoListItemRowProps) { - const badgeStyle = useMemo( - () => [ - todoListCardStylesheet.radioBadge, - completed - ? todoListCardStylesheet.radioBadgeComplete - : todoListCardStylesheet.radioBadgeIncomplete, - ], - [completed], - ); - const textStyle = useMemo( - () => [todoListCardStylesheet.itemText, completed && todoListCardStylesheet.itemTextCompleted], - [completed], - ); - return ( - <View style={todoListCardStylesheet.itemRow}> - <View - style={[badgeStyle, status === "in_progress" && todoListCardStylesheet.radioBadgeActive]} - > - {completed ? ( - <ThemedTodoCheckIcon size={12} uniProps={primaryForegroundColorMapping} /> - ) : null} - </View> - <Text style={textStyle}>{text}</Text> - </View> - ); -} - const todoListCardStylesheet = StyleSheet.create((theme) => ({ detailsWrapper: { padding: theme.spacing[2], @@ -2295,40 +2258,6 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({ list: { gap: theme.spacing[1], }, - itemRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - radioBadge: { - width: 16, - height: 16, - borderRadius: 8, - backgroundColor: theme.colors.foregroundMuted, - alignItems: "center", - justifyContent: "center", - }, - radioBadgeIncomplete: { - opacity: 0.55, - }, - radioBadgeComplete: { - opacity: 0.95, - }, - radioBadgeActive: { - borderWidth: 2, - borderColor: theme.colors.primary, - backgroundColor: "transparent", - opacity: 1, - }, - itemText: { - flex: 1, - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - itemTextCompleted: { - color: theme.colors.foregroundMuted, - textDecorationLine: "line-through", - }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, @@ -2366,14 +2295,7 @@ export const TodoListCard = memo(function TodoListCard({ {items.length === 0 ? ( <Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text> ) : ( - items.map((item) => ( - <TodoListItemRow - key={item.id ?? item.text} - text={item.text} - completed={item.completed} - status={item.status} - /> - )) + items.map((item) => <TaskListRow key={item.id ?? item.text} task={item} />) )} </View> </View> diff --git a/packages/app/src/components/task-list-row.test.tsx b/packages/app/src/components/task-list-row.test.tsx new file mode 100644 index 0000000000..94ed47bc7d --- /dev/null +++ b/packages/app/src/components/task-list-row.test.tsx @@ -0,0 +1,115 @@ +/** + * @vitest-environment jsdom + */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { theme } = vi.hoisted(() => ({ + theme: { + spacing: { 2: 8 }, + fontSize: { sm: 13 }, + colors: { + foreground: "#foreground", + foregroundMuted: "#muted", + foregroundExtraMuted: "#extra-muted", + statusDotRunning: "#running", + }, + }, +})); + +vi.mock("react-native", () => ({ + View: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => + React.createElement("div", props, children), + Text: ({ children, style }: React.PropsWithChildren<{ style?: Record<string, unknown>[] }>) => { + const resolvedStyle = Object.assign({}, ...(style ?? []).filter(Boolean)); + return React.createElement( + "span", + { + "data-color": resolvedStyle.color, + "data-decoration": resolvedStyle.textDecorationLine, + }, + children, + ); + }, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: (value: typeof theme) => unknown) => factory(theme), + }, + withUnistyles: + (Component: React.ComponentType<Record<string, unknown>>) => + ({ uniProps, ...props }: Record<string, unknown>) => { + const themedProps = (uniProps as (value: typeof theme) => Record<string, unknown>)(theme); + return React.createElement(Component, { ...props, ...themedProps }); + }, +})); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => (props: Record<string, unknown>) => + React.createElement("span", { ...props, "data-icon": name }); + return { + Circle: createIcon("Circle"), + CircleCheck: createIcon("CircleCheck"), + CircleDot: createIcon("CircleDot"), + }; +}); + +vi.stubGlobal("React", React); +vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + +import { TaskListRow } from "./task-list-row"; + +const pendingTask = { text: "Queued", completed: false } as const; +const runningTask = { + text: "Run checks", + activeForm: "Running checks", + completed: false, + status: "in_progress", +} as const; +const completedTask = { text: "Finished", completed: true, status: "completed" } as const; + +describe("TaskListRow", () => { + let container: HTMLElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("keeps pending tasks muted", () => { + act(() => root.render(<TaskListRow task={pendingTask} />)); + + expect(container.querySelector('[data-icon="Circle"]')?.getAttribute("color")).toBe("#muted"); + expect(container.querySelector("[data-color]")?.getAttribute("data-color")).toBe("#muted"); + }); + + it("foregrounds only the running task with the running blue circle dot", () => { + act(() => root.render(<TaskListRow task={runningTask} />)); + + expect(container.textContent).toBe("Running checks"); + expect(container.querySelector('[data-icon="CircleDot"]')?.getAttribute("color")).toBe( + "#running", + ); + expect(container.querySelector("[data-color]")?.getAttribute("data-color")).toBe("#foreground"); + }); + + it("extra-mutes completed tasks with a circle check", () => { + act(() => root.render(<TaskListRow task={completedTask} />)); + + expect(container.querySelector('[data-icon="CircleCheck"]')?.getAttribute("color")).toBe( + "#extra-muted", + ); + const text = container.querySelector("[data-color]"); + expect(text?.getAttribute("data-color")).toBe("#extra-muted"); + expect(text?.getAttribute("data-decoration")).toBe("line-through"); + }); +}); diff --git a/packages/app/src/components/task-list-row.tsx b/packages/app/src/components/task-list-row.tsx new file mode 100644 index 0000000000..5dc834b617 --- /dev/null +++ b/packages/app/src/components/task-list-row.tsx @@ -0,0 +1,62 @@ +import { Circle, CircleCheck, CircleDot } from "lucide-react-native"; +import { memo } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; +import type { TodoEntry } from "@/types/stream"; + +const ThemedCircle = withUnistyles(Circle); +const ThemedCircleCheck = withUnistyles(CircleCheck); +const ThemedCircleDot = withUnistyles(CircleDot); + +const extraMutedIcon = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); +const mutedIcon = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const runningIcon = (theme: Theme) => ({ color: theme.colors.statusDotRunning }); + +function TaskStatusIcon({ isCompleted, isRunning }: { isCompleted: boolean; isRunning: boolean }) { + if (isCompleted) { + return <ThemedCircleCheck size={16} uniProps={extraMutedIcon} />; + } + if (isRunning) { + return <ThemedCircleDot size={16} uniProps={runningIcon} />; + } + return <ThemedCircle size={16} uniProps={mutedIcon} />; +} + +export const TaskListRow = memo(function TaskListRow({ task }: { task: TodoEntry }) { + const isCompleted = task.completed || task.status === "completed"; + const isRunning = !isCompleted && task.status === "in_progress"; + const text = isRunning && task.activeForm ? task.activeForm : task.text; + + return ( + <View style={styles.row} accessibilityLabel={text}> + <TaskStatusIcon isCompleted={isCompleted} isRunning={isRunning} /> + <Text + numberOfLines={1} + style={[styles.text, isRunning && styles.runningText, isCompleted && styles.completedText]} + > + {text} + </Text> + </View> + ); +}); + +const styles = StyleSheet.create((theme) => ({ + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + text: { + flex: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + runningText: { + color: theme.colors.foreground, + }, + completedText: { + color: theme.colors.foregroundExtraMuted, + textDecorationLine: "line-through", + }, +})); diff --git a/packages/app/src/composer/task-list/index.tsx b/packages/app/src/composer/task-list/index.tsx index fa44475a03..4a991abdc8 100644 --- a/packages/app/src/composer/task-list/index.tsx +++ b/packages/app/src/composer/task-list/index.tsx @@ -1,21 +1,14 @@ import { memo, useCallback, useMemo, useState } from "react"; -import { Text, View } from "react-native"; -import { Check, ChevronDown, ChevronRight, Circle, CircleDot } from "lucide-react-native"; +import { View } from "react-native"; +import { ChevronDown, ChevronRight } from "lucide-react-native"; import { useTranslation } from "react-i18next"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { Button } from "@/components/ui/button"; +import { TaskListRow } from "@/components/task-list-row"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { useSessionStore } from "@/stores/session-store"; -import type { Theme } from "@/styles/theme"; import type { TodoEntry } from "@/types/stream"; -const ThemedCheck = withUnistyles(Check); -const ThemedCircle = withUnistyles(Circle); -const ThemedCircleDot = withUnistyles(CircleDot); -const mutedIcon = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const activeIcon = (theme: Theme) => ({ color: theme.colors.statusWarning }); -const completedIcon = (theme: Theme) => ({ color: theme.colors.statusSuccess }); - export const AgentTaskList = memo(function AgentTaskList({ serverId, agentId, @@ -28,16 +21,6 @@ export const AgentTaskList = memo(function AgentTaskList({ return <TaskListCard tasks={tasks} />; }); -function TaskStatusIcon({ task }: { task: TodoEntry }) { - if (task.completed || task.status === "completed") { - return <ThemedCheck size={15} uniProps={completedIcon} />; - } - if (task.status === "in_progress") { - return <ThemedCircleDot size={15} uniProps={activeIcon} />; - } - return <ThemedCircle size={15} uniProps={mutedIcon} />; -} - const TaskListCard = memo(function TaskListCard({ tasks }: { tasks: TodoEntry[] }) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); @@ -83,25 +66,9 @@ const TaskListCard = memo(function TaskListCard({ tasks }: { tasks: TodoEntry[] </Button> {expanded ? ( <View style={styles.list}> - {tasks.map((task, index) => { - const isActive = task.status === "in_progress"; - const text = isActive && task.activeForm ? task.activeForm : task.text; - return ( - <View - key={task.id ?? `${index}:${task.text}`} - style={styles.row} - accessibilityLabel={text} - > - <TaskStatusIcon task={task} /> - <Text - numberOfLines={1} - style={[styles.taskText, task.completed && styles.completedText]} - > - {text} - </Text> - </View> - ); - })} + {tasks.map((task, index) => ( + <TaskListRow key={task.id ?? `${index}:${task.text}`} task={task} /> + ))} </View> ) : null} </View> @@ -142,22 +109,8 @@ const styles = StyleSheet.create((theme) => ({ list: { borderTopWidth: theme.borderWidth[1], borderTopColor: theme.colors.border, - paddingTop: theme.spacing[1], - }, - row: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], - }, - taskText: { - flex: 1, - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - }, - completedText: { - color: theme.colors.foregroundMuted, - textDecorationLine: "line-through", + paddingTop: theme.spacing[2], + gap: theme.spacing[1], }, })); From a4b92768d927694c042d468488484d1864081116 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 18:51:37 +0200 Subject: [PATCH 60/73] fix(providers): allow two minutes for catalog refreshes Provider catalog discovery can exceed one minute during cold startup. Increase the default deadline while preserving explicit config and environment overrides. --- docs/custom-providers.md | 2 +- .../agent/provider-snapshot-manager.test.ts | 34 +++++++++++++++++++ .../server/agent/provider-snapshot-manager.ts | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/custom-providers.md b/docs/custom-providers.md index acf8826573..924d1bc066 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -17,7 +17,7 @@ Provider definitions live under `agents.providers` in config.json: Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`). -Each provider catalog refresh waits up to 60 seconds. If a provider loads many plugins or a large +Each provider catalog refresh waits up to 2 minutes. If a provider loads many plugins or a large agent catalog during startup, raise the limit in milliseconds: ```json diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index ed67290b07..a73d8f3e05 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -442,6 +442,40 @@ describe("ProviderSnapshotManager public surface", () => { } }); + test("defaults provider refreshes to a two-minute deadline", async () => { + vi.useFakeTimers(); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { + codex: createExtraClient("codex", { isAvailable: waitUntilAborted }), + }, + }); + + try { + const entryPromise = manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + + await vi.advanceTimersByTimeAsync(120_000); + + await expect(entryPromise).resolves.toMatchObject({ + status: "error", + error: "Timed out refreshing Codex after 120000ms; pending: availability", + }); + } finally { + manager.destroy(); + vi.useRealTimers(); + } + }); + test("one refresh timeout covers availability and catalog discovery", async () => { vi.useFakeTimers(); const manager = new ProviderSnapshotManager({ diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 60f7850a6e..f0985348af 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -45,7 +45,7 @@ import { validateAgentConfigurationAgainstProvider, } from "./agent-configuration-validator.js"; -const DEFAULT_REFRESH_TIMEOUT_MS = 60_000; +const DEFAULT_REFRESH_TIMEOUT_MS = 120_000; const MAX_REFRESH_TIMEOUT_MS = 2_147_483_647; const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000; const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS"; From 52e4060d76aee227b5dec60386bc55c0d8dbf636 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 19:34:37 +0200 Subject: [PATCH 61/73] fix(server): prefer portable project icons Project icon discovery could select an ICO that native clients cannot decode even when a renderable SVG or PNG was available. Keep ICO as the final fallback and recognize common sized icon names. --- .../server/src/utils/project-icon.test.ts | 25 +++++++++++++++++-- packages/server/src/utils/project-icon.ts | 16 ++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/server/src/utils/project-icon.test.ts b/packages/server/src/utils/project-icon.test.ts index 18289d8480..e424fd1ded 100644 --- a/packages/server/src/utils/project-icon.test.ts +++ b/packages/server/src/utils/project-icon.test.ts @@ -125,11 +125,11 @@ describe("findProjectIcon", () => { }); it("prioritizes favicon over logo", async () => { - writeFileSync(join(tempDir, "favicon.ico"), "favicon"); + writeFileSync(join(tempDir, "favicon.png"), "favicon"); writeFileSync(join(tempDir, "logo.png"), "logo"); const result = await findProjectIcon(tempDir); - expect(result).toBe(join(tempDir, "favicon.ico")); + expect(result).toBe(join(tempDir, "favicon.png")); }); it("prioritizes priority dirs over root", async () => { @@ -180,6 +180,14 @@ describe("findProjectIcon", () => { expect(result).toBe(join(tempDir, "apple-touch-icon.png")); }); + it("prefers an Apple touch PNG over ICO", async () => { + writeFileSync(join(tempDir, "favicon.ico"), "ico"); + writeFileSync(join(tempDir, "apple-touch-icon.png"), "apple icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "apple-touch-icon.png")); + }); + it("finds icon-*.png patterns", async () => { writeFileSync(join(tempDir, "icon-192.png"), "192 icon"); @@ -187,6 +195,19 @@ describe("findProjectIcon", () => { expect(result).toBe(join(tempDir, "icon-192.png")); }); + it.each([ + "favicon-32x32.png", + "apple-touch-icon-180x180.png", + "android-chrome-192x192.png", + "safari-pinned-tab.svg", + "mstile-150x150.png", + ])("finds common sized icon %s", async (fileName) => { + writeFileSync(join(tempDir, fileName), "icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, fileName)); + }); + it("handles non-existent directory gracefully", async () => { const result = await findProjectIcon(join(tempDir, "nonexistent")); expect(result).toBeNull(); diff --git a/packages/server/src/utils/project-icon.ts b/packages/server/src/utils/project-icon.ts index 259e293773..ed122b4536 100644 --- a/packages/server/src/utils/project-icon.ts +++ b/packages/server/src/utils/project-icon.ts @@ -5,25 +5,31 @@ import { extname, join } from "path"; * Icon file patterns to search for, in priority order. * Patterns starting with '*' are glob patterns (e.g., icon-*.png). * - * Within each name, SVG and PNG outrank ICO: every client renders them (React - * Native's <Image> cannot decode ICO at all) and they scale better. ICO is the - * last resort for each name, not the first. + * SVG and PNG outrank ICO across all known names: every client renders them + * and they scale better. ICO is the final fallback because native clients + * cannot decode containers whose frames use the legacy bitmap format. */ export const ICON_PATTERNS = [ "favicon.svg", "favicon.png", - "favicon.ico", + "favicon-*.svg", + "favicon-*.png", "favico.svg", "favico.png", - "favico.ico", "icon.svg", "icon.png", "app-icon.svg", "app-icon.png", "apple-touch-icon.png", + "apple-touch-icon-*.png", "icon-*.png", + "android-chrome-*.png", + "safari-pinned-tab.svg", + "mstile-*.png", "logo.svg", "logo.png", + "favicon.ico", + "favico.ico", ]; /** From 2b9501a8108b40310f2652f13354c1e7173f513b Mon Sep 17 00:00:00 2001 From: Desmond <desmond@returning.ai> Date: Fri, 14 Aug 2026 01:54:38 +0800 Subject: [PATCH 62/73] fix(app): support copying Hermes resume commands (#3300) --- .../app/src/utils/provider-command-templates.test.ts | 10 ++++++++++ packages/app/src/utils/provider-command-templates.ts | 3 +++ 2 files changed, 13 insertions(+) diff --git a/packages/app/src/utils/provider-command-templates.test.ts b/packages/app/src/utils/provider-command-templates.test.ts index c995368a90..8ed8e18567 100644 --- a/packages/app/src/utils/provider-command-templates.test.ts +++ b/packages/app/src/utils/provider-command-templates.test.ts @@ -3,6 +3,16 @@ import { describe, expect, test } from "vitest"; import { buildProviderCommand } from "@/utils/provider-command-templates"; describe("buildProviderCommand", () => { + test("builds Hermes resume commands from native session ids", () => { + expect( + buildProviderCommand({ + provider: "hermes", + id: "resume", + sessionId: "20260813_111500_abc123", + }), + ).toBe("hermes --resume 20260813_111500_abc123"); + }); + test("builds OpenCode resume commands from native session ids", () => { expect( buildProviderCommand({ diff --git a/packages/app/src/utils/provider-command-templates.ts b/packages/app/src/utils/provider-command-templates.ts index 0d55a731ee..d8e65f5788 100644 --- a/packages/app/src/utils/provider-command-templates.ts +++ b/packages/app/src/utils/provider-command-templates.ts @@ -17,6 +17,9 @@ export const PROVIDER_COMMAND_TEMPLATES: Record< claude: { resume: "claude --resume {sessionId}", }, + hermes: { + resume: "hermes --resume {sessionId}", + }, pi: { resume: "pi --session {sessionId}", }, From 9c91dbbf425ba28803980fabaeeb414459e889c0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 20:01:08 +0200 Subject: [PATCH 63/73] docs(release): require PR context for changelogs --- docs/release.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release.md b/docs/release.md index 96ecd3d362..aa0272c7e4 100644 --- a/docs/release.md +++ b/docs/release.md @@ -464,6 +464,7 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or ## Changelog ownership - **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent. +- **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding. - For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it. ## Changelog voice @@ -567,6 +568,7 @@ Each beta entry records what its testers receive. Promotion produces the single ### Beta release - [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green +- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog - [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release - [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved - [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag @@ -587,6 +589,7 @@ Each beta entry records what its testers receive. Promotion produces the single - [ ] Run the pre-release sanity check (see above) and address any findings - [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved - [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green +- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog - [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command - [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command - [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release From a732cc5756c805f47c8d238de6f075dc9e632570 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 20:34:21 +0200 Subject: [PATCH 64/73] fix(app): open sole provider model picker directly Running chats expose one provider, so an intermediate provider screen and back action are redundant. Restore the direct model-list entry behavior while preserving the multi-provider picker. --- .../e2e/browser/agent-profiles-picker.spec.ts | 24 +++++++------------ .../src/components/model-browser-view.test.ts | 4 ++-- .../app/src/components/model-browser-view.ts | 14 ++++------- packages/app/src/components/model-browser.tsx | 4 +--- 4 files changed, 16 insertions(+), 30 deletions(-) diff --git a/packages/app/e2e/browser/agent-profiles-picker.spec.ts b/packages/app/e2e/browser/agent-profiles-picker.spec.ts index 87fcffcab9..d67de4da36 100644 --- a/packages/app/e2e/browser/agent-profiles-picker.spec.ts +++ b/packages/app/e2e/browser/agent-profiles-picker.spec.ts @@ -1,17 +1,14 @@ -import { test } from "../support/fixtures"; +import { expect, test } from "../support/fixtures"; import { applyProfileFromPicker, closeModelPicker, - drillIntoProvider, expectComposerDoesNotName, expectAgentProfilesEmptyPrompt, expectProfileEditTooltip, expectComposerMode, expectComposerModel, expectModelRowSelected, - expectNothingSelectedInPickerRoot, expectProfileEditIsPencilOnly, - expectProfilePinnedAboveProviders, expectProfileVisibleForProvider, openModelPicker, openAgentProfilesFromEmptyPrompt, @@ -74,9 +71,12 @@ test.describe("Agent profiles in the model picker", () => { await expectComposerMode(page, "Load test"); }); - await test.step("the profile is pinned above the provider list", async () => { + await test.step("the sole provider opens directly", async () => { await openModelPicker(page); - await expectProfilePinnedAboveProviders(page, { + await expect(page.getByTestId("model-search-input").first()).toBeVisible(); + await expect(page.getByTestId("sheet-header-back")).toHaveCount(0); + await expect(page.locator('[data-testid^="model-provider-"]')).toHaveCount(0); + await expectProfileVisibleForProvider(page, { name: PROFILE.name, summary: PROFILE_SUMMARY, }); @@ -100,17 +100,9 @@ test.describe("Agent profiles in the model picker", () => { await expectComposerDoesNotName(page, PROFILE.name); }); - await test.step("reopening the picker shows no profile marked as selected", async () => { + await test.step("reopening returns directly to the provider models", async () => { await openModelPicker(page); - await expectProfilePinnedAboveProviders(page, { - name: PROFILE.name, - summary: PROFILE_SUMMARY, - }); - await expectNothingSelectedInPickerRoot(page); - }); - - await test.step("the model it materialized is the one marked selected", async () => { - await drillIntoProvider(page, "mock"); + await expect(page.getByTestId("model-search-input").first()).toBeVisible(); await expectProfileVisibleForProvider(page, { name: PROFILE.name, summary: PROFILE_SUMMARY, diff --git a/packages/app/src/components/model-browser-view.test.ts b/packages/app/src/components/model-browser-view.test.ts index edb499a4a1..f1e1998ebf 100644 --- a/packages/app/src/components/model-browser-view.test.ts +++ b/packages/app/src/components/model-browser-view.test.ts @@ -70,7 +70,7 @@ describe("model browser initial view", () => { ).toEqual({ kind: "all" }); }); - it("keeps the root even when a single provider would otherwise be skipped", () => { + it("opens a sole provider directly regardless of root content", () => { expect( resolveInitialModelBrowserView({ providers: [pi], @@ -78,7 +78,7 @@ describe("model browser initial view", () => { selectedModel: "pi-pro", hasProfiles: true, }), - ).toEqual({ kind: "all" }); + ).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" }); }); it("falls back to the root when the selected provider is gone", () => { diff --git a/packages/app/src/components/model-browser-view.ts b/packages/app/src/components/model-browser-view.ts index acdde18b04..75b1c75411 100644 --- a/packages/app/src/components/model-browser-view.ts +++ b/packages/app/src/components/model-browser-view.ts @@ -32,11 +32,7 @@ export function resolveModelBrowserAllView({ return { kind: "searchResults", rows }; } -/** - * Where the picker lands when it opens. Agent profiles live on the root view, so - * a host that has any profile always opens there — including the single-provider - * case, which would otherwise skip the root entirely and hide them. - */ +/** Where the picker lands when it opens. A sole provider skips the redundant root view. */ export function resolveInitialModelBrowserView({ providers, selectedProvider, @@ -48,10 +44,6 @@ export function resolveInitialModelBrowserView({ selectedModel: string; hasProfiles: boolean; }): ModelBrowserView { - if (hasProfiles) { - return { kind: "all" }; - } - const singleProvider = providers.length === 1 ? providers[0] : undefined; if (singleProvider) { return { @@ -61,6 +53,10 @@ export function resolveInitialModelBrowserView({ }; } + if (hasProfiles) { + return { kind: "all" }; + } + if (selectedProvider.length > 0 && selectedModel.length > 0) { const provider = providers.find((entry) => entry.id === selectedProvider); if (provider) { diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index 5fa2e45f7f..49a37bfe09 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -292,9 +292,7 @@ export function useModelBrowser({ setSearchQuery(value); }, []); - // A pinned profiles section makes the root worth returning to, so the back - // affordance stays even when the drill-down was the only provider. - const singleProviderView = providers.length === 1 && !hasProfiles; + const singleProviderView = providers.length === 1; const header = useMemo<SheetHeader>(() => { if (view.kind === "all") { return { From 4149eeb4077778b4045061aabbd053d33dc39e47 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 21:26:48 +0200 Subject: [PATCH 65/73] docs(release): make changelog wording factual --- docs/release.md | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/docs/release.md b/docs/release.md index aa0272c7e4..1556de9493 100644 --- a/docs/release.md +++ b/docs/release.md @@ -467,24 +467,34 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or - **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding. - For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it. -## Changelog voice +## Changelog wording -The changelog is shown on the Paseo homepage. Write it for **end users**, not developers. +The changelog is shown on the Paseo homepage. Each bullet is a compact factual record of +product behavior that changed. -- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts. -- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy. -- **Concrete WRONG → RIGHT examples** (real mistakes from past releases): +- **Name the exact change.** Prefer `Added <capability>`, `Removed <behavior>`, + `Changed <behavior>`, or `Fixed <failure> when <condition>`. +- **Keep the scope exact.** A conditional bug is not a general reliability problem. Do not + broaden one failure into claims that Paseo is now faster, smoother, responsive, or reliable. +- **Use concrete product and runtime terms.** Git polling, persisted cache, provider catalog, + and WebSocket reconnects can identify the affected behavior. Component names, internal + modules, code symbols, and implementation techniques cannot: omit `WorkingIndicator`, + `reconcileAndEmitWorkspaceUpdates`, remounts, memoization, and controlled inputs. +- **State the consequence only when the change itself is unclear.** Keep the condition that + makes the consequence true. Do not replace a precise change with a broad benefit claim. +- **Do not invent context.** Mention an upgrade, platform, workload, or user action only when + the PR or linked issue establishes that scope. - | Wrong (implementation-facing) | Right (user-facing) | - | ----------------------------------------------------------------------------------- | ----------------------------------------------------------- | - | Switching layouts no longer remounts the active agent | Splitting a pane no longer loses your scroll position | - | Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking | Mobile model selector is faster and more straightforward | - | Text inputs in mobile sheets no longer flicker while typing fast | Typing in mobile sheets no longer flickers | - | Compact web sheets no longer crash when swiped to dismiss | Sheets on mobile web no longer crash when swiped to dismiss | - | Reduced re-renders in the agent list | Agent list scrolls smoothly | - | Added debouncing to the search input | Search results no longer lag behind typing | +| Avoid | Write | +| ------------------------------------------------------------ | ----------------------------------------------------- | +| Paseo stays responsive with many idle Git workspaces | Removed periodic Git polling for idle workspaces | +| Incompatible saved app data no longer crashes after upgrades | Fixed crash when persisted cache was incompatible | +| Splitting layouts no longer remounts the active agent | Fixed scroll position resetting when splitting a pane | +| Mobile model selector is faster and more straightforward | Added search to the mobile model selector | - Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences. +Test each bullet against the source PR and issue: can a reviewer point to the exact behavior +that changed, the failure that was fixed, or the capability that was added? If the bullet only +claims a general improvement, rewrite it with the concrete change. - **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**. - **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior. @@ -499,8 +509,7 @@ Every bullet must be scannable at a glance. The changelog is not release documen - **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long. - **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change. - **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it. -- **Lead with what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog. -- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…". +- **Stop after identifying the change.** Do not explain LAN/WAN topology, TLS handshakes, IPC, or other architecture in a changelog bullet. Put necessary background in product docs. - **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets. ## Changelog attribution From 4950d4c2ea188f4ad51211485c9d1ee95b180f21 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 21:27:01 +0200 Subject: [PATCH 66/73] docs(changelog): prepare 0.4.0 --- CHANGELOG.md | 75 ++++++++++++++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bbb664d6c..efe337f7a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,55 +1,54 @@ # Changelog -## 0.4.0-beta.2 - 2026-08-12 - -### Fixed - -- Fix old chat caches making the app refuse to start -- Open delegated agents remain available when their parent is archived ([#3279](https://github.com/getpaseo/paseo/pull/3279)) - -## 0.4.0-beta.1 - 2026-08-12 +## 0.4.0 - 2026-08-13 ### Breaking -- `paseo chat` and `paseo loop` have been removed ([#3053](https://github.com/getpaseo/paseo/pull/3053)) +- Removed `paseo chat` and `paseo loop` ([#3053](https://github.com/getpaseo/paseo/pull/3053)) ### Added -- Save host-wide agent profiles and apply them from the model picker ([#3208](https://github.com/getpaseo/paseo/pull/3208)) -- Search workspace files with Cmd/Ctrl+P ([#3059](https://github.com/getpaseo/paseo/pull/3059)) -- Create, rename, duplicate, delete, reveal, and discard files from Files and Changes ([#3027](https://github.com/getpaseo/paseo/pull/3027) by [@nikuscs](https://github.com/nikuscs)) -- View interactive Mermaid diagrams in chats and Markdown previews ([#2306](https://github.com/getpaseo/paseo/pull/2306) by [@dmeledon](https://github.com/dmeledon)) -- Follow live task progress above the composer and through the timeline ([#3227](https://github.com/getpaseo/paseo/pull/3227)) -- Choose the model used for workspace titles, branch names, commits, and pull request drafts ([#3215](https://github.com/getpaseo/paseo/pull/3215)) -- Deploy Hub workflows from multi-file `.paseo` bundles ([#3128](https://github.com/getpaseo/paseo/pull/3128)) -- Build integrations with the supported `@getpaseo/client` TypeScript SDK ([#3141](https://github.com/getpaseo/paseo/pull/3141)) -- Diagnose provider installations with `paseo provider diagnostic` ([#3243](https://github.com/getpaseo/paseo/pull/3243)) -- Rename workspaces from the CLI ([#3209](https://github.com/getpaseo/paseo/pull/3209) by [@martinhanzik](https://github.com/martinhanzik)) +- Added host-wide agent profiles for reusable provider, model, mode, thinking, and feature settings ([#3208](https://github.com/getpaseo/paseo/pull/3208)) +- Added workspace file search to Cmd/Ctrl+P ([#3059](https://github.com/getpaseo/paseo/pull/3059)) +- Added file and folder actions to Files and Changes ([#3027](https://github.com/getpaseo/paseo/pull/3027) by [@nikuscs](https://github.com/nikuscs)) +- Added interactive Mermaid diagrams to chats and Markdown previews ([#2306](https://github.com/getpaseo/paseo/pull/2306) by [@dmeledon](https://github.com/dmeledon)) +- Added live task progress above the composer and in the timeline ([#3227](https://github.com/getpaseo/paseo/pull/3227)) +- Added a model setting for generated workspace titles, branch names, commits, and pull request drafts ([#3215](https://github.com/getpaseo/paseo/pull/3215)) +- Added the supported `@getpaseo/client` TypeScript SDK ([#3141](https://github.com/getpaseo/paseo/pull/3141)) +- Added provider installation diagnostics to the CLI ([#3243](https://github.com/getpaseo/paseo/pull/3243)) +- Added workspace rename to the CLI ([#3209](https://github.com/getpaseo/paseo/pull/3209) by [@martinhanzik](https://github.com/martinhanzik)) ### Improved -- Markdown file previews use a centered reading layout with formatted YAML front matter ([#3240](https://github.com/getpaseo/paseo/pull/3240)) -- Switch sidebar grouping directly from the Command Center ([#3063](https://github.com/getpaseo/paseo/pull/3063) by [@cleiter](https://github.com/cleiter)) -- Theme shortcuts now cycle through every built-in theme ([#3214](https://github.com/getpaseo/paseo/pull/3214)) -- Newly enrolled Hub hosts use their machine hostname ([#3172](https://github.com/getpaseo/paseo/pull/3172)) +- Added a centered reading layout and formatted YAML front matter to Markdown previews ([#3240](https://github.com/getpaseo/paseo/pull/3240)) +- Added sidebar grouping to the Command Center ([#3063](https://github.com/getpaseo/paseo/pull/3063) by [@cleiter](https://github.com/cleiter)) +- Added every built-in theme to theme shortcuts ([#3214](https://github.com/getpaseo/paseo/pull/3214)) +- Removed periodic Git polling for idle workspaces ([#3323](https://github.com/getpaseo/paseo/pull/3323)) +- Set provider catalog refreshes to a configurable two-minute deadline ([#3322](https://github.com/getpaseo/paseo/pull/3322)) ### Fixed -- Terminal sessions survive host sleep and severe resource stalls ([#3235](https://github.com/getpaseo/paseo/pull/3235), [#3263](https://github.com/getpaseo/paseo/pull/3263)) -- Archiving a workspace no longer risks hanging the daemon ([#3107](https://github.com/getpaseo/paseo/pull/3107)) -- New Workspace no longer crashes on Android when projects span multiple hosts ([#3241](https://github.com/getpaseo/paseo/pull/3241)) -- Provider caching no longer exhausts Android local storage ([#3234](https://github.com/getpaseo/paseo/pull/3234)) -- Dictation recordings survive connection interruptions ([#3159](https://github.com/getpaseo/paseo/pull/3159)) -- Repeated worktree creation always produces a fresh workspace ([#3224](https://github.com/getpaseo/paseo/pull/3224)) -- New worktrees use the setup command currently saved in Project Settings ([#3233](https://github.com/getpaseo/paseo/pull/3233)) -- Removed hosts stop sending push notifications ([#3176](https://github.com/getpaseo/paseo/pull/3176)) -- Delegated work still reports back after permission prompts or workspace closure ([#3177](https://github.com/getpaseo/paseo/pull/3177), [#3192](https://github.com/getpaseo/paseo/pull/3192) by [@thomasvan](https://github.com/thomasvan), [@wilgon456](https://github.com/wilgon456)) -- OMP agents start correctly with large model catalogs ([#3184](https://github.com/getpaseo/paseo/pull/3184) by [@pi3123](https://github.com/pi3123)) -- OpenCode models can return to their default variant ([#3281](https://github.com/getpaseo/paseo/pull/3281)) -- Completed Codex subagents no longer remain marked active ([#3188](https://github.com/getpaseo/paseo/pull/3188) by [@Strainy](https://github.com/Strainy)) -- Codex compaction no longer leaves turns marked as working ([#3211](https://github.com/getpaseo/paseo/pull/3211) by [@edihasaj](https://github.com/edihasaj)) -- Cursor usage works with current Cursor logins without requiring `sqlite3` ([#2704](https://github.com/getpaseo/paseo/pull/2704) by [@QuteSaltyFish](https://github.com/QuteSaltyFish)) -- SVG project icons render correctly on mobile ([#2579](https://github.com/getpaseo/paseo/pull/2579) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed terminal sessions being lost after host sleep or daemon worker stalls ([#3235](https://github.com/getpaseo/paseo/pull/3235), [#3263](https://github.com/getpaseo/paseo/pull/3263)) +- Fixed daemon hang when archiving a workspace ([#3107](https://github.com/getpaseo/paseo/pull/3107)) +- Fixed New Workspace crash on Android when projects span multiple hosts ([#3241](https://github.com/getpaseo/paseo/pull/3241)) +- Fixed provider cache exhausting Android local storage ([#3234](https://github.com/getpaseo/paseo/pull/3234)) +- Fixed dictation recordings being lost during connection interruptions ([#3159](https://github.com/getpaseo/paseo/pull/3159)) +- Fixed new workspaces reusing an existing worktree ([#3224](https://github.com/getpaseo/paseo/pull/3224)) +- Fixed new worktrees ignoring the setup command saved in Project Settings ([#3233](https://github.com/getpaseo/paseo/pull/3233)) +- Fixed new worktrees becoming dirty when the selected base contained a different `paseo.json` ([#3311](https://github.com/getpaseo/paseo/pull/3311)) +- Fixed removed hosts continuing to receive push notifications ([#3176](https://github.com/getpaseo/paseo/pull/3176)) +- Fixed delegated-agent notifications stopping after permission prompts or workspace closure ([#3177](https://github.com/getpaseo/paseo/pull/3177), [#3192](https://github.com/getpaseo/paseo/pull/3192) by [@thomasvan](https://github.com/thomasvan), [@wilgon456](https://github.com/wilgon456)) +- Fixed OMP agent startup when model catalogs exceed the protocol-v1 frame limit ([#3184](https://github.com/getpaseo/paseo/pull/3184) by [@pi3123](https://github.com/pi3123)) +- Fixed OpenCode models being unable to return to their default variant ([#3281](https://github.com/getpaseo/paseo/pull/3281)) +- Fixed completed Codex subagents remaining marked active ([#3188](https://github.com/getpaseo/paseo/pull/3188) by [@Strainy](https://github.com/Strainy)) +- Fixed Codex compaction leaving turns marked as working ([#3211](https://github.com/getpaseo/paseo/pull/3211) by [@edihasaj](https://github.com/edihasaj)) +- Fixed Cursor usage with current Cursor logins ([#2704](https://github.com/getpaseo/paseo/pull/2704) by [@QuteSaltyFish](https://github.com/QuteSaltyFish)) +- Fixed SVG project icons rendering as blank on mobile ([#2579](https://github.com/getpaseo/paseo/pull/2579) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed crash when persisted cache was incompatible ([#3289](https://github.com/getpaseo/paseo/pull/3289)) +- Fixed open delegated agents being archived with their parent ([#3279](https://github.com/getpaseo/paseo/pull/3279)) +- Fixed agent creation when profiles carried modes unsupported by the selected provider ([#3331](https://github.com/getpaseo/paseo/pull/3331)) +- Fixed Claude agents waiting on Paseo's ten-minute MCP timeout ([#3315](https://github.com/getpaseo/paseo/pull/3315)) +- Fixed Copy resume command for Hermes agents ([#3300](https://github.com/getpaseo/paseo/pull/3300) by [@desmond-rai](https://github.com/desmond-rai)) ## 0.3.1 - 2026-08-09 From b44bb63cf4ce089ab5750b9fc621ed52827b2820 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 21:31:47 +0200 Subject: [PATCH 67/73] chore(release): cut 0.4.0 --- package-lock.json | 42 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++--- packages/client/package.json | 6 ++-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 10 +++--- packages/website/package.json | 2 +- 12 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39716fc5a8..f6a403bfed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.4.0-beta.2", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.4.0-beta.2", + "version": "0.4.0", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -36091,7 +36091,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.4.0-beta.2", + "version": "0.4.0", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37139,12 +37139,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.4.0-beta.2", + "version": "0.4.0", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.4.0-beta.2", - "@getpaseo/protocol": "0.4.0-beta.2", - "@getpaseo/server": "0.4.0-beta.2", + "@getpaseo/client": "0.4.0", + "@getpaseo/protocol": "0.4.0", + "@getpaseo/server": "0.4.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -37391,10 +37391,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.4.0-beta.2", + "version": "0.4.0", "dependencies": { - "@getpaseo/protocol": "0.4.0-beta.2", - "@getpaseo/relay": "0.4.0-beta.2", + "@getpaseo/protocol": "0.4.0", + "@getpaseo/relay": "0.4.0", "zod": "^4.4.3" }, "devDependencies": { @@ -37405,7 +37405,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.4.0-beta.2", + "version": "0.4.0", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -37648,7 +37648,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.4.0-beta.2", + "version": "0.4.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -38544,7 +38544,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.4.0-beta.2", + "version": "0.4.0", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -38573,7 +38573,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.4.0-beta.2", + "version": "0.4.0", "dependencies": { "zod": "^4.4.3" }, @@ -38586,7 +38586,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.4.0-beta.2", + "version": "0.4.0", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38804,15 +38804,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.4.0-beta.2", + "version": "0.4.0", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.4.0-beta.2", - "@getpaseo/highlight": "0.4.0-beta.2", - "@getpaseo/protocol": "0.4.0-beta.2", - "@getpaseo/relay": "0.4.0-beta.2", + "@getpaseo/client": "0.4.0", + "@getpaseo/highlight": "0.4.0", + "@getpaseo/protocol": "0.4.0", + "@getpaseo/relay": "0.4.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -39360,7 +39360,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.4.0-beta.2", + "version": "0.4.0", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index a503d49c92..74abe05988 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.4.0-beta.2", + "version": "0.4.0", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index 33aeec069a..b46400887a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.4.0-beta.2", + "version": "0.4.0", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 3277a62533..3e4bea361a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.4.0-beta.2", + "version": "0.4.0", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.4.0-beta.2", - "@getpaseo/protocol": "0.4.0-beta.2", - "@getpaseo/server": "0.4.0-beta.2", + "@getpaseo/client": "0.4.0", + "@getpaseo/protocol": "0.4.0", + "@getpaseo/server": "0.4.0", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index c63f1f08e4..ac42d7ce07 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.4.0-beta.2", + "version": "0.4.0", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.4.0-beta.2", - "@getpaseo/relay": "0.4.0-beta.2", + "@getpaseo/protocol": "0.4.0", + "@getpaseo/relay": "0.4.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 0bb283d300..5fd025bdbd 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.4.0-beta.2", + "version": "0.4.0", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index f3d6aed8e2..0b880ad771 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.4.0-beta.2", + "version": "0.4.0", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 81982b00c2..9a9b8a6ad0 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.4.0-beta.2", + "version": "0.4.0", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 865ff1e6ec..9e9a9f14c5 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.4.0-beta.2", + "version": "0.4.0", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 7f8822a6f0..fc8f9ffd95 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.4.0-beta.2", + "version": "0.4.0", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index c3881672d6..3fc98085c5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.4.0-beta.2", + "version": "0.4.0", "description": "Paseo backend server", "files": [ "dist/server", @@ -71,10 +71,10 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.4.0-beta.2", - "@getpaseo/highlight": "0.4.0-beta.2", - "@getpaseo/protocol": "0.4.0-beta.2", - "@getpaseo/relay": "0.4.0-beta.2", + "@getpaseo/client": "0.4.0", + "@getpaseo/highlight": "0.4.0", + "@getpaseo/protocol": "0.4.0", + "@getpaseo/relay": "0.4.0", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 63e60738e2..78b5bf6a89 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.4.0-beta.2", + "version": "0.4.0", "private": true, "type": "module", "scripts": { From b5610928a585d465c3bb646d715b8ff1640e6add Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:41:15 +0000 Subject: [PATCH 68/73] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index a6ac0caf10..45fa8b1379 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-zO0mNouwY+thgefl2zB/Lk8pIZGh0QowC8klXll2qOc= +sha256-i5PbVUe2Ec+GtghV9IpCJQJ9hcUT5hFhmxneNvoD584= From 599846650cb412a1b3f835239f289ab8ead9f4f4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 23:10:28 +0200 Subject: [PATCH 69/73] 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. --- packages/cli/tests/03-daemon.test.ts | 219 +++++++++++++++++++++------ 1 file changed, 174 insertions(+), 45 deletions(-) diff --git a/packages/cli/tests/03-daemon.test.ts b/packages/cli/tests/03-daemon.test.ts index b5401fb5ba..dcafbb8e83 100644 --- a/packages/cli/tests/03-daemon.test.ts +++ b/packages/cli/tests/03-daemon.test.ts @@ -16,9 +16,13 @@ */ import assert from "node:assert"; -import { mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises"; +import { spawn, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { mkdtemp, readFile, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; import { runLocalPaseo } from "./helpers/local-cli.ts"; console.log("=== Daemon Commands ===\n"); @@ -26,11 +30,82 @@ console.log("=== Daemon Commands ===\n"); // Keep restart off default 6767 to avoid collisions with any existing daemon. const port = 10000 + Math.floor(Math.random() * 50000); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); +const require = createRequire(import.meta.url); function daemonCommand(args: string[]) { return runLocalPaseo(["daemon", ...args], { PASEO_HOME: paseoHome }); } +async function stopChildProcess(child: ChildProcess): Promise<void> { + if (child.exitCode !== null || child.signalCode !== null) return; + + const exited = once(child, "exit"); + child.kill("SIGTERM"); + const forceKill = setTimeout(() => child.kill("SIGKILL"), 5_000); + try { + await exited; + } finally { + clearTimeout(forceKill); + } +} + +function resolveDaemonWorkerEntry(): string { + let currentDir = dirname(require.resolve("@getpaseo/server")); + + while (true) { + const packageJsonPath = join(currentDir, "package.json"); + if (existsSync(packageJsonPath)) { + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + if (packageJson.name === "@getpaseo/server") { + const candidates = [ + join(currentDir, "dist", "server", "server", "daemon-worker.js"), + join(currentDir, "src", "server", "daemon-worker.ts"), + ]; + const workerEntry = candidates.find(existsSync); + if (workerEntry) return workerEntry; + throw new Error(`Daemon worker not found under ${currentDir}`); + } + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + + throw new Error("Unable to resolve @getpaseo/server package root"); +} + +async function tailDaemonLog(): Promise<string> { + try { + const log = await readFile(join(paseoHome, "daemon.log"), "utf-8"); + return log.split("\n").slice(-30).join("\n"); + } catch { + return "<daemon log unavailable>"; + } +} + +async function retryWhileWorkerRuns<T>( + worker: ChildProcess, + probe: () => Promise<T>, + accepts: (result: T) => boolean, + failureDetails: (result: T) => string | Promise<string>, +): Promise<T> { + const deadline = Date.now() + 120_000; + let result = await probe(); + + while (!accepts(result)) { + assert( + worker.exitCode === null && worker.signalCode === null, + "IPC daemon exited before the probe completed", + ); + assert(Date.now() < deadline, await failureDetails(result)); + await new Promise((resolve) => setTimeout(resolve, 100)); + result = await probe(); + } + + return result; +} + try { // Test 1: daemon --help shows subcommands { @@ -132,56 +207,110 @@ try { process.platform === "win32" ? `\\\\.\\pipe\\paseo-status-${process.pid}-${Date.now()}` : join(paseoHome, "status.sock"); - const start = await daemonCommand(["start", "--listen", listen, "--relay"]); - assert.strictEqual(start.exitCode, 0, `IPC daemon should start: ${start.stderr}`); - const configPath = join(paseoHome, "config.json"); const config = JSON.parse(await readFile(configPath, "utf-8")); - config.daemon = { ...config.daemon, listen }; + config.daemon = { + ...config.daemon, + listen, + relay: { ...config.daemon?.relay, enabled: false }, + }; await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); - const pidPath = join(paseoHome, "paseo.pid"); - const pidContents = await readFile(pidPath, "utf-8"); - await unlink(pidPath); - const status = await daemonCommand(["status", "--json"]); - const pairing = await daemonCommand(["pair", "--json"]); - await writeFile(pidPath, pidContents, "utf-8"); - assert.strictEqual(status.exitCode, 0, `IPC daemon status should succeed: ${status.stderr}`); - const payload = JSON.parse(status.stdout); - assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable"); - assert.strictEqual(payload.localDaemon, "stopped", "missing PID should report stopped locally"); - assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state"); - assert.strictEqual(pairing.exitCode, 0, `IPC daemon pairing should succeed: ${pairing.stderr}`); - const pairingPayload = JSON.parse(pairing.stdout); - assert.strictEqual(pairingPayload.relayEnabled, true, "pairing should use live relay state"); - assert.match(pairingPayload.url, /#offer=/, "pairing should use the live daemon offer"); + // A supervised daemon owns and heartbeats paseo.pid. Launch the worker + // directly so this fixture naturally has a reachable daemon without a PID file. + const workerEntry = resolveDaemonWorkerEntry(); + const workerArgs = workerEntry.endsWith(".ts") + ? ["--import", "tsx", workerEntry, "--relay"] + : [workerEntry, "--relay"]; + const worker = spawn(process.execPath, workerArgs, { + cwd: join(import.meta.dirname, ".."), + env: { + ...process.env, + PASEO_HOME: paseoHome, + PASEO_LISTEN: listen, + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", + CI: "true", + }, + stdio: "ignore", + }); - const foreignHome = await mkdtemp(join(tmpdir(), "paseo-test-foreign-home-")); try { - await writeFile( - join(foreignHome, "config.json"), - `${JSON.stringify({ daemon: { listen } }, null, 2)}\n`, - "utf-8", + const relayProbe = await retryWhileWorkerRuns( + worker, + async () => { + const statusResult = await daemonCommand(["status", "--json"]); + const statusPayload = + statusResult.exitCode === 0 ? JSON.parse(statusResult.stdout) : null; + const pairingResult = await daemonCommand(["pair", "--json"]); + return { statusResult, statusPayload, pairingResult }; + }, + (result) => + result.statusPayload?.connectedDaemon === "reachable" && + result.statusPayload.relay !== "disabled" && + result.pairingResult.exitCode === 0, + async (result) => + [ + "IPC daemon did not expose live relay state", + `status: ${result.statusResult.stdout || result.statusResult.stderr}`, + `pairing: ${result.pairingResult.stdout || result.pairingResult.stderr}`, + `daemon log:\n${await tailDaemonLog()}`, + ].join("\n"), ); - const foreignPairing = await runLocalPaseo( - ["daemon", "pair", "--home", foreignHome, "--json"], - { PASEO_HOME: foreignHome }, - ); - assert.notStrictEqual( - foreignPairing.exitCode, - 0, - "pairing should reject a daemon owned by another home", - ); - assert( - foreignPairing.stderr.includes("different Paseo home"), - "pairing should explain the daemon identity mismatch", - ); - assert(!foreignPairing.stdout.includes("#offer="), "pairing should not expose another offer"); - } finally { - await rm(foreignHome, { recursive: true, force: true }); - } + const { statusResult: status, statusPayload: payload, pairingResult: pairing } = relayProbe; - const cleanup = await daemonCommand(["stop", "--force"]); - assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after IPC status"); + assert.strictEqual(status.exitCode, 0, `IPC daemon status should succeed: ${status.stderr}`); + assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable"); + assert.strictEqual( + payload.localDaemon, + "stopped", + "missing PID should report stopped locally", + ); + assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state"); + assert.strictEqual( + pairing.exitCode, + 0, + `IPC daemon pairing should succeed: ${pairing.stderr}`, + ); + const pairingPayload = JSON.parse(pairing.stdout); + assert.strictEqual(pairingPayload.relayEnabled, true, "pairing should use live relay state"); + assert.match(pairingPayload.url, /#offer=/, "pairing should use the live daemon offer"); + + const foreignHome = await mkdtemp(join(tmpdir(), "paseo-test-foreign-home-")); + try { + await writeFile( + join(foreignHome, "config.json"), + `${JSON.stringify({ daemon: { listen } }, null, 2)}\n`, + "utf-8", + ); + const foreignPairing = await retryWhileWorkerRuns( + worker, + () => + runLocalPaseo(["daemon", "pair", "--home", foreignHome, "--json"], { + PASEO_HOME: foreignHome, + }), + (result) => result.stderr.includes("different Paseo home"), + (result) => `Pairing did not report the daemon identity mismatch: ${result.stderr}`, + ); + assert.notStrictEqual( + foreignPairing.exitCode, + 0, + "pairing should reject a daemon owned by another home", + ); + assert( + foreignPairing.stderr.includes("different Paseo home"), + "pairing should explain the daemon identity mismatch", + ); + assert( + !foreignPairing.stdout.includes("#offer="), + "pairing should not expose another offer", + ); + } finally { + await rm(foreignHome, { recursive: true, force: true }); + } + } finally { + await stopChildProcess(worker); + } console.log("✓ daemon status probes live relay state over local IPC\n"); } From 373a98c64d62ea6a049d99a35dedeaef451137c7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 23:10:44 +0200 Subject: [PATCH 70/73] 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 --- docs/architecture.md | 23 +- packages/app/app.config.js | 2 +- .../browser/rewind-menu.ui-contract.spec.ts | 112 ++++----- .../e2e/support/helpers/replica-cache-perf.ts | 62 ++--- .../support/helpers/replica-cache-storage.ts | 150 ++++++++++++ .../support/helpers/timeline-pagination.ts | 24 +- .../src/android-async-storage-config.test.ts | 6 +- .../app/src/components/project-edit-sheet.tsx | 2 - .../src/hooks/use-sidebar-workspaces-list.ts | 8 +- packages/app/src/projects/icon-cache.test.ts | 68 ++++++ packages/app/src/projects/icon-cache.ts | 217 +++++++++++++++++ packages/app/src/projects/icons.test.ts | 4 + packages/app/src/projects/icons.ts | 46 +--- .../app/src/projects/workspace-structure.ts | 2 + .../directory-sync/agent-replica.test.ts | 25 ++ .../runtime/directory-sync/agent-replica.ts | 27 ++- .../src/runtime/directory-sync/index.test.ts | 134 ++++++++++- .../app/src/runtime/directory-sync/index.ts | 225 +++++++++++++++--- .../directory-sync/workspace-replica.ts | 3 + packages/app/src/runtime/host-features.ts | 19 ++ packages/app/src/runtime/host-runtime.ts | 58 +++-- .../src/runtime/replica-cache/index.test.ts | 131 +++++++--- .../app/src/runtime/replica-cache/index.ts | 118 ++++++--- .../app/src/runtime/replica-cache/storage.ts | 4 + .../src/runtime/replica-cache/storage.web.ts | 70 ++++++ .../src/stores/session-store-hooks/index.ts | 9 + .../session-store-hooks/selectors.test.ts | 25 +- .../stores/session-store-hooks/selectors.ts | 13 + packages/app/src/stores/session-store.ts | 5 + .../app/src/utils/agent-snapshots.test.ts | 14 +- packages/app/src/utils/agent-snapshots.ts | 43 ++++ .../src/utils/sidebar-project-row-model.ts | 3 + packages/client/src/daemon-client.ts | 10 +- packages/protocol/src/messages.test.ts | 23 ++ packages/protocol/src/messages.ts | 63 ++++- .../protocol/src/messages.workspaces.test.ts | 53 +++++ .../src/server/directory-sync/index.test.ts | 92 +++++++ .../server/src/server/directory-sync/index.ts | 174 ++++++++++++++ .../internal/versioned-collection.test.ts | 77 ++++++ .../internal/versioned-collection.ts | 141 +++++++++++ packages/server/src/server/session.test.ts | 6 +- packages/server/src/server/session.ts | 148 +++++++++--- .../src/server/session.workspaces.test.ts | 57 ++++- .../agent-updates-service.test.ts | 9 + .../agent-updates/agent-updates-service.ts | 111 ++++++--- .../session/checkout/checkout-session.test.ts | 10 + .../session/checkout/checkout-session.ts | 30 ++- .../server/src/server/websocket-server.ts | 11 +- .../server/src/server/wire-compat.test.ts | 60 ++++- .../src/utils/project-custom-icon.test.ts | 40 ++++ .../server/src/utils/project-custom-icon.ts | 61 ++++- 51 files changed, 2428 insertions(+), 400 deletions(-) create mode 100644 packages/app/e2e/support/helpers/replica-cache-storage.ts create mode 100644 packages/app/src/projects/icon-cache.test.ts create mode 100644 packages/app/src/projects/icon-cache.ts create mode 100644 packages/app/src/runtime/replica-cache/storage.ts create mode 100644 packages/app/src/runtime/replica-cache/storage.web.ts create mode 100644 packages/server/src/server/directory-sync/index.test.ts create mode 100644 packages/server/src/server/directory-sync/index.ts create mode 100644 packages/server/src/server/directory-sync/internal/versioned-collection.test.ts create mode 100644 packages/server/src/server/directory-sync/internal/versioned-collection.ts diff --git a/docs/architecture.md b/docs/architecture.md index 4dcf7a676a..2946da8b40 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,6 +69,7 @@ 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 | @@ -97,19 +98,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 diff --git a/packages/app/app.config.js b/packages/app/app.config.js index fef9d0e32c..db7120882a 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -139,7 +139,7 @@ export default { }, plugins: [ "expo-router", - [withAndroidAsyncStorageSize, 10], + [withAndroidAsyncStorageSize, 64], ...buildProfile.cameraPlugins, [ "expo-splash-screen", diff --git a/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts b/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts index e620b4d0fb..ab1ce4e199 100644 --- a/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts +++ b/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts @@ -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(); } diff --git a/packages/app/e2e/support/helpers/replica-cache-perf.ts b/packages/app/e2e/support/helpers/replica-cache-perf.ts index 834a694751..5b48b02b9c 100644 --- a/packages/app/e2e/support/helpers/replica-cache-perf.ts +++ b/packages/app/e2e/support/helpers/replica-cache-perf.ts @@ -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 }; } diff --git a/packages/app/e2e/support/helpers/replica-cache-storage.ts b/packages/app/e2e/support/helpers/replica-cache-storage.ts new file mode 100644 index 0000000000..8d42be348d --- /dev/null +++ b/packages/app/e2e/support/helpers/replica-cache-storage.ts @@ -0,0 +1,150 @@ +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 { + hosts?: Array<{ + timeline?: { + agentId?: string; + items?: Array<Record<string, unknown>>; + } | 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; + }); +} diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index 8acdcac3b6..f7c9722955 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -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, @@ -284,22 +285,13 @@ 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(); diff --git a/packages/app/src/android-async-storage-config.test.ts b/packages/app/src/android-async-storage-config.test.ts index 03091afbbd..fa49bbf83d 100644 --- a/packages/app/src/android-async-storage-config.test.ts +++ b/packages/app/src/android-async-storage-config.test.ts @@ -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" }, ]); }); }); diff --git a/packages/app/src/components/project-edit-sheet.tsx b/packages/app/src/components/project-edit-sheet.tsx index 65801dee2f..a2790cb432 100644 --- a/packages/app/src/components/project-edit-sheet.tsx +++ b/packages/app/src/components/project-edit-sheet.tsx @@ -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(); }, diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 38e9d84ffb..279d838eab 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -2,7 +2,10 @@ import { useCallback, useEffect, useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore } from "@/stores/session-store"; -import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks"; +import { + useHydratedWorkspaceServerIds, + useWorkspaceDirectoryServerIds, +} from "@/stores/session-store-hooks"; import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors"; import { useHostProjects } from "@/projects/host-projects"; import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime"; @@ -132,8 +135,9 @@ export function useSidebarWorkspacesList(options?: { const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER); const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds); + const directoryServerIds = useWorkspaceDirectoryServerIds(serverIds); - const hostProjects = useHostProjects(hydratedServerIds); + const hostProjects = useHostProjects(directoryServerIds); const sidebarModel = useMemo( () => diff --git a/packages/app/src/projects/icon-cache.test.ts b/packages/app/src/projects/icon-cache.test.ts new file mode 100644 index 0000000000..92df4c4099 --- /dev/null +++ b/packages/app/src/projects/icon-cache.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { ProjectIconCache, type ProjectIconCacheStorage } from "./icon-cache"; + +class MemoryStorage implements ProjectIconCacheStorage { + readonly values = new Map<string, string>(); + async getItem(key: string): Promise<string | null> { + return this.values.get(key) ?? null; + } + async setItem(key: string, value: string): Promise<void> { + this.values.set(key, value); + } +} + +const target = { + serverId: "host-a", + projectId: "project-a", + iconWorkingDir: "/project-a", + iconRevision: "revision-a", +}; +const icon = { data: "aWNvbg==", mimeType: "image/png" }; + +function clientWith(iconResult: typeof icon | null) { + return { + getProjectIcon: async () => ({ requestId: "icon", icon: iconResult }), + } as unknown as DaemonClient; +} + +describe("ProjectIconCache", () => { + it("resolves, persists, and hydrates an icon before a host connects", async () => { + const storage = new MemoryStorage(); + const writer = new ProjectIconCache(storage); + writer.setHosts([target.serverId]); + await writer.query(target, true, () => clientWith(icon), true).queryFn(); + await writer.flush(); + + const reader = new ProjectIconCache(storage); + reader.setHosts([target.serverId]); + await reader.restore(); + + expect(reader.query(target, true, () => null, false).initialData).toEqual(icon); + }); + + it("persists an explicit no-icon result", async () => { + const storage = new MemoryStorage(); + const writer = new ProjectIconCache(storage); + writer.setHosts([target.serverId]); + await writer.query(target, true, () => clientWith(null), true).queryFn(); + await writer.flush(); + + const reader = new ProjectIconCache(storage); + reader.setHosts([target.serverId]); + await reader.restore(); + expect(reader.query(target, true, () => null, false)).toHaveProperty("initialData", null); + }); + + it("misses only the project whose revision changed", async () => { + const cache = new ProjectIconCache(new MemoryStorage()); + const other = { ...target, projectId: "project-b" }; + await cache.query(target, true, () => clientWith(icon), true).queryFn(); + await cache.query(other, true, () => clientWith(null), true).queryFn(); + + expect( + cache.query({ ...target, iconRevision: "revision-b" }, true, () => null, false), + ).not.toHaveProperty("initialData"); + expect(cache.query(other, true, () => null, false)).toHaveProperty("initialData", null); + }); +}); diff --git a/packages/app/src/projects/icon-cache.ts b/packages/app/src/projects/icon-cache.ts new file mode 100644 index 0000000000..93761708cf --- /dev/null +++ b/packages/app/src/projects/icon-cache.ts @@ -0,0 +1,217 @@ +import type { ProjectIcon } from "@getpaseo/protocol/messages"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { replicaCacheStorage } from "@/runtime/replica-cache/storage"; + +const STORAGE_KEY = "@paseo:project-icon-cache"; +const CACHE_VERSION = 1; +const PERSIST_DELAY_MS = 250; +const MAX_ENTRIES = 512; + +export interface ProjectIconCacheStorage { + getItem(key: string): Promise<string | null>; + setItem(key: string, value: string): Promise<void>; +} + +interface ProjectIconIdentity { + serverId: string; + projectId: string; + revision: string; +} + +type ProjectIconCacheRead = { hit: true; icon: ProjectIcon | null } | { hit: false }; + +interface ProjectIconTarget { + serverId: string; + projectId: string; + iconWorkingDir: string; + customIconRevision?: string | null; + iconRevision?: string; +} + +interface StoredIcon extends ProjectIconIdentity { + icon: ProjectIcon | null; +} + +function keyOf(identity: ProjectIconIdentity): string { + return JSON.stringify([identity.serverId, identity.projectId, identity.revision]); +} + +function parseStoredIcon(value: unknown): StoredIcon | null { + if (!value || typeof value !== "object") return null; + const entry = value as Record<string, unknown>; + if ( + typeof entry.serverId !== "string" || + typeof entry.projectId !== "string" || + typeof entry.revision !== "string" + ) { + return null; + } + if (entry.icon === null) { + return { + serverId: entry.serverId, + projectId: entry.projectId, + revision: entry.revision, + icon: null, + }; + } + if (!entry.icon || typeof entry.icon !== "object") return null; + const icon = entry.icon as Record<string, unknown>; + if (typeof icon.data !== "string" || typeof icon.mimeType !== "string") return null; + return { + serverId: entry.serverId, + projectId: entry.projectId, + revision: entry.revision, + icon: { data: icon.data, mimeType: icon.mimeType }, + }; +} + +/** Owns project-icon persistence, revision matching, and negative results. */ +export class ProjectIconCache { + private readonly entries = new Map<string, StoredIcon>(); + private activeServerIds = new Set<string>(); + private persistTimer: ReturnType<typeof setTimeout> | null = null; + private writeQueue: Promise<void> = Promise.resolve(); + + constructor(private readonly storage: ProjectIconCacheStorage) {} + + setHosts(serverIds: Iterable<string>): void { + this.activeServerIds = new Set(serverIds); + let changed = false; + for (const [key, entry] of this.entries) { + if (!this.activeServerIds.has(entry.serverId)) { + this.entries.delete(key); + changed = true; + } + } + if (changed) this.schedulePersist(); + } + + async restore(): Promise<void> { + let raw: string | null; + try { + raw = await this.storage.getItem(STORAGE_KEY); + } catch { + return; + } + if (!raw) return; + try { + const parsed = JSON.parse(raw) as { version?: unknown; entries?: unknown }; + if (parsed.version !== CACHE_VERSION || !Array.isArray(parsed.entries)) return; + for (const value of parsed.entries) { + const entry = parseStoredIcon(value); + if (entry && this.activeServerIds.has(entry.serverId)) { + this.entries.set(keyOf(entry), entry); + } + } + } catch { + // A corrupt cache is disposable. + } + } + + private read(identity: ProjectIconIdentity): ProjectIconCacheRead { + const entry = this.entries.get(keyOf(identity)); + return entry ? { hit: true, icon: entry.icon } : { hit: false }; + } + + private write(identity: ProjectIconIdentity, icon: ProjectIcon | null): void { + const key = keyOf(identity); + this.entries.delete(key); + this.entries.set(key, { ...identity, icon }); + while (this.entries.size > MAX_ENTRIES) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + this.schedulePersist(); + } + + query( + target: ProjectIconTarget, + supportsCustomIcons: boolean | null, + getClient: () => DaemonClient | null, + connected: boolean, + ) { + // COMPAT(projectIconCache): daemons before v0.2.7 do not expose an effective revision, so + // their icon results remain memory-only. + const revision = target.iconRevision ?? target.customIconRevision ?? "automatic"; + const identity: ProjectIconIdentity | null = target.iconRevision + ? { serverId: target.serverId, projectId: target.projectId, revision: target.iconRevision } + : null; + const cached = identity ? this.read(identity) : { hit: false as const }; + const lookup = resolveLookup(target, supportsCustomIcons); + let queryKey: readonly string[]; + if (!lookup) { + queryKey = ["projectIcon", target.serverId, "pending", target.projectId]; + } else if (lookup.kind === "project") { + queryKey = ["projectIcon", target.serverId, target.projectId, revision]; + } else { + queryKey = ["projectIcon", target.serverId, "legacy", lookup.cwd]; + } + return { + queryKey, + queryFn: async () => { + if (!lookup) return null; + const client = getClient(); + if (!client) return null; + const result = + lookup.kind === "project" + ? await client.getProjectIcon(lookup.projectId) + : await client.requestProjectIcon(lookup.cwd); + if (result.error) throw new Error(result.error); + if (identity) this.write(identity, result.icon); + return result.icon; + }, + enabled: Boolean(lookup && getClient() && connected), + staleTime: Infinity, + gcTime: 1000 * 60 * 60, + refetchOnMount: false as const, + refetchOnWindowFocus: false as const, + refetchOnReconnect: false as const, + ...(cached.hit ? { initialData: cached.icon } : {}), + }; + } + + reconcileServerId(oldServerId: string, newServerId: string): void { + for (const [key, entry] of this.entries) { + if (entry.serverId !== oldServerId) continue; + this.entries.delete(key); + const reconciled = { ...entry, serverId: newServerId }; + this.entries.set(keyOf(reconciled), reconciled); + } + if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId); + this.schedulePersist(); + } + + async flush(): Promise<void> { + if (this.persistTimer) { + clearTimeout(this.persistTimer); + this.persistTimer = null; + } + const payload = JSON.stringify({ version: CACHE_VERSION, entries: [...this.entries.values()] }); + const write = this.writeQueue + .catch(() => undefined) + .then(() => this.storage.setItem(STORAGE_KEY, payload)); + this.writeQueue = write; + await write.catch(() => undefined); + } + + private schedulePersist(): void { + if (this.persistTimer) return; + this.persistTimer = setTimeout(() => { + this.persistTimer = null; + void this.flush(); + }, PERSIST_DELAY_MS); + } +} + +export const projectIconCache = new ProjectIconCache(replicaCacheStorage); + +function resolveLookup( + target: Pick<ProjectIconTarget, "projectId" | "iconWorkingDir">, + supportsCustomIcons: boolean | null, +): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } | null { + if (supportsCustomIcons === null) return null; + return supportsCustomIcons + ? { kind: "project", projectId: target.projectId } + : { kind: "legacy", cwd: target.iconWorkingDir }; +} diff --git a/packages/app/src/projects/icons.test.ts b/packages/app/src/projects/icons.test.ts index 48b99df4a4..e13b468cb5 100644 --- a/packages/app/src/projects/icons.test.ts +++ b/packages/app/src/projects/icons.test.ts @@ -20,4 +20,8 @@ describe("project icon lookup", () => { cwd: "/projects/paseo", }); }); + + it("waits while custom-icon capability is unknown", () => { + expect(resolveProjectIconLookup(target, null)).toBeNull(); + }); }); diff --git a/packages/app/src/projects/icons.ts b/packages/app/src/projects/icons.ts index f94fdc0f41..92bef909a6 100644 --- a/packages/app/src/projects/icons.ts +++ b/packages/app/src/projects/icons.ts @@ -2,7 +2,8 @@ import { useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useQueries, useQuery } from "@tanstack/react-query"; import type { ProjectIcon } from "@getpaseo/protocol/messages"; -import { useHostFeatureMap } from "@/runtime/host-features"; +import { useHostFeatureAvailabilityMap } from "@/runtime/host-features"; +import { projectIconCache } from "@/projects/icon-cache"; import { getHostRuntimeStore, isHostRuntimeConnected, @@ -16,6 +17,7 @@ interface ProjectIconTarget { projectId: string; iconWorkingDir: string; customIconRevision?: string | null; + iconRevision?: string; } /** @@ -24,8 +26,9 @@ interface ProjectIconTarget { */ export function resolveProjectIconLookup( target: Pick<ProjectIconTarget, "projectId" | "iconWorkingDir">, - supportsCustomIcons: boolean, -): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } { + supportsCustomIcons: boolean | null, +): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } | null { + if (supportsCustomIcons === null) return null; return supportsCustomIcons ? { kind: "project", projectId: target.projectId } : { kind: "legacy", cwd: target.iconWorkingDir }; @@ -35,10 +38,6 @@ function legacyIconQueryKey(serverId: string, cwd: string) { return ["projectIcon", serverId, "legacy", cwd] as const; } -function iconQueryKey(serverId: string, projectId: string, revision: string) { - return ["projectIcon", serverId, projectId, revision] as const; -} - function iconDataUri(icon: ProjectIcon | null): string | null { if (!icon) return null; return `data:${icon.mimeType};base64,${icon.data}`; @@ -88,7 +87,7 @@ export function useProjectIcons(input: { () => [...new Set(input.projects.map((project) => project.serverId))], [input.projects], ); - const supportsCustomIcons = useHostFeatureMap(serverIds, "projectCustomIcon"); + const supportsCustomIcons = useHostFeatureAvailabilityMap(serverIds, "projectCustomIcon"); const requests = useMemo(() => { const unique = new Map<string, ProjectIconTarget>(); for (const project of input.projects) { @@ -100,35 +99,14 @@ export function useProjectIcons(input: { const queries = useQueries({ queries: requests.map((request) => { - const revision = request.customIconRevision ?? "automatic"; - const lookup = resolveProjectIconLookup( - request, - supportsCustomIcons.get(request.serverId) === true, - ); return { - queryKey: - lookup.kind === "project" - ? iconQueryKey(request.serverId, lookup.projectId, revision) - : legacyIconQueryKey(request.serverId, lookup.cwd), - queryFn: async () => { - const client = getHostRuntimeStore().getClient(request.serverId); - if (!client) return null; - const result = - lookup.kind === "project" - ? await client.getProjectIcon(lookup.projectId) - : await client.requestProjectIcon(lookup.cwd); - return result.icon; - }, - select: iconDataUri, - enabled: Boolean( - getHostRuntimeStore().getClient(request.serverId) && + ...projectIconCache.query( + request, + supportsCustomIcons.get(request.serverId) ?? null, + () => getHostRuntimeStore().getClient(request.serverId), isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)), ), - staleTime: Infinity, - gcTime: 1000 * 60 * 60, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, + select: iconDataUri, }; }), }); diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index 6a70f21fb5..939e1381d2 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -7,6 +7,7 @@ export interface WorkspaceStructureHostPlacement { iconWorkingDir: string; worktreeSupport: "supported" | "unsupported" | "unknown"; customIconRevision?: string | null; + iconRevision?: string; } export interface WorkspaceStructureProject { @@ -160,6 +161,7 @@ function addProjectToView(input: { iconWorkingDir: project.projectRootPath, worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported", customIconRevision: project.projectCustomIconRevision, + iconRevision: project.projectIconRevision, }; const draft = byProject.get(viewKey); if (!draft) { diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index 4456ecc0a8..d798282aa8 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -51,6 +51,31 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { } describe("AgentDirectoryReplica", () => { + it("preserves an unchanged running agent's turn identity during catch-up", () => { + const serverId = "agent-replica-catch-up"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica(serverId, () => undefined); + replica.commitSnapshot( + [ + entry({ + ...payload("running"), + status: "running", + activeTurn: { turnId: "turn-1", startedAt: "2026-07-17T00:01:00.000Z" }, + }), + ], + [], + ); + + replica.commitChanges([], [], []); + + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.activeTurn).toEqual({ + turnId: "turn-1", + startedAt: new Date("2026-07-17T00:01:00.000Z"), + }); + store.clearSession(serverId); + }); + it("keeps membership authoritative across remove, stale timeline, and re-add", () => { const serverId = "agent-replica"; const store = useSessionStore.getState(); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index 62de843cdc..7f90998e5b 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -3,7 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; import { queryClient } from "@/data/query-client"; import { useSessionStore, type Agent } from "@/stores/session-store"; -import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; import { applyAgentDirectoryDelta, type AgentDirectoryDelta, @@ -15,6 +15,12 @@ import { import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; +function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null { + return agent.projectPlacement + ? { agent: projectAgentSnapshot(agent), project: agent.projectPlacement } + : null; +} + export interface AgentLifecycleToken { readonly agentId: string; readonly version: number; @@ -97,6 +103,25 @@ export class AgentDirectoryReplica { return agents; } + commitChanges( + entries: FetchAgentsEntry[], + removals: readonly { id: string }[], + deltas: readonly AgentDirectoryDelta[], + ): Map<string, Agent> { + const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); + const merged = new Map<string, FetchAgentsEntry>(); + for (const agent of previous.values()) { + const entry = projectAgentDirectoryEntry(agent); + if (entry) merged.set(agent.id, entry); + } + for (const entry of entries) merged.set(entry.agent.id, entry); + const removalsAsDeltas: AgentDirectoryDelta[] = removals.map(({ id }) => ({ + kind: "remove", + agentId: id, + })); + return this.commitSnapshot(Array.from(merged.values()), [...removalsAsDeltas, ...deltas]); + } + archive(agentId: string, archivedAt: string): void { this.advance(agentId); useSessionStore.getState().setAgents(this.serverId, (current) => { diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index fc9368fae0..9725170d9f 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { useSessionStore } from "@/stores/session-store"; +import { normalizeProjectDescriptor, useSessionStore } from "@/stores/session-store"; import { DirectoryRefreshSupersededError, DirectorySync } from "./index"; type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>; @@ -9,8 +9,11 @@ type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>; class FakeDirectoryClient { fetchAgentsCalls = 0; + lastAgentOptions: unknown; fetchWorkspacesCalls = 0; listProjectsCalls = 0; + lastProjectOptions: unknown; + projectResult: ProjectListResult | null = null; private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null; private readonly handlers = new Map< SessionOutboundMessage["type"], @@ -42,8 +45,9 @@ class FakeDirectoryClient { return complete; } - async fetchAgents(): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> { + async fetchAgents(options?: unknown): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> { this.fetchAgentsCalls += 1; + this.lastAgentOptions = options; return { requestId: "agents", entries: [], @@ -66,8 +70,10 @@ class FakeDirectoryClient { }; } - async listProjects(): Promise<ProjectListResult> { + async listProjects(options?: unknown): Promise<ProjectListResult> { this.listProjectsCalls += 1; + this.lastProjectOptions = options; + if (this.projectResult) return this.projectResult; return { requestId: "projects", projects: [ @@ -111,6 +117,52 @@ afterEach(() => { }); describe("DirectorySync session readiness", () => { + it("uses the saved agent sequence while bounding the bootstrap page", async () => { + const serverId = "agent-list-sequence-page"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readDirectoryCheckpoint: () => ({ agents: { generation: "generation", afterSeq: 12 } }), + writeDirectoryCheckpoint: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { directorySync: true, workspaceMultiplicity: true }, + }); + + await directory.refreshAgents({ + subscribe: { subscriptionId: `app:${serverId}` }, + page: { limit: 200 }, + }); + + expect(client.lastAgentOptions).toEqual({ + scope: "active", + sort: [{ key: "updated_at", direction: "desc" }], + subscribe: { subscriptionId: `app:${serverId}` }, + page: { limit: 200 }, + sync: { generation: "generation", afterSeq: 12 }, + }); + directory.dispose(); + }); + it("waits for workspace capability metadata before choosing the workspace protocol", async () => { const serverId = "workspace-metadata"; const { client, directory } = createDirectory(serverId); @@ -159,6 +211,82 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it("merges project changes from the existing list RPC and advances its cursor", async () => { + const serverId = "project-list-sequence"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const writes: unknown[] = []; + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readDirectoryCheckpoint: () => ({ projects: { generation: "generation", afterSeq: 4 } }), + writeDirectoryCheckpoint: (_serverId, checkpoint) => writes.push(checkpoint), + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.setProjects(serverId, [ + normalizeProjectDescriptor({ + projectId: "project-1", + projectDisplayName: "Old name", + projectRootPath: "/repo/one", + projectKind: "git", + }), + normalizeProjectDescriptor({ + projectId: "project-2", + projectDisplayName: "Removed", + projectRootPath: "/repo/two", + projectKind: "git", + }), + ]); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, projectList: true, directorySync: true }, + }); + client.projectResult = { + requestId: "projects", + projects: [ + { + projectId: "project-1", + projectDisplayName: "New name", + projectRootPath: "/repo/one", + projectKind: "git", + syncSeq: 5, + }, + ], + sync: { + generation: "generation", + headSeq: 6, + mode: "changes", + removals: [{ id: "project-2", seq: 6 }], + }, + }; + + await directory.refreshWorkspaces(); + + expect(client.lastProjectOptions).toEqual({ + sync: { generation: "generation", afterSeq: 4 }, + }); + const projects = useSessionStore.getState().sessions[serverId]?.projects; + expect(Array.from(projects?.keys() ?? [])).toEqual(["project-1"]); + expect(projects?.get("project-1")?.projectDisplayName).toBe("New name"); + expect(writes).toContainEqual({ projects: { generation: "generation", afterSeq: 6 } }); + directory.dispose(); + }); + it("rejects a session wait on disconnect so the reconnect can refresh", async () => { const serverId = "session-wait-reconnect"; const { client, directory } = createDirectory(serverId); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index bfb2f93a6b..ac80a81bd9 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -36,10 +36,41 @@ const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [ { key: "updated_at", direction: "desc" }, ]; +function resolveAgentNextPage(pageInfo: AgentPageInfo): { + hasMore: boolean; + nextCursor: string | null; +} { + return { + hasMore: pageInfo.hasMore ?? pageInfo.hasMoreAfter ?? false, + nextCursor: pageInfo.nextCursor ?? pageInfo.afterCursor ?? null, + }; +} + interface AgentSnapshot { entries: FetchAgentsEntry[]; subscriptionId: string | null; legacy: boolean; + syncMode?: "snapshot" | "changes"; + syncCursor?: DirectoryCursor; + syncRemovals: Array<{ id: string; seq: number }>; +} + +interface AgentPageInfo { + hasMore?: boolean; + hasMoreAfter?: boolean; + nextCursor?: string | null; + afterCursor?: string | null; +} + +interface DirectoryCursor { + generation: string; + afterSeq: number; +} + +interface DirectoryCursors { + projects?: DirectoryCursor; + workspaces?: DirectoryCursor; + agents?: DirectoryCursor; } export interface DirectoryConnection { @@ -48,6 +79,11 @@ export interface DirectoryConnection { source: DirectorySourceToken; } +export interface DirectoryCheckpointStorage { + readDirectoryCheckpoint(serverId: string): unknown; + writeDirectoryCheckpoint(serverId: string, checkpoint: unknown): void; +} + export interface RefreshAgentDirectoryInput { filter?: FetchAgentsOptions["filter"]; subscribe?: FetchAgentsOptions["subscribe"]; @@ -86,6 +122,7 @@ export class DirectorySync { markAgentReady: () => void; markAgentError: (error: string) => void; }, + private readonly checkpoints?: DirectoryCheckpointStorage, ) { this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning); this.workspaces = new WorkspaceDirectoryReplica(serverId); @@ -116,19 +153,26 @@ export class DirectorySync { const subscriptions = [ client.on("agent_update", (message) => { if (message.type !== "agent_update" || !this.isCurrent(client, source)) return; - if (!this.agentTransactions.record(source, message.payload)) + const recorded = this.agentTransactions.record(source, message.payload); + if (!recorded) { this.agents.applyDelta(message.payload); + this.noteLiveCursor("agents", message.payload); + } }), client.on("workspace_update", (message) => { if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return; - if (!this.workspaceTransactions.record(source, message.payload)) { + const recorded = this.workspaceTransactions.record(source, message.payload); + if (!recorded) { this.workspaces.applyDelta(message.payload); + this.noteLiveCursor("workspaces", message.payload); } }), client.on("project.update", (message) => { if (message.type !== "project.update" || !this.isCurrent(client, source)) return; - if (!this.workspaceTransactions.record(source, message.payload)) { + const recorded = this.workspaceTransactions.record(source, message.payload); + if (!recorded) { this.workspaces.applyDelta(message.payload); + this.noteLiveCursor("projects", message.payload); } }), client.on("agent_deleted", (message) => { @@ -174,6 +218,7 @@ export class DirectorySync { entries: [], subscriptionId: null, legacy: false, + syncRemovals: [], })); this.callbacks.markAgentLoading(); try { @@ -222,7 +267,8 @@ export class DirectorySync { : delta, ) : completion.deltas; - const agents = this.agents.commitSnapshot(completion.snapshot.entries, deltas); + const agents = this.commitAgentSnapshot(completion.snapshot, deltas); + this.persistAgentCursors(completion.snapshot, completion.deltas); this.callbacks.markAgentReady(); return { agents, subscriptionId: completion.snapshot.subscriptionId }; } catch (error) { @@ -238,8 +284,9 @@ export class DirectorySync { async refreshWorkspaces(input?: { subscribe?: boolean }): Promise<void> { const { client, source } = this.requireOnline(); const transaction = this.workspaceTransactions.begin(source, () => ({ - workspaces: new Map(), - projects: new Map(), + workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces), + projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects), + syncCursors: {}, })); try { await this.waitForSessionMetadata(client, source); @@ -250,24 +297,19 @@ export class DirectorySync { return; } const supportsProjectList = serverInfo.features?.projectList === true; + const supportsDirectorySync = serverInfo.features?.directorySync === true; if (supportsProjectList) { - const payload = await client.listProjects(); - this.assertWorkspaceTransactionCurrent(client, source, transaction); - for (const entry of payload.projects) { - const project = normalizeProjectDescriptor(entry); - transaction.snapshot.projects.set(project.projectId, project); - } + await this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync); } - await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true); + await this.fetchWorkspaceSnapshot( + client, + source, + transaction, + input?.subscribe === true, + supportsDirectorySync, + ); if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot); - if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) { - throw new DirectoryRefreshSupersededError("workspace completion no longer current"); - } - const completion = this.workspaceTransactions.complete(transaction); - if (completion.kind === "stale") { - throw new DirectoryRefreshSupersededError("workspace completion was superseded"); - } - this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); + this.completeWorkspaceRefresh(client, source, transaction); } catch (error) { const deltas = this.workspaceTransactions.fail(transaction); if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); @@ -280,6 +322,7 @@ export class DirectorySync { source: DirectorySourceToken, transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>, initialSubscribe: boolean, + supportsDirectorySync: boolean, ): Promise<void> { let cursor: string | null = null; let subscribe = initialSubscribe; @@ -288,12 +331,23 @@ export class DirectorySync { sort: [{ key: "activity_at", direction: "desc" }], ...(subscribe ? { subscribe: {} } : {}), page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT }, + ...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}), }); this.assertWorkspaceTransactionCurrent(client, source, transaction); + if (payload.sync?.mode === "snapshot") transaction.snapshot.workspaces.clear(); for (const entry of payload.entries) { const workspace = normalizeWorkspaceDescriptor(entry); transaction.snapshot.workspaces.set(workspace.id, workspace); } + for (const removal of payload.sync?.removals ?? []) { + transaction.snapshot.workspaces.delete(removal.id); + } + if (payload.sync) { + (transaction.snapshot.syncCursors ??= {}).workspaces = { + generation: payload.sync.generation, + afterSeq: payload.sync.headSeq, + }; + } for (const entry of payload.emptyProjects ?? []) { const project = normalizeProjectDescriptor(entry); transaction.snapshot.projects.set(project.projectId, project); @@ -332,18 +386,15 @@ export class DirectorySync { sort: AGENT_SORT, ...(subscribe ? { subscribe } : {}), page: cursor ? { limit, cursor } : { limit }, + ...(!input.filter && cursor === null && this.supportsDirectorySync() + ? { sync: this.readCursors().agents ?? {} } + : {}), }); this.assertAgentTransactionCurrent(client, source, transaction); transaction.snapshot.entries.push(...payload.entries); transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null; - const pageInfo = payload.pageInfo as { - hasMore?: boolean; - hasMoreAfter?: boolean; - nextCursor?: string | null; - afterCursor?: string | null; - }; - const hasMore = pageInfo.hasMore ?? pageInfo.hasMoreAfter ?? false; - const nextCursor = pageInfo.nextCursor ?? pageInfo.afterCursor ?? null; + this.recordAgentSync(transaction.snapshot, payload.sync); + const { hasMore, nextCursor } = resolveAgentNextPage(payload.pageInfo as AgentPageInfo); if (!hasMore || !nextCursor) break; cursor = nextCursor; subscribe = undefined; @@ -377,6 +428,122 @@ export class DirectorySync { return { client: this.connection.client, source: this.connection.source }; } + private supportsDirectorySync(): boolean { + return ( + useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync === + true + ); + } + + private commitAgentSnapshot( + snapshot: AgentSnapshot, + deltas: readonly AgentDirectoryDelta[], + ): Map<string, Agent> { + return snapshot.syncMode === "changes" + ? this.agents.commitChanges(snapshot.entries, snapshot.syncRemovals, deltas) + : this.agents.commitSnapshot(snapshot.entries, deltas); + } + + private persistAgentCursors( + snapshot: AgentSnapshot, + deltas: readonly AgentDirectoryDelta[], + ): void { + if (snapshot.syncCursor) this.writeCursor("agents", snapshot.syncCursor); + for (const delta of deltas) this.noteLiveCursor("agents", delta); + } + + private async fetchProjectSnapshot( + client: DaemonClient, + source: DirectorySourceToken, + transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>, + supportsDirectorySync: boolean, + ): Promise<void> { + const payload = await client.listProjects( + supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined, + ); + this.assertWorkspaceTransactionCurrent(client, source, transaction); + if (payload.sync?.mode === "snapshot") transaction.snapshot.projects.clear(); + for (const entry of payload.projects) { + const project = normalizeProjectDescriptor(entry); + transaction.snapshot.projects.set(project.projectId, project); + } + for (const removal of payload.sync?.removals ?? []) { + transaction.snapshot.projects.delete(removal.id); + } + if (payload.sync) { + (transaction.snapshot.syncCursors ??= {}).projects = { + generation: payload.sync.generation, + afterSeq: payload.sync.headSeq, + }; + } + } + + private completeWorkspaceRefresh( + client: DaemonClient, + source: DirectorySourceToken, + transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>, + ): void { + if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) { + throw new DirectoryRefreshSupersededError("workspace completion no longer current"); + } + const completion = this.workspaceTransactions.complete(transaction); + if (completion.kind === "stale") { + throw new DirectoryRefreshSupersededError("workspace completion was superseded"); + } + this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); + for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) { + if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor); + } + for (const delta of completion.deltas) { + const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces"; + this.noteLiveCursor(entity, delta); + } + } + + private recordAgentSync( + snapshot: AgentSnapshot, + sync: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["sync"], + ): void { + if (!sync) return; + snapshot.syncMode = sync.mode; + snapshot.syncRemovals.push(...sync.removals); + snapshot.syncCursor = { generation: sync.generation, afterSeq: sync.headSeq }; + } + + private noteLiveCursor( + entity: keyof DirectoryCursors, + payload: { generation?: string; seq?: number }, + ): void { + if (!payload.generation || payload.seq === undefined) return; + this.writeCursor(entity, { + generation: payload.generation, + afterSeq: payload.seq, + }); + } + + private readCursors(): DirectoryCursors { + const value = this.checkpoints?.readDirectoryCheckpoint(this.serverId); + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const cursors: DirectoryCursors = {}; + for (const entity of ["projects", "workspaces", "agents"] as const) { + const cursor = (value as Record<string, unknown>)[entity]; + if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) continue; + const { generation, afterSeq } = cursor as Record<string, unknown>; + if (typeof generation === "string" && typeof afterSeq === "number" && afterSeq >= 0) { + cursors[entity] = { generation, afterSeq }; + } + } + return cursors; + } + + private writeCursor(entity: keyof DirectoryCursors, cursor: DirectoryCursor): void { + if (!this.checkpoints) return; + const current = this.readCursors(); + const previous = current[entity]; + if (previous?.generation === cursor.generation && previous.afterSeq >= cursor.afterSeq) return; + this.checkpoints.writeDirectoryCheckpoint(this.serverId, { ...current, [entity]: cursor }); + } + private isCurrent(client: DaemonClient, source: DirectorySourceToken): boolean { return ( this.connection.client === client && diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index 0d940724bb..b64b5b14c1 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -21,6 +21,9 @@ type ProjectDirectoryDelta = Extract<SessionOutboundMessage, { type: "project.up export interface WorkspaceDirectorySnapshot { workspaces: Map<string, WorkspaceDescriptor>; projects: Map<string, ProjectDescriptor>; + syncCursors?: Partial< + Record<"projects" | "workspaces", { generation: string; afterSeq: number }> + >; } function applyProjectDelta( diff --git a/packages/app/src/runtime/host-features.ts b/packages/app/src/runtime/host-features.ts index e513c33647..37453fecb2 100644 --- a/packages/app/src/runtime/host-features.ts +++ b/packages/app/src/runtime/host-features.ts @@ -51,3 +51,22 @@ export function useHostFeatureMap( [flags, serverIds], ); } + +export function useHostFeatureAvailabilityMap( + serverIds: readonly string[], + feature: HostFeatureName, +): ReadonlyMap<string, boolean | null> { + const flags = useSessionStore( + useShallow((state) => + serverIds.map((serverId) => { + const serverInfo = state.sessions[serverId]?.serverInfo; + return serverInfo ? hostSupportsFeature(serverInfo, feature) : null; + }), + ), + ); + + return useMemo( + () => new Map(serverIds.map((serverId, index) => [serverId, flags[index]] as const)), + [flags, serverIds], + ); +} diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index eff24ee328..86ee71c73f 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -60,6 +60,8 @@ import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/su import { encodeImages } from "@/utils/encode-images"; import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; import { ReplicaCache } from "@/runtime/replica-cache"; +import { replicaCacheStorage } from "@/runtime/replica-cache/storage"; +import { projectIconCache } from "@/projects/icon-cache"; import { nativePerformanceTrace } from "@/performance/native-trace"; import { revokePushNotifications } from "@/push-notifications"; import { createAppWebSocketFactory } from "./websocket-factory"; @@ -1374,7 +1376,7 @@ export class HostRuntimeStore { }) { this.deps = input?.deps ?? createDefaultDeps(); this.storage = input?.storage ?? AsyncStorage; - this.replicaCache = new ReplicaCache(this.storage); + this.replicaCache = new ReplicaCache(input?.storage ?? replicaCacheStorage); this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications; } @@ -1473,7 +1475,8 @@ export class HostRuntimeStore { } this.hosts = profiles; this.replicaCache.setHosts(profiles.map((profile) => profile.serverId)); - await this.replicaCache.restore(); + projectIconCache.setHosts(profiles.map((profile) => profile.serverId)); + await Promise.all([this.replicaCache.restore(), projectIconCache.restore()]); this.syncHosts(profiles); } catch (error) { console.error("[HostRuntime] Failed to load host registry from storage", error); @@ -1610,14 +1613,19 @@ export class HostRuntimeStore { rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId); rekeyMap(this.directoryBootstrapInFlight, oldServerId, newServerId); this.replicaCache.reconcileServerId(oldServerId, newServerId); + projectIconCache.reconcileServerId(oldServerId, newServerId); this.directorySyncByServer.get(oldServerId)?.dispose(); this.directorySyncByServer.delete(oldServerId); - const directory = new DirectorySync(newServerId, { - onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId), - markAgentLoading: () => controller.markAgentDirectorySyncLoading(), - markAgentReady: () => controller.markAgentDirectorySyncReady(), - markAgentError: (error) => controller.markAgentDirectorySyncError(error), - }); + const directory = new DirectorySync( + newServerId, + { + onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId), + markAgentLoading: () => controller.markAgentDirectorySyncLoading(), + markAgentReady: () => controller.markAgentDirectorySyncReady(), + markAgentError: (error) => controller.markAgentDirectorySyncError(error), + }, + this.replicaCache, + ); this.directorySyncByServer.set(newServerId, directory); controller.adoptReconciledServerId(newServerId); const snapshot = controller.getSnapshot(); @@ -1970,6 +1978,7 @@ export class HostRuntimeStore { }, ): void { this.replicaCache.setHosts(hosts.map((host) => host.serverId)); + projectIconCache.setHosts(hosts.map((host) => host.serverId)); const nextIds = new Set(hosts.map((host) => host.serverId)); for (const [serverId, controller] of this.controllers) { if (nextIds.has(serverId)) { @@ -2006,12 +2015,17 @@ export class HostRuntimeStore { this.controllers.set(host.serverId, controller); this.directorySyncByServer.set( host.serverId, - new DirectorySync(host.serverId, { - onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId), - markAgentLoading: () => controller.markAgentDirectorySyncLoading(), - markAgentReady: () => controller.markAgentDirectorySyncReady(), - markAgentError: (error) => controller.markAgentDirectorySyncError(error), - }), + new DirectorySync( + host.serverId, + { + onAgentStoppedRunning: (agentId) => + this.drainQueuedAgentMessage(host.serverId, agentId), + markAgentLoading: () => controller.markAgentDirectorySyncLoading(), + markAgentReady: () => controller.markAgentDirectorySyncReady(), + markAgentError: (error) => controller.markAgentDirectorySyncError(error), + }, + this.replicaCache, + ), ); const initialSnapshot = controller.getSnapshot(); this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus); @@ -2294,22 +2308,6 @@ export class HostRuntimeStore { } } - markAgentDirectorySyncLoading(serverId: string): void { - this.controllers.get(serverId)?.markAgentDirectorySyncLoading(); - } - - markAgentDirectorySyncReady(serverId: string): void { - this.controllers.get(serverId)?.markAgentDirectorySyncReady(); - } - - markAgentDirectorySyncError(serverId: string, error: string): void { - this.controllers.get(serverId)?.markAgentDirectorySyncError(error); - } - - markAgentDirectorySyncIdle(serverId: string): void { - this.controllers.get(serverId)?.markAgentDirectorySyncIdle(); - } - private emit(serverId: string): void { this.version += 1; const listeners = this.serverListeners.get(serverId); diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 6066aed514..ee008f2ab0 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -55,34 +55,50 @@ function workspace( } function agent(id: string, workspaceId = "workspace-1", cwd = "/repo/paseo") { - return normalizeAgentSnapshot( - { - id, - provider: "codex", - cwd, - workspaceId, - model: null, - createdAt: "2026-07-18T08:00:00.000Z", - updatedAt: "2026-07-18T08:01:00.000Z", - lastUserMessageAt: "2026-07-18T08:01:00.000Z", - status: "idle", - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, + return { + ...normalizeAgentSnapshot( + { + id, + provider: "codex", + cwd, + workspaceId, + model: null, + createdAt: "2026-07-18T08:00:00.000Z", + updatedAt: "2026-07-18T08:01:00.000Z", + lastUserMessageAt: "2026-07-18T08:01:00.000Z", + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: `Agent ${id}`, + labels: {}, + }, + SERVER_ID, + ), + projectPlacement: { + projectKey: cwd, + projectName: cwd.split("/").at(-1) ?? cwd, + workspaceName: workspaceId, + checkout: { + cwd, + isGit: false as const, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false as const, + mainRepoRoot: null, }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - title: `Agent ${id}`, - labels: {}, }, - SERVER_ID, - ); + }; } function message(id: string, text: string): StreamItem { @@ -212,10 +228,12 @@ describe("ReplicaCache", () => { expect(session.client).toBeNull(); expect(session.hasHydratedAgents).toBe(false); expect(session.hasHydratedWorkspaces).toBe(false); + expect(session.hasWorkspaceDirectorySnapshot).toBe(true); expect(Array.from(session.agents.keys())).toEqual(["agent-1"]); expect(Array.from(session.workspaces.keys())).toEqual(["workspace-1"]); - expect(Array.from(session.projects.keys())).toEqual(["project-1"]); + expect(Array.from(session.projects.keys())).toEqual(["project-1", "empty-project"]); expect(session.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date); + expect(session.agents.get("agent-1")?.projectPlacement?.checkout.cwd).toBe("/repo/paseo"); expect(session.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date); expect(session.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree"); expect(session.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); @@ -230,7 +248,7 @@ describe("ReplicaCache", () => { }); }); - it("persists only the focused agent view with a short timeline tail", async () => { + it("persists the complete directory with only the focused timeline tail", async () => { const storage = new MemoryStorage(); const cache = new ReplicaCache(storage); cache.setHosts([SERVER_ID]); @@ -266,9 +284,13 @@ describe("ReplicaCache", () => { const session = useSessionStore.getState().sessions[SERVER_ID]; const timelines = session?.agentStreamTail; - expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-2"]); - expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-2"]); - expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-2"]); + expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-1", "agent-2"]); + expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-1", "workspace-2"]); + expect(Array.from(session?.projects.keys() ?? [])).toEqual([ + "project-1", + "project-2", + "empty-project", + ]); expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]); expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50)); @@ -310,6 +332,53 @@ describe("ReplicaCache", () => { ]); }); + it("persists monotonic directory cursors with the complete host replica", async () => { + const storage = new MemoryStorage(); + const cache = new ReplicaCache(storage); + cache.setHosts([SERVER_ID]); + seedSession(); + await cache.flush(); + + cache.writeDirectoryCheckpoint(SERVER_ID, { + agents: { generation: "daemon-generation", afterSeq: 7 }, + }); + useSessionStore.getState().setAgents(SERVER_ID, (agents) => { + const current = agents.get("agent-1"); + if (!current) throw new Error("Expected seeded agent"); + return new Map(agents).set("agent-1", { ...current, title: "Updated agent" }); + }); + await cache.flush(); + + const reader = new ReplicaCache(storage); + reader.setHosts([SERVER_ID]); + await reader.restore(); + expect(reader.readDirectoryCheckpoint(SERVER_ID)).toEqual({ + agents: { generation: "daemon-generation", afterSeq: 7 }, + }); + }); + + it("restores every registered host directory before any host reconnects", async () => { + const storage = new MemoryStorage(); + const writer = new ReplicaCache(storage); + writer.setHosts(LRU_SERVER_IDS); + for (const serverId of LRU_SERVER_IDS) seedTimeline(serverId, `cached-${serverId}`); + await writer.flush(); + for (const serverId of LRU_SERVER_IDS) useSessionStore.getState().clearSession(serverId); + + const reader = new ReplicaCache(storage); + reader.setHosts(LRU_SERVER_IDS); + await reader.restore(); + + for (const serverId of LRU_SERVER_IDS) { + const session = useSessionStore.getState().sessions[serverId]; + expect(Array.from(session?.agents.keys() ?? [])).toEqual([`agent-${serverId}`]); + expect(Array.from(session?.workspaces.keys() ?? [])).toEqual([`workspace-${serverId}`]); + expect(session?.hasHydratedAgents).toBe(false); + expect(session?.hasHydratedWorkspaces).toBe(false); + expect(session?.hasWorkspaceDirectorySnapshot).toBe(true); + } + }); + it("evicts the least recently written host when the cache exceeds its byte budget", async () => { const storage = new MemoryStorage(); const cache = new ReplicaCache(storage, { maxBytes: 7_000 }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 7459b526e3..35ddf09fe5 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -8,7 +8,6 @@ import { selectAgentTimelineState, useSessionStore, type Agent, - type AgentTimelineState, type SessionReplica, type SessionState, type ProjectDescriptor, @@ -21,7 +20,7 @@ const STORAGE_KEY = "@paseo:replica-cache"; const CACHE_VERSION = 5; const PERSIST_DELAY_MS = 750; const MAX_TIMELINE_ITEMS = 50; -const MAX_CACHE_BYTES = 1024 * 1024; +const MAX_CACHE_BYTES = 32 * 1024 * 1024; const IsoDateSchema = z.iso.datetime(); const TimelinePositionSchema = z.strictObject({ epoch: z.string(), @@ -107,6 +106,43 @@ const AgentCapabilitiesSchema = z.strictObject({ supportsRewindBoth: z.boolean().optional(), }); +const StoredProjectCheckoutSchema = z.union([ + z.strictObject({ + cwd: z.string(), + isGit: z.literal(false), + currentBranch: z.null(), + remoteUrl: z.null(), + worktreeRoot: z.null(), + isPaseoOwnedWorktree: z.literal(false), + mainRepoRoot: z.null(), + }), + z.strictObject({ + cwd: z.string(), + isGit: z.literal(true), + currentBranch: z.string().nullable(), + remoteUrl: z.string().nullable(), + worktreeRoot: z.string(), + isPaseoOwnedWorktree: z.literal(false), + mainRepoRoot: z.string().nullable(), + }), + z.strictObject({ + cwd: z.string(), + isGit: z.literal(true), + currentBranch: z.string().nullable(), + remoteUrl: z.string().nullable(), + worktreeRoot: z.string(), + isPaseoOwnedWorktree: z.literal(true), + mainRepoRoot: z.string(), + }), +]); + +const StoredProjectPlacementSchema = z.strictObject({ + projectKey: z.string(), + projectName: z.string(), + workspaceName: z.string().nullable().optional(), + checkout: StoredProjectCheckoutSchema, +}); + const StoredAgentSnapshotSchema = z.strictObject({ id: z.string(), provider: AgentProviderSchema, @@ -141,7 +177,7 @@ const StoredAgentSnapshotSchema = z.strictObject({ const StoredAgentSchema = z.strictObject({ snapshot: StoredAgentSnapshotSchema, - projectPlacement: z.null(), + projectPlacement: StoredProjectPlacementSchema.nullable(), lastActivityAt: IsoDateSchema, }); @@ -201,6 +237,8 @@ const StoredProjectSchema = z.strictObject({ projectKey: z.string().optional(), projectDisplayName: z.string(), projectCustomName: z.string().nullable(), + projectCustomIconRevision: z.string().nullable(), + projectIconRevision: z.string().optional(), projectRootPath: z.string(), projectKind: z.enum(["git", "non_git", "directory"]), }); @@ -217,6 +255,7 @@ const StoredHostSchema = z.strictObject({ projects: z.array(StoredProjectSchema), emptyProjects: z.array(StoredProjectSchema), timeline: StoredTimelineSchema.nullable(), + directorySync: z.unknown().optional(), }); const StoredCacheSchema = z.strictObject({ @@ -231,9 +270,10 @@ type StoredWorkspace = z.infer<typeof StoredWorkspaceSchema>; type StoredProject = z.infer<typeof StoredProjectSchema>; interface ReplicaInput { - agent: Agent | undefined; - workspace: WorkspaceDescriptor | undefined; - project: ProjectDescriptor | undefined; + agents: ReadonlyMap<string, Agent>; + workspaces: ReadonlyMap<string, WorkspaceDescriptor>; + projects: ReadonlyMap<string, ProjectDescriptor>; + focusedAgent: Agent | undefined; timelineItems: StreamItem[] | undefined; } @@ -367,6 +407,10 @@ function deserializeTimelineItem(item: StoredTimelineItem): StreamItem { } } +function serializeProjectPlacement(agent: Agent): StoredAgent["projectPlacement"] { + return agent.projectPlacement ?? null; +} + function serializeAgent(agent: Agent): StoredAgent { const snapshot = { id: agent.id, @@ -421,7 +465,7 @@ function serializeAgent(agent: Agent): StoredAgent { }; return { snapshot, - projectPlacement: null, + projectPlacement: serializeProjectPlacement(agent), lastActivityAt: agent.lastActivityAt.toISOString(), }; } @@ -478,6 +522,8 @@ function serializeProject(project: ProjectDescriptor): StoredProject { ...(project.projectKey ? { projectKey: project.projectKey } : {}), projectDisplayName: project.projectDisplayName, projectCustomName: project.projectCustomName, + projectCustomIconRevision: project.projectCustomIconRevision ?? null, + projectIconRevision: project.projectIconRevision, projectRootPath: project.projectRootPath, projectKind: project.projectKind, }; @@ -485,50 +531,47 @@ function serializeProject(project: ProjectDescriptor): StoredProject { function replicaInputsEqual(left: ReplicaInput, right: ReplicaInput): boolean { return ( - left.agent === right.agent && - left.workspace === right.workspace && - left.project === right.project && + left.agents === right.agents && + left.workspaces === right.workspaces && + left.projects === right.projects && + left.focusedAgent === right.focusedAgent && left.timelineItems === right.timelineItems ); } function selectReplicaInput(session: SessionState, agentId: string | null): ReplicaInput { const agent = agentId ? session.agents.get(agentId) : undefined; - const workspace = agent - ? ((agent.workspaceId ? session.workspaces.get(agent.workspaceId) : undefined) ?? - Array.from(session.workspaces.values()).find( - (candidate) => candidate.workspaceDirectory === agent.cwd, - )) - : undefined; - const timeline: AgentTimelineState = agentId + const timeline = agentId ? selectAgentTimelineState(session, agentId) - : { status: "cold" }; + : { status: "cold" as const }; return { - agent, - workspace, - project: workspace ? session.projects.get(workspace.projectId) : undefined, + agents: session.agents, + workspaces: session.workspaces, + projects: session.projects, + focusedAgent: agent, timelineItems: timeline.status === "cold" ? undefined : timeline.items, }; } -function serializeHost(serverId: string, input: ReplicaInput): StoredHost { +function serializeHost(serverId: string, input: ReplicaInput, directorySync?: unknown): StoredHost { const items = input.timelineItems ?.filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item)) .map(serializeTimelineItem) .filter((item) => item !== null); return { serverId, - agents: input.agent ? [serializeAgent(input.agent)] : [], - workspaces: input.workspace ? [serializeWorkspace(input.workspace)] : [], - projects: input.project ? [serializeProject(input.project)] : [], + agents: Array.from(input.agents.values(), serializeAgent), + workspaces: Array.from(input.workspaces.values(), serializeWorkspace), + projects: Array.from(input.projects.values(), serializeProject), emptyProjects: [], timeline: - input.agent && items + input.focusedAgent && items ? { - agentId: input.agent.id, + agentId: input.focusedAgent.id, items: items.slice(-MAX_TIMELINE_ITEMS), } : null, + ...(directorySync ? { directorySync } : {}), }; } @@ -688,6 +731,24 @@ export class ReplicaCache { this.schedulePersist(); } + readDirectoryCheckpoint(serverId: string): unknown { + return this.storedHosts.get(serverId)?.directorySync; + } + + writeDirectoryCheckpoint(serverId: string, checkpoint: unknown): void { + let stored = this.storedHosts.get(serverId); + if (!stored) { + const session = useSessionStore.getState().sessions[serverId]; + if (!session) return; + const input = selectReplicaInput(session, this.lastFocusedAgentIds.get(serverId) ?? null); + this.capturedInputs.set(serverId, input); + stored = serializeHost(serverId, input); + } + this.storedHosts.delete(serverId); + this.storedHosts.set(serverId, { ...stored, directorySync: checkpoint }); + this.schedulePersist(); + } + async flush(): Promise<void> { if (this.persistTimer) { clearTimeout(this.persistTimer); @@ -727,8 +788,9 @@ export class ReplicaCache { if (previous && replicaInputsEqual(previous, input)) return false; this.capturedInputs.set(serverId, input); + const directorySync = this.storedHosts.get(serverId)?.directorySync; this.storedHosts.delete(serverId); - this.storedHosts.set(serverId, serializeHost(serverId, input)); + this.storedHosts.set(serverId, serializeHost(serverId, input, directorySync)); return true; } diff --git a/packages/app/src/runtime/replica-cache/storage.ts b/packages/app/src/runtime/replica-cache/storage.ts new file mode 100644 index 0000000000..e4bc220e24 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/storage.ts @@ -0,0 +1,4 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { ReplicaCacheStorage } from "./index"; + +export const replicaCacheStorage: ReplicaCacheStorage = AsyncStorage; diff --git a/packages/app/src/runtime/replica-cache/storage.web.ts b/packages/app/src/runtime/replica-cache/storage.web.ts new file mode 100644 index 0000000000..ef2ef68960 --- /dev/null +++ b/packages/app/src/runtime/replica-cache/storage.web.ts @@ -0,0 +1,70 @@ +import type { ReplicaCacheStorage } from "./index"; + +const DATABASE_NAME = "paseo-replica-cache"; +const STORE_NAME = "key-value"; + +let databasePromise: Promise<IDBDatabase> | null = null; + +function openDatabase(): Promise<IDBDatabase> { + databasePromise ??= new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, 1); + request.addEventListener("upgradeneeded", () => { + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME); + } + }); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => + reject(request.error ?? new Error("Failed to open replica cache")), + ); + request.addEventListener("blocked", () => + reject(new Error("Replica cache database upgrade was blocked")), + ); + }); + return databasePromise; +} + +async function getItem(key: string): Promise<string | null> { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const request = database.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).get(key); + request.addEventListener("success", () => + resolve(typeof request.result === "string" ? request.result : null), + ); + request.addEventListener("error", () => + reject(request.error ?? new Error("Failed to read replica cache")), + ); + }); +} + +async function setItem(key: string, value: string): Promise<void> { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readwrite"); + transaction.objectStore(STORE_NAME).put(value, key); + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("error", () => + reject(transaction.error ?? new Error("Failed to write replica cache")), + ); + transaction.addEventListener("abort", () => + reject(transaction.error ?? new Error("Replica cache write was aborted")), + ); + }); +} + +async function removeItem(key: string): Promise<void> { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, "readwrite"); + transaction.objectStore(STORE_NAME).delete(key); + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("error", () => + reject(transaction.error ?? new Error("Failed to remove replica cache")), + ); + transaction.addEventListener("abort", () => + reject(transaction.error ?? new Error("Replica cache removal was aborted")), + ); + }); +} + +export const replicaCacheStorage: ReplicaCacheStorage = { getItem, setItem, removeItem }; diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index f2021a4909..7d01fc9a99 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -5,6 +5,7 @@ import { composeWorkspaceStructure, selectHasHydratedWorkspaces, selectHydratedWorkspaceServerIds, + selectWorkspaceDirectoryServerIds, selectHasWorkspaces, selectProjectOrder, selectRecommendedProjectPaths, @@ -79,6 +80,14 @@ export function useHydratedWorkspaceServerIds(serverIds: string[]): string[] { ); } +export function useWorkspaceDirectoryServerIds(serverIds: string[]): string[] { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectWorkspaceDirectoryServerIds(state, serverIds), + workspaceEqualityFns.deep, + ); +} + export function useWorkspaceDirectory( serverId: string | null, workspaceId: string | null, diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index 96f21de53b..2c0ec9b46b 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -6,6 +6,7 @@ import { composeWorkspaceStructure, selectHasWorkspaces, selectHydratedWorkspaceServerIds, + selectWorkspaceDirectoryServerIds, selectProjectOrder, selectRecommendedProjectPaths, selectWorkspace, @@ -131,20 +132,32 @@ afterEach(() => { }); describe("workspace replica authority", () => { - it("keeps a cached workspace addressable without publishing it as an authoritative directory", () => { + it("publishes a restored directory while keeping remote hydration false", () => { const cachedWorkspace = createWorkspace({ id: "cached-workspace" }); - initializeWorkspaces([cachedWorkspace]); + useSessionStore.getState().restoreSessionReplica(SERVER_ID, { + agents: new Map(), + workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), + projects: new Map([ + [cachedWorkspace.projectId, projectDescriptorFromTestWorkspace(cachedWorkspace)], + ]), + timeline: null, + }); + const cachedSession = useSessionStore.getState().sessions[SERVER_ID]; + if (!cachedSession) throw new Error("expected initialized session"); - const cachedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [ + const cachedServerIds = selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [ SERVER_ID, ]); expect(selectWorkspace(useSessionStore.getState(), SERVER_ID, cachedWorkspace.id)).toBe( cachedWorkspace, ); - expect(selectWorkspaceStructureProjects(useSessionStore.getState(), cachedServerIds)).toEqual( - [], - ); + expect(cachedSession.hasHydratedWorkspaces).toBe(false); + expect( + selectWorkspaceStructureProjects(useSessionStore.getState(), cachedServerIds).map( + (project) => project.workspaceKeys, + ), + ).toEqual([[`${SERVER_ID}:${cachedWorkspace.id}`]]); const authoritativeWorkspace = createWorkspace({ id: "authoritative-workspace", diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index f911e30d41..409a660557 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -16,6 +16,7 @@ export interface SessionsSnapshot { string, { hasHydratedWorkspaces?: boolean; + hasWorkspaceDirectorySnapshot?: boolean; workspaces: Map<string, WorkspaceDescriptor>; projects?: Map<string, ProjectDescriptor>; } @@ -138,6 +139,18 @@ export function selectHydratedWorkspaceServerIds( return serverIds.filter((serverId) => state.sessions[serverId]?.hasHydratedWorkspaces === true); } +export function selectWorkspaceDirectoryServerIds( + state: SessionsSnapshot, + serverIds: readonly string[], +): string[] { + return serverIds.filter((serverId) => { + const session = state.sessions[serverId]; + return ( + session?.hasHydratedWorkspaces === true || session?.hasWorkspaceDirectorySnapshot === true + ); + }); +} + export function selectWorkspaceStructureProjects( state: SessionsSnapshot, serverIds: readonly string[], diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 758b02c28c..6045ce5f40 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -172,6 +172,7 @@ export interface ProjectDescriptor { projectDisplayName: string; projectCustomName: string | null; projectCustomIconRevision?: string | null; + projectIconRevision?: string; projectRootPath: string; projectKind: WorkspaceDescriptorPayload["projectKind"]; } @@ -185,6 +186,7 @@ export function normalizeProjectDescriptor( projectDisplayName: payload.projectDisplayName, projectCustomName: payload.projectCustomName ?? null, projectCustomIconRevision: payload.projectCustomIconRevision ?? null, + projectIconRevision: payload.projectIconRevision, projectRootPath: payload.projectRootPath, projectKind: payload.projectKind, }; @@ -391,6 +393,7 @@ export interface SessionState { // Hydration status hasHydratedAgents: boolean; hasHydratedWorkspaces: boolean; + hasWorkspaceDirectorySnapshot: boolean; // Audio state isPlayingAudio: boolean; @@ -653,6 +656,7 @@ function createInitialSessionState( serverInfo: null, hasHydratedAgents: false, hasHydratedWorkspaces: false, + hasWorkspaceDirectorySnapshot: false, isPlayingAudio: false, focusedAgentId: null, focusedTerminalId: null, @@ -799,6 +803,7 @@ export const useSessionStore = create<SessionStore>()( workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents), workspaces: replica.workspaces, projects: replica.projects, + hasWorkspaceDirectorySnapshot: true, agentStreamTail, agentTasks, }, diff --git a/packages/app/src/utils/agent-snapshots.test.ts b/packages/app/src/utils/agent-snapshots.test.ts index 966b17eb34..334e36d9f2 100644 --- a/packages/app/src/utils/agent-snapshots.test.ts +++ b/packages/app/src/utils/agent-snapshots.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; -import { normalizeAgentSnapshot } from "./agent-snapshots"; +import { normalizeAgentSnapshot, projectAgentSnapshot } from "./agent-snapshots"; function createSnapshot( input: Partial<Omit<AgentSnapshotPayload, "labels">> & { @@ -36,6 +36,18 @@ function createSnapshot( } describe("normalizeAgentSnapshot", () => { + it("round-trips identified active turns through the canonical snapshot boundary", () => { + const snapshot = createSnapshot({ + status: "running", + activeTurn: { turnId: "turn-1", startedAt: "2026-07-31T12:00:00.000Z" }, + }); + + expect(projectAgentSnapshot(normalizeAgentSnapshot(snapshot, "server-1"))).toMatchObject({ + status: "running", + activeTurn: snapshot.activeTurn, + }); + }); + it("normalizes identified and legacy active turns at the snapshot boundary", () => { const startedAt = "2026-07-31T12:00:00.000Z"; expect( diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index aeaef3ca44..a035a054a2 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -2,6 +2,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import type { ActiveTurnIdentity } from "@/timeline/turn-liveness"; +import type { Agent } from "@/stores/session-store"; function normalizeActiveTurn( snapshot: AgentSnapshotPayload, @@ -17,6 +18,17 @@ function normalizeActiveTurn( return snapshot.status === "running" ? { turnId: null, startedAt: lastUserMessageAt } : null; } +function projectActiveTurn(agent: Agent): Pick<AgentSnapshotPayload, "activeTurn"> { + if (agent.activeTurn === null) return { activeTurn: null }; + if (agent.activeTurn.turnId === null) return {}; + return { + activeTurn: { + turnId: agent.activeTurn.turnId, + startedAt: agent.activeTurn.startedAt?.toISOString() ?? null, + }, + }; +} + export function derivePendingPermissionKey( agentId: string, request: AgentPermissionRequest, @@ -31,6 +43,37 @@ export function derivePendingPermissionKey( return `${agentId}:${fallbackId}`; } +export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload { + return { + id: agent.id, + provider: agent.provider, + cwd: agent.cwd, + ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), + model: agent.model, + ...(agent.features ? { features: agent.features } : {}), + thinkingOptionId: agent.thinkingOptionId ?? null, + createdAt: agent.createdAt.toISOString(), + updatedAt: agent.updatedAt.toISOString(), + lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, + status: agent.status, + ...projectActiveTurn(agent), + capabilities: agent.capabilities, + currentModeId: agent.currentModeId, + availableModes: agent.availableModes, + pendingPermissions: agent.pendingPermissions, + persistence: agent.persistence, + ...(agent.runtimeInfo ? { runtimeInfo: agent.runtimeInfo } : {}), + ...(agent.lastUsage ? { lastUsage: agent.lastUsage } : {}), + ...(agent.lastError ? { lastError: agent.lastError } : {}), + title: agent.title, + labels: agent.labels, + requiresAttention: agent.requiresAttention ?? false, + attentionReason: agent.attentionReason ?? null, + attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null, + archivedAt: agent.archivedAt?.toISOString() ?? null, + }; +} + export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string) { const createdAt = new Date(snapshot.createdAt); const updatedAt = new Date(snapshot.updatedAt); diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index 76e42097ae..4a9bf0a8b6 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -5,6 +5,7 @@ export interface SidebarProjectHostTarget { projectId: string; iconWorkingDir: string; customIconRevision?: string | null; + iconRevision?: string; } export type SidebarProjectTrailingAction = @@ -25,6 +26,7 @@ function hostTarget(input: { projectId: string; iconWorkingDir: string; customIconRevision?: string | null; + iconRevision?: string; }): SidebarProjectHostTarget | null { const iconWorkingDir = input.iconWorkingDir.trim(); if (!input.serverId || !iconWorkingDir) { @@ -35,6 +37,7 @@ function hostTarget(input: { projectId: input.projectId, iconWorkingDir, customIconRevision: input.customIconRevision, + iconRevision: input.iconRevision, }; } diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 6df09fb275..cf1053d125 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -677,6 +677,10 @@ export type ProjectListPayload = Extract< SessionOutboundMessage, { type: "project.list.response" } >["payload"]; +type ProjectListRequest = Extract<SessionInboundMessage, { type: "project.list.request" }>; +export type ProjectListOptions = Omit<ProjectListRequest, "type" | "requestId"> & { + requestId?: string; +}; export interface CreateScheduleOptions { prompt: string; name?: string | null; @@ -2004,6 +2008,7 @@ export class DaemonClient { ...(options?.sort ? { sort: options.sort } : {}), ...(options?.page ? { page: options.page } : {}), ...(options?.subscribe ? { subscribe: options.subscribe } : {}), + ...(options?.sync ? { sync: options.sync } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, @@ -2085,6 +2090,7 @@ export class DaemonClient { ...(options?.sort ? { sort: options.sort } : {}), ...(options?.page ? { page: options.page } : {}), ...(options?.subscribe ? { subscribe: options.subscribe } : {}), + ...(options?.sync ? { sync: options.sync } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, @@ -2102,11 +2108,13 @@ export class DaemonClient { }); } - async listProjects(requestId?: string): Promise<ProjectListPayload> { + async listProjects(options?: string | ProjectListOptions): Promise<ProjectListPayload> { + const requestId = typeof options === "string" ? options : options?.requestId; const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "project.list.request", requestId: resolvedRequestId, + ...(typeof options === "object" && options.sync ? { sync: options.sync } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 9e6f628b1f..015164f703 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -5,6 +5,7 @@ import { parseServerInfoStatusPayload, SessionInboundMessageSchema, SessionOutboundMessageSchema, + WorkspaceProjectDescriptorPayloadSchema, } from "./messages.js"; function workspaceDescriptor(overrides: Record<string, unknown> = {}) { @@ -53,6 +54,28 @@ describe("project icon message security", () => { }); }); +describe("project icon revision compatibility", () => { + const project = { + projectId: "project-1", + projectDisplayName: "Project", + projectRootPath: "/repo/project", + projectKind: "git" as const, + }; + + test("accepts an old project snapshot without an effective icon revision", () => { + expect(WorkspaceProjectDescriptorPayloadSchema.parse(project)).toEqual(project); + }); + + test("accepts an effective icon revision on a new project snapshot", () => { + expect( + WorkspaceProjectDescriptorPayloadSchema.parse({ + ...project, + projectIconRevision: "automatic:none:v1", + }), + ).toEqual({ ...project, projectIconRevision: "automatic:none:v1" }); + }); +}); + describe("workspace descriptor message compatibility", () => { test("old-shaped fetch_workspaces_response without project still parses", () => { const parsed = SessionOutboundMessageSchema.parse( diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1243b9adcd..70f5544310 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1104,6 +1104,26 @@ export const SendAgentMessageSchema = z.object({ // Agent RPCs (requestId-correlated) // ============================================================================ +const DirectorySyncRequestSchema = z.object({ + generation: z.string().optional(), + afterSeq: z.number().int().nonnegative().optional(), +}); + +const DirectorySyncRemovalSchema = z.object({ + id: z.string(), + seq: z.number().int().positive(), +}); + +const DirectorySyncMetadataSchema = z.object({ + generation: z.string(), + headSeq: z.number().int().nonnegative(), + mode: z.enum(["snapshot", "changes"]), + reason: z + .enum(["no_cursor", "generation_changed", "cursor_expired", "changes_too_large"]) + .optional(), + removals: z.array(DirectorySyncRemovalSchema), +}); + export const FetchAgentsRequestMessageSchema = z.object({ type: z.literal("fetch_agents_request"), requestId: z.string(), @@ -1128,6 +1148,8 @@ export const FetchAgentsRequestMessageSchema = z.object({ subscriptionId: z.string().optional(), }) .optional(), + // COMPAT(directorySync): added in v0.3.x, remove optional after 2027-02-12. + sync: DirectorySyncRequestSchema.optional(), }); const WorkspaceStateBucketSchema = z.enum([ @@ -1168,11 +1190,15 @@ export const FetchWorkspacesRequestMessageSchema = z.object({ subscriptionId: z.string().optional(), }) .optional(), + // COMPAT(directorySync): added in v0.3.x, remove optional after 2027-02-12. + sync: DirectorySyncRequestSchema.optional(), }); export const ProjectListRequestMessageSchema = z.object({ type: z.literal("project.list.request"), requestId: z.string(), + // COMPAT(directorySync): added in v0.3.x, remove optional after 2027-02-12. + sync: DirectorySyncRequestSchema.optional(), }); export const FetchAgentHistoryRequestMessageSchema = z.object({ @@ -3038,6 +3064,8 @@ export const ServerInfoStatusPayloadSchema = z providersSnapshot: z.boolean().optional(), // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. providersSnapshotCwd: z.boolean().optional(), + // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. + directorySync: z.boolean().optional(), // COMPAT(checkoutForgeSetAutoMerge): added in v0.1.106, remove old // checkoutGithubSetAutoMerge fallback after 2026-12-28. checkoutForgeSetAutoMerge: z.boolean().optional(), @@ -3457,6 +3485,8 @@ export const WorkspaceDescriptorPayloadSchema = z // Old daemons omit it; absent means the client falls back to GitHub. forge: z.string().optional(), project: ProjectPlacementPayloadSchema.optional(), + // COMPAT(directorySync): sequence of this latest directory projection. + syncSeq: z.number().int().positive().optional(), }) .transform((workspace) => ({ ...workspace, @@ -3470,10 +3500,14 @@ export const AgentUpdateMessageSchema = z.object({ kind: z.literal("upsert"), agent: AgentSnapshotPayloadSchema, project: ProjectPlacementPayloadSchema.nullable().optional(), + generation: z.string().optional(), + seq: z.number().int().positive().optional(), }), z.object({ kind: z.literal("remove"), agentId: z.string(), + generation: z.string().optional(), + seq: z.number().int().positive().optional(), }), ]), }); @@ -3531,6 +3565,8 @@ const AgentDirectoryResponseEntrySchema = z.object({ // anyway; sending it keeps the client from re-deriving a second opinion that // could disagree with the ranking it is explaining. searchMatches: z.array(AgentSearchMatchSchema).optional(), + // COMPAT(directorySync): sequence of this latest directory projection. + syncSeq: z.number().int().positive().optional(), }); const AgentDirectoryPageInfoSchema = z.object({ @@ -3546,6 +3582,7 @@ export const FetchAgentsResponseMessageSchema = z.object({ subscriptionId: z.string().nullable().optional(), entries: z.array(AgentDirectoryResponseEntrySchema), pageInfo: AgentDirectoryPageInfoSchema, + sync: DirectorySyncMetadataSchema.optional(), }), }); @@ -3583,8 +3620,14 @@ export const WorkspaceProjectDescriptorPayloadSchema = z.object({ projectCustomName: z.string().nullable().optional(), // COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20. projectCustomIconRevision: z.string().nullable().optional(), + // Fingerprints the effective icon, including automatic discovery and the + // absence of an icon. Clients may persist icon results against this value. + // COMPAT(projectIconCache): added in v0.2.7, remove optional after 2027-02-12. + projectIconRevision: z.string().optional(), projectRootPath: z.string(), projectKind: z.enum(["git", "non_git", "directory"]), + // COMPAT(directorySync): sequence of this latest directory projection. + syncSeq: z.number().int().positive().optional(), }); export const FetchWorkspacesResponseMessageSchema = z.object({ @@ -3602,6 +3645,7 @@ export const FetchWorkspacesResponseMessageSchema = z.object({ prevCursor: z.string().nullable(), hasMore: z.boolean(), }), + sync: DirectorySyncMetadataSchema.optional(), }), }); @@ -3611,6 +3655,8 @@ export const WorkspaceUpdateMessageSchema = z.object({ z.object({ kind: z.literal("upsert"), workspace: WorkspaceDescriptorPayloadSchema, + generation: z.string().optional(), + seq: z.number().int().positive().optional(), }), z.object({ kind: z.literal("remove"), @@ -3625,6 +3671,8 @@ export const WorkspaceUpdateMessageSchema = z.object({ // Project removal is represented on the existing workspace update channel // so old clients can still parse the message and ignore the extra field. removedProjectId: z.string().optional(), + generation: z.string().optional(), + seq: z.number().int().positive().optional(), }), ]), }); @@ -3632,8 +3680,18 @@ export const WorkspaceUpdateMessageSchema = z.object({ export const ProjectUpdateMessageSchema = z.object({ type: z.literal("project.update"), payload: z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("upsert"), project: WorkspaceProjectDescriptorPayloadSchema }), - z.object({ kind: z.literal("remove"), projectId: z.string() }), + z.object({ + kind: z.literal("upsert"), + project: WorkspaceProjectDescriptorPayloadSchema, + generation: z.string().optional(), + seq: z.number().int().positive().optional(), + }), + z.object({ + kind: z.literal("remove"), + projectId: z.string(), + generation: z.string().optional(), + seq: z.number().int().positive().optional(), + }), ]), }); @@ -3642,6 +3700,7 @@ export const ProjectListResponseMessageSchema = z.object({ payload: z.object({ requestId: z.string(), projects: z.array(WorkspaceProjectDescriptorPayloadSchema), + sync: DirectorySyncMetadataSchema.optional(), }), }); diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index 03a0bd4321..bdf985152d 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -93,6 +93,59 @@ describe("workspace message schemas", () => { expect(activeScoped.scope).toBe("active"); }); + test("parses optional sequenced directory requests and responses", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "project.list.request", + requestId: "projects-sync", + sync: { generation: "daemon-generation", afterSeq: 7 }, + }), + ).toMatchObject({ sync: { generation: "daemon-generation", afterSeq: 7 } }); + expect( + SessionInboundMessageSchema.parse({ + type: "fetch_workspaces_request", + requestId: "workspaces-sync", + sync: { generation: "daemon-generation", afterSeq: 11 }, + }), + ).toMatchObject({ sync: { generation: "daemon-generation", afterSeq: 11 } }); + expect( + SessionInboundMessageSchema.parse({ + type: "fetch_agents_request", + requestId: "agents-sync", + scope: "active", + sync: { generation: "daemon-generation", afterSeq: 13 }, + }), + ).toMatchObject({ sync: { generation: "daemon-generation", afterSeq: 13 } }); + + const response = SessionOutboundMessageSchema.parse({ + type: "project.list.response", + payload: { + requestId: "projects-sync", + projects: [ + { + projectId: "project-1", + projectDisplayName: "Project", + projectRootPath: "/repo", + projectKind: "git", + syncSeq: 12, + }, + ], + sync: { + generation: "daemon-generation", + headSeq: 12, + mode: "changes", + removals: [{ id: "project-removed", seq: 10 }], + }, + }, + }); + expect(response).toMatchObject({ + payload: { + projects: [{ projectId: "project-1", syncSeq: 12 }], + sync: { headSeq: 12, removals: [{ id: "project-removed", seq: 10 }] }, + }, + }); + }); + test("parses agent_update without project placement", () => { const result = SessionOutboundMessageSchema.safeParse({ type: "agent_update", diff --git a/packages/server/src/server/directory-sync/index.test.ts b/packages/server/src/server/directory-sync/index.test.ts new file mode 100644 index 0000000000..0d41d449ac --- /dev/null +++ b/packages/server/src/server/directory-sync/index.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "vitest"; +import { DirectorySyncService } from "./index.js"; + +function project(projectId: string, name: string) { + return { + projectId, + projectDisplayName: name, + projectRootPath: `/${projectId}`, + projectKind: "git" as const, + }; +} + +describe("DirectorySyncService", () => { + test("owns sequencing and returns wire-ready latest project changes", () => { + const service = new DirectorySyncService("generation"); + expect(service.synchronizeProjects([project("one", "One")], {}).sync).toMatchObject({ + mode: "snapshot", + headSeq: 1, + }); + + service.sequenceProjectUpdate({ kind: "upsert", project: project("one", "Renamed") }, true); + service.sequenceProjectUpdate({ kind: "upsert", project: project("one", "Latest") }, true); + expect( + service.synchronizeProjects([project("one", "Latest")], { + generation: "generation", + afterSeq: 1, + }), + ).toEqual({ + projects: [{ ...project("one", "Latest"), syncSeq: 3 }], + sync: { generation: "generation", mode: "changes", headSeq: 3, removals: [] }, + }); + }); + + test("sequences a hard project removal once", () => { + const service = new DirectorySyncService("generation"); + service.synchronizeProjects([project("one", "One")], {}); + const update = { kind: "remove" as const, projectId: "one" }; + expect(service.sequenceProjectUpdate(update, true)).toEqual({ + ...update, + generation: "generation", + seq: 2, + }); + expect(service.sequenceProjectUpdate(update, true)).toEqual({ + ...update, + generation: "generation", + seq: 2, + }); + expect( + service.synchronizeProjects([], { generation: "generation", afterSeq: 1 }).sync.removals, + ).toEqual([{ id: "one", seq: 2 }]); + }); + + test("keeps one daemon-wide version for repeated observations", () => { + const service = new DirectorySyncService("generation"); + const update = { kind: "upsert" as const, project: project("one", "One") }; + const sequenced = { ...update, generation: "generation", seq: 1 }; + expect(service.sequenceProjectUpdate(update, true)).toEqual(sequenced); + expect(service.sequenceProjectUpdate(update, true)).toEqual(sequenced); + }); + + test("sequences only the project whose effective icon changed", () => { + const service = new DirectorySyncService("generation"); + const one = { ...project("one", "One"), projectIconRevision: "icon-a" }; + const two = { ...project("two", "Two"), projectIconRevision: "icon-a" }; + service.synchronizeProjects([one, two], {}); + const changed = { ...one, projectIconRevision: "icon-b" }; + service.sequenceProjectUpdate({ kind: "upsert", project: changed }, true); + + expect( + service.synchronizeProjects([changed, two], { + generation: "generation", + afterSeq: 2, + }), + ).toEqual({ + projects: [{ ...changed, syncSeq: 3 }], + sync: { generation: "generation", mode: "changes", headSeq: 3, removals: [] }, + }); + }); + + test("falls back to a snapshot when the daemon generation changes", () => { + const service = new DirectorySyncService("new-generation"); + const read = service.synchronizeProjects([project("one", "One")], { + generation: "old-generation", + afterSeq: 10, + }); + expect(read.sync).toMatchObject({ + mode: "snapshot", + reason: "generation_changed", + headSeq: 1, + }); + }); +}); diff --git a/packages/server/src/server/directory-sync/index.ts b/packages/server/src/server/directory-sync/index.ts new file mode 100644 index 0000000000..1b08360cb7 --- /dev/null +++ b/packages/server/src/server/directory-sync/index.ts @@ -0,0 +1,174 @@ +import { randomUUID } from "node:crypto"; +import type { + AgentSnapshotPayload, + ProjectPlacementPayload, + SessionOutboundMessage, + WorkspaceDescriptorPayload, + WorkspaceProjectDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { type CollectionRead, VersionedCollection } from "./internal/versioned-collection.js"; + +interface AgentDirectoryEntry { + agent: AgentSnapshotPayload; + project: ProjectPlacementPayload; +} + +interface DirectorySyncCursor { + generation?: string; + afterSeq?: number; +} + +interface DirectoryVersion { + generation: string; + seq: number; +} + +type ProjectUpdate = Extract<SessionOutboundMessage, { type: "project.update" }>; +type ProjectListResponse = Extract< + SessionOutboundMessage, + { type: "project.list.response" } +>["payload"]; +type FetchWorkspacesResponse = Extract< + SessionOutboundMessage, + { type: "fetch_workspaces_response" } +>["payload"]; +type FetchAgentsResponse = Extract< + SessionOutboundMessage, + { type: "fetch_agents_response" } +>["payload"]; + +/** Daemon-global latest-state sequence owner for the active app directory. */ +export class DirectorySyncService { + private readonly generation: string; + private readonly projects = new VersionedCollection<WorkspaceProjectDescriptorPayload>({ + getId: (project) => project.projectId, + }); + private readonly workspaces = new VersionedCollection<WorkspaceDescriptorPayload>({ + getId: (workspace) => workspace.id, + }); + private readonly agents = new VersionedCollection<AgentDirectoryEntry>({ + getId: (entry) => entry.agent.id, + }); + + constructor(generation = randomUUID()) { + this.generation = generation; + } + + synchronizeProjects( + snapshot: Iterable<WorkspaceProjectDescriptorPayload>, + cursor: DirectorySyncCursor, + ): Pick<ProjectListResponse, "projects" | "sync"> { + this.projects.replaceAll(snapshot); + const read = this.read(this.projects, cursor); + return { + projects: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })), + sync: this.metadata(read), + }; + } + + synchronizeWorkspaces( + snapshot: Iterable<WorkspaceDescriptorPayload>, + cursor: DirectorySyncCursor, + ): Pick<FetchWorkspacesResponse, "entries" | "emptyProjects" | "pageInfo" | "sync"> { + this.workspaces.replaceAll(snapshot); + const read = this.read(this.workspaces, cursor); + return { + entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })), + emptyProjects: [], + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + sync: this.metadata(read), + }; + } + + synchronizeAgents( + snapshot: Iterable<AgentDirectoryEntry>, + cursor: DirectorySyncCursor, + ): Pick<FetchAgentsResponse, "entries" | "pageInfo" | "sync"> { + this.agents.replaceAll(snapshot); + const read = this.read(this.agents, cursor); + return { + entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })), + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + sync: this.metadata(read), + }; + } + + sequenceProjectUpdate( + update: ProjectUpdate["payload"], + includeSequence: boolean, + ): ProjectUpdate["payload"] { + if (update.kind === "upsert") { + this.projects.replace(update.project); + } else { + this.projects.remove(update.projectId); + } + const id = update.kind === "upsert" ? update.project.projectId : update.projectId; + return this.withVersion(update, includeSequence, this.version(this.projects, id)); + } + + sequenceWorkspaceUpdate<T extends object>( + payload: T, + value: WorkspaceDescriptorPayload | null, + id: string, + includeSequence: boolean, + ): T & Partial<DirectoryVersion> { + if (value) { + this.workspaces.replace(value); + } else { + this.workspaces.remove(id); + } + return this.withVersion(payload, includeSequence, this.version(this.workspaces, id)); + } + + sequenceAgentUpdate<T extends object>( + payload: T, + value: AgentDirectoryEntry | null, + id: string, + includeSequence: boolean, + ): T & Partial<DirectoryVersion> { + if (value) { + this.agents.replace(value); + } else { + this.agents.remove(id); + } + return this.withVersion(payload, includeSequence, this.version(this.agents, id)); + } + + private metadata(read: CollectionRead<unknown>) { + return { + generation: this.generation, + headSeq: read.headSeq, + mode: read.mode, + ...(read.reason ? { reason: read.reason } : {}), + removals: read.removals, + }; + } + + private withVersion<T extends object>( + payload: T, + includeSequence: boolean, + version: DirectoryVersion | null, + ): T & Partial<DirectoryVersion> { + return version && includeSequence ? { ...payload, ...version } : payload; + } + + private read<T>( + collection: VersionedCollection<T>, + cursor: DirectorySyncCursor, + ): CollectionRead<T> { + if (cursor.generation !== this.generation) { + return collection.readSnapshot( + cursor.generation === undefined ? "no_cursor" : "generation_changed", + ); + } + if (cursor.afterSeq === undefined) { + return collection.readSnapshot("no_cursor"); + } + return collection.readAfter(cursor.afterSeq); + } + + private version<T>(collection: VersionedCollection<T>, id: string): DirectoryVersion | null { + const seq = collection.sequenceFor(id); + return seq === null ? null : { generation: this.generation, seq }; + } +} diff --git a/packages/server/src/server/directory-sync/internal/versioned-collection.test.ts b/packages/server/src/server/directory-sync/internal/versioned-collection.test.ts new file mode 100644 index 0000000000..f74cdd7874 --- /dev/null +++ b/packages/server/src/server/directory-sync/internal/versioned-collection.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "vitest"; +import { VersionedCollection } from "./versioned-collection.js"; + +describe("VersionedCollection", () => { + test("returns only each entity's latest visible snapshot after a sequence", () => { + const collection = new VersionedCollection<{ id: string; title: string }>({ + getId: (value) => value.id, + }); + collection.replace({ id: "one", title: "first" }); + const cursor = collection.headSeq; + collection.replace({ id: "one", title: "second" }); + collection.replace({ id: "one", title: "latest" }); + collection.replace({ id: "two", title: "other" }); + + expect(collection.readAfter(cursor)).toEqual({ + mode: "changes", + headSeq: 4, + values: [ + { seq: 3, value: { id: "one", title: "latest" } }, + { seq: 4, value: { id: "two", title: "other" } }, + ], + removals: [], + }); + }); + + test("does not advance for an unchanged projection or repeated removal", () => { + const collection = new VersionedCollection<{ id: string; title: string }>({ + getId: (value) => value.id, + }); + expect(collection.replace({ id: "one", title: "same" })?.seq).toBe(1); + expect(collection.replace({ id: "one", title: "same" })).toBeNull(); + expect(collection.remove("one")?.seq).toBe(2); + expect(collection.remove("one")).toBeNull(); + expect(collection.headSeq).toBe(2); + }); + + test("returns tombstones and lets a later value replace them", () => { + const collection = new VersionedCollection<{ id: string; title: string }>({ + getId: (value) => value.id, + }); + collection.replace({ id: "one", title: "before" }); + collection.remove("one"); + expect(collection.readAfter(1)).toEqual({ + mode: "changes", + headSeq: 2, + values: [], + removals: [{ id: "one", seq: 2 }], + }); + + collection.replace({ id: "one", title: "after" }); + expect(collection.readAfter(2)).toEqual({ + mode: "changes", + headSeq: 3, + values: [{ seq: 3, value: { id: "one", title: "after" } }], + removals: [], + }); + }); + + test("falls back to a latest snapshot when a required tombstone expired", () => { + const collection = new VersionedCollection<{ id: string }>({ + getId: (value) => value.id, + maxTombstones: 1, + }); + collection.replace({ id: "one" }); + collection.replace({ id: "two" }); + collection.remove("one"); + collection.remove("two"); + + expect(collection.readAfter(2)).toEqual({ + mode: "snapshot", + reason: "cursor_expired", + headSeq: 4, + values: [], + removals: [], + }); + }); +}); diff --git a/packages/server/src/server/directory-sync/internal/versioned-collection.ts b/packages/server/src/server/directory-sync/internal/versioned-collection.ts new file mode 100644 index 0000000000..e25bc958f2 --- /dev/null +++ b/packages/server/src/server/directory-sync/internal/versioned-collection.ts @@ -0,0 +1,141 @@ +export interface VersionedValue<T> { + seq: number; + value: T; +} + +export interface VersionedRemoval { + id: string; + seq: number; +} + +export interface CollectionRead<T> { + mode: "changes" | "snapshot"; + reason?: "no_cursor" | "generation_changed" | "cursor_expired"; + headSeq: number; + values: VersionedValue<T>[]; + removals: VersionedRemoval[]; +} + +export type PublishedChange<T> = + | { kind: "upsert"; seq: number; value: T } + | { kind: "remove"; seq: number; id: string }; + +interface StoredValue<T> extends VersionedValue<T> { + fingerprint: string; +} + +interface VersionedCollectionOptions<T> { + getId(value: T): string; + fingerprint?(value: T): string; + maxTombstones?: number; +} + +/** Owns latest-state sequencing for one directory entity type. */ +export class VersionedCollection<T> { + private readonly valuesById = new Map<string, StoredValue<T>>(); + private readonly tombstonesById = new Map<string, number>(); + private readonly getId: (value: T) => string; + private readonly fingerprint: (value: T) => string; + private readonly maxTombstones: number; + private expiredThroughSeq = 0; + private nextSeq = 0; + + constructor(options: VersionedCollectionOptions<T>) { + this.getId = options.getId; + this.fingerprint = options.fingerprint ?? ((value) => JSON.stringify(value)); + this.maxTombstones = options.maxTombstones ?? 5_000; + } + + get headSeq(): number { + return this.nextSeq; + } + + sequenceFor(id: string): number | null { + return this.valuesById.get(id)?.seq ?? this.tombstonesById.get(id) ?? null; + } + + replace(value: T): Extract<PublishedChange<T>, { kind: "upsert" }> | null { + const id = this.getId(value); + const fingerprint = this.fingerprint(value); + const previous = this.valuesById.get(id); + if (previous?.fingerprint === fingerprint) return null; + + const seq = this.advance(); + this.valuesById.set(id, { seq, fingerprint, value }); + this.tombstonesById.delete(id); + return { kind: "upsert", seq, value }; + } + + remove(id: string): Extract<PublishedChange<T>, { kind: "remove" }> | null { + if (!this.valuesById.has(id) && this.tombstonesById.has(id)) return null; + if (!this.valuesById.delete(id) && !this.tombstonesById.has(id)) return null; + + const seq = this.advance(); + this.tombstonesById.delete(id); + this.tombstonesById.set(id, seq); + this.trimTombstones(); + return { kind: "remove", seq, id }; + } + + replaceAll(values: Iterable<T>): void { + const retainedIds = new Set<string>(); + for (const value of values) { + const id = this.getId(value); + retainedIds.add(id); + this.replace(value); + } + for (const id of this.valuesById.keys()) { + if (!retainedIds.has(id)) this.remove(id); + } + } + + readSnapshot( + reason: "no_cursor" | "generation_changed" | "cursor_expired" = "no_cursor", + ): CollectionRead<T> { + return { + mode: "snapshot", + reason, + headSeq: this.headSeq, + values: this.sortedValues(), + removals: [], + }; + } + + readAfter(afterSeq: number): CollectionRead<T> { + if (afterSeq < this.expiredThroughSeq || afterSeq > this.headSeq) { + return this.readSnapshot("cursor_expired"); + } + return { + mode: "changes", + headSeq: this.headSeq, + values: this.sortedValues().filter((entry) => entry.seq > afterSeq), + removals: Array.from(this.tombstonesById, ([id, seq]) => ({ id, seq })) + .filter((entry) => entry.seq > afterSeq) + .sort((left, right) => left.seq - right.seq || left.id.localeCompare(right.id)), + }; + } + + private advance(): number { + this.nextSeq += 1; + return this.nextSeq; + } + + private sortedValues(): VersionedValue<T>[] { + return Array.from(this.valuesById.values()) + .map(({ seq, value }) => ({ seq, value })) + .sort( + (left, right) => + left.seq - right.seq || this.getId(left.value).localeCompare(this.getId(right.value)), + ); + } + + private trimTombstones(): void { + while (this.tombstonesById.size > this.maxTombstones) { + const oldest = this.tombstonesById.entries().next().value; + if (!oldest) return; + const [id, seq] = oldest; + this.tombstonesById.delete(id); + this.expiredThroughSeq = Math.max(this.expiredThroughSeq, seq); + } + } +} diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 81312a757b..663e147efe 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -710,6 +710,7 @@ describe("project command-center RPCs", () => { projectDisplayName: "new-project", projectCustomName: null, projectCustomIconRevision: null, + projectIconRevision: "automatic:none:v1", projectRootPath: directoryPath, projectKind: "non_git", }, @@ -4967,7 +4968,7 @@ test("keeps selective delivery scoped per socket when a retained session also ha ]); }); -test("sends project updates only to capable sockets in a retained session", () => { +test("sends project updates only to capable sockets in a retained session", async () => { const messages: SessionOutboundMessage[] = []; const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = []; const session = createSessionForTest({ messages, targetedMessages }); @@ -4976,7 +4977,7 @@ test("sends project updates only to capable sockets in a retained session", () = session.updateClientCapabilities(null, legacySocket); session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true }, capableSocket); - session.emitProjectUpdate({ + await session.emitProjectUpdate({ kind: "upsert", project: createPersistedProjectRecord({ projectId: "project-capable-socket", @@ -5039,6 +5040,7 @@ test("project.list returns every active project descriptor", async () => { projectDisplayName: "acme/app", projectCustomName: null, projectCustomIconRevision: null, + projectIconRevision: "automatic:none:v1", projectRootPath: "/tmp/project-active", projectKind: "git", }, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 1fb8228a8f..1710a7072b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -70,6 +70,7 @@ import { CLIENT_SHUTDOWN_RPC_REASON, normalizeClientRestartRpcReason, } from "./lifecycle-reasons.js"; +import { DirectorySyncService } from "./directory-sync/index.js"; import { AgentManager, AgentRunCancellationError } from "./agent/agent-manager.js"; import { buildTimelinePromptIndex } from "./agent/timeline-prompt-index.js"; @@ -144,7 +145,7 @@ import { import { wrapSpokenInput } from "./voice-config.js"; import { isVoicePermissionAllowed } from "./voice-permission-policy.js"; import { - readProjectIcon, + ProjectIconReader, removeProjectCustomIcon, setProjectCustomIcon, } from "../utils/project-custom-icon.js"; @@ -394,6 +395,7 @@ type WorkspaceUpdatePayload = Extract< >["payload"]; interface WorkspaceUpdatesSubscriptionState { subscriptionId: string; + syncEnabled?: boolean; filter?: WorkspaceUpdatesFilter; isBootstrapping: boolean; pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>; @@ -446,6 +448,7 @@ export interface SessionOptions { agentStorage: AgentStorage; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; + directorySync?: DirectorySyncService; filesystem?: SessionFileSystem; scheduleService: ScheduleService; checkoutDiffManager: CheckoutDiffManager; @@ -579,6 +582,10 @@ interface WorkspaceUpdateOptions { optimisticStatus?: WorkspaceDescriptorPayload["status"]; } +function resolveDirectorySync(service: DirectorySyncService | undefined): DirectorySyncService { + return service ?? new DirectorySyncService(); +} + function describeRegistryTransition(record: ArchivedRecordSnapshot | null): RegistryTransition { if (!record) { return "created"; @@ -612,12 +619,14 @@ export class Session { | null; private readonly sessionLogger: pino.Logger; private readonly paseoHome: string; + private readonly projectIcons: ProjectIconReader; private readonly worktreesRoot: string | undefined; private agentManager: AgentManager; private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly directorySync: DirectorySyncService; private readonly filesystem: SessionFileSystem; private readonly github: ForgeService; private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; @@ -632,6 +641,7 @@ export class Session { private unsubscribeProjectMutations: (() => void) | null = null; private unsubscribeWorkspaceMutations: (() => void) | null = null; private registryMutationQueue: Promise<void> = Promise.resolve(); + private projectUpdateQueue: Promise<void> = Promise.resolve(); private isCleanedUp = false; private viewedTimelineAgentIds = new Set<string>(); private readonly viewedTimelineAgentIdsBySource = new Map<object, Set<string>>(); @@ -640,6 +650,7 @@ export class Session { private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; private readonly agentUpdates: AgentUpdatesService; private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null; + private projectSyncEnabled = false; private readonly workspaceUpdateTails = new Map<string, Promise<void>>(); private clientActivity: { deviceType: "web" | "mobile"; @@ -699,6 +710,7 @@ export class Session { agentStorage, projectRegistry, workspaceRegistry, + directorySync, filesystem, scheduleService, checkoutDiffManager, @@ -744,6 +756,7 @@ export class Session { this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; this.pushNotifications = pushNotifications; this.paseoHome = paseoHome; + this.projectIcons = new ProjectIconReader(paseoHome); this.worktreesRoot = worktreesRoot; this.sessionLogger = logger.child({ module: "session", @@ -764,6 +777,7 @@ export class Session { this.agentStorage = agentStorage; this.projectRegistry = projectRegistry; this.workspaceRegistry = workspaceRegistry; + this.directorySync = resolveDirectorySync(directorySync); this.filesystem = filesystem ?? nodeSessionFileSystem; this.github = github ?? createGitHubService(); this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; @@ -922,6 +936,13 @@ export class Session { this.buildProjectPlacementForWorkspaceId(workspaceId), emitWorkspaceUpdateForWorkspaceId: (workspaceId) => this.emitWorkspaceUpdateForWorkspaceId(workspaceId), + sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) => + this.directorySync.sequenceAgentUpdate( + payload, + agent && project ? { agent, project } : null, + agentId, + includeSequence, + ), logger: this.sessionLogger, }); this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({ @@ -1141,13 +1162,22 @@ export class Session { ); } - emitProjectUpdate(update: ProjectUpdate): void { + emitProjectUpdate(update: ProjectUpdate): Promise<void> { + const queued = this.projectUpdateQueue + .catch(() => undefined) + .then(() => this.publishProjectUpdate(update)); + this.projectUpdateQueue = queued; + return queued; + } + + private async publishProjectUpdate(update: ProjectUpdate): Promise<void> { + const projectedPayload = + update.kind === "upsert" + ? { kind: "upsert" as const, project: await this.buildProjectDescriptor(update.project) } + : update; const message: SessionOutboundMessage = { type: "project.update", - payload: - update.kind === "upsert" - ? { kind: "upsert", project: this.buildProjectDescriptor(update.project) } - : update, + payload: this.directorySync.sequenceProjectUpdate(projectedPayload, this.projectSyncEnabled), }; if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { if (this.supports(CLIENT_CAPS.projectUpdates)) this.emit(message); @@ -2121,7 +2151,7 @@ export class Session { case "fetch_workspaces_request": return this.handleFetchWorkspacesRequest(msg); case "project.list.request": - return this.handleProjectListRequest(msg.requestId); + return this.handleProjectListRequest(msg); case "paseo_worktree_list_request": return this.handlePaseoWorktreeListRequest(msg); case "paseo_worktree_archive_request": @@ -2692,7 +2722,7 @@ export class Session { // Emit a project.update so clients that track the project as an empty // project (no workspaces yet) receive the resolved name immediately. - this.emitProjectUpdate({ kind: "upsert", project: updated }); + await this.emitProjectUpdate({ kind: "upsert", project: updated }); // Re-emit descriptors for every workspace under this project so the new // resolved name lands in the UI immediately. @@ -2746,7 +2776,7 @@ export class Session { type: "project.icon.set.response", payload: { requestId, projectId, accepted: true, error: null }, }); - this.emitProjectUpdate({ kind: "upsert", project: updated }); + await this.emitProjectUpdate({ kind: "upsert", project: updated }); const affectedWorkspaceIds = (await this.workspaceRegistry.list()) .filter((workspace) => workspace.projectId === projectId) @@ -2772,7 +2802,7 @@ export class Session { const project = await this.projectRegistry.get(projectId); if (!project) throw new Error("Project not found"); - const icon = await readProjectIcon({ paseoHome: this.paseoHome, project }); + const icon = await this.projectIcons.read(project); this.emit({ type: "project.icon.get.response", payload: { projectId, icon, error: null, requestId }, @@ -4684,15 +4714,17 @@ export class Session { } } - private buildProjectDescriptor( + private async buildProjectDescriptor( project: PersistedProjectRecord, - ): WorkspaceProjectDescriptorPayload { + ): Promise<WorkspaceProjectDescriptorPayload> { + const icon = await this.projectIcons.snapshot(project); return { projectId: project.projectId, ...(project.projectKey ? { projectKey: project.projectKey } : {}), projectDisplayName: resolveProjectDisplayName(project), projectCustomName: project.customName ?? null, projectCustomIconRevision: project.customIconRevision ?? null, + projectIconRevision: icon.revision, projectRootPath: project.rootPath, projectKind: project.kind, }; @@ -4890,7 +4922,6 @@ export class Session { continue; } this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace); - if (!nextWorkspace) { if (this.shouldSkipWorkspaceRemoval(lastEmitted, options?.removedProjectId)) { continue; @@ -4899,17 +4930,28 @@ export class Session { return; } subscription.lastEmittedByWorkspaceId.delete(workspaceId); + const removePayload = await this.buildWorkspaceRemoveUpdatePayload( + workspaceId, + options?.removedProjectId, + ); this.bufferOrEmitWorkspaceUpdate( subscription, - await this.buildWorkspaceRemoveUpdatePayload(workspaceId, options?.removedProjectId), + this.directorySync.sequenceWorkspaceUpdate( + removePayload, + workspace ?? null, + workspaceId, + subscription.syncEnabled === true, + ), ); continue; } - const nextPayload: WorkspaceUpdatePayload = { - kind: "upsert", - workspace: nextWorkspace, - }; + const nextPayload: WorkspaceUpdatePayload = this.directorySync.sequenceWorkspaceUpdate( + { kind: "upsert", workspace: nextWorkspace }, + workspace ?? null, + workspaceId, + subscription.syncEnabled === true, + ); if ( lastEmitted && @@ -5013,10 +5055,13 @@ export class Session { this.agentUpdates.beginSubscription({ subscriptionId, filter: request.filter, + syncEnabled: Boolean(request.sync), }); } - const payload = await this.listFetchAgentsEntries(request); + const payload = request.sync + ? await this.readAgentDirectorySync(request) + : await this.listFetchAgentsEntries(request); const snapshotUpdatedAtByAgentId = new Map<string, number>(); for (const entry of payload.entries) { const parsedUpdatedAt = Date.parse(entry.agent.updatedAt); @@ -5147,6 +5192,7 @@ export class Session { if (subscriptionId) { this.workspaceUpdatesSubscription = { subscriptionId, + syncEnabled: Boolean(request.sync), filter: request.filter, isBootstrapping: true, pendingUpdatesByWorkspaceId: new Map(), @@ -5155,7 +5201,9 @@ export class Session { }; } - const payload = await this.listFetchWorkspacesEntries(request); + const payload = request.sync + ? await this.readWorkspaceDirectorySync(request) + : await this.listFetchWorkspacesEntries(request); this.workspaceGitObserver.syncObservers(payload.entries); this.sessionLogger.debug( { @@ -5205,21 +5253,32 @@ export class Session { } } - private async handleProjectListRequest(requestId: string): Promise<void> { + private async handleProjectListRequest( + request: Extract<SessionInboundMessage, { type: "project.list.request" }>, + ): Promise<void> { try { - const projects = (await this.projectRegistry.list()) - .filter((project) => !project.archivedAt) - .map((project) => this.buildProjectDescriptor(project)); + const projects = await Promise.all( + (await this.projectRegistry.list()) + .filter((project) => !project.archivedAt) + .map((project) => this.buildProjectDescriptor(project)), + ); + const synchronized = request.sync + ? this.directorySync.synchronizeProjects(projects, request.sync) + : null; + if (synchronized) this.projectSyncEnabled = true; this.emit({ type: "project.list.response", - payload: { requestId, projects }, + payload: { + requestId: request.requestId, + ...(synchronized ?? { projects }), + }, }); } catch (error) { this.sessionLogger.error({ err: error }, "Failed to handle project.list.request"); this.emit({ type: "rpc_error", payload: { - requestId, + requestId: request.requestId, requestType: "project.list.request", error: error instanceof Error ? error.message : "Failed to list projects", code: "project_list_failed", @@ -5228,6 +5287,37 @@ export class Session { } } + private async readAgentDirectorySync( + request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>, + ) { + if (request.scope !== "active" || request.filter) { + throw new SessionRequestError( + "invalid_request", + "Sequenced agent directory reads require scope=active and no filter.", + ); + } + const snapshot = await this.listFetchAgentsEntries({ + ...request, + page: { limit: Number.MAX_SAFE_INTEGER }, + }); + return this.directorySync.synchronizeAgents(snapshot.entries, request.sync ?? {}); + } + + private async readWorkspaceDirectorySync( + request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>, + ) { + if (request.filter) { + throw new SessionRequestError( + "invalid_request", + "Sequenced workspace directory reads do not support filters.", + ); + } + return this.directorySync.synchronizeWorkspaces( + await this.workspaceDirectory.listDescriptors(), + request.sync ?? {}, + ); + } + // Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates` // to decide which pending updates to drop. Captures the status, // statusEnteredAt, and activityAt (parsed to ms) for each workspace entry @@ -5580,7 +5670,7 @@ export class Session { type: "project.add.response", payload: { requestId: request.requestId, - project: this.buildProjectDescriptor(project), + project: await this.buildProjectDescriptor(project), error: null, }, }); @@ -5614,7 +5704,7 @@ export class Session { payload: { requestId: request.requestId, directoryPath: result.directoryPath, - project: this.buildProjectDescriptor(result.project), + project: await this.buildProjectDescriptor(result.project), error: null, errorCode: null, }, @@ -5779,7 +5869,7 @@ export class Session { requestId: request.requestId, repo: repo.displayName, checkoutPath, - project: this.buildProjectDescriptor(project), + project: await this.buildProjectDescriptor(project), error: null, }, }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 572680f9aa..d72e0b7993 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -736,6 +736,55 @@ function createSessionForWorkspaceTests( return session; } +test("project.list.request catches up by sequence on the existing RPC", async () => { + const emitted: SessionOutboundMessage[] = []; + let project = createPersistedProjectRecord({ + projectId: "project-sequenced", + rootPath: "/repo/sequenced", + kind: "git", + displayName: "Before", + createdAt: "2026-08-12T08:00:00.000Z", + updatedAt: "2026-08-12T08:00:00.000Z", + }); + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [project], + get: async () => project, + getOrCreateActiveByRoot: async () => project, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }, + }); + + await session.handleMessage({ + type: "project.list.request", + requestId: "initial", + sync: {}, + }); + const initial = findByType(emitted, "project.list.response")?.payload; + expect(initial?.sync).toMatchObject({ mode: "snapshot", headSeq: 1 }); + + project = { ...project, displayName: "After", updatedAt: "2026-08-12T08:01:00.000Z" }; + await session.handleMessage({ + type: "project.list.request", + requestId: "catch-up", + sync: { + generation: initial?.sync?.generation, + afterSeq: initial?.sync?.headSeq, + }, + }); + const responses = filterByType(emitted, "project.list.response"); + expect(responses.at(-1)?.payload).toMatchObject({ + requestId: "catch-up", + projects: [{ projectId: "project-sequenced", projectDisplayName: "After", syncSeq: 2 }], + sync: { mode: "changes", headSeq: 2, removals: [] }, + }); +}); + test("agent updates preserve queued live transitions across stored metadata reads", async () => { const running = makeManagedAgent({ id: "agent-coherent", @@ -7754,15 +7803,17 @@ const ICON_PNG_1X1 = Buffer.from([ test("project.icon.set.request publishes a custom icon that project.icon.get serves back", async () => { const emitted: SessionOutboundMessage[] = []; + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-project-icon-test-"))); const session = asTestSession( - createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }), + createSessionForWorkspaceTests({ + paseoHome: path.join(tempDir, "paseo-home"), + onMessage: (message) => emitted.push(message), + }), ); session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true }); - const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-project-icon-test-"))); const projectRoot = path.join(tempDir, "project-without-icons"); mkdirSync(projectRoot, { recursive: true }); - session.paseoHome = path.join(tempDir, "paseo-home"); const project = createPersistedProjectRecord({ projectId: "prj_icon", diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts index 29da3106f0..66d58042bf 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts @@ -8,6 +8,7 @@ import type { } from "../../messages.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; import type { StoredAgentRecord } from "../../agent/agent-storage.js"; +import { DirectorySyncService } from "../../directory-sync/index.js"; // No mocks — every dependency is an injected in-memory fake. The agent payloads // are supplied through the fake builders, so each test fully controls the @@ -100,6 +101,7 @@ function buildHarness() { let providerVisible: (provider: string) => boolean = () => true; let buildAgentPayloadError: Error | null = null; let enrichProjectedPayload = false; + const directorySync = new DirectorySyncService("test-generation"); const service = createAgentUpdatesService({ emit: (message) => emitted.push(message), @@ -133,6 +135,13 @@ function buildHarness() { emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { workspaceUpdates.push(workspaceId); }, + sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) => + directorySync.sequenceAgentUpdate( + payload, + agent && project ? { agent, project } : null, + agentId, + includeSequence, + ), logger: { error: (...args: unknown[]) => loggedErrors.push(args) } as unknown as pino.Logger, }); diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.ts index ac4f3c8d7a..763275a395 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.ts @@ -16,6 +16,7 @@ type AgentUpdatesFilter = NonNullable< interface AgentUpdatesSubscriptionState { subscriptionId: string; + syncEnabled?: boolean; filter?: AgentUpdatesFilter; isBootstrapping: boolean; pendingUpdatesByAgentId: Map<string, AgentUpdatePayload>; @@ -37,7 +38,11 @@ interface AgentUpdatesSubscriptionState { * stay consistent. */ export interface AgentUpdatesService { - beginSubscription(input: { subscriptionId: string; filter?: AgentUpdatesFilter }): void; + beginSubscription(input: { + subscriptionId: string; + filter?: AgentUpdatesFilter; + syncEnabled?: boolean; + }): void; flushBootstrapped( subscriptionId: string, options?: { snapshotUpdatedAtByAgentId?: Map<string, number> }, @@ -57,6 +62,13 @@ export interface AgentUpdatesServiceDeps { isProviderVisibleToClient(provider: string): boolean; buildProjectPlacementForWorkspaceId(workspaceId: string): Promise<ProjectPlacementPayload | null>; emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void>; + sequenceAgentUpdate<T extends AgentUpdatePayload>( + payload: T, + agent: AgentSnapshotPayload | null, + project: ProjectPlacementPayload | null, + agentId: string, + includeSequence: boolean, + ): T; logger: pino.Logger; } @@ -151,6 +163,13 @@ function agentUpdateTargetId(update: AgentUpdatePayload): string { export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentUpdatesService { let subscription: AgentUpdatesSubscriptionState | null = null; const liveAgentUpdateTails = new Map<string, Promise<void>>(); + const sequence = <T extends AgentUpdatePayload>( + sub: AgentUpdatesSubscriptionState, + payload: T, + agent: AgentSnapshotPayload | null, + project: ProjectPlacementPayload | null, + agentId: string, + ) => deps.sequenceAgentUpdate(payload, agent, project, agentId, sub.syncEnabled === true); function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void { if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) { @@ -167,9 +186,14 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU }); } - function beginSubscription(input: { subscriptionId: string; filter?: AgentUpdatesFilter }): void { + function beginSubscription(input: { + subscriptionId: string; + filter?: AgentUpdatesFilter; + syncEnabled?: boolean; + }): void { subscription = { subscriptionId: input.subscriptionId, + syncEnabled: input.syncEnabled, filter: input.filter, isBootstrapping: true, pendingUpdatesByAgentId: new Map(), @@ -230,10 +254,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) : null; if (!project) { - bufferOrEmit(sub, { - kind: "remove", - agentId: payload.id, - }); + bufferOrEmit( + sub, + sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id), + ); return payload; } @@ -244,16 +268,22 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU }); bufferOrEmit( sub, - matches - ? { - kind: "upsert", - agent: payload, - project, - } - : { - kind: "remove", - agentId: payload.id, - }, + sequence( + sub, + matches + ? { + kind: "upsert", + agent: payload, + project, + } + : { + kind: "remove", + agentId: payload.id, + }, + payload, + project, + payload.id, + ), ); return payload; } @@ -267,10 +297,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) : null; if (!project) { - bufferOrEmit(sub, { - kind: "remove", - agentId: payload.id, - }); + bufferOrEmit( + sub, + sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id), + ); } else { const matches = matchesAgentUpdatesFilter({ agent: payload, @@ -279,16 +309,34 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU }); if (matches) { - bufferOrEmit(sub, { - kind: "upsert", - agent: payload, - project, - }); + bufferOrEmit( + sub, + sequence( + sub, + { + kind: "upsert", + agent: payload, + project, + }, + payload, + project, + payload.id, + ), + ); } else { - bufferOrEmit(sub, { - kind: "remove", - agentId: payload.id, - }); + bufferOrEmit( + sub, + sequence( + sub, + { + kind: "remove", + agentId: payload.id, + }, + payload, + project, + payload.id, + ), + ); } } } @@ -326,7 +374,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU function removeAgent(agentId: string): Promise<void> { return enqueueAgentUpdate(agentId, () => { if (subscription) { - bufferOrEmit(subscription, { kind: "remove", agentId }); + bufferOrEmit( + subscription, + sequence(subscription, { kind: "remove", agentId }, null, null, agentId), + ); } }); } diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index 94e80cc327..d29418a9a5 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -645,6 +645,16 @@ describe("CheckoutSession", () => { }, ]); }); + + it("does not emit the same checkout status twice", () => { + const { checkout, emitted } = makeCheckoutSession(); + const snapshot = createGitSnapshot("/repo", "main"); + + checkout.emitStatusUpdate("/repo", snapshot); + checkout.emitStatusUpdate("/repo", snapshot); + + expect(emitted).toHaveLength(1); + }); }); describe("switch branch", () => { diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index e349f8edd7..7545b9f510 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -153,6 +153,7 @@ export class CheckoutSession { private readonly worktreesRoot: string | undefined; private readonly logger: pino.Logger; private readonly diffSubscriptions = new Map<string, () => void>(); + private readonly statusUpdateFingerprints = new Map<string, string>(); constructor(options: CheckoutSessionOptions) { this.host = options.host; @@ -480,20 +481,24 @@ export class CheckoutSession { emitStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void { try { const requestId = `subscription:${cwd}`; + const payload = { + ...buildCheckoutStatusPayloadFromSnapshot({ + cwd, + requestId, + snapshot, + }), + prStatus: buildCheckoutPrStatusPayloadFromSnapshot({ + cwd, + requestId, + snapshot, + }), + }; + const fingerprint = JSON.stringify(payload); + if (this.statusUpdateFingerprints.get(cwd) === fingerprint) return; + this.statusUpdateFingerprints.set(cwd, fingerprint); this.host.emit({ type: "checkout_status_update", - payload: { - ...buildCheckoutStatusPayloadFromSnapshot({ - cwd, - requestId, - snapshot, - }), - prStatus: buildCheckoutPrStatusPayloadFromSnapshot({ - cwd, - requestId, - snapshot, - }), - }, + payload, }); } catch (error) { this.logger.warn({ err: error, cwd }, "Failed to emit workspace checkout status update"); @@ -1411,6 +1416,7 @@ export class CheckoutSession { unsubscribe(); } this.diffSubscriptions.clear(); + this.statusUpdateFingerprints.clear(); } } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index d14681b531..bc2c347a81 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -91,6 +91,7 @@ import { } from "@getpaseo/protocol/browser-automation/capabilities"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; +import { DirectorySyncService } from "./directory-sync/index.js"; import { APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS, ApplicationSocketLease, @@ -577,6 +578,7 @@ export class VoiceAssistantWebSocketServer { private acceptingConnections = true; private readonly advertiseDaemonStatusRpc: boolean; private readonly advertiseRelayConfig: boolean; + private readonly directorySync = new DirectorySyncService(); constructor( server: HTTPServer, @@ -900,7 +902,11 @@ export class VoiceAssistantWebSocketServer { } public publishProjectUpdate(update: ProjectUpdate): void { - for (const session of this.listTrustedSessions()) session.emitProjectUpdate(update); + for (const session of this.listTrustedSessions()) { + void session + .emitProjectUpdate(update) + .catch((error) => this.logger.warn({ err: error }, "Failed to publish project update")); + } } public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void { @@ -1344,6 +1350,7 @@ export class VoiceAssistantWebSocketServer { agentStorage: this.agentStorage, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, + directorySync: this.directorySync, scheduleService: this.scheduleService, checkoutDiffManager: this.checkoutDiffManager, github: this.github, @@ -1529,6 +1536,8 @@ export class VoiceAssistantWebSocketServer { desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), features: { + // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. + directorySync: true, // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. providersSnapshot: true, // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 623e2be2c7..89c4419823 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -11,6 +11,7 @@ import { type SessionOutboundMessage, } from "@getpaseo/protocol/messages"; import { Session, type SessionOptions } from "./session.js"; +import { DirectorySyncService } from "./directory-sync/index.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import type { AgentTimelineRow } from "./agent/agent-manager.js"; import { InMemoryAgentTimelineStore } from "./agent/agent-timeline-store.js"; @@ -180,6 +181,7 @@ class InMemoryWorktreeWorkflow { function createSessionForWireCompatTest(options?: { clientCapabilities?: Record<string, unknown> | null; + directorySync?: DirectorySyncService; messages?: SessionOutboundMessage[]; rows?: AgentTimelineRow[]; }): Session { @@ -218,6 +220,7 @@ function createSessionForWireCompatTest(options?: { projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"], workspaceRegistry: new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"], + directorySync: options?.directorySync, scheduleService: {} as SessionOptions["scheduleService"], checkoutDiffManager: { scheduleRefreshForCwd() {}, @@ -304,7 +307,7 @@ async function emitTimelineResponse(options?: { } describe("wire compatibility", () => { - test("sends project updates only to clients that declare support", () => { + test("sends project updates only to clients that declare support", async () => { const project = createPersistedProjectRecord({ projectId: "project-1", rootPath: "/tmp/project", @@ -322,10 +325,12 @@ describe("wire compatibility", () => { messages: capableMessages, }); - legacy.emitProjectUpdate({ kind: "upsert", project }); - legacy.emitProjectUpdate({ kind: "remove", projectId: project.projectId }); - capable.emitProjectUpdate({ kind: "upsert", project }); - capable.emitProjectUpdate({ kind: "remove", projectId: project.projectId }); + await Promise.all([ + legacy.emitProjectUpdate({ kind: "upsert", project }), + legacy.emitProjectUpdate({ kind: "remove", projectId: project.projectId }), + capable.emitProjectUpdate({ kind: "upsert", project }), + capable.emitProjectUpdate({ kind: "remove", projectId: project.projectId }), + ]); expect(legacyMessages).toEqual([]); expect(capableMessages.map((message) => SessionOutboundMessageSchema.parse(message))).toEqual([ @@ -338,6 +343,7 @@ describe("wire compatibility", () => { projectDisplayName: "Favorite project", projectCustomName: "Favorite project", projectCustomIconRevision: null, + projectIconRevision: "automatic:none:v1", projectRootPath: "/tmp/project", projectKind: "git", }, @@ -350,6 +356,50 @@ describe("wire compatibility", () => { ]); }); + test("publishes rapid project mutations in order before incremental reconciliation", async () => { + const directorySync = new DirectorySyncService("generation"); + const initial = directorySync.synchronizeProjects([], {}); + const messages: SessionOutboundMessage[] = []; + const session = createSessionForWireCompatTest({ + clientCapabilities: { [CLIENT_CAPS.projectUpdates]: true }, + directorySync, + messages, + }); + const project = createPersistedProjectRecord({ + projectId: "project-ordered", + rootPath: "/tmp/project-ordered", + kind: "git", + displayName: "Ordered project", + createdAt: "2026-07-15T00:00:00.000Z", + updatedAt: "2026-07-15T00:00:00.000Z", + }); + + await Promise.all([ + session.emitProjectUpdate({ kind: "upsert", project }), + session.emitProjectUpdate({ kind: "remove", projectId: project.projectId }), + ]); + + expect( + messages.flatMap((message) => + message.type === "project.update" ? [message.payload.kind] : [], + ), + ).toEqual(["upsert", "remove"]); + expect( + directorySync.synchronizeProjects([], { + generation: initial.sync.generation, + afterSeq: initial.sync.headSeq, + }), + ).toEqual({ + projects: [], + sync: { + generation: "generation", + mode: "changes", + headSeq: 2, + removals: [{ id: "project-ordered", seq: 2 }], + }, + }); + }); + test("downgrades reasoning_merge for clients that do not declare the capability", async () => { const response = await emitTimelineResponse(); diff --git a/packages/server/src/utils/project-custom-icon.test.ts b/packages/server/src/utils/project-custom-icon.test.ts index fa1a81ba88..dc9568d1d0 100644 --- a/packages/server/src/utils/project-custom-icon.test.ts +++ b/packages/server/src/utils/project-custom-icon.test.ts @@ -10,7 +10,9 @@ import { type ProjectRegistry, } from "../server/workspace-registry.js"; import { + ProjectIconReader, readProjectIcon, + readProjectIconSnapshot, removeProjectCustomIcon, setProjectCustomIcon, } from "./project-custom-icon.js"; @@ -52,6 +54,7 @@ async function project() { return record; }, } as unknown as ProjectRegistry; + const reader = new ProjectIconReader(paseoHome); return { paseoHome, @@ -59,6 +62,9 @@ async function project() { set: (source: ProjectIconSource) => setProjectCustomIcon({ paseoHome, projectId: "project-a", source, projects }), read: () => readProjectIcon({ paseoHome, project: record }), + snapshot: () => readProjectIconSnapshot({ paseoHome, project: record }), + advertisedSnapshot: () => reader.snapshot(record), + readAdvertised: () => reader.read(record), revision: () => record.customIconRevision, remove: () => removeProjectCustomIcon({ paseoHome, projectId: "project-a" }), }; @@ -88,6 +94,40 @@ describe("project custom icon", () => { await expect(target.read()).resolves.toEqual(PNG_1X1_ICON); }); + it("fingerprints automatic icons and explicit no-icon results", async () => { + const target = await project(); + + const missing = await target.snapshot(); + expect(missing).toEqual({ icon: null, revision: "automatic:none:v1" }); + + await mkdir(join(target.rootPath, "public")); + await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1); + const first = await target.snapshot(); + await writeFile( + join(target.rootPath, "public", "favicon.png"), + Buffer.concat([PNG_1X1, Buffer.from([1])]), + ); + const changed = await target.snapshot(); + + expect(first.icon).toEqual(PNG_1X1_ICON); + expect(first.revision).toMatch(/^automatic:[a-f0-9]{64}$/); + expect(changed.revision).not.toBe(first.revision); + }); + + it("serves the bytes that match the advertised revision", async () => { + const target = await project(); + await mkdir(join(target.rootPath, "public")); + await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1); + const advertised = await target.advertisedSnapshot(); + + await writeFile( + join(target.rootPath, "public", "favicon.png"), + Buffer.concat([PNG_1X1, Buffer.from([1])]), + ); + + await expect(target.readAdvertised()).resolves.toEqual(advertised.icon); + }); + it("drops the stored image when the project goes back to automatic", async () => { const target = await project(); await target.set({ type: "upload", data: PNG_1X1.toString("base64") }); diff --git a/packages/server/src/utils/project-custom-icon.ts b/packages/server/src/utils/project-custom-icon.ts index 412ca831ce..7d5aba2fa1 100644 --- a/packages/server/src/utils/project-custom-icon.ts +++ b/packages/server/src/utils/project-custom-icon.ts @@ -57,12 +57,63 @@ export async function readProjectIcon(input: { paseoHome: string; project: PersistedProjectRecord; }): Promise<ProjectIcon | null> { - if (!input.project.customIconRevision) return getProjectIcon(input.project.rootPath); - try { - return validateIcon(await readFile(cachePath(input.paseoHome, input.project.projectId))); - } catch { - return null; + return (await readProjectIconSnapshot(input)).icon; +} + +export interface ProjectIconSnapshot { + icon: ProjectIcon | null; + revision: string; +} + +/** Keeps the icon bytes served by a session aligned with its advertised revision. */ +export class ProjectIconReader { + private readonly snapshots = new Map<string, ProjectIconSnapshot>(); + + constructor(private readonly paseoHome: string) {} + + async snapshot(project: PersistedProjectRecord): Promise<ProjectIconSnapshot> { + const snapshot = await readProjectIconSnapshot({ paseoHome: this.paseoHome, project }); + this.snapshots.set(project.projectId, snapshot); + return snapshot; } + + async read(project: PersistedProjectRecord): Promise<ProjectIcon | null> { + return (this.snapshots.get(project.projectId) ?? (await this.snapshot(project))).icon; + } +} + +/** Reads the effective icon and gives that exact result a stable identity. */ +export async function readProjectIconSnapshot(input: { + paseoHome: string; + project: PersistedProjectRecord; +}): Promise<ProjectIconSnapshot> { + if (input.project.customIconRevision) { + let icon: ProjectIcon | null = null; + try { + icon = validateIcon(await readFile(cachePath(input.paseoHome, input.project.projectId))); + } catch { + // A missing/corrupt custom icon is itself a cacheable result until the + // registry revision changes. + } + return { + icon, + revision: icon + ? iconRevision("custom", icon) + : `custom:none:${input.project.customIconRevision}`, + }; + } + const icon = await getProjectIcon(input.project.rootPath); + if (!icon) return { icon: null, revision: "automatic:none:v1" }; + return { icon, revision: iconRevision("automatic", icon) }; +} + +function iconRevision(source: "automatic" | "custom", icon: ProjectIcon): string { + const fingerprint = createHash("sha256") + .update(icon.mimeType) + .update("\0") + .update(icon.data) + .digest("hex"); + return `${source}:${fingerprint}`; } export async function removeProjectCustomIcon(input: { From b03dc804eb8cf9a1c15fbeca1e9f511e9daec2f1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Thu, 13 Aug 2026 23:48:01 +0200 Subject: [PATCH 71/73] 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. --- .../app/e2e/browser/sidebar-reorder.spec.ts | 40 ++- .../app/e2e/support/helpers/seed-client.ts | 1 + .../components/draggable-list.web.test.tsx | 90 ++++++ .../app/src/components/draggable-list.web.tsx | 78 ++++- .../src/components/sidebar-workspace-list.tsx | 90 +++++- .../src/components/sidebar/sidebar-model.tsx | 4 + .../sidebar/sidebar-projection.test.ts | 1 + .../components/sidebar/sidebar-projection.ts | 2 + .../sidebar/sidebar-status-list.tsx | 268 ++++++++++++++---- .../sidebar/sidebar-workspace-row-content.tsx | 27 +- .../sidebar/sidebar-workspace-row.tsx | 12 +- .../use-long-press-drag-interaction.ts | 15 +- .../app/src/hooks/use-sidebar-pins.test.ts | 26 ++ packages/app/src/hooks/use-sidebar-pins.ts | 13 +- .../src/stores/sidebar-order-store.test.ts | 9 + .../app/src/stores/sidebar-order-store.ts | 21 +- 16 files changed, 602 insertions(+), 95 deletions(-) create mode 100644 packages/app/src/components/draggable-list.web.test.tsx diff --git a/packages/app/e2e/browser/sidebar-reorder.spec.ts b/packages/app/e2e/browser/sidebar-reorder.spec.ts index c0d199148e..6129d37a63 100644 --- a/packages/app/e2e/browser/sidebar-reorder.spec.ts +++ b/packages/app/e2e/browser/sidebar-reorder.spec.ts @@ -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(); diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 71a27dde5b..ff4aa01909 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -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: diff --git a/packages/app/src/components/draggable-list.web.test.tsx b/packages/app/src/components/draggable-list.web.test.tsx new file mode 100644 index 0000000000..4ccca38297 --- /dev/null +++ b/packages/app/src/components/draggable-list.web.test.tsx @@ -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); + }); +}); diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index 7c1e683e50..92a4d2287e 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -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 diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 124783733f..68de9d73df 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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; @@ -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} @@ -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} diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index 1e4afa12b5..f9f25bb558 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -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, ], ); diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index b5d15d2451..5eb82a9a83 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -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], diff --git a/packages/app/src/components/sidebar/sidebar-projection.ts b/packages/app/src/components/sidebar/sidebar-projection.ts index be96f55979..d6c0c0e3a5 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.ts @@ -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 = diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 3bb406cf54..a1ca1324e6 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -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} @@ -749,8 +889,9 @@ function StatusWorkspaceRowInner({ 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, }, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 40ef6e0a48..2e6b5ac5e5 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -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> ); } diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index e7a34a5f32..87c28cf64f 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -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 : {})} @@ -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} diff --git a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts index 747f2b6bcd..8e3a508b3b 100644 --- a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts +++ b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts @@ -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 }) => { diff --git a/packages/app/src/hooks/use-sidebar-pins.test.ts b/packages/app/src/hooks/use-sidebar-pins.test.ts index 80af801e07..f418027fca 100644 --- a/packages/app/src/hooks/use-sidebar-pins.test.ts +++ b/packages/app/src/hooks/use-sidebar-pins.test.ts @@ -39,6 +39,7 @@ describe("splitPinnedSidebarGroups", () => { pinnedWorkspaceKeys: ["w1"], pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" }, }, + pinnedWorkspaceOrder: [], }); expect(result.pinnedChats).toHaveLength(1); expect(result.unpinnedProjects).toHaveLength(0); @@ -49,6 +50,7 @@ describe("splitPinnedSidebarGroups", () => { const result = splitPinnedSidebarGroups({ projects, keys: { pinnedWorkspaceKeys: [], pinnedAtByKey: {} }, + pinnedWorkspaceOrder: [], }); expect(result.unpinnedProjects).toHaveLength(1); }); @@ -61,6 +63,7 @@ describe("splitPinnedSidebarGroups", () => { pinnedWorkspaceKeys: ["w1"], pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" }, }, + pinnedWorkspaceOrder: [], }); expect(result.pinnedChats.map((w) => w.workspaceKey)).toEqual(["w1"]); expect(result.unpinnedProjects[0]?.workspaces.map((w) => w.workspaceKey)).toEqual(["w2"]); @@ -77,6 +80,7 @@ describe("splitPinnedSidebarGroups", () => { newer: "2026-02-01T00:00:00Z", }, }, + pinnedWorkspaceOrder: [], }); expect(result.pinnedChats.map((workspace) => workspace.workspaceKey)).toEqual([ @@ -84,4 +88,26 @@ describe("splitPinnedSidebarGroups", () => { "older", ]); }); + + it("applies the saved order while keeping a newly pinned chat first", () => { + const projects = [project("p1", [placement("older"), placement("newer"), placement("new")])]; + const result = splitPinnedSidebarGroups({ + projects, + keys: { + pinnedWorkspaceKeys: ["older", "newer", "new"], + pinnedAtByKey: { + older: "2026-01-01T00:00:00Z", + newer: "2026-02-01T00:00:00Z", + new: "2026-03-01T00:00:00Z", + }, + }, + pinnedWorkspaceOrder: ["older", "newer"], + }); + + expect(result.pinnedChats.map((workspace) => workspace.workspaceKey)).toEqual([ + "new", + "older", + "newer", + ]); + }); }); diff --git a/packages/app/src/hooks/use-sidebar-pins.ts b/packages/app/src/hooks/use-sidebar-pins.ts index 37524b2461..fade0507d4 100644 --- a/packages/app/src/hooks/use-sidebar-pins.ts +++ b/packages/app/src/hooks/use-sidebar-pins.ts @@ -5,6 +5,7 @@ import type { SidebarProjectEntry, SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; +import { applyStoredOrdering } from "@/hooks/sidebar-workspaces-view-model"; import { useSessionStore } from "@/stores/session-store"; export interface PinnedSidebarKeys { @@ -101,8 +102,9 @@ export function usePinnedSidebarKeys(projects: SidebarProjectEntry[]): PinnedSid export function splitPinnedSidebarGroups(input: { projects: SidebarProjectEntry[]; keys: PinnedSidebarKeys; + pinnedWorkspaceOrder: string[]; }): PinnedSidebarGroups { - const { projects, keys } = input; + const { projects, keys, pinnedWorkspaceOrder } = input; if (keys.pinnedWorkspaceKeys.length === 0) { return { pinnedChats: [], unpinnedProjects: projects }; } @@ -138,5 +140,12 @@ export function splitPinnedSidebarGroups(input: { ), ); - return { pinnedChats, unpinnedProjects }; + return { + pinnedChats: applyStoredOrdering({ + items: pinnedChats, + storedOrder: pinnedWorkspaceOrder, + getKey: (workspace) => workspace.workspaceKey, + }), + unpinnedProjects, + }; } diff --git a/packages/app/src/stores/sidebar-order-store.test.ts b/packages/app/src/stores/sidebar-order-store.test.ts index d28e03f73a..c58931ca69 100644 --- a/packages/app/src/stores/sidebar-order-store.test.ts +++ b/packages/app/src/stores/sidebar-order-store.test.ts @@ -16,9 +16,18 @@ describe("migrateSidebarOrderState", () => { expect(migrated).toEqual({ projectOrder: ["project-a"], + pinnedWorkspaceOrder: [], workspaceOrderByProject: { "project-a": ["host-a:main", "host-a:feature", "host-b:main"], }, }); }); + + it("normalizes pinned workspace order", () => { + const migrated = migrateSidebarOrderState({ + pinnedWorkspaceOrder: [" host-a:one ", "host-a:one", "", "host-b:two"], + }); + + expect(migrated.pinnedWorkspaceOrder).toEqual(["host-a:one", "host-b:two"]); + }); }); diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index bd6bdbbf30..27e7a190c8 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -6,15 +6,19 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora interface SidebarOrderStoreState { projectOrder: string[]; + pinnedWorkspaceOrder: string[]; workspaceOrderByProject: Record<string, string[]>; getProjectOrder: () => string[]; setProjectOrder: (keys: string[]) => void; + getPinnedWorkspaceOrder: () => string[]; + setPinnedWorkspaceOrder: (keys: string[]) => void; getWorkspaceOrder: (projectViewKey: string) => string[]; setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void; } interface SidebarOrderPersistedState { projectOrder?: string[]; + pinnedWorkspaceOrder?: string[]; workspaceOrderByProject?: Record<string, string[]>; projectOrderByServerId?: Record<string, string[]>; workspaceOrderByServerAndProject?: Record<string, string[]>; @@ -23,6 +27,7 @@ interface SidebarOrderPersistedState { const StringArrayRecordSchema = z.record(z.string(), z.array(z.string())); const SidebarOrderPersistedStateSchema = z.strictObject({ projectOrder: z.array(z.string()).optional(), + pinnedWorkspaceOrder: z.array(z.string()).optional(), workspaceOrderByProject: StringArrayRecordSchema.optional(), projectOrderByServerId: StringArrayRecordSchema.optional(), workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(), @@ -79,11 +84,12 @@ function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): export function migrateSidebarOrderState(persistedState: unknown): { projectOrder: string[]; + pinnedWorkspaceOrder: string[]; workspaceOrderByProject: Record<string, string[]>; } { const result = SidebarOrderPersistedStateSchema.safeParse(persistedState); if (!result.success) { - return { projectOrder: [], workspaceOrderByProject: {} }; + return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} }; } const state: SidebarOrderPersistedState = result.data; @@ -113,19 +119,29 @@ export function migrateSidebarOrderState(persistedState: unknown): { workspaceOrderByProject[scope.projectViewKey] = merged; } - return { projectOrder, workspaceOrderByProject }; + return { + projectOrder, + pinnedWorkspaceOrder: normalizeKeys(state.pinnedWorkspaceOrder ?? []), + workspaceOrderByProject, + }; } export const useSidebarOrderStore = create<SidebarOrderStoreState>()( persist( (set, get) => ({ projectOrder: [], + pinnedWorkspaceOrder: [], workspaceOrderByProject: {}, getProjectOrder: () => get().projectOrder, setProjectOrder: (keys) => { const normalized = normalizeKeys(keys); set({ projectOrder: normalized }); }, + getPinnedWorkspaceOrder: () => get().pinnedWorkspaceOrder, + setPinnedWorkspaceOrder: (keys) => { + const normalized = normalizeKeys(keys); + set({ pinnedWorkspaceOrder: normalized }); + }, getWorkspaceOrder: (projectViewKey) => { const scope = projectViewKey.trim(); if (!scope) return []; @@ -148,6 +164,7 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()( storage: createValidatedPersistStorage(AsyncStorage, SidebarOrderPersistedStateSchema), partialize: (state) => ({ projectOrder: state.projectOrder, + pinnedWorkspaceOrder: state.pinnedWorkspaceOrder, workspaceOrderByProject: state.workspaceOrderByProject, }), version: 1, From 770b87a15cab539f658529e39281b26daff9c06d Mon Sep 17 00:00:00 2001 From: "Daniel G. Kang" <kangmumu@gmail.com> Date: Fri, 14 Aug 2026 16:51:42 +0900 Subject: [PATCH 72/73] 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> --- .../browser/pane/element-selector.electron.ts | 470 ++++++++++++++++++ .../desktop/browser/pane/index.electron.tsx | 380 +++----------- .../browser/resident-webviews.browser.test.ts | 23 - .../src/desktop/browser/resident-webviews.ts | 15 - packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ja.ts | 2 + packages/app/src/i18n/resources/ko.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 2 + packages/desktop/e2e/browser-tabs.e2e.mjs | 105 +++- 14 files changed, 649 insertions(+), 362 deletions(-) create mode 100644 packages/app/src/desktop/browser/pane/element-selector.electron.ts diff --git a/packages/app/src/desktop/browser/pane/element-selector.electron.ts b/packages/app/src/desktop/browser/pane/element-selector.electron.ts new file mode 100644 index 0000000000..3161168284 --- /dev/null +++ b/packages/app/src/desktop/browser/pane/element-selector.electron.ts @@ -0,0 +1,470 @@ +import type { BrowserElementAttachment } from "@/attachments/types"; + +export type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted" | "comment"> & { + attributes?: Record<string, string>; +}; + +export type ElementSelectorMode = "annotate" | "screenshot"; + +export type ElementSelectorOutcome = + | { type: "selected"; mode: ElementSelectorMode; selection: BrowserElementSelection } + | { type: "cancelled" } + | { type: "failed"; reason: "loading" | "timeout" | "unavailable" }; + +export type ElementSelectorStartResult = "started" | "loading" | "unavailable"; + +interface ElementSelectorWebview extends HTMLElement { + isLoading?: () => boolean; + executeJavaScript?: (code: string) => Promise<unknown>; +} + +interface ElementSelectorSession { + mode: ElementSelectorMode; + token: string; + webview: ElementSelectorWebview; + onFinish: (outcome: ElementSelectorOutcome) => void; + stopPolling?: () => void; + timeoutId?: number; +} + +export interface ElementSelectorController { + start(input: { + webview: ElementSelectorWebview; + mode: ElementSelectorMode; + onFinish: (outcome: ElementSelectorOutcome) => void; + }): ElementSelectorStartResult; + cancel(): void; + stopForWebview(webview: ElementSelectorWebview): void; +} + +const SELECTOR_TIMEOUT_MS = 30_000; + +function executeWebviewJavaScript(webview: ElementSelectorWebview, code: string): Promise<unknown> { + if (!webview.isConnected) { + return Promise.resolve(null); + } + try { + return webview.executeJavaScript?.(code) ?? Promise.resolve(null); + } catch (error) { + return Promise.reject(error); + } +} + +function ignoreWebviewJavaScriptError() {} + +function destroyWebviewSelector(webview: ElementSelectorWebview, sessionToken: string): void { + const token = JSON.stringify(sessionToken); + void executeWebviewJavaScript( + webview, + `if (window.__paseoSelector?.sessionToken === ${token}) window.__paseoSelector.destroy();`, + ).catch(ignoreWebviewJavaScriptError); +} + +function clearWebviewSelector(webview: ElementSelectorWebview, sessionToken: string): void { + const token = JSON.stringify(sessionToken); + void executeWebviewJavaScript( + webview, + `if (window.__paseoSelector?.sessionToken === ${token}) window.__paseoSelector.destroy(); if (window.__paseoSelectorResult?.__paseoSessionToken === ${token}) window.__paseoSelectorResult = null;`, + ).catch(ignoreWebviewJavaScriptError); +} + +function readSelectorInstallation( + value: unknown, + sessionToken: string, +): "installed" | "loading" | "unavailable" { + if (value === null || typeof value !== "object") { + return "unavailable"; + } + if (Reflect.get(value, "sessionToken") !== sessionToken) { + return "unavailable"; + } + if (Reflect.get(value, "installed") === true) { + return "installed"; + } + return Reflect.get(value, "reason") === "document-loading" ? "loading" : "unavailable"; +} + +function stopSessionTimers(session: ElementSelectorSession): void { + session.stopPolling?.(); + session.stopPolling = undefined; + if (session.timeoutId !== undefined) { + window.clearTimeout(session.timeoutId); + session.timeoutId = undefined; + } +} + +function startSelectorResultPolling(input: { + webview: ElementSelectorWebview; + sessionToken: string; + onResult: (selection: BrowserElementSelection | null) => void; +}): () => void { + const { webview, sessionToken, onResult } = input; + const token = JSON.stringify(sessionToken); + let stopped = false; + let timerId: number | undefined; + + const schedule = () => { + if (!stopped) { + timerId = window.setTimeout(poll, 200); + } + }; + const poll = () => { + void (async () => { + try { + const raw = await executeWebviewJavaScript( + webview, + `JSON.stringify(window.__paseoSelectorResult?.__paseoSessionToken === ${token} ? window.__paseoSelectorResult : null)`, + ); + const result = typeof raw === "string" ? JSON.parse(raw) : null; + if (!result) { + schedule(); + return; + } + stopped = true; + await executeWebviewJavaScript( + webview, + `if (window.__paseoSelectorResult?.__paseoSessionToken === ${token}) window.__paseoSelectorResult = null;`, + ).catch(ignoreWebviewJavaScriptError); + const cancelled = result.__cancelled === true; + delete result.__cancelled; + delete result.__paseoSessionToken; + onResult(cancelled ? null : (result as BrowserElementSelection)); + } catch { + schedule(); + } + })(); + }; + + schedule(); + return () => { + stopped = true; + if (timerId !== undefined) { + window.clearTimeout(timerId); + } + }; +} + +function buildElementSelectorScript(sessionToken: string): string { + const token = JSON.stringify(sessionToken); + return ` + (function() { + var sessionToken = ${token}; + if (document.readyState === 'loading' || !document.head || !document.documentElement) { + return { installed: false, reason: 'document-loading', sessionToken: sessionToken }; + } + if (window.__paseoSelector) { window.__paseoSelector.destroy(); } + window.__paseoSelectorResult = null; + var style = document.createElement('style'); + style.textContent = [ + '.__paseo-hover { outline: 2px solid #3b82f6 !important; outline-offset: 2px !important; cursor: crosshair !important; }', + '.__paseo-select-mode, .__paseo-select-mode * { cursor: crosshair !important; pointer-events: auto !important; user-select: none !important; }', + '.__paseo-select-mode *, .__paseo-select-mode *::before, .__paseo-select-mode *::after { animation: none !important; transition: none !important; }', + '.__paseo-select-mode a, .__paseo-select-mode button, .__paseo-select-mode input, .__paseo-select-mode select, .__paseo-select-mode textarea, .__paseo-select-mode [role="button"], .__paseo-select-mode [onclick] { pointer-events: none !important; }', + '.__paseo-select-mode iframe, .__paseo-select-mode video, .__paseo-select-mode audio { pointer-events: none !important; }', + '.__paseo-hover-label { position: fixed; z-index: 2147483647; pointer-events: none; max-width: 360px; padding: 4px 8px; border-radius: 6px; background: rgba(24,24,27,0.96); color: #fff; font: 500 11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; box-shadow: 0 2px 10px rgba(0,0,0,0.35); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }', + '.__paseo-hover-label .__paseo-tag { color: #93c5fd; }', + '.__paseo-hover-label .__paseo-id { color: #fca5a5; }', + '.__paseo-hover-label .__paseo-cls { color: #fcd34d; }', + '.__paseo-hover-label .__paseo-dim { color: #a1a1aa; margin-left: 6px; }', + '.__paseo-hover-label .__paseo-comp { color: #86efac; margin-left: 6px; }', + ].join('\\n'); + document.head.appendChild(style); + document.documentElement.classList.add('__paseo-select-mode'); + var hoverLabel = document.createElement('div'); + hoverLabel.className = '__paseo-hover-label'; + hoverLabel.style.display = 'none'; + document.documentElement.appendChild(hoverLabel); + var last = null; + function escapeHtml(value) { + return String(value).replace(/[&<>"]/g, function(ch) { + return ch === '&' ? '&' : ch === '<' ? '<' : ch === '>' ? '>' : '"'; + }); + } + function describeElement(el) { + var tag = el.tagName ? el.tagName.toLowerCase() : 'node'; + var parts = ['<span class="__paseo-tag">' + escapeHtml(tag) + '</span>']; + if (el.id) { + parts.push('<span class="__paseo-id">#' + escapeHtml(el.id) + '</span>'); + } + if (el.classList && el.classList.length) { + var cls = Array.prototype.slice.call(el.classList, 0, 2) + .filter(function(c) { return c.indexOf('__paseo') !== 0; }) + .map(function(c) { return '.' + escapeHtml(c); }) + .join(''); + if (cls) parts.push('<span class="__paseo-cls">' + cls + '</span>'); + } + var comp = getReactSource(el); + if (comp && comp.componentName) { + parts.push('<span class="__paseo-comp"><' + escapeHtml(comp.componentName) + '></span>'); + } + var rect = el.getBoundingClientRect(); + parts.push('<span class="__paseo-dim">' + Math.round(rect.width) + '×' + Math.round(rect.height) + '</span>'); + return { html: parts.join(''), rect: rect }; + } + function positionLabel(rect, e) { + var lw = hoverLabel.offsetWidth || 0; + var lh = hoverLabel.offsetHeight || 0; + var top = rect.top - lh - 6; + if (top < 4) top = rect.bottom + 6; + if (top + lh > window.innerHeight - 4) top = Math.max(4, e.clientY - lh - 6); + var left = rect.left; + if (left + lw > window.innerWidth - 4) left = Math.max(4, window.innerWidth - lw - 4); + if (left < 4) left = 4; + hoverLabel.style.top = Math.round(top) + 'px'; + hoverLabel.style.left = Math.round(left) + 'px'; + } + function onMove(e) { + e.preventDefault(); + e.stopPropagation(); + if (last) last.classList.remove('__paseo-hover'); + var el = e.target; + el.classList.add('__paseo-hover'); + last = el; + try { + var info = describeElement(el); + hoverLabel.innerHTML = info.html; + hoverLabel.style.display = 'block'; + positionLabel(info.rect, e); + } catch (err) { + hoverLabel.style.display = 'none'; + } + } + function buildSelector(el) { + if (el.id) return '#' + el.id; + var path = []; + while (el && el.nodeType === 1) { + var seg = el.tagName.toLowerCase(); + if (el.id) { path.unshift('#' + el.id); break; } + var sib = el, nth = 1; + while (sib = sib.previousElementSibling) { if (sib.tagName === el.tagName) nth++; } + if (nth > 1) seg += ':nth-of-type(' + nth + ')'; + path.unshift(seg); + el = el.parentElement; + } + return path.join(' > '); + } + function getReactSource(el) { + var keys = Object.keys(el); + for (var i = 0; i < keys.length; i++) { + if (keys[i].startsWith('__reactFiber$') || keys[i].startsWith('__reactInternalInstance$')) { + var fiber = el[keys[i]]; + while (fiber) { + if (fiber._debugSource) { + return { + fileName: fiber._debugSource.fileName || null, + lineNumber: fiber._debugSource.lineNumber || null, + columnNumber: fiber._debugSource.columnNumber || null, + componentName: (fiber.type && (typeof fiber.type === 'string' ? fiber.type : fiber.type.displayName || fiber.type.name)) || null + }; + } + if (fiber._debugOwner) { fiber = fiber._debugOwner; } + else if (fiber.return) { fiber = fiber.return; } + else break; + } + } + } + return null; + } + function getParentChain(el, depth) { + var chain = []; + var cur = el.parentElement; + for (var i = 0; i < (depth || 5) && cur; i++) { + var desc = cur.tagName.toLowerCase(); + if (cur.id) desc += '#' + cur.id; + if (cur.className && typeof cur.className === 'string') { var cls = cur.className.trim().replace(/ +/g, ' ').split(' ').slice(0,2).join('.'); if (cls) desc += '.' + cls; } + chain.push(desc); + cur = cur.parentElement; + } + return chain; + } + function getChildSummary(el, max) { + var kids = []; + for (var i = 0; i < Math.min(el.children.length, max || 8); i++) { + var c = el.children[i]; + var desc = c.tagName.toLowerCase(); + if (c.id) desc += '#' + c.id; + kids.push(desc); + } + if (el.children.length > (max || 8)) kids.push('...(' + el.children.length + ' total)'); + return kids; + } + function getRelevantStyles(el) { + var cs = window.getComputedStyle(el); + var pick = ['display','position','width','height','color','background-color','font-size','font-family','padding','margin','border','flex','grid-template-columns','gap','overflow','opacity','z-index']; + var out = {}; + pick.forEach(function(p) { + var v = cs.getPropertyValue(p); + if (v && v !== 'none' && v !== 'normal' && v !== 'auto' && v !== '0px' && v !== 'rgba(0, 0, 0, 0)') out[p] = v; + }); + return out; + } + function onClick(e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + var el = e.target; + if (last) last.classList.remove('__paseo-hover'); + hoverLabel.style.display = 'none'; + var attrs = {}; + for (var i = 0; i < el.attributes.length; i++) { + attrs[el.attributes[i].name] = el.attributes[i].value; + } + var rect = el.getBoundingClientRect(); + var result = { + tag: el.tagName.toLowerCase(), + text: (el.innerText || '').substring(0, 500), + selector: buildSelector(el), + attributes: attrs, + url: location.href, + outerHTML: el.outerHTML.substring(0, 2000), + computedStyles: getRelevantStyles(el), + __paseoSessionToken: sessionToken, + boundingRect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + reactSource: getReactSource(el), + parentChain: getParentChain(el, 5), + children: getChildSummary(el, 8) + }; + destroy(); + window.__paseoSelectorResult = result; + } + function onKey(e) { + if (e.key === 'Escape') { + destroy(); + window.__paseoSelectorResult = { __cancelled: true, __paseoSessionToken: sessionToken }; + } + } + function blockEvent(e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + } + function destroy() { + document.removeEventListener('mousemove', onMove, true); + document.removeEventListener('click', onClick, true); + document.removeEventListener('keydown', onKey, true); + document.removeEventListener('mousedown', blockEvent, true); + document.removeEventListener('mouseup', blockEvent, true); + document.removeEventListener('pointerdown', blockEvent, true); + document.removeEventListener('pointerup', blockEvent, true); + document.removeEventListener('touchstart', blockEvent, true); + document.removeEventListener('touchend', blockEvent, true); + document.removeEventListener('focus', blockEvent, true); + document.removeEventListener('submit', blockEvent, true); + document.documentElement.classList.remove('__paseo-select-mode'); + if (last) last.classList.remove('__paseo-hover'); + if (hoverLabel.parentNode) hoverLabel.parentNode.removeChild(hoverLabel); + style.remove(); + window.__paseoSelector = null; + } + document.addEventListener('mousemove', onMove, true); + document.addEventListener('click', onClick, true); + document.addEventListener('keydown', onKey, true); + document.addEventListener('mousedown', blockEvent, true); + document.addEventListener('mouseup', blockEvent, true); + document.addEventListener('pointerdown', blockEvent, true); + document.addEventListener('pointerup', blockEvent, true); + document.addEventListener('touchstart', blockEvent, true); + document.addEventListener('touchend', blockEvent, true); + document.addEventListener('focus', blockEvent, true); + document.addEventListener('submit', blockEvent, true); + window.__paseoSelector = { destroy: destroy, sessionToken: sessionToken }; + return { installed: true, sessionToken: sessionToken }; + })() + `; +} + +export function createElementSelectorController(): ElementSelectorController { + let currentSession: ElementSelectorSession | null = null; + let sessionCounter = 0; + + const finish = ( + session: ElementSelectorSession, + outcome: ElementSelectorOutcome, + guestCleanup: "clear" | "destroy" | null, + ): boolean => { + stopSessionTimers(session); + if (currentSession !== session) { + return false; + } + currentSession = null; + if (guestCleanup === "clear") { + clearWebviewSelector(session.webview, session.token); + } else if (guestCleanup === "destroy") { + destroyWebviewSelector(session.webview, session.token); + } + session.onFinish(outcome); + return true; + }; + + return { + start({ webview, mode, onFinish }) { + if (!webview.isConnected) { + return "unavailable"; + } + if (webview.isLoading?.()) { + return "loading"; + } + + if (currentSession) { + finish(currentSession, { type: "cancelled" }, "clear"); + } + + sessionCounter += 1; + const session: ElementSelectorSession = { + mode, + token: `${sessionCounter}:${crypto.randomUUID()}`, + webview, + onFinish, + }; + currentSession = session; + session.timeoutId = window.setTimeout(() => { + finish(session, { type: "failed", reason: "timeout" }, "destroy"); + }, SELECTOR_TIMEOUT_MS); + + void executeWebviewJavaScript(webview, buildElementSelectorScript(session.token)) + .then((installation) => { + const installationState = readSelectorInstallation(installation, session.token); + if (currentSession !== session) { + if (installationState === "installed") { + destroyWebviewSelector(webview, session.token); + } + return undefined; + } + if (installationState !== "installed") { + finish(session, { type: "failed", reason: installationState }, null); + return undefined; + } + session.stopPolling = startSelectorResultPolling({ + webview, + sessionToken: session.token, + onResult: (selection) => { + finish( + session, + selection + ? { type: "selected", mode: session.mode, selection } + : { type: "cancelled" }, + null, + ); + }, + }); + return undefined; + }) + .catch(() => { + finish(session, { type: "failed", reason: "unavailable" }, "destroy"); + return undefined; + }); + + return "started"; + }, + cancel() { + if (currentSession) { + finish(currentSession, { type: "cancelled" }, "clear"); + } + }, + stopForWebview(webview) { + if (currentSession?.webview === webview) { + finish(currentSession, { type: "cancelled" }, "destroy"); + } + }, + }; +} diff --git a/packages/app/src/desktop/browser/pane/index.electron.tsx b/packages/app/src/desktop/browser/pane/index.electron.tsx index a97e9ee92e..4edc016eed 100644 --- a/packages/app/src/desktop/browser/pane/index.electron.tsx +++ b/packages/app/src/desktop/browser/pane/index.electron.tsx @@ -59,7 +59,6 @@ import { } from "@/desktop/browser/store"; import { applyInactiveBrowserWebviewViewport, - isResidentBrowserWebviewReady, prepareBrowserWebview, presentBrowserWebview, rememberBrowserWebviewSize, @@ -67,6 +66,11 @@ import { removeResidentBrowserWebview, takeResidentBrowserWebview, } from "../resident-webviews"; +import { + createElementSelectorController, + type BrowserElementSelection, + type ElementSelectorOutcome, +} from "./element-selector.electron"; type ElectronWebview = HTMLElement & { canGoBack?: () => boolean; @@ -77,6 +81,7 @@ type ElectronWebview = HTMLElement & { stop?: () => void; loadURL?: (url: string) => Promise<void>; getURL?: () => string; + isLoading?: () => boolean; executeJavaScript?: (code: string) => Promise<unknown>; focus?: () => void; addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void; @@ -87,10 +92,6 @@ type WebTextInput = TextInput & { getNativeRef?: () => unknown; }; -type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted" | "comment"> & { - attributes?: Record<string, string>; -}; - interface BrowserElementAnnotation { comment: string; } @@ -315,20 +316,6 @@ function executeWebviewJavaScript(webview: ElectronWebview, code: string): Promi function ignoreWebviewJavaScriptError() {} -function destroyWebviewSelector(webview: ElectronWebview): void { - void executeWebviewJavaScript( - webview, - "if(window.__paseoSelector) window.__paseoSelector.destroy();", - ).catch(ignoreWebviewJavaScriptError); -} - -function clearWebviewSelector(webview: ElectronWebview): void { - void executeWebviewJavaScript( - webview, - "if(window.__paseoSelector) window.__paseoSelector.destroy(); window.__paseoSelectorResult = null;", - ).catch(ignoreWebviewJavaScriptError); -} - interface BrowserAnnotationMarker { index: number; selector: string; @@ -437,38 +424,6 @@ function isDesktopBrowserShortcutEvent(payload: unknown): payload is DesktopBrow return event.action === "focus-url"; } -function startSelectorResultPolling(input: { - webview: ElectronWebview; - onSelection: (selection: BrowserElementSelection) => void; - onDone: () => void; -}): number { - const { webview, onSelection, onDone } = input; - const poll = window.setInterval(() => { - void (async () => { - try { - const raw = await executeWebviewJavaScript( - webview, - "JSON.stringify(window.__paseoSelectorResult || null)", - ); - const result = typeof raw === "string" ? JSON.parse(raw) : null; - if (!result) { - return; - } - window.clearInterval(poll); - onDone(); - await executeWebviewJavaScript(webview, "window.__paseoSelectorResult = null;"); - if (!result.__cancelled) { - onSelection(result as BrowserElementSelection); - } - } catch { - // Keep polling; cross-origin/webview timing can make this transient. - } - })(); - }, 200); - - return poll; -} - function ToolbarButton({ label, children, @@ -655,13 +610,15 @@ export function BrowserPane({ const browserRef = useRef(browser); browserRef.current = browser; const pendingNavigationUrlRef = useRef<string | null>(null); - const domReadyRef = useRef(false); const annotationMarkersRef = useRef<BrowserAnnotationMarker[]>([]); const [selectorMode, setSelectorMode] = useState<"annotate" | "screenshot" | null>(null); + const selectorControllerRef = useRef<ReturnType<typeof createElementSelectorController> | null>( + null, + ); + if (!selectorControllerRef.current) { + selectorControllerRef.current = createElementSelectorController(); + } const selectorActive = selectorMode !== null; - // Which action the active selector performs on click: open the annotation card - // ("annotate") or copy a screenshot of the element to the clipboard ("screenshot"). - const selectorModeRef = useRef<"annotate" | "screenshot">("annotate"); const toast = useToast(); const toastRef = useRef(toast); toastRef.current = toast; @@ -737,7 +694,7 @@ export function BrowserPane({ const syncNavigationState = useCallback((input?: { syncUrl?: boolean }) => { const webview = webviewRef.current; - if (!webview || !domReadyRef.current) { + if (!webview) { return; } @@ -776,7 +733,6 @@ export function BrowserPane({ const residentWebview = takeResidentBrowserWebview(browserId) as ElectronWebview | null; const webview = residentWebview ?? (document.createElement("webview") as ElectronWebview); webviewRef.current = webview; - domReadyRef.current = isResidentBrowserWebviewReady(webview); if (!residentWebview) { prepareBrowserWebview(webview, { browserId, @@ -808,7 +764,7 @@ export function BrowserPane({ }); const handleStartLoading = () => { - domReadyRef.current = false; + selectorControllerRef.current?.stopForWebview(webview); updateBrowser(browserId, { isLoading: true, lastError: null }); syncNavigationState({ syncUrl: false }); }; @@ -875,7 +831,6 @@ export function BrowserPane({ }); }; const handleDomReady = () => { - domReadyRef.current = true; syncNavigationState(); // The previous page's overlay is gone after a load; re-apply markers for // the freshly loaded document. @@ -940,10 +895,10 @@ export function BrowserPane({ } else { removeResidentBrowserWebview(browserIdRef.current); } + selectorControllerRef.current?.stopForWebview(webview); if (webviewRef.current === webview) { webviewRef.current = null; } - domReadyRef.current = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [browserId, onFocusPane]); @@ -1196,8 +1151,8 @@ export function BrowserPane({ ); const handleSelectorResult = useCallback( - (selection: BrowserElementSelection) => { - if (selectorModeRef.current === "screenshot") { + (selection: BrowserElementSelection, mode: "annotate" | "screenshot") => { + if (mode === "screenshot") { void screenshotElementToClipboard(selection); return; } @@ -1230,271 +1185,62 @@ export function BrowserPane({ setPendingSelection(null); }, []); + const showSelectorFailure = useCallback( + (reason: "loading" | "timeout" | "unavailable") => { + const message = + reason === "loading" + ? t("workspace.browser.controls.selectorLoading") + : t("workspace.browser.controls.selectorFailed"); + toastRef.current?.error(message); + }, + [t], + ); + + const handleElementSelectorOutcome = useCallback( + (outcome: ElementSelectorOutcome) => { + setSelectorMode(null); + if (outcome.type === "selected") { + handleSelectorResult(outcome.selection, outcome.mode); + } else if (outcome.type === "failed") { + showSelectorFailure(outcome.reason); + } + }, + [handleSelectorResult, showSelectorFailure], + ); + const startElementSelector = useCallback( (mode: "annotate" | "screenshot") => { + if (mode === "annotate" && !workspaceAttachmentScopeKey) { + showSelectorFailure("unavailable"); + return; + } const webview = webviewRef.current; - if (!webview || !domReadyRef.current) return; - // Annotate needs a workspace scope to attach to; screenshot only copies. - if (mode === "annotate" && !workspaceAttachmentScopeKey) return; - selectorModeRef.current = mode; + const controller = selectorControllerRef.current; + if (!webview || !controller) { + showSelectorFailure("unavailable"); + return; + } + + const startResult = controller.start({ + webview, + mode, + onFinish: handleElementSelectorOutcome, + }); + if (startResult !== "started") { + showSelectorFailure(startResult); + return; + } + pendingScreenshotRef.current = undefined; setPendingSelection(null); setSelectorMode(mode); - - const js = ` - (function() { - if (window.__paseoSelector) { window.__paseoSelector.destroy(); } - var overlay = null; - var style = document.createElement('style'); - style.textContent = [ - '.__paseo-hover { outline: 2px solid #3b82f6 !important; outline-offset: 2px !important; cursor: crosshair !important; }', - '.__paseo-select-mode, .__paseo-select-mode * { cursor: crosshair !important; pointer-events: auto !important; user-select: none !important; }', - '.__paseo-select-mode *, .__paseo-select-mode *::before, .__paseo-select-mode *::after { animation: none !important; transition: none !important; }', - '.__paseo-select-mode a, .__paseo-select-mode button, .__paseo-select-mode input, .__paseo-select-mode select, .__paseo-select-mode textarea, .__paseo-select-mode [role="button"], .__paseo-select-mode [onclick] { pointer-events: none !important; }', - '.__paseo-select-mode iframe, .__paseo-select-mode video, .__paseo-select-mode audio { pointer-events: none !important; }', - '.__paseo-hover-label { position: fixed; z-index: 2147483647; pointer-events: none; max-width: 360px; padding: 4px 8px; border-radius: 6px; background: rgba(24,24,27,0.96); color: #fff; font: 500 11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; box-shadow: 0 2px 10px rgba(0,0,0,0.35); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }', - '.__paseo-hover-label .__paseo-tag { color: #93c5fd; }', - '.__paseo-hover-label .__paseo-id { color: #fca5a5; }', - '.__paseo-hover-label .__paseo-cls { color: #fcd34d; }', - '.__paseo-hover-label .__paseo-dim { color: #a1a1aa; margin-left: 6px; }', - '.__paseo-hover-label .__paseo-comp { color: #86efac; margin-left: 6px; }', - ].join('\\n'); - document.head.appendChild(style); - document.documentElement.classList.add('__paseo-select-mode'); - var hoverLabel = document.createElement('div'); - hoverLabel.className = '__paseo-hover-label'; - hoverLabel.style.display = 'none'; - document.documentElement.appendChild(hoverLabel); - var last = null; - function escapeHtml(value) { - return String(value).replace(/[&<>"]/g, function(ch) { - return ch === '&' ? '&' : ch === '<' ? '<' : ch === '>' ? '>' : '"'; - }); - } - function describeElement(el) { - var tag = el.tagName ? el.tagName.toLowerCase() : 'node'; - var parts = ['<span class="__paseo-tag">' + escapeHtml(tag) + '</span>']; - if (el.id) { - parts.push('<span class="__paseo-id">#' + escapeHtml(el.id) + '</span>'); - } - if (el.classList && el.classList.length) { - var cls = Array.prototype.slice.call(el.classList, 0, 2) - .filter(function(c) { return c.indexOf('__paseo') !== 0; }) - .map(function(c) { return '.' + escapeHtml(c); }) - .join(''); - if (cls) parts.push('<span class="__paseo-cls">' + cls + '</span>'); - } - var comp = getReactSource(el); - if (comp && comp.componentName) { - parts.push('<span class="__paseo-comp"><' + escapeHtml(comp.componentName) + '></span>'); - } - var rect = el.getBoundingClientRect(); - parts.push('<span class="__paseo-dim">' + Math.round(rect.width) + '×' + Math.round(rect.height) + '</span>'); - return { html: parts.join(''), rect: rect }; - } - function positionLabel(rect, e) { - var pad = 12; - var lw = hoverLabel.offsetWidth || 0; - var lh = hoverLabel.offsetHeight || 0; - var top = rect.top - lh - 6; - if (top < 4) top = rect.bottom + 6; - if (top + lh > window.innerHeight - 4) top = Math.max(4, e.clientY - lh - 6); - var left = rect.left; - if (left + lw > window.innerWidth - 4) left = Math.max(4, window.innerWidth - lw - 4); - if (left < 4) left = 4; - hoverLabel.style.top = Math.round(top) + 'px'; - hoverLabel.style.left = Math.round(left) + 'px'; - } - function onMove(e) { - e.preventDefault(); - e.stopPropagation(); - if (last) last.classList.remove('__paseo-hover'); - var el = e.target; - el.classList.add('__paseo-hover'); - last = el; - try { - var info = describeElement(el); - hoverLabel.innerHTML = info.html; - hoverLabel.style.display = 'block'; - positionLabel(info.rect, e); - } catch (err) { - hoverLabel.style.display = 'none'; - } - } - function buildSelector(el) { - if (el.id) return '#' + el.id; - var path = []; - while (el && el.nodeType === 1) { - var seg = el.tagName.toLowerCase(); - if (el.id) { path.unshift('#' + el.id); break; } - var sib = el, nth = 1; - while (sib = sib.previousElementSibling) { if (sib.tagName === el.tagName) nth++; } - if (nth > 1) seg += ':nth-of-type(' + nth + ')'; - path.unshift(seg); - el = el.parentElement; - } - return path.join(' > '); - } - function getReactSource(el) { - var keys = Object.keys(el); - for (var i = 0; i < keys.length; i++) { - if (keys[i].startsWith('__reactFiber$') || keys[i].startsWith('__reactInternalInstance$')) { - var fiber = el[keys[i]]; - while (fiber) { - if (fiber._debugSource) { - return { - fileName: fiber._debugSource.fileName || null, - lineNumber: fiber._debugSource.lineNumber || null, - columnNumber: fiber._debugSource.columnNumber || null, - componentName: (fiber.type && (typeof fiber.type === 'string' ? fiber.type : fiber.type.displayName || fiber.type.name)) || null - }; - } - if (fiber._debugOwner) { fiber = fiber._debugOwner; } - else if (fiber.return) { fiber = fiber.return; } - else break; - } - } - } - return null; - } - function getParentChain(el, depth) { - var chain = []; - var cur = el.parentElement; - for (var i = 0; i < (depth || 5) && cur; i++) { - var desc = cur.tagName.toLowerCase(); - if (cur.id) desc += '#' + cur.id; - if (cur.className && typeof cur.className === 'string') { var cls = cur.className.trim().replace(/ +/g, ' ').split(' ').slice(0,2).join('.'); if (cls) desc += '.' + cls; } - chain.push(desc); - cur = cur.parentElement; - } - return chain; - } - function getChildSummary(el, max) { - var kids = []; - for (var i = 0; i < Math.min(el.children.length, max || 8); i++) { - var c = el.children[i]; - var desc = c.tagName.toLowerCase(); - if (c.id) desc += '#' + c.id; - kids.push(desc); - } - if (el.children.length > (max || 8)) kids.push('...(' + el.children.length + ' total)'); - return kids; - } - function getRelevantStyles(el) { - var cs = window.getComputedStyle(el); - var pick = ['display','position','width','height','color','background-color','font-size','font-family','padding','margin','border','flex','grid-template-columns','gap','overflow','opacity','z-index']; - var out = {}; - pick.forEach(function(p) { - var v = cs.getPropertyValue(p); - if (v && v !== 'none' && v !== 'normal' && v !== 'auto' && v !== '0px' && v !== 'rgba(0, 0, 0, 0)') out[p] = v; - }); - return out; - } - function onClick(e) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - var el = e.target; - if (last) last.classList.remove('__paseo-hover'); - hoverLabel.style.display = 'none'; - var attrs = {}; - for (var i = 0; i < el.attributes.length; i++) { - attrs[el.attributes[i].name] = el.attributes[i].value; - } - var rect = el.getBoundingClientRect(); - var result = { - tag: el.tagName.toLowerCase(), - text: (el.innerText || '').substring(0, 500), - selector: buildSelector(el), - attributes: attrs, - url: location.href, - outerHTML: el.outerHTML.substring(0, 2000), - computedStyles: getRelevantStyles(el), - boundingRect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, - reactSource: getReactSource(el), - parentChain: getParentChain(el, 5), - children: getChildSummary(el, 8) - }; - destroy(); - window.__paseoSelectorResult = result; - } - function onKey(e) { - if (e.key === 'Escape') { destroy(); window.__paseoSelectorResult = { __cancelled: true }; } - } - function blockEvent(e) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - } - function destroy() { - document.removeEventListener('mousemove', onMove, true); - document.removeEventListener('click', onClick, true); - document.removeEventListener('keydown', onKey, true); - document.removeEventListener('mousedown', blockEvent, true); - document.removeEventListener('mouseup', blockEvent, true); - document.removeEventListener('pointerdown', blockEvent, true); - document.removeEventListener('pointerup', blockEvent, true); - document.removeEventListener('touchstart', blockEvent, true); - document.removeEventListener('touchend', blockEvent, true); - document.removeEventListener('focus', blockEvent, true); - document.removeEventListener('submit', blockEvent, true); - document.documentElement.classList.remove('__paseo-select-mode'); - if (last) last.classList.remove('__paseo-hover'); - if (hoverLabel.parentNode) hoverLabel.parentNode.removeChild(hoverLabel); - style.remove(); - window.__paseoSelector = null; - } - document.addEventListener('mousemove', onMove, true); - document.addEventListener('click', onClick, true); - document.addEventListener('keydown', onKey, true); - document.addEventListener('mousedown', blockEvent, true); - document.addEventListener('mouseup', blockEvent, true); - document.addEventListener('pointerdown', blockEvent, true); - document.addEventListener('pointerup', blockEvent, true); - document.addEventListener('touchstart', blockEvent, true); - document.addEventListener('touchend', blockEvent, true); - document.addEventListener('focus', blockEvent, true); - document.addEventListener('submit', blockEvent, true); - window.__paseoSelector = { destroy: destroy }; - })() - `; - - try { - void executeWebviewJavaScript(webview, js) - .then(() => { - const poll = startSelectorResultPolling({ - webview, - onSelection: handleSelectorResult, - onDone: () => setSelectorMode(null), - }); - window.setTimeout(() => { - window.clearInterval(poll); - setSelectorMode(null); - if (webviewRef.current !== webview || !domReadyRef.current) { - return; - } - destroyWebviewSelector(webview); - }, 30000); - return undefined; - }) - .catch(() => { - setSelectorMode(null); - }); - } catch { - setSelectorMode(null); - } }, - [handleSelectorResult, workspaceAttachmentScopeKey], + [handleElementSelectorOutcome, showSelectorFailure, workspaceAttachmentScopeKey], ); const cancelElementSelector = useCallback(() => { - const webview = webviewRef.current; + selectorControllerRef.current?.cancel(); setSelectorMode(null); - if (webview && domReadyRef.current) { - try { - clearWebviewSelector(webview); - } catch {} - } }, []); const currentPageUrl = browser?.url ?? null; @@ -1526,7 +1272,7 @@ export function BrowserPane({ return; } const webview = webviewRef.current; - if (!webview || !domReadyRef.current) { + if (!webview) { return; } if (annotationMarkers.length === 0) { diff --git a/packages/app/src/desktop/browser/resident-webviews.browser.test.ts b/packages/app/src/desktop/browser/resident-webviews.browser.test.ts index 868005817f..679081df0e 100644 --- a/packages/app/src/desktop/browser/resident-webviews.browser.test.ts +++ b/packages/app/src/desktop/browser/resident-webviews.browser.test.ts @@ -5,7 +5,6 @@ import { clearResidentBrowserWebviewsForTests, ensureResidentBrowserWebview, getResidentBrowserWebview, - isResidentBrowserWebviewReady, prepareBrowserWebview, presentBrowserWebview, rememberBrowserWebviewSize, @@ -205,28 +204,6 @@ describe("resident browser webviews", () => { expect(webview.parentElement.style.height).toBe("600px"); }); - it("retains document readiness with the resident webview", () => { - const webview = ensureTestBrowser({ - browserId: "browser-ready", - workspaceId: "workspace-ready", - url: "https://example.com", - }); - if (!webview) { - throw new Error("Expected resident browser webview"); - } - - expect(isResidentBrowserWebviewReady(webview)).toBe(false); - webview.dispatchEvent(new Event("dom-ready")); - releaseResidentBrowserWebview("browser-ready", webview); - - const reusedWebview = takeResidentBrowserWebview("browser-ready"); - expect(reusedWebview).toBe(webview); - expect(isResidentBrowserWebviewReady(reusedWebview as HTMLElement)).toBe(true); - - webview.dispatchEvent(new Event("did-start-loading")); - expect(isResidentBrowserWebviewReady(webview)).toBe(false); - }); - it("creates a resident webview for an agent-created unfocused tab", () => { const webview = ensureTestBrowser({ browserId: "browser-agent", diff --git a/packages/app/src/desktop/browser/resident-webviews.ts b/packages/app/src/desktop/browser/resident-webviews.ts index 0b53525849..dfce3551e0 100644 --- a/packages/app/src/desktop/browser/resident-webviews.ts +++ b/packages/app/src/desktop/browser/resident-webviews.ts @@ -15,7 +15,6 @@ const RESIDENT_VIEWPORT_HEIGHT = 800; const residentWebviewsByBrowserId = new Map<string, HTMLElement>(); const residentSurfacesByBrowserId = new Map<string, HTMLElement>(); const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>(); -const readyResidentWebviews = new WeakSet<HTMLElement>(); interface BrowserWebviewElement extends HTMLElement { src: string; @@ -75,15 +74,6 @@ function registerBrowserWhenAttached( }); } -function registerBrowserReadiness(webview: HTMLElement): void { - webview.addEventListener("did-start-loading", () => { - readyResidentWebviews.delete(webview); - }); - webview.addEventListener("dom-ready", () => { - readyResidentWebviews.add(webview); - }); -} - function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -322,11 +312,6 @@ export function prepareBrowserWebview( (webview as BrowserWebviewElement).src = input.initialUrl; } registerBrowserWhenAttached(webview as BrowserWebviewElement, input, browser); - registerBrowserReadiness(webview); -} - -export function isResidentBrowserWebviewReady(webview: HTMLElement): boolean { - return readyResidentWebviews.has(webview); } export function ensureResidentBrowserWebview(input: { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f72bcb1a36..0247a2ba44 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -523,6 +523,8 @@ export const ar: TranslationResources = { screenshotCopied: "تم نسخ لقطة الشاشة إلى الحافظة", elementCopied: "تم نسخ العنصر إلى الحافظة", screenshotFailed: "تعذّر نسخ لقطة الشاشة", + selectorLoading: "انتظر حتى ينتهي تحميل الصفحة", + selectorFailed: "تعذّر بدء محدد العنصر", }, annotate: { title: "التعليق على العنصر", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 83ff3c8b36..b655cd3970 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -522,6 +522,8 @@ export const en = { screenshotCopied: "Copied screenshot to clipboard", elementCopied: "Copied element to clipboard", screenshotFailed: "Couldn't copy screenshot", + selectorLoading: "Wait for the page to finish loading", + selectorFailed: "Couldn't start element selector", }, annotate: { title: "Annotate element", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 4d38d6e705..911c3977fd 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -527,6 +527,8 @@ export const es: TranslationResources = { screenshotCopied: "Captura copiada al portapapeles", elementCopied: "Elemento copiado al portapapeles", screenshotFailed: "No se pudo copiar la captura", + selectorLoading: "Espera a que la página termine de cargar", + selectorFailed: "No se pudo iniciar el selector de elementos", }, annotate: { title: "Anotar elemento", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 44c61c65e3..ac39cbd044 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -527,6 +527,8 @@ export const fr: TranslationResources = { screenshotCopied: "Capture d'écran copiée dans le presse-papiers", elementCopied: "Élément copié dans le presse-papiers", screenshotFailed: "Impossible de copier la capture", + selectorLoading: "Attendez la fin du chargement de la page", + selectorFailed: "Impossible de démarrer le sélecteur d’élément", }, annotate: { title: "Annoter l'élément", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 6d2eaace67..915bb6aaf0 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -527,6 +527,8 @@ export const ja: TranslationResources = { screenshotCopied: "スクリーンショットをクリップボードにコピーしました", elementCopied: "要素をクリップボードにコピーしました", screenshotFailed: "スクリーンショットをコピーできませんでした", + selectorLoading: "ページの読み込みが完了するまでお待ちください", + selectorFailed: "要素セレクターを開始できませんでした", }, annotate: { title: "要素に注釈を付ける", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index f7e76a6c50..17bb85dbbb 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -524,6 +524,8 @@ export const ko: TranslationResources = { screenshotCopied: "스크린샷을 클립보드에 복사했습니다.", elementCopied: "요소를 클립보드에 복사했습니다.", screenshotFailed: "스크린샷을 복사할 수 없습니다.", + selectorLoading: "페이지 로딩이 끝날 때까지 기다려 주세요.", + selectorFailed: "요소 선택기를 시작할 수 없습니다.", }, annotate: { title: "요소에 주석 달기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5006b22519..fea908d4b1 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -527,6 +527,8 @@ export const ptBR: TranslationResources = { screenshotCopied: "Captura copiada para a área de transferência", elementCopied: "Elemento copiado para a área de transferência", screenshotFailed: "Não foi possível copiar a captura", + selectorLoading: "Aguarde o carregamento da página terminar", + selectorFailed: "Não foi possível iniciar o seletor de elemento", }, annotate: { title: "Anotar elemento", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 0bd5ccbbe5..3305774cba 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -527,6 +527,8 @@ export const ru: TranslationResources = { screenshotCopied: "Снимок скопирован в буфер обмена", elementCopied: "Элемент скопирован в буфер обмена", screenshotFailed: "Не удалось скопировать снимок", + selectorLoading: "Дождитесь окончания загрузки страницы", + selectorFailed: "Не удалось запустить выбор элемента", }, annotate: { title: "Аннотировать элемент", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index fffc9dc6a6..bc02aa5bf9 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -523,6 +523,8 @@ export const zhCN: TranslationResources = { screenshotCopied: "已将截图复制到剪贴板", elementCopied: "已将元素复制到剪贴板", screenshotFailed: "无法复制截图", + selectorLoading: "请等待页面加载完成", + selectorFailed: "无法启动元素选择器", }, annotate: { title: "标注元素", diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 6c8ec44d7e..7ff2bee920 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -232,6 +232,21 @@ async function callBrowserTool(client, name, args = {}) { return mcpPayload(await client.callTool({ name, args }), name); } +async function waitForGuestSelector(client, browserId) { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const evaluated = await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => Boolean(globalThis.__paseoSelector)", + }); + if (JSON.parse(evaluated.resultJson) === true) { + return true; + } + await delay(50); + } + return false; +} + async function createCallerAgent(daemonPort) { const transport = new StreamableHTTPClientTransport( new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`), @@ -384,7 +399,7 @@ function recordViewportMismatch(failures, label, actual, expected) { ); } -async function runRegression({ page, client, serverId, targetUrl, callerAgentId }) { +async function runRegression({ page, client, serverId, targetUrl, callerAgentId, artifactDir }) { const failures = []; const originalWorkspaceId = workspaceIds[0]; const originalWorkspaceRow = page.getByTestId( @@ -673,16 +688,90 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId responsiveViewport, ); - await originalDeck.getByRole("button", { name: "Annotate element" }).click(); - await page.waitForTimeout(250); - const selectorResult = await callBrowserTool(client, "browser_evaluate", { + const annotateButton = originalDeck.getByRole("button", { name: "Annotate element" }); + await page.evaluate((id) => { + const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); + if (!(webview instanceof HTMLElement)) throw new Error(`Browser webview ${id} was unavailable`); + globalThis.__paseoOriginalIsLoadingDescriptor = Object.getOwnPropertyDescriptor( + webview, + "isLoading", + ); + Object.defineProperty(webview, "isLoading", { + configurable: true, + value: () => true, + }); + }, browserId); + await annotateButton.click(); + await page.waitForTimeout(100); + assert( + !(await originalDeck.getByRole("button", { name: "Cancel element selector" }).isVisible()), + "Element selector started while the guest reported a genuine load", + ); + const selectorDuringLoad = await callBrowserTool(client, "browser_evaluate", { browserId, function: "() => Boolean(globalThis.__paseoSelector)", }); - if (JSON.parse(selectorResult.resultJson) !== true) { - failures.push("reused loaded browser remains ready for element annotation after remount"); + assert( + JSON.parse(selectorDuringLoad.resultJson) === false, + "Selector injected while the guest reported a genuine load", + ); + assert( + await page.getByText("Wait for the page to finish loading").isVisible(), + "Element selector loading failure was not visible", + ); + await page.evaluate((id) => { + const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); + if (!(webview instanceof HTMLElement)) throw new Error(`Browser webview ${id} was unavailable`); + const descriptor = globalThis.__paseoOriginalIsLoadingDescriptor; + if (descriptor) Object.defineProperty(webview, "isLoading", descriptor); + else delete webview.isLoading; + delete globalThis.__paseoOriginalIsLoadingDescriptor; + }, browserId); + + const readyStateResult = await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => document.readyState", + }); + assert( + JSON.parse(readyStateResult.resultJson) === "complete", + "Local browser document did not finish loading", + ); + // Reproduce the report's mismatch: the guest is complete, but the pane's + // last loading signal says it is not ready. + await page.evaluate((id) => { + const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); + if (!(webview instanceof HTMLElement)) { + throw new Error(`Browser webview ${id} was unavailable`); + } + webview.dispatchEvent(new Event("did-start-loading")); + }, browserId); + + await annotateButton.click(); + const annotateSelectorActive = await waitForGuestSelector(client, browserId); + await page.screenshot({ path: path.join(artifactDir, "local-page-annotate-selector.png") }); + if (!annotateSelectorActive) { + failures.push("loaded local browser remains ready for element annotation"); } + await originalDeck.getByRole("button", { name: "Cancel element selector" }).click(); + await delay(10_000); + const screenshotButton = originalDeck.getByRole("button", { name: "Screenshot element" }); + await screenshotButton.click(); + const screenshotSelectorActive = await waitForGuestSelector(client, browserId); + if (!screenshotSelectorActive) { + failures.push("loaded local browser remains ready for element screenshots"); + } + await delay(20_500); + const selectorAfterPriorTimeout = await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => Boolean(globalThis.__paseoSelector)", + }); + if (JSON.parse(selectorAfterPriorTimeout.resultJson) !== true) { + failures.push("a previous selector timeout does not destroy the current selector session"); + } + await page.screenshot({ path: path.join(artifactDir, "local-page-screenshot-selector.png") }); + await originalDeck.getByRole("button", { name: "Cancel element selector" }).click(); + if (failures.length > 0) { throw new Error(`Browser viewport regressions:\n- ${failures.join("\n- ")}`); } @@ -698,6 +787,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId list: "passed", snapshot: "passed", click: "passed", + localPageSelectors: "passed", }; } @@ -796,10 +886,11 @@ async function main() { serverId: status.serverId, targetUrl: target.url, callerAgentId, + artifactDir, }); writeJson(path.join(artifactDir, "result.json"), report); console.log( - `Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click passed.`, + `Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`, ); } catch (error) { console.error(`Browser desktop browser E2E failed. Artifacts: ${artifactDir}`); From 30195d3bd18854a7472c5e86dc8f0c9ccda8907a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra <boudra.moha@gmail.com> Date: Fri, 14 Aug 2026 11:52:24 +0200 Subject: [PATCH 73/73] 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> --- nix/npm-deps.hash | 2 +- package-lock.json | 11 ++ packages/app/app.config.js | 2 + packages/app/package.json | 1 + packages/app/plugins/with-paste-input.js | 82 ++++++++++ packages/app/plugins/with-paste-input.test.ts | 47 ++++++ .../src/components/workspace-setup-dialog.tsx | 3 +- .../composer/draft/input-draft.live.test.tsx | 24 ++- .../app/src/composer/draft/input-draft.ts | 20 ++- .../app/src/composer/draft/workspace-tab.tsx | 11 +- packages/app/src/composer/index.tsx | 62 ++++++-- packages/app/src/composer/input/input.tsx | 97 ++++++++---- packages/app/src/composer/input/state.test.ts | 4 +- packages/app/src/composer/input/state.ts | 8 +- .../src/composer/input/text-input-types.ts | 20 +++ .../app/src/composer/input/text-input.d.ts | 1 + .../src/composer/input/text-input.native.tsx | 72 +++++++++ .../input/text-input.web.browser.test.tsx | 148 ++++++++++++++++++ .../app/src/composer/input/text-input.web.tsx | 107 +++++++++++++ .../src/composer/native-pasted-image.test.ts | 47 ++++++ .../app/src/composer/native-pasted-image.ts | 35 +++++ packages/app/src/panels/agent-panel.tsx | 20 ++- .../app/src/screens/new-workspace-screen.tsx | 4 +- 23 files changed, 757 insertions(+), 71 deletions(-) create mode 100644 packages/app/plugins/with-paste-input.js create mode 100644 packages/app/plugins/with-paste-input.test.ts create mode 100644 packages/app/src/composer/input/text-input-types.ts create mode 100644 packages/app/src/composer/input/text-input.d.ts create mode 100644 packages/app/src/composer/input/text-input.native.tsx create mode 100644 packages/app/src/composer/input/text-input.web.browser.test.tsx create mode 100644 packages/app/src/composer/input/text-input.web.tsx create mode 100644 packages/app/src/composer/native-pasted-image.test.ts create mode 100644 packages/app/src/composer/native-pasted-image.ts diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 45fa8b1379..ddbe6bd11e 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-i5PbVUe2Ec+GtghV9IpCJQJ9hcUT5hFhmxneNvoD584= +sha256-qPIKchcbGkJHFSqgeM/BUSmu7TiVfKy3IEgUcMNNJYc= diff --git a/package-lock.json b/package-lock.json index f6a403bfed..7a691c1df5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7762,6 +7762,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", @@ -36108,6 +36118,7 @@ "@getpaseo/protocol": "*", "@gorhom/bottom-sheet": "^5.2.14", "@gorhom/portal": "^1.0.14", + "@mattermost/react-native-paste-input": "2.0.1", "@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", diff --git a/packages/app/app.config.js b/packages/app/app.config.js index db7120882a..1e13997956 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -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,6 +140,7 @@ export default { }, plugins: [ "expo-router", + withPasteInput, [withAndroidAsyncStorageSize, 64], ...buildProfile.cameraPlugins, [ diff --git a/packages/app/package.json b/packages/app/package.json index b46400887a..89e183cf6a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -48,6 +48,7 @@ "@getpaseo/protocol": "*", "@gorhom/bottom-sheet": "^5.2.14", "@gorhom/portal": "^1.0.14", + "@mattermost/react-native-paste-input": "2.0.1", "@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", diff --git a/packages/app/plugins/with-paste-input.js b/packages/app/plugins/with-paste-input.js new file mode 100644 index 0000000000..76be6a8fcd --- /dev/null +++ b/packages/app/plugins/with-paste-input.js @@ -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; diff --git a/packages/app/plugins/with-paste-input.test.ts b/packages/app/plugins/with-paste-input.test.ts new file mode 100644 index 0000000000..fdc271d7ab --- /dev/null +++ b/packages/app/plugins/with-paste-input.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 1985a83615..0ca4b2f8b7 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -438,7 +438,8 @@ export function WorkspaceSetupDialog() { isSubmitLoading={pendingAction === "chat"} blurOnSubmit={true} value={chatDraft.text} - onChangeText={chatDraft.setText} + onChangeText={chatDraft.editText} + textReplacementKey={chatDraft.textReplacementKey} attachments={chatDraft.attachments} onChangeAttachments={chatDraft.setAttachments} cwd={sourceDirectory} diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index ff3797466b..ed7a332fb2 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -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 () => { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 1a68755179..a579064e0b 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -52,7 +52,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; @@ -84,6 +86,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 +110,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 +147,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr void (async () => { await useDraftStore.getState().hydrateDraftInput({ draftKey }); if (!cancelled) { + setTextReplacementRevision((revision) => revision + 1); setHydratedDraftKey(draftKey); } })(); @@ -264,7 +276,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr return { text, - setText, + editText, + replaceText, + textReplacementKey: `${draftKey}:${textReplacementRevision}`, attachments, setAttachments, clear, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index e8c992b4e8..0293d7c3e9 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -390,7 +390,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 +587,7 @@ export function WorkspaceDraftAgentTab({ return; } autoSubmitKeyRef.current = submitKey; - setDraftText(""); + replaceDraftText(""); setDraftAttachments([]); const preparedAttempt = initialCreateAttempt?.clientMessageId === submission.clientMessageId @@ -604,7 +604,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 +617,7 @@ export function WorkspaceDraftAgentTab({ isReadyForPendingAutoSubmit, serverId, setDraftAttachments, - setDraftText, + replaceDraftText, workspaceId, ]); @@ -699,7 +699,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} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 02e8d6d1be..f2b6c6788e 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -125,6 +125,7 @@ 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 { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands"; import { appendWorkspaceFileAttachment, @@ -846,6 +847,7 @@ interface ComposerProps { blurOnSubmit?: boolean; value: string; onChangeText: (text: string) => void; + textReplacementKey: string; attachments: UserComposerAttachment[]; attachmentScopeKeys?: readonly string[]; onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void; @@ -1054,6 +1056,7 @@ export function Composer({ blurOnSubmit = false, value, onChangeText, + textReplacementKey, attachments, attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS, onOpenWorkspaceAttachment, @@ -1151,6 +1154,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); @@ -1163,6 +1167,17 @@ export function Composer({ `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 +1188,7 @@ export function Composer({ messageInputRef.current?.blur(); } clearDraft("sent"); - setUserInput(""); + replaceUserInput(""); setSelectedAttachments([]); resetSuppression(); setSendError(null); @@ -1194,14 +1209,14 @@ export function Composer({ onClientSlashCommand, resetSuppression, setSelectedAttachments, - setUserInput, + replaceUserInput, ], ); const autocomplete = useAgentAutocomplete({ userInput, cursorIndex, - setUserInput, + setUserInput: replaceUserInput, serverId, agentId, draftConfig: commandDraftConfig, @@ -1356,7 +1371,7 @@ export function Composer({ }); if (!result.queued) return; - setUserInput(""); + replaceUserInput(""); setSelectedAttachments([]); resetSuppression(); clearSentAttachments(queuedAttachments); @@ -1367,7 +1382,7 @@ export function Composer({ queueWriter, resetSuppression, setSelectedAttachments, - setUserInput, + replaceUserInput, ], ); @@ -1398,7 +1413,7 @@ export function Composer({ await submitMessage(submitText, submitAttachments); }, clearDraft, - setUserInput, + setUserInput: replaceUserInput, setAttachments: (nextAttachments) => { setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(nextAttachments)); }, @@ -1423,7 +1438,7 @@ export function Composer({ isAgentRunning, queueMessage, setSelectedAttachments, - setUserInput, + replaceUserInput, submitBehavior, submitMessage, t, @@ -1484,6 +1499,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 +1725,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( @@ -2073,7 +2112,8 @@ export function Composer({ const messageInputContainerRef = useRef<View>(null); - const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile; + const isSubmitLoadingVisible = + isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0; const isSubmitDisabled = isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving); @@ -2142,6 +2182,7 @@ export function Composer({ attachmentMenuItems={attachmentMenuItems} onAttachButtonRef={handleAttachButtonRef} onAddImages={addImages} + onPasteImages={handleNativePasteImages} client={client} isReadyForDictation={isDictationReady} placeholder={messagePlaceholder} @@ -2167,6 +2208,7 @@ export function Composer({ attachmentSlot={attachmentTray} inputMode={inputMode} readOnly={readOnly} + textReplacementKey={textReplacementKey} submitLabel={submitLabel} /> <Combobox diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 7d85be91d9..d81e3c3f41 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -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, -}); diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index d6265b063d..2b0c216a5c 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -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", ]); }); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 733a30cc4c..15ea7199ee 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -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; } diff --git a/packages/app/src/composer/input/text-input-types.ts b/packages/app/src/composer/input/text-input-types.ts new file mode 100644 index 0000000000..5963057bc0 --- /dev/null +++ b/packages/app/src/composer/input/text-input-types.ts @@ -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; +} diff --git a/packages/app/src/composer/input/text-input.d.ts b/packages/app/src/composer/input/text-input.d.ts new file mode 100644 index 0000000000..5043ffbeb6 --- /dev/null +++ b/packages/app/src/composer/input/text-input.d.ts @@ -0,0 +1 @@ +export * from "./text-input.native"; diff --git a/packages/app/src/composer/input/text-input.native.tsx b/packages/app/src/composer/input/text-input.native.tsx new file mode 100644 index 0000000000..875c983d34 --- /dev/null +++ b/packages/app/src/composer/input/text-input.native.tsx @@ -0,0 +1,72 @@ +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import PasteInput, { + type PastedFile, + type PasteTextInputInstance, +} from "@mattermost/react-native-paste-input"; +import { withUnistyles } from "react-native-unistyles"; +import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types"; + +type NativeInputInstance = PasteTextInputInstance & { + blur: () => void; + focus: () => void; + setNativeProps: (props: { text: string }) => void; +}; + +const ThemedPasteInput = withUnistyles(PasteInput, (theme) => ({ + placeholderTextColor: theme.colors.surface4, +})); + +export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTextInputProps>( + function ComposerTextInputNative( + { text, onChangeText, onPasteImages, onPasteError, ...props }, + ref, + ) { + const inputRef = useRef<NativeInputInstance | null>(null); + const textRef = useRef(text); + + useImperativeHandle(ref, () => ({ + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + getText: () => textRef.current, + replaceText: (nextText, selection) => { + textRef.current = nextText; + inputRef.current?.setNativeProps({ text: nextText }); + if (selection) { + inputRef.current?.setSelection(selection.start, selection.end); + } + }, + getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current, + })); + + const handleChangeText = useCallback( + (nextText: string) => { + textRef.current = nextText; + onChangeText(nextText); + }, + [onChangeText], + ); + + const handlePaste = useCallback( + (error: string | null | undefined, files: PastedFile[]) => { + if (error) { + onPasteError?.(error); + return; + } + if (files.length > 0) { + onPasteImages?.(files); + } + }, + [onPasteError, onPasteImages], + ); + + return ( + <ThemedPasteInput + {...props} + ref={inputRef} + defaultValue={text} + onChangeText={handleChangeText} + onPaste={handlePaste} + /> + ); + }, +); diff --git a/packages/app/src/composer/input/text-input.web.browser.test.tsx b/packages/app/src/composer/input/text-input.web.browser.test.tsx new file mode 100644 index 0000000000..10fc2696a4 --- /dev/null +++ b/packages/app/src/composer/input/text-input.web.browser.test.tsx @@ -0,0 +1,148 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ComposerTextInput } from "./text-input.web"; + +interface MountedInput { + root: Root; + container: HTMLDivElement; + textarea: HTMLTextAreaElement; +} + +interface TextRecorder { + changes: string[]; + onChangeText: (text: string) => void; +} + +const mountedInputs: MountedInput[] = []; + +function mountInput(onChangeText: (text: string) => void): MountedInput { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + <ComposerTextInput + text="" + multiline={true} + onChangeText={onChangeText} + testID="composer-input" + />, + ); + }); + + const textarea = container.querySelector("textarea"); + if (!textarea) { + throw new Error("Composer text input did not render a textarea"); + } + + const mounted = { root, container, textarea }; + mountedInputs.push(mounted); + return mounted; +} + +function dispatchComposition( + textarea: HTMLTextAreaElement, + type: "compositionstart" | "compositionend", +) { + textarea.dispatchEvent(new CompositionEvent(type, { bubbles: true })); +} + +function typeFromIme(textarea: HTMLTextAreaElement, text: string): void { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + if (!valueSetter) { + throw new Error("HTML textarea value setter is unavailable"); + } + valueSetter.call(textarea, text); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, data: text })); +} + +function ignoreTextChange(): void {} + +function createTextRecorder(): TextRecorder { + const changes: string[] = []; + return { + changes, + onChangeText: (text) => changes.push(text), + }; +} + +afterEach(() => { + for (const mounted of mountedInputs.splice(0)) { + act(() => mounted.root.unmount()); + mounted.container.remove(); + } + vi.useRealTimers(); +}); + +describe("ComposerTextInput web IME composition", () => { + it("keeps the DOM-owned candidate during composition and reports the committed text", () => { + vi.useFakeTimers(); + const recorder = createTextRecorder(); + const mounted = mountInput(recorder.onChangeText); + + act(() => { + dispatchComposition(mounted.textarea, "compositionstart"); + mounted.textarea.value = "你好"; + mounted.root.render( + <ComposerTextInput + text="" + multiline={true} + onChangeText={recorder.onChangeText} + placeholder="rerender while composing" + testID="composer-input" + />, + ); + }); + + expect(mounted.textarea.value).toBe("你好"); + + act(() => { + dispatchComposition(mounted.textarea, "compositionend"); + vi.runAllTimers(); + }); + + expect(recorder.changes).toEqual(["你好"]); + }); + + it("does not let a previous composition-end timer take ownership from a new composition", () => { + vi.useFakeTimers(); + const mounted = mountInput(ignoreTextChange); + + act(() => { + dispatchComposition(mounted.textarea, "compositionstart"); + mounted.textarea.value = "你"; + dispatchComposition(mounted.textarea, "compositionend"); + dispatchComposition(mounted.textarea, "compositionstart"); + mounted.textarea.value = "你好"; + vi.runAllTimers(); + mounted.root.render( + <ComposerTextInput + text="你" + multiline={true} + onChangeText={ignoreTextChange} + placeholder="second composition" + testID="composer-input" + />, + ); + }); + + expect(mounted.textarea.value).toBe("你好"); + }); + + it("does not report committed text twice when the input event already reported it", () => { + vi.useFakeTimers(); + const changes: string[] = []; + const mounted = mountInput((text) => changes.push(text)); + + act(() => { + dispatchComposition(mounted.textarea, "compositionstart"); + typeFromIme(mounted.textarea, "你好"); + dispatchComposition(mounted.textarea, "compositionend"); + vi.runAllTimers(); + }); + + expect(changes).toEqual(["你好"]); + }); +}); diff --git a/packages/app/src/composer/input/text-input.web.tsx b/packages/app/src/composer/input/text-input.web.tsx new file mode 100644 index 0000000000..5b46172ee2 --- /dev/null +++ b/packages/app/src/composer/input/text-input.web.tsx @@ -0,0 +1,107 @@ +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { TextInput } from "react-native"; +import { withUnistyles } from "react-native-unistyles"; +import type { ComposerTextInputHandle, ComposerTextInputProps } from "./text-input-types"; + +interface WebTextInputElement extends TextInput { + value?: string; + setSelectionRange?: (start: number, end: number) => void; + addEventListener: (type: "compositionstart" | "compositionend", listener: EventListener) => void; + removeEventListener: ( + type: "compositionstart" | "compositionend", + listener: EventListener, + ) => void; +} + +const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ + placeholderTextColor: theme.colors.surface4, +})); + +export const ComposerTextInput = forwardRef<ComposerTextInputHandle, ComposerTextInputProps>( + function ComposerTextInputWeb( + { text, onChangeText, onPasteImages: _, onPasteError: __, ...props }, + ref, + ) { + const inputRef = useRef<TextInput | null>(null); + const textRef = useRef(text); + const onChangeTextRef = useRef(onChangeText); + const [isComposing, setIsComposing] = useState(false); + if (!isComposing) textRef.current = text; + onChangeTextRef.current = onChangeText; + + useEffect(() => { + const input = inputRef.current as WebTextInputElement | null; + if (!input) return; + + let compositionEndTimer: ReturnType<typeof setTimeout> | null = null; + const startComposition = () => { + if (compositionEndTimer !== null) { + clearTimeout(compositionEndTimer); + compositionEndTimer = null; + } + setIsComposing(true); + }; + const endComposition = () => { + const nextText = input.value ?? ""; + if (nextText !== textRef.current) { + textRef.current = nextText; + onChangeTextRef.current(nextText); + } + compositionEndTimer = setTimeout(() => { + compositionEndTimer = null; + setIsComposing(false); + }, 0); + }; + + input.addEventListener("compositionstart", startComposition); + input.addEventListener("compositionend", endComposition); + return () => { + if (compositionEndTimer !== null) clearTimeout(compositionEndTimer); + input.removeEventListener("compositionstart", startComposition); + input.removeEventListener("compositionend", endComposition); + }; + }, []); + + const handleChangeText = useCallback( + (nextText: string) => { + if (nextText === textRef.current) return; + textRef.current = nextText; + onChangeText(nextText); + }, + [onChangeText], + ); + + useImperativeHandle(ref, () => ({ + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + getText: () => textRef.current, + replaceText: (nextText, selection) => { + textRef.current = nextText; + const input = inputRef.current as WebTextInputElement | null; + if (input && "value" in input) { + input.value = nextText; + } + if (selection && typeof input?.setSelectionRange === "function") { + input.setSelectionRange(selection.start, selection.end); + } + }, + getNativeRef: () => inputRef.current, + })); + + return ( + <ThemedTextInput + {...props} + ref={inputRef} + value={isComposing ? undefined : text} + onChangeText={handleChangeText} + /> + ); + }, +); diff --git a/packages/app/src/composer/native-pasted-image.test.ts b/packages/app/src/composer/native-pasted-image.test.ts new file mode 100644 index 0000000000..59d2954a1b --- /dev/null +++ b/packages/app/src/composer/native-pasted-image.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { normalizeNativePastedImages, UnsupportedPastedImageError } from "./native-pasted-image"; + +describe("normalizeNativePastedImages", () => { + it("turns pasted native image files into the existing picked-image input", () => { + expect( + normalizeNativePastedImages([ + { + fileName: "clipboard.jpg", + fileSize: 128, + type: "image/jpg", + uri: "file:///cache/clipboard.jpg", + }, + { + fileName: "animation.gif", + fileSize: 256, + type: "image/gif", + uri: "file:///cache/animation.gif", + }, + ]), + ).toEqual([ + { + source: { kind: "file_uri", uri: "file:///cache/clipboard.jpg" }, + mimeType: "image/jpeg", + fileName: "clipboard.jpg", + }, + { + source: { kind: "file_uri", uri: "file:///cache/animation.gif" }, + mimeType: "image/gif", + fileName: "animation.gif", + }, + ]); + }); + + it("rejects non-image content instead of swallowing it as an attachment", () => { + expect(() => + normalizeNativePastedImages([ + { + fileName: "notes.txt", + fileSize: 12, + type: "text/plain", + uri: "file:///cache/notes.txt", + }, + ]), + ).toThrow(UnsupportedPastedImageError); + }); +}); diff --git a/packages/app/src/composer/native-pasted-image.ts b/packages/app/src/composer/native-pasted-image.ts new file mode 100644 index 0000000000..34f3d822b3 --- /dev/null +++ b/packages/app/src/composer/native-pasted-image.ts @@ -0,0 +1,35 @@ +import { resolveRasterImageMimeType } from "@/attachments/file-types"; +import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker"; + +export interface NativePastedFile { + fileName: string; + fileSize: number; + type: string; + uri: string; +} + +export class UnsupportedPastedImageError extends Error { + constructor(fileName: string) { + super(`Unsupported pasted image '${fileName}'.`); + this.name = "UnsupportedPastedImageError"; + } +} + +export function normalizeNativePastedImages( + files: readonly NativePastedFile[], +): PickedImageAttachmentInput[] { + return files.map((file) => { + const mimeType = resolveRasterImageMimeType({ + mimeType: file.type, + path: file.fileName, + }); + if (!mimeType) { + throw new UnsupportedPastedImageError(file.fileName); + } + return { + source: { kind: "file_uri", uri: file.uri }, + mimeType, + fileName: file.fileName, + }; + }); +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 24a85b3070..59eb41112b 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1228,7 +1228,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ // when only toast state changes (which does not affect any draft field). const { text, - setText, + editText, + replaceText, + textReplacementKey, attachments, setAttachments, clear, @@ -1239,7 +1241,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ const agentInputDraft = useMemo( (): AgentInputDraft => ({ text, - setText, + editText, + replaceText, + textReplacementKey, attachments, setAttachments, clear, @@ -1249,7 +1253,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ }), [ text, - setText, + editText, + replaceText, + textReplacementKey, attachments, setAttachments, clear, @@ -1296,7 +1302,10 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>; return ( - <RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}> + <RewindComposerRestoreProvider + text={agentInputDraft.text} + setText={agentInputDraft.replaceText} + > <View style={styles.root}> <FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}> {contentContainer} @@ -1629,7 +1638,8 @@ function ActiveAgentComposer({ externalKeyboardShift isPaneFocused={isPaneFocused} value={agentInputDraft.text} - onChangeText={agentInputDraft.setText} + onChangeText={agentInputDraft.editText} + textReplacementKey={agentInputDraft.textReplacementKey} attachments={agentInputDraft.attachments} attachmentScopeKeys={attachmentScopeKeys} onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index d887fa1287..2b75b1b2d8 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -2309,6 +2309,7 @@ export function NewWorkspaceScreen({ blurOnSubmit={true} value={terminalComposerValue} onChangeText={setTerminalPromptText} + textReplacementKey={launchFocusKey} attachments={NO_TERMINAL_ATTACHMENTS} onChangeAttachments={noopChangeAttachments} cwd={selectedSourceDirectory ?? ""} @@ -2332,7 +2333,8 @@ export function NewWorkspaceScreen({ submitBehavior="preserve-and-lock" blurOnSubmit={true} value={chatDraft.text} - onChangeText={chatDraft.setText} + onChangeText={chatDraft.editText} + textReplacementKey={chatDraft.textReplacementKey} attachments={chatDraft.attachments} attachmentScopeKeys={visibleDraftContextScopeKeys} onChangeAttachments={chatDraft.setAttachments}