diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index a4a248e1cf..f37a8b0cdf 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: PASEO_HOME="$(mktemp -d)" export PASEO_LISTEN=127.0.0.1:6767 - WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log" + WRAPPER_LOG="$PASEO_HOME/paseo-cli.log" cleanup() { if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then @@ -47,7 +47,7 @@ jobs: } trap cleanup EXIT - PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 & + PASEO_WEB_UI_ENABLED=true PASEO_RELAY_ENABLED=false ./result/bin/paseo daemon run >"$WRAPPER_LOG" 2>&1 & DAEMON_PID=$! deadline=$((SECONDS + 30)) @@ -57,7 +57,22 @@ jobs: echo "$STATUS_JSON" curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html" [[ -s "$PASEO_HOME/web-ui.html" ]] - exit 0 + if ! TERMINAL_JSON="$(./result/bin/paseo terminal create --cwd "$PASEO_HOME" --name nix-smoke --json)"; then + break + fi + TERMINAL_ID="$(jq -er '.id' <<<"$TERMINAL_JSON")" + ./result/bin/paseo terminal send-keys "$TERMINAL_ID" 'PATH= "$PASEO_HOOK_CLI" --version && printf "nix-terminal-%s\n" ok' Enter + deadline=$((SECONDS + 15)) + while (( SECONDS < deadline )); do + if ./result/bin/paseo terminal capture "$TERMINAL_ID" --json \ + | jq -e '.lines | any(. == "nix-terminal-ok")' >/dev/null; then + ./result/bin/paseo terminal kill "$TERMINAL_ID" + exit 0 + fi + sleep 1 + done + echo "Nix terminal did not execute the command." + break fi if ! kill -0 "$DAEMON_PID" 2>/dev/null; then @@ -72,7 +87,7 @@ jobs: cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "" echo "::endgroup::" - echo "::group::paseo-server stdout/stderr" + echo "::group::paseo daemon run stdout/stderr" cat "$WRAPPER_LOG" 2>/dev/null || echo "" echo "::endgroup::" diff --git a/.oxlintrc.json b/.oxlintrc.json index 269097aab4..d1566195e9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -84,6 +84,45 @@ "max-nested-callbacks": ["error", { "max": 3 }] }, "overrides": [ + { + "files": [ + "plugins/*-provider/server/**/*.ts", + "packages/plugin/src/server/acp-internal/**/*.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "node:child_process", + "importNames": [ + "spawn", + "exec", + "execFile", + "spawnSync", + "execSync", + "execFileSync" + ], + "message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently." + }, + { + "name": "child_process", + "importNames": [ + "spawn", + "exec", + "execFile", + "spawnSync", + "execSync", + "execFileSync" + ], + "message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently." + } + ] + } + ] + } + }, { "files": ["packages/app/src/**/*.{ts,tsx}"], "rules": { diff --git a/CHANGELOG.md b/CHANGELOG.md index 5219bda03e..3a4c3ab914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.11.0-beta.4 - 2026-10-05 +## 0.11.0-beta.5 - 2026-10-06 ### Added @@ -11,7 +11,7 @@ - Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) - Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) - Added OpenCode Go to Usage ([#5465](https://github.com/getpaseo/paseo/pull/5465)) -- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876)) +- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it, listing each login's error when none works ([#5876](https://github.com/getpaseo/paseo/pull/5876), [#6156](https://github.com/getpaseo/paseo/pull/6156)) - Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m)) - Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi)) - Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet)) @@ -20,16 +20,19 @@ - Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone)) - Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685)) - Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876)) +- Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151)) - Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976)) - Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood)) - Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707)) ### Changed +- Enabled the plugin registry by default: `paseo plugin add owner/slug` installs a reviewed artifact even when a local directory matches; use explicit local paths (including `.`, `..`, and Windows drive or UNC paths), and use `github:owner/repository` for direct GitHub installs. Registry connection, missing-ID, and `--ref` errors include recovery steps. + - Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465)) - Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975)) - Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089)) -- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708)) +- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot)) - Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942)) - Changed Pi and OMP extension context to show as expandable tool rows instead of assistant text, and to hide Pi messages marked hidden ([#6090](https://github.com/getpaseo/paseo/pull/6090)) - Moved Import session from the sidebar footer to the New workspace screen ([#5805](https://github.com/getpaseo/paseo/pull/5805)) @@ -43,11 +46,12 @@ ### Fixed - Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer)) -- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running ([#6067](https://github.com/getpaseo/paseo/pull/6067)) +- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running, or as "Agent" after a daemon restart ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100)) - Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022)) - Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089)) - Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074)) - Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov)) +- Fixed ACP plugin providers failing to launch a `.cmd` or `.bat` CLI on Windows ([#6151](https://github.com/getpaseo/paseo/pull/6151)) - Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045)) - Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang)) - Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765)) @@ -60,12 +64,15 @@ - Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi)) - Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff)) - Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan)) +- Fixed an OMP turn staying running forever when the run contains a message Paseo does not show, such as an OMP rule reminder ([#6112](https://github.com/getpaseo/paseo/pull/6112)) +- Fixed a prompt OMP rejects, such as for a missing API key, ending as a completed turn with no error ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115)) - Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061)) - Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ)) - Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755)) - Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826)) - Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93)) - Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul)) +- Fixed the Nix packages failing to start the daemon and to open terminals ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum)) - Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988)) - Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae)) @@ -80,8 +87,11 @@ - Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796)) - Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970)) - Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970)) +- Fixed copying all the code in a chat code block sometimes including its ``` fence lines ([#6138](https://github.com/getpaseo/paseo/pull/6138)) +- Fixed copying across a code block nested in a numbered list item renumbering the next item ([#6158](https://github.com/getpaseo/paseo/pull/6158)) - Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968)) +- Fixed the desktop window not dragging from the workspace header after scrolling a long chat ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen)) - Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu)) - Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio)) - Fixed the daemon freezing when an Add Project directory search reached a folder whose read never returns ([#6029](https://github.com/getpaseo/paseo/pull/6029), [#6041](https://github.com/getpaseo/paseo/pull/6041) by [@lardissone](https://github.com/lardissone)) @@ -89,11 +99,16 @@ - Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart)) - Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898)) - Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922)) +- Fixed `paseo daemon run` exiting with "Daemon failed to start" when the program that launched it stops reading its output ([#6117](https://github.com/getpaseo/paseo/pull/6117)) +- Fixed CLI commands crashing with `EPIPE`, and the Windows desktop app showing an error dialog, when their output stops being read ([#6097](https://github.com/getpaseo/paseo/pull/6097)) +- Fixed daemon self-update on a linked global install giving the wrong reason for refusing ([#6095](https://github.com/getpaseo/paseo/pull/6095)) - Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae)) - Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694)) - Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh)) +- Fixed Remote SSH hosts failing to connect to a password-protected daemon, with a new Daemon password field ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart)) - Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722)) - Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585)) +- Fixed the centered New workspace form collapsing and overlapping when the keyboard opens on tablets ([#6093](https://github.com/getpaseo/paseo/pull/6093) by [@colonelpanic8](https://github.com/colonelpanic8)) - Fixed the Android sidebar jumping back to the top when reopened after selecting a workspace ([#6058](https://github.com/getpaseo/paseo/pull/6058)) - Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang)) - Fixed iPad chat text keeping its old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716)) diff --git a/README.md b/README.md index aa0a841e7e..a8fe3f5b25 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,11 @@ Paseo mobile app

-Run agents in parallel on your own machines. Ship from your phone or your desk. +Paseo is a desktop, mobile, web, and CLI app for coding agents. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android. +- **Parallel agents:** Run many agents at once, each in its own worktree. +- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers. +- **Full IDE:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want. - **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills. - **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job. - **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it. @@ -49,11 +52,18 @@ Run agents in parallel on your own machines. Ship from your phone or your desk. ## Plugins -Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted -TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install `. +Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web, +iOS, and Android. Write a plugin once and it is on your phone. -Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon -machine and inside connected clients; install only code you trust. +- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes. +- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends. +- **Providers:** add a coding agent as a provider. + +Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory. + +**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)** + +Plugins run with access to your daemon machine and inside connected clients; install only code you trust. ## Getting Started @@ -77,16 +87,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r To connect from your phone, open **Settings → your host → Pair Device**. -### CLI / headless +### Server -Install the CLI and start Paseo: +For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon: ```bash npm install -g @getpaseo/cli paseo ``` -Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines. +Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host. For full setup and configuration, see: @@ -197,7 +207,7 @@ npm run typecheck ## Sponsors -Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage. +Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage. diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md index 594aacf5f6..f760fafa67 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -43,6 +43,18 @@ Run the shared browser profile fixture with: PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop ``` +On macOS, test native titlebar dragging after scrolling with: + +```bash +PASEO_CAPTURE_HARNESS_GROUP=titlebar-drag npm run capture-harness --workspace=@getpaseo/desktop +``` + +This group needs Xcode Command Line Tools and Accessibility permission for the +terminal running it. It sends real mouse input to its test window and checks +window movement, including button clicks and a portal over the titlebar. Leave +the mouse idle during this group. It reads the app's production CSS and writes +screenshots and window bounds to the output directory. + The browser profile group runs two Electron processes in sequence. It verifies that each renderer-side `did-attach` identity maps to the correct main-process guest, that two live tabs share cookies and local storage through one persistent session, and that the data is diff --git a/docs/glossary.md b/docs/glossary.md index f9eb458173..3ed97d1fc3 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -33,8 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). - **Report ID** — Stable daemon-composed identity for one source account: `:`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). -- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). -- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage screen on wide layouts and the same bottom sheet on phones. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.ts`. +- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). +- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`. - **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`). - **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`). diff --git a/docs/plugins.md b/docs/plugins.md index 510ef68af1..866c74128e 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -117,15 +117,19 @@ the bundled integration; an entry with `extends` shadows it with a custom provid ## Install from a registry -Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or -`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare -`owner/repo` is GitHub shorthand and the daemon never contacts a registry. - -With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed -artifact, and GitHub shorthand requires `github:`. Registry installs +`paseo plugin add owner/slug` installs the registry's reviewed artifact by default. +Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins). +Use `git:owner/repository` or a full Git URL for a Git source. Registry installs keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref selection is unavailable for registry installs; install an explicit source to select your own. +Registry overviews are untrusted author content, even before installation. Render them with +`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative +URLs, and isolated external links. The website adapter and corpus tests live in +`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is +`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share +this policy. Installed-plugin descriptions in the app remain plain text. + Use `host/owner/slug` for a registry at `https://host`, or set `PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix). Private registry credentials live in daemon config under @@ -138,13 +142,13 @@ owns static hosting, record shapes, pins, and advisory install counts. ## Install a Git source -GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git -URL. An existing directory still wins over source resolution. +GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git +URL. ```bash -paseo plugin install owner/repository +paseo plugin install git:owner/repository paseo plugin install https://gitlab.com/group/repository.git -paseo plugin install owner/monorepo:plugins/review +paseo plugin install git:owner/monorepo:plugins/review paseo plugin install github:owner/repository --ref main ``` @@ -155,8 +159,9 @@ and ask for approval. `ls` reports the installed commit without contacting the r ## Managed source ownership The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier -syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged -through `installPluginSource`; only the daemon resolves host paths and acquires sources. +syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before +calling `installPluginSource`; see the public source reference for CLI and app path semantics. +Only the daemon reads plugin files and acquires sources. `ManagedPluginSources` owns acquisition and offline source description. Config stores the active directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed @@ -248,7 +253,7 @@ dependency; shared types must not refer to React components, hooks, Node APIs, o | Entry | Owns | May depend on | | ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- | | `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only | -| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code | +| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code | | `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code | | `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code | | `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code | @@ -488,9 +493,9 @@ SVG or URL. ## Usage sources -Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. +Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. -The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version. +The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. ## Contribute sidebar items diff --git a/docs/providers.md b/docs/providers.md index 47c39fd1a8..c5f37d4161 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -10,7 +10,9 @@ Keep a bundled provider in `plugins//` and register it through import rules belong to [plugins.md](plugins.md#built-in-plugins); the [public provider guide](../public-docs/plugins/providers.md) covers the provider contract. -The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns +The plugin owns the CLI transport, session state, catalog, and capabilities. Launch CLI transports +and probes through the [SDK process helpers](../public-docs/plugins/providers.md#launch-the-provider-cli), +which share Windows launcher handling with core providers. The daemon owns executable resolution and applies `agents.providers..command` and `env` before connecting. Register the provider's icon with the plugin rather than adding it to the app's provider icon map. You do not need a core manifest entry or provider factory. diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix index d00b382083..dae45db386 100644 --- a/nix/desktop-package.nix +++ b/nix/desktop-package.nix @@ -157,6 +157,9 @@ buildNpmPackage { cp -a "$path" "$out/share/paseo-desktop/$path" done < desktop-files.txt + # Shell hooks invoke the retained CLI bin directly, without a system Node. + patchShebangs --build "$out/share/paseo-desktop" + # Keep the same unpackaged monorepo layout expected by main.js. cp package.json $out/share/paseo-desktop/ mkdir -p $out/share/paseo-desktop/packages/app diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 338b70da5f..32aedbf346 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-aAt1FdwSeAqg8u14oHHHf0B0/eN97qIqApcbxv2y3s0= +sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4= diff --git a/nix/package.nix b/nix/package.nix index 417f3d2ad1..b032d88396 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -120,6 +120,9 @@ buildNpmPackage rec { cp -a "$path" "$out/lib/paseo/$path" done < daemon-files.txt + # Shell hooks invoke the retained CLI bin directly, without a system Node. + patchShebangs --build "$out/lib/paseo" + # Root package.json lets node resolve the workspace layout when the # CLI/server bin starts from $out. cp package.json $out/lib/paseo/ diff --git a/package-lock.json b/package-lock.json index dd5b77c2f9..a2856c464a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -36413,7 +36413,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37496,12 +37496,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.11.0-beta.4", - "@getpaseo/protocol": "0.11.0-beta.4", - "@getpaseo/server": "0.11.0-beta.4", + "@getpaseo/client": "0.11.0-beta.5", + "@getpaseo/protocol": "0.11.0-beta.5", + "@getpaseo/server": "0.11.0-beta.5", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -37748,10 +37748,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "dependencies": { - "@getpaseo/protocol": "0.11.0-beta.4", - "@getpaseo/relay": "0.11.0-beta.4", + "@getpaseo/protocol": "0.11.0-beta.5", + "@getpaseo/relay": "0.11.0-beta.5", "zod": "^4.4.3" }, "devDependencies": { @@ -37762,7 +37762,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -38006,7 +38006,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -38902,7 +38902,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -38931,14 +38931,14 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.11.0-beta.4", - "@getpaseo/protocol": "0.11.0-beta.4", + "@getpaseo/client": "0.11.0-beta.5", + "@getpaseo/protocol": "0.11.0-beta.5", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -38948,8 +38948,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.11.0-beta.4", - "@getpaseo/protocol": "0.11.0-beta.4", + "@getpaseo/client": "0.11.0-beta.5", + "@getpaseo/protocol": "0.11.0-beta.5", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -38971,7 +38971,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -38998,7 +38998,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -39216,16 +39216,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.11.0-beta.4", - "@getpaseo/highlight": "0.11.0-beta.4", - "@getpaseo/plugin": "0.11.0-beta.4", - "@getpaseo/protocol": "0.11.0-beta.4", - "@getpaseo/relay": "0.11.0-beta.4", + "@getpaseo/client": "0.11.0-beta.5", + "@getpaseo/highlight": "0.11.0-beta.5", + "@getpaseo/plugin": "0.11.0-beta.5", + "@getpaseo/protocol": "0.11.0-beta.5", + "@getpaseo/relay": "0.11.0-beta.5", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -40146,7 +40146,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", @@ -40174,6 +40174,7 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.1", + "parse5": "^7.3.0", "typescript": "^5.9.3", "vite": "^7.3.3", "vite-tsconfig-paths": "^5.1.4", @@ -41753,7 +41754,7 @@ }, "plugins": { "name": "@getpaseo/builtin-plugins", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "dependencies": { "@getpaseo/plugin": "*", "zod": "^4.4.3" diff --git a/package.json b/package.json index da3fca62a4..3eebf95522 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/e2e/browser/assistant-selection-copy.spec.ts b/packages/app/e2e/browser/assistant-selection-copy.spec.ts index 5d4838f133..bf8e455c88 100644 --- a/packages/app/e2e/browser/assistant-selection-copy.spec.ts +++ b/packages/app/e2e/browser/assistant-selection-copy.spec.ts @@ -120,6 +120,84 @@ async function selectAssistantElement(page: Page, selector: string): Promise { + await assistantMessageBlocks(page).evaluateAll( + (blocks, selected) => { + let startNode: Text | null = null; + for (const block of blocks) { + const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + if (walker.currentNode.textContent?.endsWith(selected.startText)) { + startNode = walker.currentNode as Text; + } + } + } + const end = blocks.map((block) => block.querySelector(selected.selector)).find(Boolean); + if (!startNode || !end) { + throw new Error(`Could not find ${selected.startText} — ${selected.selector}`); + } + const range = document.createRange(); + range.setStart(startNode, startNode.length); + range.setEnd(end, end.childNodes.length); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }, + { startText, selector }, + ); +} + +/** A drag over an image alone selects it inside its rendered frame. */ +async function selectAssistantImage(page: Page): Promise { + await assistantMessageBlocks(page) + .locator("img") + .evaluate((element) => { + const range = document.createRange(); + range.selectNode(element); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }); +} + +/** Press in the gap below the image and drag to the end of `text` in the block after it. */ +async function dragFromBelowImageTo(page: Page, text: string): Promise { + const blocks = assistantMessageBlocks(page); + const target = blocks.getByText(text); + await target.scrollIntoViewIfNeeded(); + const frame = await blocks.locator('[data-paseo-markdown-tag="img"]').boundingBox(); + const end = await target.evaluate((element, selectedText) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const offset = node.textContent?.indexOf(selectedText) ?? -1; + if (offset >= 0) { + const range = document.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset + selectedText.length); + const box = range.getBoundingClientRect(); + return { x: box.right - 1, y: box.top + box.height / 2, left: box.left }; + } + } + throw new Error(`Could not find ${selectedText}`); + }, text); + if (!frame) { + throw new Error("Expected a rendered image"); + } + await page.mouse.move(end.left + 5, frame.y + frame.height + 4); + await page.mouse.down(); + await page.mouse.move(end.x, end.y, { steps: 10 }); + await page.mouse.up(); +} + async function selectAssistantText(page: Page, text: string): Promise { await selectAssistantTextRange(page, text, text); } @@ -565,6 +643,19 @@ test("copying an assistant selection preserves Markdown structure and links", as const completeCodeClipboard = await readRichClipboard(page); expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;'); + // Touching the block before it without selecting any of its text is still a + // selection inside the code. + await selectAssistantElementFromEndOf( + page, + "not a generated link.", + '[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]', + ); + await copySelection(page); + + expect((await readRichClipboard(page)).plainText).toBe( + 'const answer = "yes";\n return answer;', + ); + // Crossing the block boundary expresses an intent to carry its Markdown structure. await selectAssistantAcrossCodeLines(page, "const", "After code."); await copySelection(page); @@ -601,3 +692,55 @@ test("copying an assistant selection preserves Markdown structure and links", as await agent.cleanup(); } }); + +test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({ + context, + page, +}) => { + const image = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "assistant-selection-copy-image-", + title: "Assistant selection copy image", + initialPrompt: "Render the image fixture.", + featureValues: { + mockAssistantResponse: [ + "Before the image.", + "", + `![chart](${image})`, + "", + "After the image.", + ].join("\n"), + }, + }); + + try { + await allowRichClipboard(context); + await agent.client.waitForAgentUpsert( + agent.agentId, + (snapshot) => snapshot.status === "idle", + 30_000, + ); + await openAgentRoute(page, agent); + await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1); + + await selectAssistantTextRange(page, "Before the image.", "After the image."); + await copySelection(page); + + expect((await readRichClipboard(page)).plainText).toBe( + `Before the image.\n\n![chart](${image})\n\nAfter the image.`, + ); + + await selectAssistantImage(page); + await copySelection(page); + + expect((await readRichClipboard(page)).plainText).toBe(`![chart](${image})`); + + await dragFromBelowImageTo(page, "After the"); + await copySelection(page); + + expect((await readRichClipboard(page)).plainText).toBe("After the"); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/codex-speed.spec.ts b/packages/app/e2e/browser/codex-speed.spec.ts index 654f15207d..ca8c3d8991 100644 --- a/packages/app/e2e/browser/codex-speed.spec.ts +++ b/packages/app/e2e/browser/codex-speed.spec.ts @@ -26,6 +26,10 @@ test.use({ }, }); +// Rendered light-theme colors: palette yellow 400 for Fast and Ultrafast, muted foreground for Normal. +const ACTIVE_SPEED_COLOR = "rgb(251, 191, 36)"; +const NORMAL_SPEED_COLOR = "rgb(113, 113, 122)"; + async function openSpeedSelector(page: Page): Promise { const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ }); const features = page.getByRole("button", { name: "Open agent features", exact: true }); @@ -51,6 +55,13 @@ async function selectSpeed(page: Page, label: string): Promise { else await expect(sheetTrigger).toContainText(label); } +async function expectSpeedIconColor(page: Page, color: string): Promise { + const trigger = page + .getByRole("button", { name: /^(Speed: .+|Select speed)$/ }) + .filter({ visible: true }); + await expect(trigger.locator("svg").first()).toHaveCSS("stroke", color); +} + async function expectSpeedChoices(page: Page, choices: string[]): Promise { for (const choice of ["Normal", "Fast", "Ultrafast"]) { const option = page.getByText(choice, { exact: true }).last(); @@ -100,15 +111,21 @@ for (const viewport of [ await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") }); } await openSpeedSelector(page); + await selectSpeed(page, "Fast"); + await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR); + await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-fast-selected.png`) }); + await openSpeedSelector(page); await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]); await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) }); await selectSpeed(page, "Ultrafast"); + await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR); await openSpeedSelector(page); await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]); await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`), }); await selectSpeed(page, "Normal"); + await expectSpeedIconColor(page, NORMAL_SPEED_COLOR); if (viewport.name === "desktop") { await switchAgentModel(agent.id, "gpt-6-sol"); await openSpeedSelector(page); diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index 9bd5193173..5d024de761 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -268,12 +268,18 @@ for (const theme of ["light", "dark"] as const) { await expect(card).toBeVisible(); }); - await test.step("Refresh replaces the card with the source's new report", async () => { - usage.answerNext([expiredLogin(onWorkLogin(claude!))]); + await test.step("Refresh keeps the card open and replaces it with the new report", async () => { + const refreshed = gate(); + usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] }); const card = contextWindowDetails(page); await refreshUsageCard(card, "Claude"); + await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled(); + // Past the card's close grace, with the pointer still on the card. + await page.waitForTimeout(500); + await expect(card).toBeVisible(); + refreshed.open(); await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); - expect(usage.refreshedReports()).toEqual([["claude:work"]]); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); }); await test.step("after a resume under another login, only that login shows", async () => { @@ -321,7 +327,7 @@ for (const theme of ["light", "dark"] as const) { await test.step("a host without usage reports shows only the context window", async () => { await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot); // One request per open that had usage; this one sends none. - expect(usage.agentRequests()).toHaveLength(5); + expect(usage.agentRequests()).toHaveLength(6); }); }); @@ -351,7 +357,7 @@ for (const theme of ["light", "dark"] as const) { const sheet = contextWindowDetails(page); await refreshUsageCard(sheet, "Claude"); await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible(); - expect(usage.refreshedReports()).toEqual([["claude:work"]]); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); await shot("problem"); }); @@ -365,7 +371,7 @@ for (const theme of ["light", "dark"] as const) { await test.step("a host without usage reports shows only the context window", async () => { await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot); - expect(usage.agentRequests()).toHaveLength(2); + expect(usage.agentRequests()).toHaveLength(3); }); }); } diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 7e32f61350..84b4cc63a2 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -1,3 +1,4 @@ +import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json"; import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; @@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows( await installPlugin(page, missingSource); await expect(page.getByTestId("plugin-management-feedback")).toContainText( - "Plugin source is neither an existing directory nor a Git URL", + `Plugin directory does not exist: ${missingSource}`, ); await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource); await capturePluginInstallForm(page, testInfo, "compact-error"); @@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) { await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible(); await expect(page.getByLabel("npm-review running")).toHaveCount(0); } + +test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => { + const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n"); + await installPluginWithUntrustedDescription(pluginEnvironment, description); + await gotoAppShell(page); + await openPluginSettings(page); + const text = page.getByText(description, { exact: true }); + await expect(text).toBeVisible(); + // This field must remain a text node, with no Markdown links, images, or HTML children. + expect(await text.evaluate((element) => element.childElementCount)).toBe(0); +}); + +async function installPluginWithUntrustedDescription( + environment: PluginEnvironment, + description: string, +) { + const directory = await createDirectoryPlugin( + environment.directory, + "plain-text-plugin", + description, + "Plain text plugin", + ); + await environment.client.installPluginSource({ source: directory }); +} diff --git a/packages/app/e2e/browser/sidebar-help.spec.ts b/packages/app/e2e/browser/sidebar-help.spec.ts index 2d038abbdb..4c0162e8d8 100644 --- a/packages/app/e2e/browser/sidebar-help.spec.ts +++ b/packages/app/e2e/browser/sidebar-help.spec.ts @@ -183,13 +183,16 @@ async function openCompactSidebar(page: Page): Promise { await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible(); } +const MIN_TARGET_SIZE = 44; + async function expectFingerSizedFooterTargets(page: Page): Promise { const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button"); await expect(buttons).toHaveCount(5); for (const button of await buttons.all()) { const bounds = await button.boundingBox(); - expect(bounds?.width).toBeGreaterThanOrEqual(44); - expect(bounds?.height).toBeGreaterThanOrEqual(44); + // Layout can report a 44pt box as 43.99999, so compare at a hundredth of a point. + expect(bounds?.width).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01); + expect(bounds?.height).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01); // Glyphs stay at the composer toolbar size (some optically smaller); only the target grows. const glyphWidth = await button.locator("svg").first().getAttribute("width"); expect(Number(glyphWidth)).toBeLessThanOrEqual(20); diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 700d1e200f..8071b3b4f3 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -6,6 +6,11 @@ import { closeMobileAgentSidebar, expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, + expectWorkspaceHoverCardClosed, + expectWorkspaceHoverCardOpen, + expectWorkspaceHoverCardStaysOpen, + focusWorkspaceRowWithKeyboard, + movePointerAwayFromWorkspaceHoverCard, openMobileAgentSidebar, pinWorkspaceFromSidebar, } from "../support/helpers/sidebar"; @@ -404,6 +409,52 @@ test.describe("Sidebar workspace list", () => { } }); + test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" }); + + try { + await gotoAppShell(page); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); + const row = await openWorkspaceFromSidebar(page, workspace.workspaceId); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + + await row.click(); + await expect(row).toBeFocused(); + await expectWorkspaceHoverCardOpen(page); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + } finally { + await workspace.cleanup(); + } + }); + + test("workspace hover card follows keyboard focus on a row", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" }); + + try { + await gotoAppShell(page); + await waitForSidebarProject(page, path.basename(workspace.repoPath)); + await openWorkspaceFromSidebar(page, workspace.workspaceId); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardClosed(page); + + await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); + await expectWorkspaceHoverCardOpen(page); + await movePointerAwayFromWorkspaceHoverCard(page); + await expectWorkspaceHoverCardStaysOpen(page); + await page.keyboard.press("Escape"); + await expectWorkspaceHoverCardClosed(page); + + await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId); + await expectWorkspaceHoverCardOpen(page); + await page.keyboard.press("Tab"); + await expectWorkspaceHoverCardClosed(page); + } finally { + await workspace.cleanup(); + } + }); + test("marks a finished workspace unread until it is opened again", async ({ page }) => { const workspace = await seedMockAgentWorkspace({ repoPrefix: "sidebar-mark-unread-", diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts similarity index 84% rename from packages/app/e2e/browser/usage-screen.spec.ts rename to packages/app/e2e/browser/usage-modal.spec.ts index e457c43d22..6f546078bf 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -18,13 +18,15 @@ import { installLoginUsage, installCodexWindowUsage, expectCodexReportedWindows, - openUsage, refreshLoginUsage, hoverUsageWindow, } from "../support/helpers/usage-login"; import { claudeAndCodexReports, expectPinnedUsage, + openCompactSidebar, + openUsageFromIcon, + openUsageFromItem, pinRow, openUsageOptions, closeUsageOptions, @@ -98,7 +100,7 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry { }; } -test.describe("usage screen", () => { +test.describe("usage modal", () => { test("opens from the sidebar on the host's reports, with no host filter for one host", async ({ page, }) => { @@ -134,14 +136,13 @@ test.describe("usage screen", () => { }); await gotoAppShell(page); - await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); - await expect(page).toHaveURL(/\/usage$/); + await openUsageFromItem(page); await usage.waitForListRequests(1); const group = page.getByTestId(`usage-host-${serverId}`); await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 }); await expect(hostFilter(page)).toHaveCount(0); - await qaScreenshot(page, "phase7-usage-screen-one-host"); + await qaScreenshot(page, "usage-modal-one-host"); await expect(group.getByText("31%")).toBeVisible(); await expect(group.getByText("Beta plan", { exact: true })).toBeVisible(); await expect(group.getByText("Unavailable", { exact: true })).toBeVisible(); @@ -176,7 +177,7 @@ test.describe("usage screen", () => { windows: [{ id: "weekly", label: "Weekly", usedPct }], }, }); - // The sidebar summary and the screen each load reports; only a card's Refresh forces one. + // The sidebar summary and the modal each load reports; only a card's Refresh forces one. const usage = await installUsageReportsFixture(page, { lists: [ (request) => @@ -187,7 +188,7 @@ test.describe("usage screen", () => { }); await gotoAppShell(page); - await page.locator('[data-testid="sidebar-usage"]:visible').first().click(); + await openUsageFromItem(page); const group = page.getByTestId(`usage-host-${serverId}`); await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 }); @@ -224,7 +225,9 @@ test.describe("usage screen", () => { }); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto("/usage"); + await gotoAppShell(page); + await openCompactSidebar(page); + await openUsageFromIcon(page); await usage.waitForListRequests(1); const group = page.getByTestId(`usage-host-${serverId}`); @@ -238,8 +241,8 @@ test.describe("usage screen", () => { const usage = await installUsageReportsFixture(page, { usageSupported: false }); await gotoAppShell(page); - // Without reports the footer has no Usage item; its Usage icon opens the screen. - await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click(); + // Without reports the footer has no Usage item; its Usage icon opens Usage. + await openUsageFromIcon(page); await expect( page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/), @@ -250,7 +253,7 @@ test.describe("usage screen", () => { expect(usage.listRequests()).toHaveLength(0); }); - test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({ + test("a host picked in the Usage modal is the sidebar's host too, after a reload", async ({ page, }) => { test.setTimeout(420_000); @@ -273,7 +276,7 @@ test.describe("usage screen", () => { // Nothing picked and no workspace open: the first host. await expectPinnedUsage(page, ["31% Weekly"]); - await usageItem(page).click(); + await openUsageFromItem(page); await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible(); await hostFilter(page).click(); @@ -282,7 +285,7 @@ test.describe("usage screen", () => { page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), ).toBeVisible({ timeout: 30_000 }); await expect(hostFilter(page)).toContainText("Secondary box"); - await qaScreenshot(page, "usage-screen-picked-host"); + await qaScreenshot(page, "usage-modal-picked-host"); await expectPinnedUsage(page, ["12% Weekly"]); // The e2e seed resets the host list on every load, so reopening re-adds the second host. @@ -292,6 +295,7 @@ test.describe("usage screen", () => { port: secondary.port, }); await expectPinnedUsage(page, ["12% Weekly"]); + await openUsageFromItem(page); await expect( page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"), ).toBeVisible({ timeout: 30_000 }); @@ -320,16 +324,16 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag }); try { await gotoAppShell(page); - await openUsage(page); + await openUsageFromIcon(page); await expect( - page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }), ).toBeVisible(); await expect(page.getByText("Unavailable", { exact: true })).toBeVisible(); await qaScreenshot(page, "usage-expired-login"); await fixture.setReport(loginWindows); await refreshLoginUsage(page); await expect( - page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }), ).toHaveCount(0); await expectPinnedUsage(page, ["31% 5h", "54% wk"]); const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / }); @@ -366,7 +370,8 @@ test("compact usage rows always show the pin glyph", async ({ page }) => { try { await page.setViewportSize({ width: 390, height: 844 }); await gotoAppShell(page); - await openUsage(page); + await openCompactSidebar(page); + await openUsageFromIcon(page); const card = page.getByTestId("usage-report-login-journey:account"); await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2); await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1"); @@ -413,7 +418,7 @@ for (const shape of ["seven-day-only", "Spark"] as const) { await page.setViewportSize({ width: 1440, height: 900 }); await gotoAppShell(page); await expect(usageItem(page)).toBeVisible(); - await usageItem(page).click(); + await openUsageFromItem(page); await qaScreenshot(page, `codex-${shape}-card-and-summary`); await expectCodexReportedWindows(page, shape); await qaScreenshot(page, `codex-${shape}-pins`); @@ -439,7 +444,9 @@ for (const theme of ["light", "dark"] as const) { lists: [() => claudeAndCodexReports()], }); await page.setViewportSize(viewport); - await page.goto("/usage"); + await gotoAppShell(page); + if (size === "compact") await openCompactSidebar(page); + await openUsageFromIcon(page); const screen = page.getByTestId(`usage-host-${getServerId()}`); await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 }); const settings = page.getByTestId("usage-options-menu"); @@ -474,3 +481,64 @@ async function captureSettingsState( await qaScreenshot(page, name); await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" }); } + +test("an account with all logins failing shows each harness and remedy, then only usage when one succeeds", async ({ + page, +}) => { + const fixture = await installLoginUsage([ + { harness: "Codex", report: { status: "error", error: "Usage API returned 500. Try again." } }, + { + harness: "OpenCode", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "opencode" }, + }, + }, + { + harness: "Pi", + report: { status: "unavailable", problem: { kind: "no_quota", detail: "No active plan" } }, + }, + { + harness: "OMP", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 403, refreshedBy: "omp" }, + }, + }, + ]); + try { + await gotoAppShell(page); + await openUsageFromIcon(page); + await expectLoginErrors(page, [ + "Codex: Usage API returned 500. Try again.", + "OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.", + "Pi: No active plan", + "OMP: Login rejected (HTTP 403). Run omp to refresh it.", + ]); + await fixture.setReport([ + { + harness: "Codex", + report: { status: "error", error: "Usage API returned 500. Try again." }, + }, + { + harness: "OpenCode", + report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }, + }, + ]); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "42%"); + } finally { + await fixture.cleanup(); + } +}); + +async function expectLoginErrors(page: Page, lines: string[]) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByTestId("usage-login-error")).toHaveText(lines.join("\n")); +} + +async function expectLoginUsageOnly(page: Page, percentage: string) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByText(percentage, { exact: true })).toBeVisible(); + await expect(card.getByTestId("usage-login-error")).toHaveCount(0); +} diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index 2b8d982de7..1b509f5640 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -7,7 +7,11 @@ import { installUsageReportsFixture, type UsageReportsFixture, } from "../support/helpers/usage-reports"; -import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item"; +import { + openUsageFromIcon, + refreshAllUsage, + showUsageAs, +} from "../support/helpers/usage-sidebar-item"; const ICON = ''; @@ -98,7 +102,8 @@ test.describe("usage settings", () => { // The shared percentages setting applies to the host section. await expect(page.getByTestId("usage-options-menu")).toBeVisible(); const hostUsageUrl = page.url(); - await page.goto("/usage"); + await gotoAppShell(page); + await openUsageFromIcon(page); await showUsageAs(page, "remaining"); await page.goto(hostUsageUrl); await expect(card.getByText("30% left")).toBeVisible(); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index e43ccdc4a3..fb738b3cb5 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -2,6 +2,11 @@ import path from "node:path"; import type { Locator, Page } from "@playwright/test"; import { expect, test } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { gotoWorkspace, waitForTabBar } from "../support/helpers/launcher"; +import { + connectNewWorkspaceDaemonClient, + openProjectViaDaemon, +} from "../support/helpers/new-workspace"; import { getServerId } from "../support/helpers/server-id"; import { openSettingsHostSection } from "../support/helpers/settings"; import { @@ -14,28 +19,29 @@ import { import { installUsageReportsFixture } from "../support/helpers/usage-reports"; import { claudeAndCodexReports, - closeUsageSheet, + closeUsage, + closeUsageFromBackdrop, closeUsageOptions, - openUsageSheetFromIcon, expectNoUsageItem, - expectOnUsageScreen, expectPinnedUsage, expectSummaryInSidebar, expectUnpinnableRows, - leaveUsageScreen, openCompactSidebar, - openUsageScreenFromIcon, + openUsageFromIcon, + openUsageFromItem, pinRow, + scrollUsage, setSummaryInSidebar, openUsageOptions, showUsageAs, togglePin, usageItem, - usageSheet, + usageModal, installTallUsageSource, } from "../support/helpers/usage-sidebar-item"; import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; import { waitForSettledPosition } from "../support/helpers/sheet-layout"; +import { createTempGitRepo } from "../support/helpers/workspace"; const WIDE = { width: 1440, height: 900 }; const COMPACT = { width: 390, height: 844 }; @@ -75,49 +81,96 @@ test.describe("Usage item", () => { await expectNoUsageItem(page); await test.step("the icon opens a sheet without leaving the current screen", async () => { - await openUsageSheetFromIcon(page); - await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible(); + await openUsageFromIcon(page); + await expect(usageModal(page).getByText("Claude", { exact: true })).toBeVisible(); await expect(page).toHaveURL(startingUrl); await qaScreenshot(page, "compact-usage-icon-sheet"); await setSummaryInSidebar(page, true); await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); - await closeUsageSheet(page); + await closeUsageFromBackdrop(page); }); await test.step("the summary opens the same sheet", async () => { - await usageItem(page).click(); - await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible(); + await openUsageFromItem(page); + await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible(); await expect(page).toHaveURL(startingUrl); await qaScreenshot(page, "compact-usage-summary-sheet"); - await closeUsageSheet(page); + await closeUsageFromBackdrop(page); }); }); - test("long usage reports stay in an 80% sheet and scroll to the final window", async ({ + + test("on desktop Usage opens as a dialog over the workspace and closes back to it", async ({ page, }) => { - test.setTimeout(120_000); - const source = await installTallUsageSource(); + const repo = await createTempGitRepo("usage-modal-"); + const client = await connectNewWorkspaceDaemonClient(); try { - await page.setViewportSize(COMPACT); + const project = await openProjectViaDaemon(client, repo.path); + await page.setViewportSize(WIDE); + await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); - await gotoAppShell(page); - await openCompactSidebar(page); - await expect(usageItem(page)).toBeInViewport(); - await usageItem(page).click(); - const content = page.getByTestId("sidebar-usage-sheet-content"); - await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1); - await waitForSettledPosition(content); - const bounds = (await content.boundingBox())!; - expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2); - await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); - await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded(); - await expect(content.getByText("Window 20", { exact: true })).toBeInViewport(); - await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded(); - await expect(content.getByText("Window 1", { exact: true })).toBeInViewport(); + await gotoWorkspace(page, project.workspaceId); + const workspaceUrl = page.url(); + + await test.step("the summary opens a dialog over the workspace", async () => { + await openUsageFromItem(page); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByText("Claude", { exact: true })).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Refresh all", exact: true }), + ).toBeVisible(); + await expect(page).toHaveURL(workspaceUrl); + await qaScreenshot(page, "desktop-usage-dialog"); + }); + + await test.step("Close returns to the same workspace", async () => { + await closeUsage(page); + await expect(page).toHaveURL(workspaceUrl); + await waitForTabBar(page); + }); + + await test.step("the icon opens it again, and Escape closes it", async () => { + await openUsageFromIcon(page); + await page.keyboard.press("Escape"); + await expect(usageModal(page)).toHaveCount(0); + await expect(page).toHaveURL(workspaceUrl); + }); } finally { - await source.cleanup(); + await client.close().catch(() => undefined); + await repo.cleanup().catch(() => undefined); } }); + + const layouts = { + desktop: { viewport: WIDE, showSidebar: async (_page: Page) => {} }, + compact: { viewport: COMPACT, showSidebar: openCompactSidebar }, + }; + for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) { + test(`long usage reports scroll to the final window on ${size}`, async ({ page }) => { + test.setTimeout(120_000); + const source = await installTallUsageSource(); + try { + await page.setViewportSize(viewport); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await showSidebar(page); + await expect(usageItem(page)).toBeInViewport(); + await openUsageFromItem(page); + const content = usageModal(page); + await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1); + await waitForSettledPosition(content); + await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); + await qaScreenshot(page, `${size}-usage-scroll-top`); + await scrollUsage(page, 5_000); + await expect(content.getByText("Window 20", { exact: true })).toBeInViewport(); + await qaScreenshot(page, `${size}-usage-scroll-bottom`); + await scrollUsage(page, -5_000); + await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport(); + } finally { + await source.cleanup(); + } + }); + } test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({ page, }) => { @@ -146,8 +199,7 @@ test.describe("Usage item", () => { await gotoAppShell(page); await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" }); - await usageItem(page).click(); - await expectOnUsageScreen(page); + await openUsageFromItem(page); await qaScreenshot(page, "default-pins-card"); await expect(pinRow(screen, "Claude", "Session")).toBeChecked(); await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked(); @@ -172,17 +224,13 @@ test.describe("Usage item", () => { await expect(usageItem(page)).not.toHaveText("Usage"); await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" }); await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible(); - await qaScreenshot(page, "usage-screen-header", { - kind: "element", - locator: page.getByTestId("page-title").locator(".."), - }); await qaScreenshot(page, "usage-card-refresh", { kind: "element", locator: screen.getByTestId("usage-report-claude:default"), }); }); - await test.step("remaining flips the Usage item and the Usage screen", async () => { + await test.step("remaining flips the Usage item and the Usage modal", async () => { await showUsageAs(page, "remaining"); await expectPinnedUsage(page, ["69% 5h", "88% wk"]); await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/); @@ -194,23 +242,15 @@ test.describe("Usage item", () => { ).toBeVisible(); }); - await test.step("on desktop the Usage item opens the Usage screen", async () => { - await gotoAppShell(page); - await expect(page).not.toHaveURL(/\/usage$/); - await usageItem(page).click(); - await expectOnUsageScreen(page); - await expect(page.getByTestId("usage-expanded")).toHaveCount(0); - }); - await test.step("on a phone the Usage item opens a bottom sheet", async () => { + await closeUsage(page); await page.setViewportSize(COMPACT); - await leaveUsageScreen(page); await openCompactSidebar(page); await expect(usageItem(page)).toBeInViewport(); await expectPinnedUsage(page, ["69% 5h", "88% wk"]); await qaScreenshot(page, "compact-footer"); - await usageItem(page).click(); - const sheet = usageSheet(page); + await openUsageFromItem(page); + const sheet = usageModal(page); await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport(); await expect(sheet.getByText("88% left")).toBeVisible(); await expect(pinRow(sheet, "Claude", "Session")).toBeChecked(); @@ -231,25 +271,20 @@ test.describe("Usage item", () => { await closeUsageOptions(page); await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0); await expect(sheet).toBeVisible(); - await expect(page).not.toHaveURL(/\/usage$/); await waitForSettledPosition(sheet); const sheetBox = (await sheet.boundingBox())!; - // The Usage sheet reserves the top 20% as its backdrop. + // The sheet leaves the screen above it as its backdrop. expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2); expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8); await qaScreenshot(page, "compact-sheet"); - await qaScreenshot(page, "phase7-compact-sheet"); - // Tap the backdrop above the sheet. - await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2); - await expect(sheet).toHaveCount(0); + await closeUsageFromBackdrop(page); await page.setViewportSize(WIDE); - await usageItem(page).click(); - await expectOnUsageScreen(page); }); await test.step("a reload keeps the pins and the toggle", async () => { await page.reload(); await expectPinnedUsage(page, ["69% 5h", "88% wk"]); + await openUsageFromItem(page); await openUsageOptions(page); await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute( "aria-selected", @@ -260,13 +295,15 @@ test.describe("Usage item", () => { }); await test.step("the Settings usage section shares the pins and the toggle", async () => { - const usageUrl = page.url(); + const workspaceUrl = page.url(); + await closeUsage(page); await openSettings(page); await openSettingsHostSection(page, serverId, "usage"); const section = page.getByTestId("usage-card"); await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 }); await expect(pinRow(section, "Codex", "Weekly")).toBeChecked(); - await page.goto(usageUrl); + await page.goto(workspaceUrl); + await openUsageFromItem(page); await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 }); }); @@ -275,6 +312,7 @@ test.describe("Usage item", () => { await togglePin(screen, "Codex", "Weekly"); await expectNoUsageItem(page); await page.reload(); + await openUsageFromIcon(page); await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 }); await expectNoUsageItem(page); await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked(); @@ -282,6 +320,7 @@ test.describe("Usage item", () => { await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked(); await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked(); await qaScreenshot(page, "explicit-empty-pins-after-reload"); + await closeUsage(page); }); await test.step("Settings > Sidebar lists the Usage item", async () => { @@ -325,8 +364,7 @@ test("without summary data the footer drops the Usage item and keeps the Usage i await expectNoUsageItem(page); await expectFooterSeparator(page, false); await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" }); - await page.locator('[data-testid="sidebar-usage-icon"]:visible').click(); - await expectOnUsageScreen(page); + await openUsageFromIcon(page); }); test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({ @@ -340,13 +378,13 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i const claude = screen.getByTestId("usage-report-claude:default"); await test.step("off by default: no Usage item, and rows that do not pin", async () => { - await openUsageScreenFromIcon(page); + await openUsageFromIcon(page); await expectNoUsageItem(page); await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 }); await expectUnpinnableRows(claude); await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default")); await expectSummaryInSidebar(page, false); - await qaScreenshot(page, "usage-screen-summary-off"); + await qaScreenshot(page, "usage-summary-off"); }); await test.step("on: the Usage item and the pins appear", async () => { @@ -355,7 +393,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked(); await togglePin(screen, "Claude", "Weekly"); await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]); - await qaScreenshot(page, "usage-screen-summary-on"); + await qaScreenshot(page, "usage-summary-on"); }); await test.step("off then on again, after a reload, brings back the saved pins", async () => { @@ -363,7 +401,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i await expectNoUsageItem(page); await expectUnpinnableRows(claude); await page.reload(); - await expectOnUsageScreen(page); + await openUsageFromIcon(page); await expectSummaryInSidebar(page, false); await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 }); await expectUnpinnableRows(claude); @@ -391,8 +429,7 @@ test("released hosts supply source logos through the client conversion", async ( await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath); await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath); await qaScreenshot(page, "released-host-footer", { kind: "footer" }); - await usageItem(page).click(); - await expectOnUsageScreen(page); + await openUsageFromItem(page); const screen = page.getByTestId(`usage-host-${getServerId()}`); await expect( screen.getByTestId("usage-report-claude").locator("svg path").first(), @@ -400,5 +437,5 @@ test("released hosts supply source logos through the client conversion", async ( await expect( screen.getByTestId("usage-report-codex").locator("svg path").first(), ).toHaveAttribute("d", codexPath); - await qaScreenshot(page, "released-host-usage-screen"); + await qaScreenshot(page, "released-host-usage-modal"); }); diff --git a/packages/app/e2e/support/helpers/context-window.ts b/packages/app/e2e/support/helpers/context-window.ts index 82f2be6da5..21341c7766 100644 --- a/packages/app/e2e/support/helpers/context-window.ts +++ b/packages/app/e2e/support/helpers/context-window.ts @@ -134,8 +134,8 @@ export interface AgentUsageScript { stopReportingUsage(): void; /** The agent of each agent usage request, in order. */ agentRequests(): string[]; - /** Each report Refresh, by report ID. */ - refreshedReports(): string[][]; + /** Each scoped agent Refresh, by agent ID. */ + refreshedAgents(): string[]; } /** @@ -162,8 +162,10 @@ export async function scriptAgentUsage(page: Page): Promise { supported = false; }, agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []), - refreshedReports: () => - usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])), + refreshedAgents: () => + usage + .listRequests() + .flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])), }; } diff --git a/packages/app/e2e/support/helpers/pr-pane.ts b/packages/app/e2e/support/helpers/pr-pane.ts index 56f0ef444c..37fe719e0f 100644 --- a/packages/app/e2e/support/helpers/pr-pane.ts +++ b/packages/app/e2e/support/helpers/pr-pane.ts @@ -1,5 +1,5 @@ import { expect, type Page } from "@playwright/test"; -import { getStateLabel } from "@/git/pull-request-panel/data"; +import { en } from "@/i18n/resources/en"; import { openPullRequestPanel } from "./workspace-tabs"; export async function openPrPane(page: Page): Promise { @@ -14,7 +14,7 @@ export async function expectPrPaneState( page: Page, state: "open" | "merged" | "closed" | "draft", ): Promise { - await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), { + await expect(page.getByTestId("pr-pane-state")).toHaveText(en.workspace.git.pr.states[state], { timeout: 15_000, }); } diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index b707abdae8..e3eff916e7 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01; * Settings together at the end. */ export async function expectFooterIconRow(page: Page): Promise { - const boxes = ( - await Promise.all( - FOOTER_ICON_TEST_IDS.map((testID) => - page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(), - ), - ) - ).map((box) => box!); + // Read one browser-frame snapshot: the compact drawer can translate between separate RPCs. + const boxes = await page + .locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(",")) + .evaluateAll((elements) => + elements.map((element) => { + const { x, y, width, height } = element.getBoundingClientRect(); + return { testID: element.getAttribute("data-testid"), x, y, width, height }; + }), + ); + expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS); const [first] = boxes; for (const box of boxes) { expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2); diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index c2afc34ef8..1909b4f31b 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -53,6 +53,37 @@ export async function showWorkspaceHoverCard(page: Page, workspaceId: string): P await expect(page.getByTestId("workspace-hover-card")).toBeVisible(); } +export async function movePointerAwayFromWorkspaceHoverCard(page: Page): Promise { + // Two moves, so the pointer always moves even when it is already parked here. + await page.mouse.move(1200, 200, { steps: 5 }); + await page.mouse.move(1200, 400, { steps: 5 }); +} + +export async function focusWorkspaceRowWithKeyboard( + page: Page, + workspaceId: string, +): Promise { + const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); + await row.focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect(row).toBeFocused(); +} + +export async function expectWorkspaceHoverCardOpen(page: Page): Promise { + await expect(page.getByTestId("workspace-hover-card")).toBeVisible(); +} + +export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise { + // Longer than the hover card's close grace period. + await page.waitForTimeout(300); + await expectWorkspaceHoverCardOpen(page); +} + +export async function expectWorkspaceHoverCardClosed(page: Page): Promise { + await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0); +} + export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise { const workspaceKey = `${getServerId()}:${workspaceId}`; await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click(); diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 234ec2bf1b..6129f0259e 100644 --- a/packages/app/e2e/support/helpers/usage-login.ts +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -8,12 +8,15 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { pluginRequirements } from "./plugin-fixture"; /** A real store-backed plugin on the worker's isolated daemon. */ -export async function installLoginUsage(initialReport: UsageReport) { +type LoginReports = Array<{ harness: string; report: UsageReport }>; + +export async function installLoginUsage(initialReport: UsageReport | LoginReports) { const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-")); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); const store = path.join(directory, "report.json"); - const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report)); + const setReport = (report: UsageReport | LoginReports) => + writeFile(store, JSON.stringify(report)); const cleanup = async () => { try { await client.removePlugin("login-journey"); @@ -35,9 +38,13 @@ export async function installLoginUsage(initialReport: UsageReport) { import {readFile} from "node:fs/promises"; import {z} from "zod"; export default function contribute(server) { - server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}), - discover: async () => [{key: "account", input: {}}], - fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")), + const read = async () => { + const value = JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")); + return Array.isArray(value) ? value : [{harness: "Claude", report: value}]; + }; + server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({index: z.number()}), + discover: async () => (await read()).map((login, index) => ({key: "account", harness: login.harness, input: {index}})), + fetch: async ({index}) => (await read())[index].report, }); return () => {}; }`, @@ -51,10 +58,6 @@ export default function contribute(server) { } } -export async function openUsage(page: Page) { - await page.goto("/usage"); -} - export async function refreshLoginUsage(page: Page) { await page.getByRole("button", { name: "Refresh Claude", exact: true }).click(); } diff --git a/packages/app/e2e/support/helpers/usage-sidebar-item.ts b/packages/app/e2e/support/helpers/usage-sidebar-item.ts index d21473867e..8cc3c07a00 100644 --- a/packages/app/e2e/support/helpers/usage-sidebar-item.ts +++ b/packages/app/e2e/support/helpers/usage-sidebar-item.ts @@ -8,7 +8,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { pluginRequirements } from "./plugin-fixture"; import { waitForSettledPosition } from "./sheet-layout"; -/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */ +/** Real usage-source plugin; its long report makes the Usage modal scroll. */ export async function installTallUsageSource() { const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-")); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); @@ -142,13 +142,9 @@ export function usageItem(page: Page): Locator { return visible(page, "sidebar-usage"); } -/** The compact usage sheet the Usage item opens. */ -export function usageSheet(page: Page): Locator { - return visible(page, "usage-expanded"); -} - -export async function expectOnUsageScreen(page: Page): Promise { - await expect(page).toHaveURL(/\/usage$/); +/** The Usage modal's body: a dialog on wide layouts, a bottom sheet on compact ones. */ +export function usageModal(page: Page): Locator { + return visible(page, "usage-modal-body"); } /** Each pinned window as it reads: its percent and short label, "31% 5h". */ @@ -189,23 +185,45 @@ export async function togglePin(scope: Locator, source: string, window: string) await expect(row).toBeChecked({ checked: !pinned }); } -/** The footer's Usage icon, which is there whether or not the Usage item is on. */ -export async function openUsageScreenFromIcon(page: Page): Promise { +/** + * The footer's Usage icon, which is there whether or not the Usage item is on. On a phone the + * sidebar drawer has to be open. + */ +export async function openUsageFromIcon(page: Page): Promise { await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 }); - await expectOnUsageScreen(page); + await expect(usageModal(page)).toBeVisible(); } -/** Both footer entry points open Usage over the current screen on a phone. */ -export async function openUsageSheetFromIcon(page: Page): Promise { - await page.getByRole("button", { name: "Usage", exact: true }).click(); - await expect(usageSheet(page)).toBeVisible(); +/** The footer's Usage item, its summary of pinned windows. */ +export async function openUsageFromItem(page: Page): Promise { + await usageItem(page).click(); + await expect(usageModal(page)).toBeVisible(); } -export async function closeUsageSheet(page: Page): Promise { - const sheet = usageSheet(page); +/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */ +export async function scrollUsage(page: Page, deltaY: number): Promise { + const body = await usageModal(page).boundingBox(); + const viewport = page.viewportSize(); + if (!body || !viewport) throw new Error("Usage must be open before scrolling it."); + // The middle of the part of the body on screen; the body itself runs past the viewport. + const top = Math.max(body.y, 0); + const bottom = Math.min(body.y + body.height, viewport.height); + await page.mouse.move(body.x + body.width / 2, (top + bottom) / 2); + await page.mouse.wheel(0, deltaY); +} + +/** The modal's own Close button. */ +export async function closeUsage(page: Page): Promise { + await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click(); + await expect(usageModal(page)).toHaveCount(0); +} + +/** A tap on the backdrop above the bottom sheet. */ +export async function closeUsageFromBackdrop(page: Page): Promise { + const sheet = usageModal(page); await waitForSettledPosition(sheet); - const bounds = await sheet.boundingBox(); - if (!bounds) throw new Error("Usage sheet must be visible before closing it."); + const bounds = await visible(page, "usage-modal").boundingBox(); + if (!bounds) throw new Error("Usage must be open before closing it."); await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2); await expect(sheet).toHaveCount(0); } @@ -214,7 +232,7 @@ function summaryInSidebarSwitch(page: Page): Locator { return page.getByRole("switch", { name: "Summary in sidebar", exact: true }); } -/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */ +/** Turns the sidebar Usage summary on or off from the Usage modal's Settings. */ export async function setSummaryInSidebar(page: Page, on: boolean): Promise { await openUsageOptions(page); await summaryInSidebarSwitch(page).click(); @@ -275,9 +293,3 @@ export async function refreshAllUsage(page: Page): Promise { export async function openCompactSidebar(page: Page): Promise { await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); } - -/** On a phone the Usage screen has a back header instead of the sidebar menu. */ -export async function leaveUsageScreen(page: Page): Promise { - await page.getByRole("button", { name: "Back", exact: true }).first().click(); - await expect(page).not.toHaveURL(/\/usage$/); -} diff --git a/packages/app/package.json b/packages/app/package.json index 5ed29a2e22..f23f06ce52 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/app/public/index.html b/packages/app/public/index.html index f7116fc86b..a95e7b8c9e 100644 --- a/packages/app/public/index.html +++ b/packages/app/public/index.html @@ -70,6 +70,15 @@ -webkit-app-region: no-drag !important; } + /* Electron does not clip app-region rectangles to scroll viewports. + Content-only viewports emit no window regions, including from their + controls. Visible controls here use normal DOM hit testing. + Use initial: Blink parses the explicit value none as no-drag. */ + [data-window-content], + [data-window-content] * { + -webkit-app-region: initial !important; + } + /* Suppress the browser's default focus outline — it appears on mouse clicks and looks out of place against our themed surfaces. Keep a themed ring for keyboard users via :focus-visible. */ diff --git a/packages/app/src/agent-controls/policy.ts b/packages/app/src/agent-controls/policy.ts index 827dfadf57..89a4a86daa 100644 --- a/packages/app/src/agent-controls/policy.ts +++ b/packages/app/src/agent-controls/policy.ts @@ -2,6 +2,7 @@ import type { AgentMode } from "@getpaseo/protocol/agent-types"; export const PLAN_MODE_FEATURE_ID = "plan_mode"; export const FAST_MODE_FEATURE_ID = "fast_mode"; +export const SPEED_FEATURE_ID = "service_tier"; export function isPlanningAgentMode(mode: Pick): boolean { return mode.colorTier === "planning" || mode.id === "plan" || mode.id.endsWith("#plan"); diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 9078b0f1c3..1f61d0a14c 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -32,7 +32,7 @@ vi.mock("react-native-unistyles", () => ({ withUnistyles: (Component: React.ComponentType) => Component, })); -function userMessage(index: number): StreamItem { +function userMessage(index: number): Extract { return { kind: "user_message", id: `message-${index}`, @@ -55,6 +55,30 @@ function createRenderers(onRowRender: () => void): StreamSegmentRenderers { }; } +function MeasuredTestRow({ item }: { item: StreamItem }) { + const [loaded, setLoaded] = React.useState(false); + const load = React.useCallback(() => setLoaded(true), []); + const height = item.kind === "user_message" && item.text === "grown" ? 120 : 24; + const measure = React.useCallback( + (node: HTMLDivElement | null) => { + if (!node?.parentElement) return; + Object.defineProperty(node.parentElement, "offsetHeight", { + configurable: true, + value: height, + }); + }, + [height], + ); + return ( +
+ + {item.id} +
+ ); +} + describe("createWebStreamStrategy", () => { let root: Root | null = null; let container: HTMLDivElement | null = null; @@ -155,6 +179,75 @@ describe("createWebStreamStrategy", () => { expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length); }); + it("retains a row's DOM and local state when recent history becomes virtualized", () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const item = userMessage(0); + const renderRow = (rowItem: StreamItem) => ; + const input: StreamRenderInput = { + agentId: "agent", + segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] }, + boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false }, + renderers: { + renderHistoryVirtualizedRow: renderRow, + renderHistoryMountedRow: renderRow, + renderLiveHeadRow: renderRow, + renderLiveAuxiliary: () => null, + }, + listEmptyComponent: null, + viewportRef: React.createRef(), + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: () => {}, + onNearHistoryStart: () => true, + isLoadingOlderHistory: false, + hasOlderHistory: false, + olderHistoryProgressKey: null, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }; + act(() => root?.render(strategy.render(input))); + const row = container.querySelector('[data-history-row-id="message-0"]')!; + act(() => row.querySelector("button")!.click()); + expect(row.querySelector("button")!.textContent).toBe("Loaded"); + const viewport = container.querySelector('[data-testid="agent-chat-scroll"]')!; + Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 500 }); + act(() => + root?.render( + strategy.render({ + ...input, + segments: { historyVirtualized: [item], historyMounted: [userMessage(1)], liveHead: [] }, + boundary: { ...input.boundary, hasVirtualizedHistory: true }, + }), + ), + ); + expect(container.querySelector('[data-history-row-id="message-0"]')).toBe(row); + expect(row.isConnected).toBe(true); + expect(row.querySelector("button")!.textContent).toBe("Loaded"); + act(() => + root?.render( + strategy.render({ + ...input, + segments: { + historyVirtualized: [{ ...item, text: "grown" }, userMessage(1)], + historyMounted: [userMessage(2)], + liveHead: [], + }, + boundary: { ...input.boundary, hasVirtualizedHistory: true }, + }), + ), + ); + expect( + (container.querySelector('[data-history-row-id="message-1"]') as HTMLElement).style.transform, + ).toBe("translateY(120px)"); + expect(row.querySelector("button")!.textContent).toBe("Loaded"); + }); + it("keeps the timeline width stable with an overlay scrollbar", () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); container = document.createElement("div"); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 25e7a313d2..226b7c1e2a 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useStableEvent } from "@/hooks/use-stable-event"; +import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; @@ -76,6 +77,8 @@ const historyStartSlotStyle: CSSProperties = { flexShrink: 0, }; +const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" }; + const streamRowStyle: CSSProperties = { display: "flex", flexDirection: "column", @@ -268,6 +271,23 @@ function isScrollContainerOverscrolledPastBottom( return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX; } +// Position updates do not change a row's content. Keep renderer work separate +// from virtual measurements while retaining the same component across lanes. +const StreamRowContent = React.memo(function StreamRowContent({ + item, + index, + items, + render, +}: { + revision?: StreamRenderInput["liveHeadRowRevision"]; + item: StreamItem; + index: number; + items: StreamItem[]; + render: StreamRenderInput["renderers"]["renderHistoryVirtualizedRow"]; +}) { + return render(item, index, items); +}); + function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { const { segments: inputSegments, @@ -425,6 +445,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, enabled: shouldUseVirtualizer, + initialRect: { + width: scrollContainerRef.current?.clientWidth ?? 0, + height: scrollContainerRef.current?.clientHeight ?? 0, + }, getScrollElement: () => scrollContainerRef.current, getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index, estimateSize: (index: number) => { @@ -536,25 +560,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduler.schedule(); }); - // ResizeObserver runs before paint. Batch measurements and commit React's new - // positions here so a resized image cannot overlap rows for an extra frame. - // Use resizeItem exclusively; measureElement would install a second observer. + const measureVirtualizedRows = useStableEvent(() => { + let resized = false; + for (const element of contentRef.current?.querySelectorAll( + "[data-history-row-id][data-index]", + ) ?? []) { + const index = Number(element.dataset.index); + const height = measureVirtualElement(element, undefined, rowVirtualizer); + if (rowVirtualizer.measurementsCache[index]?.size === height) continue; + rowVirtualizer.resizeItem(index, height); + resized = true; + } + return resized; + }); + + // Measure the committed DOM rather than queued observer entries: flushSync can + // also commit an image's pending intrinsic dimensions, invalidating those entries. const virtualRowObserver = useMemo( () => - new ResizeObserver((entries) => { + new ResizeObserver(() => { if (!isActiveRef.current) return; flushSync(() => { - for (const entry of entries) { - const element = entry.target as HTMLElement; - if (!element.isConnected) continue; - rowVirtualizer.resizeItem( - Number(element.dataset.index), - measureVirtualElement(element, entry, rowVirtualizer), - ); - } + measureVirtualizedRows(); }); }), - [rowVirtualizer], + [measureVirtualizedRows], ); const measureVirtualizedRowElement = useCallback( (node: HTMLDivElement | null) => { @@ -731,8 +761,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool // Apply row positions and their scroll correction in the same paint, including // the ordinary-to-virtualized handoff and measurements of newly mounted rows. useLayoutEffect(() => { + // Commit positions for every resized row before accepting reading geometry. + // Child layout can change during this commit, ahead of ResizeObserver delivery. + if (!isActive || measureVirtualizedRows()) return; reconcileReadingPosition(); - }, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]); + }, [ + contentWidth, + isActive, + segments, + virtualRows, + measureVirtualizedRows, + reconcileReadingPosition, + ]); const stopFollowingOutputFromUserIntent = useStableEvent(() => { cancelPendingStickToBottom(); @@ -915,6 +955,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool evaluateHistoryStart(); } const observer = new ResizeObserver(() => { + flushSync(() => { + measureVirtualizedRows(); + }); const nextGeometry = getObservedViewportGeometry(scrollContainer); if (pendingResumeGeometryCheckRef.current) { pendingResumeGeometryCheckRef.current = false; @@ -953,6 +996,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduleHistoryStartPrependSettle, scheduleStickToBottom, updateScrollMetrics, + measureVirtualizedRows, ]); useLayoutEffect(() => { @@ -1171,13 +1215,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }), [], ); - const virtualRowsContainerStyle = useMemo((): CSSProperties => { - return { - position: "relative", - width: "100%", + const virtualRowsContainerStyle = useMemo( + (): CSSProperties => ({ height: virtualTotalSize, - }; - }, [virtualTotalSize]); + flexShrink: 0, + }), + [virtualTotalSize], + ); const renderVirtualRowStyle = useCallback( (start: number): CSSProperties => ({ position: "absolute", @@ -1198,12 +1242,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool data-message-id={getStreamItemMessageId(item)} style={streamRowStyle} > - {renderHistoryMountedRow(item, index, segments.historyMounted)} + )); }, [renderHistoryMountedRow, segments.historyMounted]); const liveHeadRows = useMemo(() => { - void liveHeadRowRevision; return segments.liveHead.map((item, index) => (
- {renderLiveHeadRow(item, index, segments.liveHead)} +
)); }, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]); @@ -1241,7 +1295,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool !liveAuxiliary; return ( -
+
{historyStartSlot} - {shouldUseVirtualizer ? ( -
- {virtualRows.map((virtualRow) => { +
+
+ {[ + ...virtualRows.map((virtualRow) => { const item = segments.historyVirtualized[virtualRow.index]; - if (!item) { - return null; - } + if (!item) return null; return (
- {renderHistoryVirtualizedRow( - item, - virtualRow.index, - segments.historyVirtualized, - )} +
); - })} -
- ) : null} - {mountedRows} + }), + ...mountedRows, + ]} +
{liveAuxiliary} {shouldRenderEmpty ? listEmptyComponent : null}
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 2eb4e5185a..6e9b506b55 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -863,7 +863,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) { pathname === "/new" || pathname === "/sessions" || pathname === "/schedules" || - pathname === "/usage" || routeHasKnownHost); return {children}; @@ -896,7 +895,6 @@ function RootStack() { - diff --git a/packages/app/src/app/usage.tsx b/packages/app/src/app/usage.tsx deleted file mode 100644 index 2928cca3f3..0000000000 --- a/packages/app/src/app/usage.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; -import { UsageScreen } from "@/usage"; - -export default function UsageRoute() { - return ( - - - - ); -} diff --git a/packages/app/src/assistant-selection-copy/content.browser.test.ts b/packages/app/src/assistant-selection-copy/content.browser.test.ts index 04aa28bd99..aecb4ba5b4 100644 --- a/packages/app/src/assistant-selection-copy/content.browser.test.ts +++ b/packages/app/src/assistant-selection-copy/content.browser.test.ts @@ -143,6 +143,52 @@ describe("assistant selection copy ranges", () => { expect(copiedMarkdown(selectRange(blocks[0]!, 6, blocks[1]!, 6))).toBe("paragraph.\n\nSecond"); }); + // Chrome anchors a drag that starts in the gap below an image at the start of the + // image's rendered internals: the background-image layer, ahead of the hidden `img`. + // Only the text after it is highlighted. + it("leaves out an image when the selection starts in the gap below it", () => { + const transcript = mountTranscript([ + { + messageId: "message-1", + blocks: [ + "Before the image.", + '
', + "After the image.", + ], + }, + ]); + const frame = fixtureElement(transcript, '[data-paseo-markdown-tag="img"]'); + const range = document.createRange(); + range.setStart(frame.firstElementChild!.firstElementChild!, 0); + range.setEnd(textNode(fixtureElement(transcript, '[data-paseo-markdown-tag="p"]', 2)), 9); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + + expect(copiedMarkdown(selection)).toBe("After the"); + }); + + it("keeps a failed image when the selection starts in its error text", () => { + const transcript = mountTranscript([ + { + messageId: "message-1", + blocks: [ + '
Image failed to load
', + "After the image.", + ], + }, + ]); + const frame = fixtureElement(transcript, '[data-paseo-markdown-tag="img"]'); + const selection = selectRange( + frame.firstElementChild!, + 6, + fixtureElement(transcript, '[data-paseo-markdown-tag="p"]', 1), + 9, + ); + + expect(copiedMarkdown(selection)).toBe("![chart](https://example.test/chart.png)\n\nAfter the"); + }); + it("does not replace the browser clipboard for a selection reaching a second message", () => { const transcript = mountTranscript([ { messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] }, @@ -320,6 +366,32 @@ describe("assistant selection copy ranges", () => { ).toBe("6. Second bullet text"); }); + it("keeps the next number when a drag crosses a code block nested in an item", () => { + const message = mountFixture(); + const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]'); + list.outerHTML = [ + '
', + '
', + '
1.
', + "
Install it:
", + '
', + '
brew install foo
', + '', + "
", + "
", + '
', + '
2.
', + "
Done.
", + "
", + "
", + ].join(""); + + const content = copyAcross(message, "Install it:", "Done."); + + expect(content?.plainText).toBe("Install it:\n\n```sh\nbrew install foo\n```\n\n2. Done."); + expect(content?.html).toContain("
2. Done.
"); + }); + it("retains nested markers when a drag includes the complete outer item", () => { const message = mountFixture(); const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]'); @@ -385,6 +457,20 @@ describe("assistant selection copy ranges", () => { expect(content?.html).not.toContain("
");
   });
 
+  it("copies an image as its Markdown when the selection stays inside the rendered image", () => {
+    const message = mountFixture();
+    const frame = document.createElement("div");
+    frame.setAttribute("data-paseo-markdown-tag", "img");
+    frame.setAttribute("data-paseo-markdown-src", "https://example.test/chart.png");
+    frame.setAttribute("data-paseo-markdown-alt", "chart");
+    frame.innerHTML = '
'; + message.prepend(frame); + + expect(copiedMarkdown(selectNodeContents(fixtureElement(frame, "img").parentElement!))).toBe( + "![chart](https://example.test/chart.png)", + ); + }); + it("copies code without a fence when every character is selected inside the block", () => { const message = mountFixture(); const blockCode = fixtureElement( @@ -405,10 +491,19 @@ describe("assistant selection copy ranges", () => { * Turndown, so those cases need a fixture shaped like the real thing. */ function highlightedFixture(language: string | null): string { + return [ + '
', + '
Before the block.
', + highlightedCodeBlock(language), + '
After the block.
', + "
", + ].join(""); +} + +function highlightedCodeBlock(language: string | null): string { const languageAttribute = language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`; return [ - '
', `
`, '', "const answer = 1;", @@ -419,8 +514,6 @@ function highlightedFixture(language: string | null): string { "", '
Copy
', "
", - '
After the block.
', - "
", ].join(""); } @@ -598,6 +691,65 @@ describe("assistant selection copy inside highlighted code", () => { ); }); + // A drag that starts or ends in the gap beside a block anchors the selection at the + // edge of the neighbouring block. Nothing outside the code is highlighted or selected. + it("copies code without a fence when the selection only touches the end of the block before it", () => { + const message = mountHighlighted(); + const before = tokenText(message, "Before the block."); + + const content = copyBetween( + [before, before.length], + [tokenText(message, " doThing();"), 14], + ); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();"); + }); + + it("copies code without a fence when the selection only touches the start of the block after it", () => { + const message = mountHighlighted(); + + const content = copyBetween( + [tokenText(message, "const"), 0], + [tokenText(message, "After the block."), 0], + ); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();"); + }); + + it("copies code without a fence when the selection only touches the row before the block", () => { + const transcript = mountTranscript([{ messageId: "message-1", blocks: ["Run this:", "code"] }]); + const codeRow = transcript.querySelectorAll('[data-testid="assistant-message"]')[1]!; + codeRow.innerHTML = highlightedCodeBlock("typescript"); + const before = tokenText(transcript, "Run this:"); + + const content = copyBetween( + [before, before.length], + [tokenText(transcript, " doThing();"), 14], + ); + + expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();"); + }); + + it("keeps an image selected beside the code in the copy", () => { + const message = mountHighlighted(); + const image = document.createElement("div"); + image.setAttribute("data-paseo-markdown-tag", "p"); + // The rendered `img` shows a preview URL with no alt text; the frame carries the Markdown. + image.innerHTML = + '
'; + message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(image); + const before = tokenText(message, "Before the block."); + + const content = copyBetween( + [before, before.length], + [tokenText(message, " doThing();"), 14], + ); + + expect(content?.plainText).toBe( + "![chart](https://example.test/chart.png)\n\n```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```", + ); + }); + it("retains the fence when a complete code block is selected across its boundary", () => { const message = mountHighlighted(); diff --git a/packages/app/src/assistant-selection-copy/content.web.ts b/packages/app/src/assistant-selection-copy/content.web.ts index d019a23012..e59a71aeca 100644 --- a/packages/app/src/assistant-selection-copy/content.web.ts +++ b/packages/app/src/assistant-selection-copy/content.web.ts @@ -7,10 +7,12 @@ import { } from "@/utils/rich-clipboard"; import { MARKDOWN_COPY_ALIGN_ATTRIBUTE, + MARKDOWN_COPY_ALT_ATTRIBUTE, MARKDOWN_COPY_IGNORE_ATTRIBUTE, MARKDOWN_COPY_LANGUAGE_ATTRIBUTE, MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE, MARKDOWN_COPY_LIST_START_ATTRIBUTE, + MARKDOWN_COPY_SRC_ATTRIBUTE, MARKDOWN_COPY_TAG_ATTRIBUTE, MARKDOWN_COPY_UNWRAP_ATTRIBUTE, TRAILING_CODE_LINE_BREAKS, @@ -22,6 +24,7 @@ const CHAT_SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]'; const messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`; const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`; const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`; +const IMAGE_FRAME_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="img"]`; const turndown = new TurndownService({ bulletListMarker: "-", @@ -53,9 +56,7 @@ turndown.addRule("compactListItem", { if (parent?.nodeName !== "OL") { return `${options.bulletListMarker} ${item}\n`; } - const start = Number(parent.getAttribute("start") ?? 1); - const index = Array.from(parent.children).indexOf(node); - return `${start + index}. ${item}\n`; + return `${node.getAttribute("value")}. ${item}\n`; }, }); @@ -66,9 +67,9 @@ export function createAssistantSelectionClipboardContent( return null; } - const range = selection.getRangeAt(0); - const parts = selectedMessageParts(range); - if (!parts) { + const range = startAfterImageFrame(selection.getRangeAt(0)); + const parts = selectedMessageParts(range)?.filter((part) => selectsContent(part.range)); + if (!parts?.length) { return null; } @@ -93,6 +94,33 @@ export function createAssistantSelectionClipboardContent( return { ...content, html: flattenClipboardListMarkup(content.html) }; } +/** + * A drag that starts in the gap below an image anchors at the start of the image's + * rendered internals, ahead of the hidden `img`, while only the text after it is + * highlighted. Pressing on the image itself opens it instead of starting a selection, + * so a selection that starts inside an image, before any of its text, and ends past it + * starts after the image. A failed image shows its error as text, and a selection + * starting in that text keeps the image. + */ +function startAfterImageFrame(range: Range): Range { + const start = + range.startContainer instanceof Element + ? range.startContainer + : range.startContainer.parentElement; + const frame = start?.closest(IMAGE_FRAME_SELECTOR); + if (!frame || frame.contains(range.endContainer)) { + return range; + } + const insideFrame = range.cloneRange(); + insideFrame.setEnd(frame, frame.childNodes.length); + if (insideFrame.toString()) { + return range; + } + const afterFrame = range.cloneRange(); + afterFrame.setStartAfter(frame); + return afterFrame; +} + /** * Partly-selected code copies as the code itself, never through Turndown. * @@ -103,10 +131,12 @@ export function createAssistantSelectionClipboardContent( * * A selection contained inside code always copies as code, even when it contains every * character. A selection that crosses the code boundary stays on the Markdown path so - * a complete block retains its fence. + * a complete block retains its fence. Crossing means selecting content outside the code: + * a drag that starts or ends in the gap beside a block anchors at the edge of the + * neighbouring block, which selects nothing there. */ function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null { - const region = closestCodeRegion(range.commonAncestorContainer, message); + const region = selectedCodeRegion(range, message); if (!region) { return null; } @@ -130,6 +160,18 @@ function createPartialCodeContent(range: Range, message: Element): MarkdownClipb }); } +function selectedCodeRegion(range: Range, message: Element): Element | null { + const edgeRegions = [range.startContainer, range.endContainer].map((node) => + closestCodeRegion(node, message), + ); + return ( + edgeRegions.find( + // Measure against the whole block, so its own hover Copy button is not outside it. + (edge) => edge && selectsNothingOutside(range, edge.closest(CODE_BLOCK_SELECTOR) ?? edge), + ) ?? null + ); +} + function closestCodeRegion(node: Node, message: Element): Element | null { const element = node instanceof Element ? node : node.parentElement; const region = element?.closest(CODE_REGION_SELECTOR) ?? null; @@ -281,7 +323,8 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean return false; } const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE); - if (tag === "p" || isTableStructure(tag)) { + // An image has no part to select, so touching it selects all of it. + if (tag === "p" || tag === "img" || isTableStructure(tag)) { return true; } const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul"; @@ -384,6 +427,31 @@ function hasSelectedAllContents(range: Range, element: Element, includeIgnored = return true; } +function selectsContent(range: Range): boolean { + return hasMarkdownContent(range.cloneContents(), true); +} + +function selectsNothingOutside(range: Range, element: Element): boolean { + const contents = document.createRange(); + contents.selectNodeContents(element); + + if (range.compareBoundaryPoints(Range.START_TO_START, contents) < 0) { + const before = range.cloneRange(); + before.setEnd(contents.startContainer, contents.startOffset); + if (selectsContent(before)) { + return false; + } + } + if (range.compareBoundaryPoints(Range.END_TO_END, contents) > 0) { + const after = range.cloneRange(); + after.setStart(contents.endContainer, contents.endOffset); + if (selectsContent(after)) { + return false; + } + } + return true; +} + function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean { if (!includeIgnored) { for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) { @@ -393,8 +461,9 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean) if (fragment.textContent) { return true; } - const visibleVoidSelector = ["br", "hr"] + const visibleVoidSelector = ["br", "hr", "img"] .map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`) + .concat("img") .join(","); return Boolean(fragment.querySelector(visibleVoidSelector)); } @@ -488,14 +557,19 @@ function restoreMarkdownElements(container: HTMLElement): void { continue; } const semanticElement = document.createElement(tagName); - if (tagName !== "br") { + if (tagName !== "br" && tagName !== "img") { semanticElement.append(...element.childNodes); } + if (tagName === "img") { + semanticElement.setAttribute("src", element.getAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE) ?? ""); + semanticElement.setAttribute("alt", element.getAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE) ?? ""); + } if (tagName === "ol") { const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE); if (start) { semanticElement.setAttribute("start", start); } + numberOrderedListItems(semanticElement, Number(start ?? 1)); } if (tagName === "pre") { const language = element.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE); @@ -528,6 +602,20 @@ function restoreMarkdownElements(container: HTMLElement): void { } } +/** + * Each item carries its number before Turndown re-parses the HTML. Until then every + * child of the list is one item, including a partly selected item demoted to a `p`. + * The parser splits a `p` that holds a code block into several siblings, so counting + * siblings afterwards numbers the next item too high. + */ +function numberOrderedListItems(list: Element, start: number): void { + Array.from(list.children).forEach((child, index) => { + if (child.tagName === "LI") { + child.setAttribute("value", String(start + index)); + } + }); +} + function unwrapIncompleteTables(container: HTMLElement): void { for (const table of container.querySelectorAll("table")) { if (hasUsableTableColumnContract(table)) { diff --git a/packages/app/src/assistant-selection-copy/markup.ts b/packages/app/src/assistant-selection-copy/markup.ts index 8f220043ce..91c6c1618a 100644 --- a/packages/app/src/assistant-selection-copy/markup.ts +++ b/packages/app/src/assistant-selection-copy/markup.ts @@ -5,6 +5,8 @@ export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap"; export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start"; export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language"; export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align"; +export const MARKDOWN_COPY_SRC_ATTRIBUTE = "data-paseo-markdown-src"; +export const MARKDOWN_COPY_ALT_ATTRIBUTE = "data-paseo-markdown-alt"; /** * Trailing line breaks, with any indentation that followed the last one. @@ -63,6 +65,18 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined } as const; } +/** + * An image copies as the Markdown it came from. The rendered `img` points at a preview + * URL for data and workspace images, and carries no alt text of its own. + */ +export function markdownCopyImageDataSet(source: string, alt: string | undefined) { + return { + paseoMarkdownTag: "img", + paseoMarkdownSrc: source, + ...(alt ? { paseoMarkdownAlt: alt } : {}), + } as const; +} + export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) { const alignment = typeof style === "string" diff --git a/packages/app/src/components/assistant-turn-footer-label.test.ts b/packages/app/src/components/assistant-turn-footer-label.test.ts new file mode 100644 index 0000000000..7e3e918df8 --- /dev/null +++ b/packages/app/src/components/assistant-turn-footer-label.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { i18n } from "@/i18n/i18next"; +import { getTurnDurationLabel } from "./assistant-turn-footer-label"; + +describe("getTurnDurationLabel", () => { + it("says how long the turn worked", () => { + expect(getTurnDurationLabel(14_000, i18n.t)).toBe("Worked for 14s"); + }); + + it("reads in the active app language", async () => { + await i18n.changeLanguage("fr"); + try { + expect(getTurnDurationLabel(14_000, i18n.t)).toBe("A travaillé pendant 14s"); + } finally { + await i18n.changeLanguage("en"); + } + }); +}); diff --git a/packages/app/src/components/assistant-turn-footer-label.ts b/packages/app/src/components/assistant-turn-footer-label.ts new file mode 100644 index 0000000000..2e2a088f0c --- /dev/null +++ b/packages/app/src/components/assistant-turn-footer-label.ts @@ -0,0 +1,6 @@ +import type { TFunction } from "i18next"; +import { formatDuration } from "@/utils/time"; + +export function getTurnDurationLabel(durationMs: number, t: TFunction): string { + return t("message.turnFooter.workedFor", { duration: formatDuration(durationMs) }); +} diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 8d93f4588b..583585b782 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -74,6 +74,7 @@ import { useRevealedText } from "@/hooks/use-revealed-text"; import { colorMarkdownLinkChildren } from "@/components/markdown/link-children"; import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; +import { getTurnDurationLabel } from "./assistant-turn-footer-label"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate"; @@ -108,6 +109,7 @@ import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assist import { useRetainedPanelActive } from "@/components/retained-panel"; import { markdownCopyDataSet, + markdownCopyImageDataSet, markdownCopyOrderedListDataSet, markdownCopyTableCellDataSet, type MarkdownCopyInlineTag, @@ -628,6 +630,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ durationMs, onFork, }: AssistantTurnFooterProps) { + const { t } = useTranslation(); const [hovered, setHovered] = useState(false); const [pressedReveal, setPressedReveal] = useState(false); const revealTimerRef = useRef | null>(null); @@ -643,10 +646,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ const durationLabel = useMemo( () => - durationMs !== undefined && durationMs !== null - ? `Worked for ${formatDuration(durationMs)}` - : "", - [durationMs], + durationMs !== undefined && durationMs !== null ? getTurnDurationLabel(durationMs, t) : "", + [durationMs, t], ); const timestampLabel = useMemo( () => (completedAt ? formatMessageTimestamp(completedAt) : ""), @@ -884,10 +885,11 @@ function AssistantMarkdownImage({ ], [containerStyle, imageSizeStyle], ); + const copyDataSet = useMemo(() => markdownCopyImageDataSet(source, alt), [source, alt]); if (image.status === "failed") { return ( - + {image.message} ); @@ -895,14 +897,14 @@ function AssistantMarkdownImage({ if (!binding) { return ( - + ); } return ( - + @@ -188,7 +190,7 @@ function ProjectStatusBadge({ return ( diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index 9399a3bd13..0cd7dc2b72 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react"; import { useSidebarWorkspacesList, @@ -54,6 +55,7 @@ export function SidebarModelProvider({ active?: boolean; children: ReactNode; }) { + const { t } = useTranslation(); const list = useSidebarWorkspacesList({ enabled: active }); const groupMode = useSidebarViewStore((state) => state.groupMode); const labelFilter = useSidebarViewStore((state) => state.labelFilter); @@ -150,6 +152,7 @@ export function SidebarModelProvider({ pinnedCollapsed, collapsedProjectKeys, collapsedWorkspaceGroupKeys, + t, }), [ collapsedProjectKeys, @@ -161,6 +164,7 @@ export function SidebarModelProvider({ pinnedKeys, pinnedWorkspaceOrder, filteredWorkspaceEntriesByKey, + t, ], ); const projection = useMemo(() => buildSidebarProjection(projectionInput), [projectionInput]); diff --git a/packages/app/src/components/sidebar/sidebar-popover.tsx b/packages/app/src/components/sidebar/sidebar-popover.tsx index ec13605d08..e57f3cf6ac 100644 --- a/packages/app/src/components/sidebar/sidebar-popover.tsx +++ b/packages/app/src/components/sidebar/sidebar-popover.tsx @@ -6,7 +6,7 @@ import type { SidebarSection } from "@/sidebar-nav/model"; /** * The popover a sidebar item opens: anchored to the item on wide layouts, a bottom sheet on - * compact ones. Plugin items and the built-in Usage item open it the same way: + * compact ones. Plugin items open it this way: * `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to, * and `SidebarPopoverSurface` for the body. */ diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index 55882a0c49..c2dc31f2ee 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -1,3 +1,4 @@ +import { i18n } from "@/i18n/i18next"; import { describe, expect, it } from "vitest"; import type { SidebarProjectEntry, @@ -85,6 +86,7 @@ function projectionInput(options?: { pinnedCollapsed: options?.pinnedCollapsed ?? false, collapsedProjectKeys: new Set(), collapsedWorkspaceGroupKeys: new Set(), + t: i18n.t, }; } diff --git a/packages/app/src/components/sidebar/sidebar-projection.ts b/packages/app/src/components/sidebar/sidebar-projection.ts index c67042b45e..2e84f02aae 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import { buildStatusGroups } from "@/hooks/sidebar-status-view-model"; import { splitPinnedSidebarGroups, @@ -45,6 +46,7 @@ export interface SidebarProjectionInput { pinnedCollapsed: boolean; collapsedProjectKeys: ReadonlySet; collapsedWorkspaceGroupKeys: ReadonlySet; + t: TFunction; } export function buildSidebarProjection(input: SidebarProjectionInput): SidebarProjection { @@ -100,7 +102,7 @@ function buildWorkspaceGroups( return []; case "status": return statusWorkspaceGroups( - buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey), + buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey, input.t), ); } } diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 051121fd3f..c299d07987 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -408,6 +408,7 @@ function StatusGroupHeader({ group: SidebarWorkspaceGroup; collapsed: boolean; }) { + const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const toggleWorkspaceGroupCollapsed = useSidebarCollapsedSectionsStore( (state) => state.toggleWorkspaceGroupCollapsed, @@ -431,7 +432,7 @@ function StatusGroupHeader({ ( () => ({ diff --git a/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts index 2068dbe271..465caf1344 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts +++ b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts @@ -1,3 +1,4 @@ +import { i18n } from "@/i18n/i18next"; import { describe, expect, it } from "vitest"; import { resolveSidebarWorkspaceAccessibilityLabel, @@ -39,6 +40,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => { workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" }, workspaceTitleSource: "title", hostBadgeLabel: "Build host", + t: i18n.t, }); expect(label).toBe("Investigate search, Build host"); @@ -56,6 +58,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => { hostBadgeLabel: "Build host", pullRequestLabel: "Pull request 42", serviceLabel: "Service web running", + t: i18n.t, }); expect(label).toBe( @@ -69,6 +72,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => { workspaceTitleSource: "title", leadingProjectName: "Search project", hostBadgeLabel: "Build host", + t: i18n.t, }); expect(label).toBe("Search project, Investigate search, Build host"); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-title.ts b/packages/app/src/components/sidebar/sidebar-workspace-title.ts index 955c9d2636..da3782f082 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-title.ts +++ b/packages/app/src/components/sidebar/sidebar-workspace-title.ts @@ -1,6 +1,7 @@ +import type { TFunction } from "i18next"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { WorkspaceTitleSource } from "@/hooks/use-settings"; -import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model"; +import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model"; export function resolveSidebarWorkspacePrimaryLabel(input: { workspace: Pick; @@ -19,6 +20,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: { hostBadgeLabel?: string | null; pullRequestLabel?: string | null; serviceLabel?: string | null; + t: TFunction; }): string { return [ input.leadingProjectName, @@ -28,7 +30,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: { input.serviceLabel, input.workspace.statusBucket === "done" ? null - : STATUS_BUCKET_LABELS[input.workspace.statusBucket], + : getStatusBucketLabel(input.workspace.statusBucket, input.t), ] .filter((label): label is string => Boolean(label)) .join(", "); diff --git a/packages/app/src/components/ui/hover-card.tsx b/packages/app/src/components/ui/hover-card.tsx index f6497ce257..3d438505d3 100644 --- a/packages/app/src/components/ui/hover-card.tsx +++ b/packages/app/src/components/ui/hover-card.tsx @@ -82,6 +82,8 @@ function WebHoverCard({ const contentRef = useRef(null); const [open, setOpen] = useState(false); const graceTimerRef = useRef | null>(null); + // Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible. + const keyboardFocusedRef = useRef(false); const clearGraceTimer = useCallback(() => { if (graceTimerRef.current) { @@ -98,14 +100,21 @@ function WebHoverCard({ ); }, []); + // Keyboard focus keeps the card open for keyboard users; the focus a pointer click leaves + // behind on the trigger or a button in the card does not outlast the pointer leaving. + const keyboardFocusInside = useCallback(() => { + return focusInside() && document.activeElement?.matches(":focus-visible") === true; + }, [focusInside]); + const scheduleClose = useCallback(() => { - if (focusInside()) return; + if (keyboardFocusInside()) return; if (graceTimerRef.current) return; graceTimerRef.current = setTimeout(() => { graceTimerRef.current = null; + if (keyboardFocusInside()) return; setOpen(false); }, CLOSE_GRACE_MS); - }, [focusInside]); + }, [keyboardFocusInside]); const openNow = useCallback(() => { clearGraceTimer(); @@ -169,16 +178,20 @@ function WebHoverCard({ setOpen(false); }; const focusEntered = (event: FocusEvent) => { - const target = event.target as Node; + const target = event.target as Element; + keyboardFocusedRef.current = target.matches(":focus-visible"); if (trigger.contains(target)) { openNow(); } else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) { clearGraceTimer(); } }; + // Only keyboard focus keeps the card open, so only losing keyboard focus closes it. A button + // that disables itself on press drops pointer focus while the pointer is still on the card. const focusLeft = (event: FocusEvent) => { const next = event.relatedTarget as Node | null; if ( + keyboardFocusedRef.current && !trigger.contains(next) && !(contentRef.current as unknown as HTMLElement | null)?.contains(next) ) { diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index d5f09dc77f..72795289cf 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -55,6 +55,7 @@ import type { import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { getFeatureHighlightColor, + isFeatureActive, getFeatureTooltip, getAgentControlHintKey, resolveAgentModelSelection, @@ -211,8 +212,7 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) { } function getFeatureIconColor( - featureId: string, - enabled: boolean, + feature: AgentFeature, palette: { blue: { 400: string }; green: { 400: string }; @@ -220,11 +220,11 @@ function getFeatureIconColor( }, foregroundMuted: string, ): string { - if (!enabled) { + if (!isFeatureActive(feature)) { return foregroundMuted; } - switch (getFeatureHighlightColor(featureId)) { + switch (getFeatureHighlightColor(feature.id)) { case "blue": return palette.blue[400]; case "green": @@ -1335,8 +1335,7 @@ function DesktopFeatureItem({ { it("maps feature highlight colors by feature id", () => { expect(getFeatureHighlightColor("fast_mode")).toBe("yellow"); + expect(getFeatureHighlightColor("service_tier")).toBe("yellow"); expect(getFeatureHighlightColor("plan_mode")).toBe("blue"); expect(getFeatureHighlightColor("other")).toBe("default"); }); + + it("treats a select feature as active when a non-default option is selected", () => { + const speed = { + type: "select" as const, + id: "service_tier", + label: "Speed", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "fast", label: "Fast" }, + ], + }; + expect(isFeatureActive({ ...speed, value: "default" })).toBe(false); + expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true); + expect( + isFeatureActive({ + type: "toggle", + id: "fast_mode", + label: "Fast", + value: true, + }), + ).toBe(true); + }); }); describe("normalizeModelId", () => { diff --git a/packages/app/src/composer/agent-controls/utils.ts b/packages/app/src/composer/agent-controls/utils.ts index 631b55753e..c0b1da7c47 100644 --- a/packages/app/src/composer/agent-controls/utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -1,7 +1,11 @@ import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; import { i18n } from "@/i18n/i18next"; import { formatThinkingOptionLabel } from "@/agent-controls/labels"; -import { FAST_MODE_FEATURE_ID, PLAN_MODE_FEATURE_ID } from "@/agent-controls/policy"; +import { + FAST_MODE_FEATURE_ID, + PLAN_MODE_FEATURE_ID, + SPEED_FEATURE_ID, +} from "@/agent-controls/policy"; export type ExplainedAgentControl = "mode" | "model" | "thinking"; export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow"; @@ -35,9 +39,16 @@ export function getFeatureTooltip(feature: Pick option.id === feature.value); + return selectedOption !== undefined && !selectedOption.isDefault; +} + export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor { switch (featureId) { case FAST_MODE_FEATURE_ID: + case SPEED_FEATURE_ID: return "yellow"; case "auto_accept": return "green"; diff --git a/packages/app/src/git/pull-request-panel/checks-section.tsx b/packages/app/src/git/pull-request-panel/checks-section.tsx index 9e4f2dd942..ac1ba078c5 100644 --- a/packages/app/src/git/pull-request-panel/checks-section.tsx +++ b/packages/app/src/git/pull-request-panel/checks-section.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo } from "react"; import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native"; import { Button } from "@/components/ui/button"; import { openExternalUrl } from "@/utils/open-external-url"; @@ -71,7 +72,8 @@ export function ChecksSection({ loadingCheckKeys, onAddLogsToChat, }: ChecksSectionProps) { - const summary = useMemo(() => summarizeChecks(checks), [checks]); + const { t } = useTranslation(); + const summary = useMemo(() => summarizeChecks(checks, t), [checks, t]); const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState(); return ( @@ -91,13 +93,14 @@ export function ChecksSection({ {summary.parts.length > 0 ? ( + {summary.detailLead} {summary.parts.map((part, index) => ( {index > 0 ? ", " : ""} {part.text} ))} - {` ${summary.countNoun}`} + {summary.detailTrail} ) : null} @@ -186,6 +189,7 @@ function CheckRow({ isAddingLogsToChat: boolean; onAddLogsToChat: (check: PrPaneCheck) => void; }) { + const { t } = useTranslation(); const handlePress = useCallback(() => { void openExternalUrl(check.url); }, [check.url]); @@ -217,7 +221,9 @@ function CheckRow({ onPress={handleAddLogsToChat} style={styles.addButton} > - {isAddingLogsToChat ? "Adding..." : "Add to chat"} + {isAddingLogsToChat + ? t("workspace.git.pr.actions.addingToChat") + : t("workspace.git.pr.actions.addToChat")} ) : null} {check.timing && {check.timing}} diff --git a/packages/app/src/git/pull-request-panel/checks-summary.test.ts b/packages/app/src/git/pull-request-panel/checks-summary.test.ts index f014500753..747947b907 100644 --- a/packages/app/src/git/pull-request-panel/checks-summary.test.ts +++ b/packages/app/src/git/pull-request-panel/checks-summary.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import type { CheckStatus } from "./check-status"; import { summarizeChecks } from "./checks-summary"; import type { PrPaneCheck } from "./data"; @@ -9,7 +10,7 @@ function check(name: string, status: CheckStatus): PrPaneCheck { describe("summarizeChecks", () => { it("reports no checks without claiming the run passed", () => { - const summary = summarizeChecks([]); + const summary = summarizeChecks([], i18n.t); expect(summary.outcome).toBe("none"); expect(summary.headline).toBe("No checks"); @@ -18,11 +19,10 @@ describe("summarizeChecks", () => { }); it("leads with failures when anything failed", () => { - const summary = summarizeChecks([ - check("build", "success"), - check("lint", "failure"), - check("e2e", "pending"), - ]); + const summary = summarizeChecks( + [check("build", "success"), check("lint", "failure"), check("e2e", "pending")], + i18n.t, + ); expect(summary.outcome).toBe("failure"); expect(summary.headline).toBe("Some checks were not successful"); @@ -31,7 +31,7 @@ describe("summarizeChecks", () => { }); it("reports an unfinished run as in progress when nothing failed", () => { - const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")]); + const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")], i18n.t); expect(summary.outcome).toBe("pending"); expect(summary.headline).toBe("Some checks haven't completed yet"); @@ -39,7 +39,10 @@ describe("summarizeChecks", () => { }); it("treats a run of only successes and skips as passed", () => { - const summary = summarizeChecks([check("build", "success"), check("deploy", "skipped")]); + const summary = summarizeChecks( + [check("build", "success"), check("deploy", "skipped")], + i18n.t, + ); expect(summary.outcome).toBe("success"); expect(summary.headline).toBe("All checks have passed"); @@ -47,11 +50,10 @@ describe("summarizeChecks", () => { }); it("keeps the checks of each group together under a counted label", () => { - const summary = summarizeChecks([ - check("build", "failure"), - check("lint", "success"), - check("test", "failure"), - ]); + const summary = summarizeChecks( + [check("build", "failure"), check("lint", "success"), check("test", "failure")], + i18n.t, + ); const [failing, successful] = summary.groups; expect(failing?.label).toBe("2 failing checks"); @@ -60,9 +62,34 @@ describe("summarizeChecks", () => { }); it("says check rather than checks for a run of one", () => { - const summary = summarizeChecks([check("build", "failure")]); + const summary = summarizeChecks([check("build", "failure")], i18n.t); - expect(summary.countNoun).toBe("check"); expect(summary.detail).toBe("1 failing check"); }); + + it("reads in the active app language", async () => { + await i18n.changeLanguage("fr"); + try { + const summary = summarizeChecks( + [ + check("build", "success"), + check("deploy", "skipped"), + check("lint", "failure"), + check("test", "failure"), + ], + i18n.t, + ); + + expect(summary.headline).toBe("Certaines vérifications ont échoué"); + expect(summary.detail).toBe("Vérifications : 2 en échec, 1 réussie(s), 1 ignorée(s)"); + expect(summary.groups.map((group) => group.label)).toEqual([ + "2 vérifications en échec", + "1 vérification réussie", + "1 vérification ignorée", + ]); + expect(summarizeChecks([], i18n.t).headline).toBe("Aucune vérification"); + } finally { + await i18n.changeLanguage("en"); + } + }); }); diff --git a/packages/app/src/git/pull-request-panel/checks-summary.ts b/packages/app/src/git/pull-request-panel/checks-summary.ts index f2f9b30a38..977706a9af 100644 --- a/packages/app/src/git/pull-request-panel/checks-summary.ts +++ b/packages/app/src/git/pull-request-panel/checks-summary.ts @@ -1,4 +1,5 @@ import { classifyCheck, type CheckPresentation } from "@/git/check-presentation"; +import type { TFunction } from "i18next"; import type { PrPaneCheck } from "./data"; /** @@ -16,27 +17,10 @@ const PRESENTATION_ORDER = [ "ignored", ] as const satisfies readonly CheckPresentation[]; -/** How each status reads inside a count phrase: "2 failing", "4 in progress". */ -const PRESENTATION_NOUN: Record = { - actionRequired: "needs action", - warning: "warning", - failure: "failing", - pending: "in progress", - manual: "manual", - success: "successful", - ignored: "skipped", -}; - /** The worst thing happening in the run, which is what the headline and the ring report. */ export type ChecksOutcome = "actionRequired" | "failure" | "pending" | "success" | "none"; -const OUTCOME_HEADLINE: Record = { - actionRequired: "Some checks need your attention", - failure: "Some checks were not successful", - pending: "Some checks haven't completed yet", - success: "All checks have passed", - none: "No checks", -}; +const COPY = "workspace.git.pr.checksOverview"; export interface ChecksCountPart { status: CheckPresentation; @@ -58,8 +42,12 @@ export interface ChecksSummary { headline: string; /** The count phrases behind `detail`, kept apart so the header can label each one. */ parts: readonly ChecksCountPart[]; - /** The noun closing the detail line — "checks", or "check" for a run of one. */ - countNoun: string; + /** + * The words around the count phrases in `detail`: "" and " checks" in English, where the noun + * closes the line, while other languages lead with it. + */ + detailLead: string; + detailTrail: string; /** The whole line: "2 failing, 4 in progress, 1 successful checks". Empty with no checks. */ detail: string; total: number; @@ -72,7 +60,7 @@ export interface ChecksSummary { * header, the ring, and the grouped list all read from one derivation instead of each * filtering the array again with its own idea of what counts. */ -export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary { +export function summarizeChecks(checks: readonly PrPaneCheck[], t: TFunction): ChecksSummary { const groups: ChecksGroup[] = []; const parts: ChecksCountPart[] = []; @@ -83,24 +71,27 @@ export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary { } groups.push({ status, - label: `${matching.length} ${PRESENTATION_NOUN[status]} ${countNoun(matching.length)}`, + label: t(`${COPY}.${matching.length === 1 ? "groupOne" : "groupMany"}.${status}`, { + count: matching.length, + }), checks: matching, }); parts.push({ status, count: matching.length, - text: `${matching.length} ${PRESENTATION_NOUN[status]}`, + text: t(`${COPY}.count.${status}`, { count: matching.length }), }); } const outcome = selectOutcome(checks); - const noun = countNoun(checks.length); + const { lead, trail } = detailFrame(checks.length, t); return { outcome, - headline: OUTCOME_HEADLINE[outcome], + headline: t(`${COPY}.headline.${outcome}`), parts, - countNoun: noun, - detail: parts.length === 0 ? "" : `${parts.map((part) => part.text).join(", ")} ${noun}`, + detailLead: lead, + detailTrail: trail, + detail: parts.length === 0 ? "" : `${lead}${parts.map((part) => part.text).join(", ")}${trail}`, total: checks.length, groups, }; @@ -131,6 +122,13 @@ function selectOutcome(checks: readonly PrPaneCheck[]): ChecksOutcome { return "success"; } -function countNoun(count: number): string { - return count === 1 ? "check" : "checks"; +/** + * The header renders each count phrase on its own, so the translated line is split around + * where the phrases go. + */ +function detailFrame(total: number, t: TFunction): { lead: string; trail: string } { + const marker = "\u0000"; + const line = t(`${COPY}.${total === 1 ? "detailOne" : "detailMany"}`, { parts: marker }); + const [lead = "", trail = ""] = line.split(marker); + return { lead, trail }; } diff --git a/packages/app/src/git/pull-request-panel/data.test.ts b/packages/app/src/git/pull-request-panel/data.test.ts index 21b145bedc..837433417e 100644 --- a/packages/app/src/git/pull-request-panel/data.test.ts +++ b/packages/app/src/git/pull-request-panel/data.test.ts @@ -9,12 +9,13 @@ import { isPipelineActiveStatus, mapPipelineStatus, } from "@/git/forges/gitlab"; +import { i18n } from "@/i18n/i18next"; import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors"; import { deriveAvatarColor, formatAge, - getActivityVerb, - getStateLabel, + getActivityVerbKey, + getStateLabelKey, mapPrPaneData, } from "./data"; @@ -779,37 +780,34 @@ describe("formatAge", () => { }); }); -describe("getStateLabel", () => { +const english = i18n.getFixedT("en"); +const french = i18n.getFixedT("fr"); + +describe("getStateLabelKey", () => { it.each([ - ["open", "Open"], - ["draft", "Draft"], - ["merged", "Merged"], - ["closed", "Closed"], - ] as const)("maps %s → %s", (state, expected) => { - expect(getStateLabel(state)).toBe(expected); + ["open", "Open", "Ouverte"], + ["draft", "Draft", "Brouillon"], + ["merged", "Merged", "Fusionnée"], + ["closed", "Closed", "Fermée"], + ] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => { + expect(english(getStateLabelKey(state))).toBe(englishLabel); + expect(french(getStateLabelKey(state))).toBe(frenchLabel); }); }); -describe("getActivityVerb", () => { - it("returns Commented for comment kind", () => { - expect(getActivityVerb({ kind: "comment" })).toBe("Commented"); - }); - - it("returns Approved for approved review", () => { - expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved"); - }); - - it("returns Requested changes for changes_requested review", () => { - expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe( +describe("getActivityVerbKey", () => { + it.each([ + [{ kind: "comment" }, "Commented", "A commenté"], + [{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"], + [ + { kind: "review", reviewState: "changes_requested" }, "Requested changes", - ); - }); - - it("returns Reviewed for a commented review with body (generic case)", () => { - expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed"); - }); - - it("returns Reviewed when reviewState is undefined", () => { - expect(getActivityVerb({ kind: "review" })).toBe("Reviewed"); + "A demandé des modifications", + ], + [{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"], + [{ kind: "review" }, "Reviewed", "A relu"], + ] as const)("labels %o as %s, and %s in French", (item, englishVerb, frenchVerb) => { + expect(english(getActivityVerbKey(item))).toBe(englishVerb); + expect(french(getActivityVerbKey(item))).toBe(frenchVerb); }); }); diff --git a/packages/app/src/git/pull-request-panel/data.ts b/packages/app/src/git/pull-request-panel/data.ts index 0f1e2014e9..d1275f2f1e 100644 --- a/packages/app/src/git/pull-request-panel/data.ts +++ b/packages/app/src/git/pull-request-panel/data.ts @@ -318,16 +318,18 @@ function parsePullRequestNumber(url: string): number | null { } } -export function getStateLabel(state: PrState): string { - if (state === "draft") return "Draft"; - if (state === "merged") return "Merged"; - if (state === "closed") return "Closed"; - return "Open"; +export function getStateLabelKey(state: PrState) { + if (state === "draft") return "workspace.git.pr.states.draft"; + if (state === "merged") return "workspace.git.pr.states.merged"; + if (state === "closed") return "workspace.git.pr.states.closed"; + return "workspace.git.pr.states.open"; } -export function getActivityVerb(item: Pick): string { - if (item.kind === "comment") return "Commented"; - if (item.reviewState === "approved") return "Approved"; - if (item.reviewState === "changes_requested") return "Requested changes"; - return "Reviewed"; +export function getActivityVerbKey(item: Pick) { + if (item.kind === "comment") return "workspace.git.pr.activity.commented"; + if (item.reviewState === "approved") return "workspace.git.pr.activity.approved"; + if (item.reviewState === "changes_requested") { + return "workspace.git.pr.activity.requestedChanges"; + } + return "workspace.git.pr.activity.reviewed"; } diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index b7c0890c3a..429dd2be0e 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -63,7 +63,7 @@ import { canAddPullRequestActivityToChat, } from "./context-attachment"; import { ChecksSection, getCheckIdentity } from "./checks-section"; -import { getActivityVerb, getStateLabel } from "./data"; +import { getActivityVerbKey, getStateLabelKey } from "./data"; import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data"; import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; import { CheckPresentationIcon } from "@/git/check-presentation.view"; @@ -538,7 +538,7 @@ export function PullRequestPane({ - {getStateLabel(data.state)} + {t(getStateLabelKey(data.state))} {nativeHeaderMeta} {repoIdentity ? ( @@ -568,7 +568,7 @@ export function PullRequestPane({
- Add all to chat + {t("workspace.git.pr.actions.addAllToChat")} ) : null} {activityLoading ? : null} {!activityLoading && visibleEntries.length === 0 ? ( - No activity yet + {t("workspace.git.pr.empty.noActivity")} ) : null} {!activityLoading ? visibleEntries.map(({ entry, collapsed }) => ( @@ -702,14 +702,14 @@ function ActivityKebab({ {renderKebabTriggerIcon} {attachEnabled && canAddPullRequestActivityToChat(activity) ? ( - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} {activity.body.trim() !== "" ? ( @@ -760,7 +760,8 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu } function ActivityVerb({ activity }: { activity: PrPaneActivity }) { - const verb = getActivityVerb(activity).toLowerCase(); + const { t } = useTranslation(); + const verb = t(getActivityVerbKey(activity)).toLowerCase(); if (activity.kind === "review" && activity.reviewState === "approved") { return ( @@ -815,6 +816,7 @@ function SingleActivityCard({ entry: Extract; collapsed: boolean; }) { + const { t } = useTranslation(); const { activity } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); @@ -884,7 +886,7 @@ function SingleActivityCard({ leftIcon={MessageSquarePlus} onPress={handleAddToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} @@ -935,6 +937,7 @@ function ReviewCard({ collapsed: boolean; collapsedEntryIds: ReadonlySet; }) { + const { t } = useTranslation(); const { review, threads } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); @@ -961,7 +964,7 @@ function ReviewCard({ onPress={handleAddToChat} style={styles.checkAddButton} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} {collapsed ? ( @@ -995,7 +998,7 @@ function ReviewCard({ leftIcon={MessageSquarePlus} onPress={handleAddToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} @@ -1063,8 +1066,12 @@ function ThreadBlock({ ? formatPullRequestThreadPath(thread.location) : t("workspace.git.pr.thread.discussion")} - {thread.isResolved ? : null} - {thread.location?.isOutdated ? : null} + {thread.isResolved ? ( + + ) : null} + {thread.location?.isOutdated ? ( + + ) : null} {collapsed ? ( @@ -1080,7 +1087,7 @@ function ThreadBlock({ {renderKebabTriggerIcon} @@ -1124,7 +1131,7 @@ function ThreadBlock({ leftIcon={MessageSquarePlus} onPress={handleAddThreadToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} diff --git a/packages/app/src/hooks/sidebar-status-view-model.test.ts b/packages/app/src/hooks/sidebar-status-view-model.test.ts index 62dc7142a8..c5bd8dcd11 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import type { SidebarWorkspaceEntry } from "./sidebar-workspaces-view-model"; import { buildStatusGroups, buildStatusShortcutIndex, - STATUS_BUCKET_LABELS, + getStatusBucketLabel, STATUS_BUCKET_ORDER, type StatusGroup, } from "./sidebar-status-view-model"; @@ -55,7 +56,7 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:running-ws", statusBucket: "running", name: "running-ws" }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups.map((g) => g.bucket)).toEqual(["needs_input", "running", "done"]); expect(groups[0]?.label).toBe("Needs input"); @@ -69,7 +70,7 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:b", statusBucket: "running" }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups.map((g) => g.bucket)).toEqual(["running", "done"]); }); @@ -93,7 +94,7 @@ describe("buildStatusGroups", () => { }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:new", "srv:mid", "srv:old"]); }); @@ -109,7 +110,7 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:null-b", statusBucket: "done", statusEnteredAt: null }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual([ "srv:ts", @@ -145,13 +146,13 @@ describe("buildStatusGroups", () => { }), ]; - const groups = buildStatusGroups(workspaces, projectNames); + const groups = buildStatusGroups(workspaces, projectNames, i18n.t); expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:2", "srv:3", "srv:1"]); }); it("returns empty array for no workspaces", () => { - const groups = buildStatusGroups([], emptyProjectNames); + const groups = buildStatusGroups([], emptyProjectNames, i18n.t); expect(groups).toEqual([]); }); @@ -180,11 +181,11 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:dn", statusBucket: "done", statusEnteredAt: null }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups.map((g) => g.bucket)).toEqual(STATUS_BUCKET_ORDER); expect(groups.map((g) => g.label)).toEqual( - STATUS_BUCKET_ORDER.map((b) => STATUS_BUCKET_LABELS[b]), + STATUS_BUCKET_ORDER.map((b) => getStatusBucketLabel(b, i18n.t)), ); // Each group has exactly one row with the matching bucket for (const group of groups) { @@ -230,3 +231,20 @@ describe("buildStatusShortcutIndex", () => { expect(index.size).toBe(0); }); }); + +describe("getStatusBucketLabel", () => { + it("reads in the active app language", async () => { + await i18n.changeLanguage("fr"); + try { + expect(STATUS_BUCKET_ORDER.map((bucket) => getStatusBucketLabel(bucket, i18n.t))).toEqual([ + "Attend une réponse", + "Échec", + "À relire", + "En cours", + "Terminé", + ]); + } finally { + await i18n.changeLanguage("en"); + } + }); +}); diff --git a/packages/app/src/hooks/sidebar-status-view-model.ts b/packages/app/src/hooks/sidebar-status-view-model.ts index 7bcbe3d345..5736c7f391 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import { STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state"; import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model"; @@ -5,13 +6,17 @@ export type StatusBucket = SidebarWorkspaceEntry["statusBucket"]; export { STATUS_BUCKET_ORDER }; -export const STATUS_BUCKET_LABELS: Record = { - needs_input: "Needs input", - failed: "Failed", - attention: "Ready to review", - running: "Working", - done: "Done", -}; +const STATUS_BUCKET_LABEL_KEYS = { + needs_input: "sidebar.statusBucket.needsInput", + failed: "sidebar.statusBucket.failed", + attention: "sidebar.statusBucket.readyToReview", + running: "sidebar.statusBucket.working", + done: "sidebar.statusBucket.done", +} as const satisfies Record; + +export function getStatusBucketLabel(bucket: StatusBucket, t: TFunction): string { + return t(STATUS_BUCKET_LABEL_KEYS[bucket]); +} export interface StatusGroup { bucket: StatusBucket; @@ -22,6 +27,7 @@ export interface StatusGroup { export function buildStatusGroups( workspaces: SidebarWorkspaceEntry[], projectNamesByViewKey: Map, + t: TFunction, ): StatusGroup[] { const bucketRows = new Map(); @@ -42,7 +48,7 @@ export function buildStatusGroups( if (!rows || rows.length === 0) continue; rows.sort((a, b) => compareStatusRows(a, b, projectNamesByViewKey)); - groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows }); + groups.push({ bucket, label: getStatusBucketLabel(bucket, t), rows }); } return groups; diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index de25b85059..2030132443 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -74,6 +74,45 @@ const untranslatedLocalFallbacks = [ "Unable to save desktop settings.", ] as const; +const pullRequestPanelSources = [ + "git/pull-request-panel/pane.tsx", + "git/pull-request-panel/data.ts", + "git/pull-request-panel/checks-section.tsx", + "components/sidebar/sidebar-status-list.tsx", +] as const; +const untranslatedPullRequestPanelLabels = [ + "Activity", + "No activity yet", + "Add all to chat", + "Add to chat", + "Adding...", + "Comment actions", + "Thread actions", + "Commented", + "Approved", + "Requested changes", + "Reviewed", + "Draft", + "Merged", + "Closed", + "Open", + "Resolved", + "Outdated", +] as const; + +function findUntranslatedPullRequestPanelLabels(): string[] { + return pullRequestPanelSources.flatMap((source) => { + const contents = readFileSync(join(appSourceRoot, source), "utf8"); + const matches: string[] = untranslatedPullRequestPanelLabels.filter( + (text) => contents.includes(`"${text}"`) || new RegExp(`>\\s*${text}\\s*<`).test(contents), + ); + if (contents.includes("} group`")) { + matches.push("group"); + } + return matches.length === 0 ? [] : [`${source}: ${matches.join(", ")}`]; + }); +} + function collectSourceFiles(directory: string): string[] { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = join(directory, entry.name); @@ -231,10 +270,53 @@ describe("translation resources", () => { expect(ko.desktop.daemon.status.notRunning).toBe("실행 중이 아님"); }); + it("uses the French navigation and Git meanings for reported labels", () => { + expect(fr.common.back).toBe("Retour"); + expect(fr.common.actions.back).toBe("Retour"); + expect(fr.common.actions.copy).toBe("Copier"); + expect(fr.common.states.starting).toBe("Démarrage…"); + expect(fr.common.connectionStatus.connecting).toBe("Connexion…"); + expect(fr.workspace.git.pr.sections.checks).toBe("Vérifications"); + expect(fr.sidebar.display.show.checks).toBe("Vérifications"); + }); + + it("separates French interpolation placeholders from neighboring words", () => { + const glued = Object.entries(flattenStrings(fr)).filter(([, value]) => + /\p{L}\{\{|\}\}\p{L}{2,}/u.test(value), + ); + expect(glued).toEqual([]); + }); + it("labels the immediate add-to-chat action without an ellipsis", () => { expect(en.workspace.fileActions.addToChat).toBe("Add to chat"); }); + it("keeps pull request panel and sidebar status group labels translated", () => { + expect(findUntranslatedPullRequestPanelLabels()).toEqual([]); + for (const resource of [ar, es, fr, ja, ko, ptBR, ru, zhCN]) { + const pr = resource.workspace.git.pr; + const englishPr = en.workspace.git.pr; + expect(pr.sections.activity).not.toBe(englishPr.sections.activity); + expect(pr.empty.noActivity).not.toBe(englishPr.empty.noActivity); + expect(pr.actions.addToChat).not.toBe(englishPr.actions.addToChat); + expect(pr.actions.addAllToChat).not.toBe(englishPr.actions.addAllToChat); + expect(pr.actions.addingToChat).not.toBe(englishPr.actions.addingToChat); + expect(pr.accessibility.commentActions).not.toBe(englishPr.accessibility.commentActions); + expect(pr.accessibility.threadActions).not.toBe(englishPr.accessibility.threadActions); + for (const verb of Object.keys(englishPr.activity) as (keyof typeof englishPr.activity)[]) { + expect(pr.activity[verb]).not.toBe(englishPr.activity[verb]); + } + for (const state of Object.keys(englishPr.states) as (keyof typeof englishPr.states)[]) { + expect(pr.states[state]).not.toBe(englishPr.states[state]); + } + expect(pr.thread.resolved).not.toBe(englishPr.thread.resolved); + expect(pr.thread.outdated).not.toBe(englishPr.thread.outdated); + expect(resource.sidebar.statusGroupAccessibility).not.toBe( + en.sidebar.statusGroupAccessibility, + ); + } + }); + it("keeps local connection fallback errors translated", () => { expect(findUntranslatedConnectionErrors()).toEqual([]); }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f001a177ef..9ebd34c728 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -382,6 +382,9 @@ export const ar: TranslationResources = { completed: "اكتملت", }, }, + turnFooter: { + workedFor: "عمل لمدة {{duration}}", + }, compaction: { loading: "الضغط...", auto: "يتم ضغط السياق تلقائيًا", @@ -970,6 +973,47 @@ export const ar: TranslationResources = { actions: { viewPullRequest: "عرض", openOn: "فتح على {{brand}}", + addToChat: "إضافة إلى الدردشة", + addAllToChat: "إضافة الكل إلى الدردشة", + addingToChat: "جارٍ الإضافة...", + }, + checksOverview: { + headline: { + actionRequired: "بعض الفحوصات تحتاج إلى انتباهك", + failure: "بعض الفحوصات لم تنجح", + pending: "بعض الفحوصات لم تكتمل بعد", + success: "نجحت جميع الفحوصات", + none: "لا توجد فحوصات", + }, + count: { + actionRequired: "{{count}} تحتاج إجراءً", + warning: "{{count}} مع تحذير", + failure: "{{count}} فاشلة", + pending: "{{count}} قيد التشغيل", + manual: "{{count}} يدوية", + success: "{{count}} ناجحة", + ignored: "{{count}} متخطاة", + }, + detailOne: "الفحص: {{parts}}", + detailMany: "الفحوصات: {{parts}}", + groupOne: { + actionRequired: "{{count}} فحص يحتاج إجراءً", + warning: "{{count}} فحص مع تحذير", + failure: "{{count}} فحص فاشل", + pending: "{{count}} فحص قيد التشغيل", + manual: "{{count}} فحص يدوي", + success: "{{count}} فحص ناجح", + ignored: "{{count}} فحص متخطى", + }, + groupMany: { + actionRequired: "{{count}} فحوصات تحتاج إجراءً", + warning: "{{count}} فحوصات مع تحذير", + failure: "{{count}} فحوصات فاشلة", + pending: "{{count}} فحوصات قيد التشغيل", + manual: "{{count}} فحوصات يدوية", + success: "{{count}} فحوصات ناجحة", + ignored: "{{count}} فحوصات متخطاة", + }, }, checksSummary: { passedLabel: "نجح", @@ -983,17 +1027,21 @@ export const ar: TranslationResources = { checks: "الشيكات", pipeline: "خط المعالجة", reviews: "التعليقات", + activity: "النشاط", }, empty: { noJobs: "لا توجد مهام", loadingPipeline: "جارٍ تحميل خط المعالجة...", pipelineJobsLoadFailed: "تعذر تحميل مهام خط المعالجة", allowedToFail: "مسموح بالفشل", + noActivity: "لا يوجد نشاط بعد", }, approvals: "{{given}} من {{required}} موافقات", accessibility: { pullRequest: "سحب الطلب #{{number}}", pullRequest_mr: "طلب دمج !{{number}}", + commentActions: "إجراءات التعليق", + threadActions: "إجراءات سلسلة النقاش", checkStatus: { passed: "ناجح", failed: "فاشل", @@ -1022,6 +1070,8 @@ export const ar: TranslationResources = { }, thread: { discussion: "سلسلة المناقشة", + resolved: "تم الحل", + outdated: "قديم", }, errors: { statusLoadFailed: "غير قادر على تحميل حالة طلب السحب", @@ -1088,6 +1138,14 @@ export const ar: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "مجموعة {{label}}", + statusBucket: { + needsInput: "تحتاج إدخالاً", + failed: "فشل", + readyToReview: "جاهزة للمراجعة", + working: "قيد العمل", + done: "تم", + }, display: { trigger: "تفضيلات العرض", heading: "العرض", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 1bf5598dfd..5ddb309806 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -379,6 +379,9 @@ export const en = { completed: "Completed", }, }, + turnFooter: { + workedFor: "Worked for {{duration}}", + }, compaction: { loading: "Compacting...", auto: "Context automatically compacted", @@ -978,6 +981,47 @@ export const en = { actions: { viewPullRequest: "View", openOn: "Open on {{brand}}", + addToChat: "Add to chat", + addAllToChat: "Add all to chat", + addingToChat: "Adding...", + }, + checksOverview: { + headline: { + actionRequired: "Some checks need your attention", + failure: "Some checks were not successful", + pending: "Some checks haven't completed yet", + success: "All checks have passed", + none: "No checks", + }, + count: { + actionRequired: "{{count}} needs action", + warning: "{{count}} warning", + failure: "{{count}} failing", + pending: "{{count}} in progress", + manual: "{{count}} manual", + success: "{{count}} successful", + ignored: "{{count}} skipped", + }, + detailOne: "{{parts}} check", + detailMany: "{{parts}} checks", + groupOne: { + actionRequired: "{{count}} needs action check", + warning: "{{count}} warning check", + failure: "{{count}} failing check", + pending: "{{count}} in progress check", + manual: "{{count}} manual check", + success: "{{count}} successful check", + ignored: "{{count}} skipped check", + }, + groupMany: { + actionRequired: "{{count}} needs action checks", + warning: "{{count}} warning checks", + failure: "{{count}} failing checks", + pending: "{{count}} in progress checks", + manual: "{{count}} manual checks", + success: "{{count}} successful checks", + ignored: "{{count}} skipped checks", + }, }, checksSummary: { passedLabel: "passed", @@ -991,17 +1035,21 @@ export const en = { checks: "Checks", pipeline: "Pipeline", reviews: "Reviews", + activity: "Activity", }, empty: { noJobs: "No jobs", loadingPipeline: "Loading pipeline…", pipelineJobsLoadFailed: "Could not load pipeline jobs", allowedToFail: "allowed to fail", + noActivity: "No activity yet", }, approvals: "{{given}} of {{required}} approvals", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + commentActions: "Comment actions", + threadActions: "Thread actions", checkStatus: { passed: "Passed", failed: "Failed", @@ -1030,6 +1078,8 @@ export const en = { }, thread: { discussion: "Discussion thread", + resolved: "Resolved", + outdated: "Outdated", }, errors: { statusLoadFailed: "Unable to load pull request status", @@ -1096,6 +1146,14 @@ export const en = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} group", + statusBucket: { + needsInput: "Needs input", + failed: "Failed", + readyToReview: "Ready to review", + working: "Working", + done: "Done", + }, display: { trigger: "Display preferences", heading: "Display", @@ -2104,7 +2162,7 @@ export const en = { globalTitle: "Enable plugins", globalHint: "Global switch for every configured plugin", sourceLabel: "Plugin source", - sourcePlaceholder: "Directory, Git URL, or npm package", + sourcePlaceholder: "owner/slug, directory, Git URL, or npm package", docs: "Docs", install: "Install plugin", installing: "Installing…", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index f3e0fc9e17..896e2fe947 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -387,6 +387,9 @@ export const es: TranslationResources = { completed: "Completada", }, }, + turnFooter: { + workedFor: "Trabajó durante {{duration}}", + }, compaction: { loading: "Compactando...", auto: "Contexto compactado automáticamente", @@ -1003,6 +1006,47 @@ export const es: TranslationResources = { actions: { viewPullRequest: "Ver", openOn: "Abrir en {{brand}}", + addToChat: "Añadir al chat", + addAllToChat: "Añadir todo al chat", + addingToChat: "Añadiendo...", + }, + checksOverview: { + headline: { + actionRequired: "Algunas comprobaciones requieren tu atención", + failure: "Algunas comprobaciones no se superaron", + pending: "Algunas comprobaciones aún no han terminado", + success: "Todas las comprobaciones se superaron", + none: "Sin comprobaciones", + }, + count: { + actionRequired: "{{count}} con acción pendiente", + warning: "{{count}} con advertencia", + failure: "{{count}} con error", + pending: "{{count}} en curso", + manual: "{{count}} manual(es)", + success: "{{count}} superada(s)", + ignored: "{{count}} omitida(s)", + }, + detailOne: "Comprobación: {{parts}}", + detailMany: "Comprobaciones: {{parts}}", + groupOne: { + actionRequired: "{{count}} comprobación con acción pendiente", + warning: "{{count}} comprobación con advertencia", + failure: "{{count}} comprobación con error", + pending: "{{count}} comprobación en curso", + manual: "{{count}} comprobación manual", + success: "{{count}} comprobación superada", + ignored: "{{count}} comprobación omitida", + }, + groupMany: { + actionRequired: "{{count}} comprobaciones con acción pendiente", + warning: "{{count}} comprobaciones con advertencia", + failure: "{{count}} comprobaciones con error", + pending: "{{count}} comprobaciones en curso", + manual: "{{count}} comprobaciones manuales", + success: "{{count}} comprobaciones superadas", + ignored: "{{count}} comprobaciones omitidas", + }, }, checksSummary: { passedLabel: "pasó", @@ -1016,17 +1060,21 @@ export const es: TranslationResources = { checks: "cheques", pipeline: "Pipeline", reviews: "Reseñas", + activity: "Actividad", }, empty: { noJobs: "Sin trabajos", loadingPipeline: "Cargando pipeline...", pipelineJobsLoadFailed: "No se pudieron cargar los trabajos del pipeline", allowedToFail: "permitido fallar", + noActivity: "Aún no hay actividad", }, approvals: "{{given}} de {{required}} aprobaciones", accessibility: { pullRequest: "Solicitud de extracción n.°{{number}}", pullRequest_mr: "Solicitud de fusión !{{number}}", + commentActions: "Acciones del comentario", + threadActions: "Acciones del hilo", checkStatus: { passed: "Superado", failed: "Fallido", @@ -1045,16 +1093,18 @@ export const es: TranslationResources = { open: "Abierto", }, activity: { - commented: "Comentado", - approved: "Aprobado", - requestedChanges: "Cambios solicitados", - reviewed: "Revisado", + commented: "Comentó", + approved: "Aprobó", + requestedChanges: "Solicitó cambios", + reviewed: "Revisó", }, time: { justNow: "En este momento", }, thread: { discussion: "Hilo de discusión", + resolved: "Resuelto", + outdated: "Desactualizado", }, errors: { statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción", @@ -1124,6 +1174,14 @@ export const es: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Grupo {{label}}", + statusBucket: { + needsInput: "Necesita datos", + failed: "Con error", + readyToReview: "Para revisar", + working: "En ejecución", + done: "Terminado", + }, display: { trigger: "Preferencias de visualización", heading: "Visualización", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 2ce39d2dc1..3d6466445c 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -18,7 +18,7 @@ export const fr: TranslationResources = { matches: "Résultats de recherche", previous: "Résultat précédent", next: "Résultat suivant", - toggleReplace: "Afficher le remplacement", + toggleReplace: "Afficher/masquer le remplacement", replaceWith: "Remplacer par", replace: "Remplacer", replaceAll: "Tout remplacer", @@ -28,51 +28,50 @@ export const fr: TranslationResources = { }, common: { bottomSheetBackdrop: "Arrière-plan du panneau inférieur", - back: "Dos", - loading: "Chargement...", + back: "Retour", + loading: "Chargement…", actions: { - back: "Dos", + back: "Retour", cancel: "Annuler", close: "Fermer", - copy: "Copie", + copy: "Copier", copyLine: "Copier la ligne", - dismiss: "Rejeter", + dismiss: "Ignorer", reload: "Recharger", retry: "Réessayer", - search: "Recherche", + search: "Rechercher", select: "Sélectionner", selectAll: "Tout sélectionner", }, placeholders: { - search: "Recherche...", + search: "Rechercher…", }, empty: { - noResults: "Aucun résultat trouvé", + noResults: "Aucun résultat", noOptionsMatchSearch: "Aucune option ne correspond à votre recherche.", }, states: { - loading: "Chargement...", - starting: "Départ...", + loading: "Chargement…", + starting: "Démarrage…", copied: "Copié", - copiedLabel: "{{label}}copié", + copiedLabel: "Copié : {{label}}", downloadComplete: "Téléchargement terminé", - downloadFailed: "Le téléchargement a échoué", + downloadFailed: "Échec du téléchargement", }, errors: { error: "Erreur", - unableToSave: "Impossible d'enregistrer", + unableToSave: "Impossible d’enregistrer", unableToCopy: "Impossible de copier", nameRequired: "Le nom est requis", - daemonUnavailable: "Daemonindisponible", - daemonClientUnavailable: "ClientDaemonindisponible", - daemonClientDisconnected: "Le clientDaemonest déconnecté", - noFileFound: "Aucun fichier trouvé pour{{token}}", - unexpectedDictationError: - "Une erreur inattendue s'est produite lors du traitement de la dictée.", + daemonUnavailable: "Daemon indisponible", + daemonClientUnavailable: "Client du daemon indisponible", + daemonClientDisconnected: "Le client du daemon est déconnecté", + noFileFound: "Aucun fichier trouvé pour {{token}}", + unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.", }, connectionStatus: { online: "En ligne", - connecting: "De liaison", + connecting: "Connexion…", offline: "Hors ligne", error: "Erreur", idle: "Inactif", @@ -80,159 +79,159 @@ export const fr: TranslationResources = { }, shell: { menu: { - toggleSidebar: "Basculer la barre latérale", + toggleSidebar: "Afficher/masquer la barre latérale", open: "Ouvrir le menu", close: "Fermer le menu", }, commandCenter: { - placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...", - filePlaceholder: "Rechercher des fichiers...", - searchingFiles: "Recherche de fichiers...", - noMatches: "Aucune correspondance", - actions: "Actes", + placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents…", + filePlaceholder: "Rechercher des fichiers…", + searchingFiles: "Recherche de fichiers…", + noMatches: "Aucun résultat", + actions: "Actions", files: "Fichiers", workspaces: "Espaces de travail", agents: "Agents", newAgent: "Nouvel agent", open: "Ouvrir {{name}}", openInSidePane: "Ouvrir {{name}} dans le panneau latéral", - openInFocusedPane: "Ouvrir {{name}} dans le volet actif", + openInFocusedPane: "Ouvrir {{name}} dans le panneau actif", addProject: "Ajouter un projet", - home: "Maison", + home: "Accueil", 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", - thinkingGroupLabel: "Raisonnement", - thinkingSearchKeywords: "raisonnement effort réflexion", + "changer de modèle modifier le modèle définir le modèle sélectionner le modèle model", + thinkingGroupLabel: "Réflexion", + thinkingSearchKeywords: "réflexion raisonnement effort thinking", modeGroupLabel: "Mode", - modeSearchKeywords: "accès permission approbation mode", + modeSearchKeywords: "accès autorisation approbation mode permission", planModeGroupLabel: "Mode plan", planModeSearchKeywords: "plan planification mode plan", fastModeGroupLabel: "Rapide", - fastModeSearchKeywords: "rapide vitesse faible latence", + fastModeSearchKeywords: "rapide vitesse faible latence fast", settingOn: "Activé", settingOff: "Désactivé", }, }, composer: { placeholders: { - desktop: "Envoyez un message à l'agent, marquez@filesou utilisez/commandset/skills", - mobile: "Message,@files,/commands", - fallback: "Message...", + desktop: "Écrivez à l’agent, mentionnez des @fichiers ou utilisez les /commandes et /skills", + mobile: "Message, @fichiers, /commandes", + fallback: "Message…", terminal: "Prompt", }, input: { - accessibilityLabel: "Agent de messagerie...", - terminalAccessibilityLabel: "Terminal prompt", - focusHint: "{{shortcut}}pour se concentrer", + accessibilityLabel: "Écrire à l’agent…", + terminalAccessibilityLabel: "Prompt du terminal", + focusHint: "{{shortcut}} pour saisir", addAttachment: "Ajouter une pièce jointe", - interruptAgent: "Agent d'interruption", - queueMessage: "Message de file d'attente", + interruptAgent: "Interrompre l’agent", + queueMessage: "Mettre le message en file d’attente", sendAndInterrupt: "Envoyer et interrompre", - sendAndSteer: "Envoyer et guider", - sendMessage: "Envoyer un message", - queue: "File d'attente", + sendAndSteer: "Envoyer et réorienter", + sendMessage: "Envoyer le message", + queue: "Mettre en file d’attente", send: "Envoyer", }, cancel: { - cancelingAgent: "Agent d'annulation", - stopAgent: "Agent d'arrêt", + cancelingAgent: "Annulation de l’agent…", + stopAgent: "Arrêter l’agent", interrupt: "Interrompre", }, voice: { enableVoiceMode: "Activer le mode vocal", voiceMode: "Mode vocal", - unmuteVoiceMode: "Activer le mode vocal", - muteVoiceMode: "Mode voix muette", + unmuteVoiceMode: "Réactiver le micro du mode vocal", + muteVoiceMode: "Couper le micro du mode vocal", stopDictation: "Arrêter la dictée", startDictation: "Démarrer la dictée", - unmuteVoice: "Réactiver la voix", - muteVoice: "Voix muette", + unmuteVoice: "Réactiver le micro", + muteVoice: "Couper le micro", dictation: "Dictée", - interruptBeforeVoice: "Interrompre l'agent avant de démarrer le mode vocal", + interruptBeforeVoice: "Interrompez l’agent avant de démarrer le mode vocal", }, attachments: { addImage: "Ajouter une image", pasteImage: "Coller une image", - addFile: "Upload file", - addIssueOrPr: "Ajouter un problème ouPR", - addIssueOrPr_mr: "Ajouter un problème ou MR", + addFile: "Importer un fichier", + addIssueOrPr: "Ajouter un ticket ou une PR", + addIssueOrPr_mr: "Ajouter un ticket ou une MR", dropImagesHere: "Déposez des images ici", - dropFilesHere: "Drop files here", - editQueuedMessage: "Modifier le message en file d'attente", - sendQueuedMessageNow: "Envoyer le message en file d'attente maintenant", - openImage: "Ouvrir la pièce jointe de l'image", - removeImage: "Supprimer l'image jointe", - removeFile: "Remove file attachment", + dropFilesHere: "Déposez des fichiers ici", + editQueuedMessage: "Modifier le message en file d’attente", + sendQueuedMessageNow: "Envoyer maintenant le message en file d’attente", + openImage: "Ouvrir l’image jointe", + removeImage: "Retirer l’image jointe", + removeFile: "Retirer le fichier joint", openGithub: "Ouvrir {{kind}} {{number}}", - removeGithub: "Supprimer {{kind}} {{number}}", + removeGithub: "Retirer {{kind}} {{number}}", element: "Élément", - openBrowserElement: "Ouvrir la pièce jointe de l'élément de navigateur", - removeBrowserElement: "Supprimer la pièce jointe d'un élément de navigateur", - openReview: "Ouvrir la pièce jointe de l'avis", - removeReview: "Supprimer la pièce jointe de l'avis", + openBrowserElement: "Ouvrir l’élément de navigateur joint", + removeBrowserElement: "Retirer l’élément de navigateur joint", + openReview: "Ouvrir la revue jointe", + removeReview: "Retirer la revue jointe", }, errors: { - failedToSend: "Échec de l'envoi du message", - failedToCreateAgent: "Échec de la création de l'agent", + failedToSend: "Impossible d’envoyer le message", + failedToCreateAgent: "Impossible de créer l’agent", noHostSelected: "Aucun hôte sélectionné", - initialPromptRequired: "Une invite initiale est requise", - alreadyLoading: "Déjà en cours de chargement", - uploadFailed: "Failed to upload file", + initialPromptRequired: "Un prompt initial est requis", + alreadyLoading: "Chargement déjà en cours", + uploadFailed: "Impossible d’importer le fichier", noClipboardImage: - "Aucune image dans le presse-papiers actuel. Essayez de coller depuis votre clavier.", + "Aucune image dans le presse-papiers. Essayez de coller depuis votre clavier.", pasteImageFailed: "Impossible de coller l’image", - fileTooLarge: "{{fileName}} is too large (max {{size}})", + fileTooLarge: "{{fileName}} est trop volumineux (maximum {{size}})", }, clientCommands: { - archiveAgent: "Archiver l'agent actuel", - freshDraft: "Archivez cet agent et démarrez un nouveau brouillon", + archiveAgent: "Archiver l’agent actuel", + freshDraft: "Archiver cet agent et démarrer un nouveau brouillon", }, github: { - searching: "Recherche...", - noResults: "Aucun résultat trouvé.", - searchPlaceholder: "Problèmes de recherche et PR...", - searchPlaceholder_mr: "Problèmes de recherche et MR...", - title: "Joindre le problème ouPR", - title_mr: "Joindre le problème ou MR", + searching: "Recherche…", + noResults: "Aucun résultat.", + searchPlaceholder: "Rechercher des tickets et des PR…", + searchPlaceholder_mr: "Rechercher des tickets et des MR…", + title: "Joindre un ticket ou une PR", + title_mr: "Joindre un ticket ou une MR", }, }, agentControls: { provider: { fallback: "Fournisseur", - select: "Sélectionnez le fournisseur d'agent", + select: "Choisir le fournisseur d’agent", }, thinking: { - title: "Pensée", + title: "Réflexion", unknown: "Inconnu", - extraHigh: "Très haut", - select: "Sélectionnez l'option de réflexion", - selectWithValue: "Sélectionnez l'option de réflexion ({{value}})", + extraHigh: "Très élevé", + select: "Choisir le niveau de réflexion", + selectWithValue: "Choisir le niveau de réflexion ({{value}})", }, model: { unknown: "Modèle inconnu", }, features: { - title: "Caractéristiques", - open: "Fonctionnalités de l'agent ouvert", - on: "Sur", + title: "Fonctionnalités", + open: "Ouvrir les fonctionnalités de l’agent", + on: "Activé", off: "Désactivé", }, mode: { title: "Mode", - searchPlaceholder: "Modes de recherche...", - selectWithValue: "Sélectionnez le mode agent ({{value}})", + searchPlaceholder: "Rechercher un mode…", + selectWithValue: "Choisir le mode de l’agent ({{value}})", }, hints: { - thinking: "Mode réflexion", + thinking: "Mode de réflexion", model: "Changer de modèle", mode: "Changer de mode", }, }, agentStream: { - empty: "Commencez à discuter avec cet agent...", + empty: "Commencez à discuter avec cet agent…", scrollToBottom: "Faire défiler vers le bas", historyLoadFailed: "Impossible de charger l’historique de l’agent", messageCapped: "Ce message a été tronqué ({{bytes}} octets).", @@ -245,34 +244,34 @@ export const fr: TranslationResources = { required: "Autorisation requise", deny: "Refuser", accept: "Accepter", - implement: "Mettre en œuvre", - question: "Comment souhaitez-vous procéder?", + implement: "Implémenter", + question: "Comment souhaitez-vous procéder ?", proposedPlan: "Plan proposé", }, }, agentPanel: { states: { - notFound: "Agentintrouvable", - failedToLoad: "Échec du chargement de l'agent", - reconnecting: "Reconnexion à l’hôte", - updating: "Mise à jour des messages", + notFound: "Agent introuvable", + failedToLoad: "Impossible de charger l’agent", + reconnecting: "Reconnexion à l’hôte…", + updating: "Mise à jour des messages…", timelineSyncFailed: "Impossible d’actualiser l’historique de l’agent.", timelineSyncRetrying: "Nouvelle tentative…", - archivingTitle: "Agent d'archivage...", - archivingSubtitle: "Veuillez patienter pendant que nous archivons cet agent.", + archivingTitle: "Archivage de l’agent…", + archivingSubtitle: "Veuillez patienter pendant l’archivage de cet agent.", }, unavailable: { selectedHost: "Hôte sélectionné", unknownHost: - "Impossible d'ouvrir cet agent car{{serverLabel}}n'est pas configuré sur ce périphérique.", + "Impossible d’ouvrir cet agent, car {{serverLabel}} n’est pas configuré sur cet appareil.", addHost: - "Ajoutez l'hôte dans Paramètres ou ouvrez un agent sur un serveur configuré pour continuer.", - preparingSession: "Préparation de la séance{{serverLabel}}...", - connecting: "Connexion à{{serverLabel}}...", - showSoon: "Nous montrerons cet agent dans un instant.", - showWhenOnline: "Nous afficherons cet agent une fois que l'hôte sera en ligne.", - reconnectingTo: "Reconnexion à{{serverLabel}}...", - showAgainWhenReachable: "Nous afficherons à nouveau cet agent dès que l'hôte sera joignable.", + "Pour continuer, ajoutez l’hôte dans Paramètres ou ouvrez un agent sur un serveur configuré.", + preparingSession: "Préparation de la session {{serverLabel}}…", + connecting: "Connexion à {{serverLabel}}…", + showSoon: "Cet agent va s’afficher dans un instant.", + showWhenOnline: "Cet agent s’affichera dès que l’hôte sera en ligne.", + reconnectingTo: "Reconnexion à {{serverLabel}}…", + showAgainWhenReachable: "Cet agent s’affichera de nouveau dès que l’hôte sera joignable.", }, archived: { callout: "Cet agent est archivé", @@ -281,42 +280,42 @@ export const fr: TranslationResources = { }, sessions: { title: "Historique", - empty: "Aucune séance pour l'instant", - noMatches: "Aucune séance correspondante", + empty: "Aucune session pour l’instant", + noMatches: "Aucune session correspondante", tooManyMatches: "Trop de résultats — affinez la recherche", - hostLoadFailed: "{{host}} : Impossible de charger l'historique", - searchPlaceholder: "Rechercher dans l'historique", + hostLoadFailed: "{{host}} : impossible de charger l’historique", + searchPlaceholder: "Rechercher dans l’historique", actions: { loadMore: "Charger plus", clearSearch: "Effacer la recherche", }, }, agentList: { - fallbackTitle: "Nouvelle séance", + fallbackTitle: "Nouvelle session", dateSections: { - recent: "Récent", - today: "Aujourd'hui", + recent: "Récents", + today: "Aujourd’hui", yesterday: "Hier", thisWeek: "Cette semaine", thisMonth: "Ce mois-ci", - older: "Plus vieux", + older: "Plus anciens", }, status: { - initializing: "Départ", + initializing: "Démarrage…", idle: "Inactif", - running: "En cours d'exécution", + running: "En cours", error: "Erreur", closed: "Fermé", }, badges: { archived: "Archivé", - pending: "{{count}}en attente", + pending: "{{count}} en attente", attention: "Attention", }, archiveSheet: { - hostOffline: "Hosthors ligne", - runningAgent: "Cet agent est toujours en cours d'exécution. L’archiver arrêtera l’agent.", - archive: "Archive", + hostOffline: "Hôte hors ligne", + runningAgent: "Cet agent est toujours en cours d’exécution. L’archiver l’arrêtera.", + archive: "Archiver", }, }, message: { @@ -331,33 +330,33 @@ export const fr: TranslationResources = { }, actions: { copyCode: "Copier le code", - copyTurn: "Copier le tour", + copyTurn: "Copier l’échange", copyMessage: "Copier le message", - forkMenu: "Dupliquer le message", - forkInNewTab: "Dupliquer dans un nouvel onglet", - forkInNewWorkspace: "Dupliquer dans un nouvel espace de travail", - forkUnavailable: "Mettez l'hôte à jour pour utiliser ceci.", - forkMissingWorkspace: "Cet agent n'est pas dans un espace de travail.", - forkFailed: "Impossible de dupliquer le chat", + forkMenu: "Forker la conversation à partir d’ici", + forkInNewTab: "Forker dans un nouvel onglet", + forkInNewWorkspace: "Forker dans un nouvel espace de travail", + forkUnavailable: "Mettez à jour l’hôte pour utiliser cette fonction.", + forkMissingWorkspace: "Cet agent n’est dans aucun espace de travail.", + forkFailed: "Impossible de forker la conversation", openFile: "Ouvrir le fichier", copied: "Copié", }, attachments: { - dismissImage: "Ignorer l'image", - closeImage: "Fermer l'image", - imageLoadFailed: "Impossible de charger l'image", + dismissImage: "Ignorer l’image", + closeImage: "Fermer l’image", + imageLoadFailed: "Impossible de charger l’image", imageUnavailable: "Image indisponible", - imagePreviewUnavailable: "Aperçu de l'image indisponible.", - imagePreviewLoadFailed: "Impossible de charger l'aperçu de l'image.", + imagePreviewUnavailable: "Aperçu de l’image indisponible.", + imagePreviewLoadFailed: "Impossible de charger l’aperçu de l’image.", review: "Revue", commentsOne: "1 commentaire", commentsMany: "{{count}} commentaires", - textAttachment: "Texte en pièce jointe", + textAttachment: "Pièce jointe texte", text: "Texte", file: "Fichier", }, speak: { - header: "Rayon", + header: "Prononcé", }, activity: { details: "Détails", @@ -365,21 +364,21 @@ export const fr: TranslationResources = { dictation: { start: "Démarrer la dictée vocale", cancel: "Annuler la dictée", - retry: "Réessayer la dictée", + retry: "Relancer la dictée", insert: "Insérer la transcription", insertAndSend: "Insérer la transcription et envoyer", - failed: "Échec de la dictée:{{error}}", - failedRetry: "La dictée a échoué. Appuyez sur réessayer.", + failed: "Échec de la dictée : {{error}}", + failedRetry: "La dictée a échoué. Appuyez sur Réessayer.", }, question: { - submit: "Soumettre", + submit: "Envoyer", next: "Suivant", - answerPlaceholder: "Tapez votre réponse...", - otherPlaceholder: "Autre...", + answerPlaceholder: "Saisissez votre réponse…", + otherPlaceholder: "Autre…", }, todo: { title: "Tâches", - empty: "Aucune tâche pour l'instant.", + empty: "Aucune tâche pour l’instant.", tasksProgress: "{{completed}}/{{total}} tâches", activity: { created: "{{count}} tâches créées", @@ -388,18 +387,21 @@ export const fr: TranslationResources = { completed: "Terminée", }, }, + turnFooter: { + workedFor: "A travaillé pendant {{duration}}", + }, compaction: { - loading: "Compactage...", - auto: "Contexte automatiquement compacté", + loading: "Compaction…", + auto: "Contexte compacté automatiquement", manual: "Contexte compacté manuellement", - withTokens: "Contexte compacté (jetons{{tokens}}K)", + withTokens: "Contexte compacté ({{tokens}}K tokens)", completed: "Contexte compacté", }, }, importSession: { chooseHostTitle: en.importSession.chooseHostTitle, - title: "Session d'importation", - searchPlaceholder: "Rechercher des sessions...", + title: "Importer une session", + searchPlaceholder: "Rechercher des sessions…", scope: { host: "Sessions sur {{host}}", workspace: "Cet espace de travail", @@ -409,40 +411,40 @@ export const fr: TranslationResources = { }, status: { connectHost: "Connectez-vous à un hôte pour importer des sessions", - updateHost: "Mettez à jour l'hôte pour importer des sessions.", - noProviders: "Aucun fournisseur importable n'est activé.", - loading: "Chargement des sessions récentes...", + updateHost: "Mettez à jour l’hôte pour importer des sessions.", + noProviders: "Aucun fournisseur importable n’est activé.", + loading: "Chargement des sessions récentes…", failedProvider: "Impossible de charger les sessions {{provider}}", - failedImport: "Impossible d'importer la session sélectionnée.", + failedImport: "Impossible d’importer la session sélectionnée.", }, actions: { - refresh: "Sessions de rafraîchissement", + refresh: "Actualiser les sessions", showAll: "Tout afficher", loadMore: "Charger plus", }, preview: { - untitledSession: "Séance sans titre", - noPrompt: "Aucun aperçu rapide", + untitledSession: "Session sans titre", + noPrompt: "Aucun aperçu du prompt", }, empty: { noRecent: "Aucune session récente à importer.", noMatches: "Aucune session ne correspond à votre recherche.", alreadyImported: "Toutes les sessions récentes sont déjà importées.", - noProviderSessions: "Aucune session{{provider}}trouvée.", + noProviderSessions: "Aucune session {{provider}} trouvée.", }, row: { - importing: "Importation...", + importing: "Importation…", }, }, workspace: { route: { - loading: "Chargement de l'espace de travail", - connecting: "De liaison", - hostOffline: "{{hostName}}est hors ligne", - cannotReachHost: "Impossible d'atteindre{{hostName}}", - hostStatus: "StatutHost:{{status}}", + loading: "Chargement de l’espace de travail…", + connecting: "Connexion…", + hostOffline: "{{hostName}} est hors ligne", + cannotReachHost: "Impossible de joindre {{hostName}}", + hostStatus: "Statut de l’hôte : {{status}}", needsHostUpgrade: "Mettez à jour votre hôte pour restaurer cet espace de travail", - manageHost: "Gérer l'hôte", + manageHost: "Gérer l’hôte", recovery: { archivedTitle: "Espace de travail archivé", restoreDescription: @@ -452,14 +454,14 @@ export const fr: TranslationResources = { unarchiveDescription: "{{workspaceName}} est archivé. Désarchivez-le pour le rouvrir.", restoreAction: "Restaurer", unarchiveAction: "Désarchiver", - restoringTitle: "Restauration de l'espace de travail", - restoringAction: "Restauration...", + restoringTitle: "Restauration de l’espace de travail", + restoringAction: "Restauration…", unavailableTitle: "Espace de travail indisponible", - checkFailedTitle: "Impossible de vérifier l'espace de travail", + checkFailedTitle: "Impossible de vérifier l’espace de travail", }, }, hoverCard: { - scriptsAccessibility: "ScriptsWorkspace", + scriptsAccessibility: "Scripts de l’espace de travail", copyPath: "Copier le chemin", copyBranchName: "Copier le nom de la branche", copied: "Copié", @@ -472,8 +474,8 @@ export const fr: TranslationResources = { copyRelativePath: "Copier le chemin relatif", revealIn: "Afficher dans {{target}}", download: "Télécharger", - addToChat: "Ajouter au chat", - moreActions: "Plus de propositions", + addToChat: "Ajouter à la conversation", + moreActions: "Plus d’actions", newFile: "Nouveau fichier", newFolder: "Nouveau dossier", collapseFolder: "Replier le dossier", @@ -482,24 +484,24 @@ export const fr: TranslationResources = { revert: "Abandonner les modifications", delete: "Supprimer", confirmDelete: { - fileTitle: "Supprimer le fichier ?", - folderTitle: "Supprimer le dossier ?", - message: "« {{name}} » sera définitivement supprimé.", + 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.", + 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", + failed: "Impossible d’abandonner les modifications", }, }, fileExplorer: { sort: { name: "Nom", - modified: "Modifié", + modified: "Date de modification", size: "Taille", }, context: { @@ -507,10 +509,10 @@ export const fr: TranslationResources = { modified: "Modifié", }, actions: { - back: "Dos", + back: "Retour", retry: "Réessayer", refresh: "Actualiser les fichiers", - refreshing: "Actualisation des fichiers", + refreshing: "Actualisation des fichiers…", hideHiddenFiles: "Masquer les fichiers cachés", showHiddenFiles: "Afficher les fichiers cachés", }, @@ -519,16 +521,16 @@ export const fr: TranslationResources = { noVisibleFiles: "Aucun fichier visible", }, states: { - unavailable: "Workspacen'est pas disponible", - loading: "Chargement des fichiers...", + unavailable: "L’espace de travail est indisponible", + loading: "Chargement des fichiers…", }, 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", + failedToListDirectory: "Impossible de lister le répertoire", + createFailed: "Impossible de créer l’élément", + renameFailed: "Impossible de renommer l’élément", + duplicateFailed: "Impossible de dupliquer l’élément", + revealFailed: "Impossible d’afficher l’élément", + deleteFailed: "Impossible de supprimer l’élément", }, draft: { filePlaceholder: "Nom du fichier", @@ -537,33 +539,33 @@ export const fr: TranslationResources = { }, setup: { descriptor: { - label: "Installation", + label: "Configuration", completed: "Configuration terminée", - failed: "Échec de l'installation", - workspace: "ConfigurationWorkspace", + failed: "Échec de la configuration", + workspace: "Configuration de l’espace de travail", blocked: "Configuration bloquée", }, status: { - running: "En cours d'exécution", - completed: "Complété", - failed: "Échoué", - waiting: "En attente de la sortie de configuration", + running: "En cours", + completed: "Terminé", + failed: "Échec", + waiting: "En attente de la sortie de la configuration", blocked: "Bloqué", }, - waiting: "Configuration de l'espace de travail...", + waiting: "Configuration de l’espace de travail…", blocked: { - title: "La configuration n’a pas été exécutée", + title: "La configuration n’a pas été lancée", description: - "Cette PR provient de {{repository}}, un dépôt différent. Sa configuration et ses scripts pourraient exécuter du code que vous n’avez pas examiné.", - run: "Exécuter la configuration", - runFailed: "Échec de la configuration de l’espace de travail", + "Cette PR provient de {{repository}}, un autre dépôt. Sa configuration et ses scripts pourraient exécuter du code que vous n’avez pas relu.", + run: "Lancer la configuration", + runFailed: "Impossible de lancer la configuration de l’espace de travail", }, empty: { - noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail.", + noCommands: "Aucune commande de configuration n’a été exécutée pour cet espace de travail.", }, accessibility: { - noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail", - log: "Journal de configurationWorkspace", + noCommands: "Aucune commande de configuration n’a été exécutée pour cet espace de travail", + log: "Journal de configuration de l’espace de travail", }, log: { noOutput: "Aucune sortie", @@ -571,177 +573,177 @@ export const fr: TranslationResources = { }, browser: { unavailable: { - title: "Le navigateur est réservé au bureau", + title: "Le navigateur est réservé à l’app de bureau", subtitle: "Ouvrez cet espace de travail dans Electron pour utiliser le navigateur intégré.", }, - session: "Session de navigateur{{browserId}}", + session: "Session de navigateur {{browserId}}", controls: { - back: "Dos", - forward: "Avant", + back: "Retour", + forward: "Suivant", stopLoading: "Arrêter le chargement", - refresh: "Rafraîchir", - browserUrl: "NavigateurURL", - enterUrl: "EntrezURL", - openDevTools: "Outils de développement du navigateur ouvert", - cancelSelector: "Annuler le sélecteur d'élément", - annotateElement: "Annoter l'élément", - screenshotElement: "Capturer l'élément", - screenshotCopied: "Capture d'écran copiée dans le presse-papiers", + refresh: "Actualiser", + browserUrl: "URL du navigateur", + enterUrl: "Saisir une URL", + openDevTools: "Ouvrir les outils de développement du navigateur", + cancelSelector: "Annuler la sélection d’élément", + annotateElement: "Annoter l’élément", + screenshotElement: "Capturer l’élément", + screenshotCopied: "Capture d’écran copiée dans le presse-papiers", elementCopied: "Élément copié dans le presse-papiers", - screenshotFailed: "Impossible de copier la capture", + screenshotFailed: "Impossible de copier la capture d’écran", selectorLoading: "Attendez la fin du chargement de la page", - selectorFailed: "Impossible de démarrer le sélecteur d’élément", + selectorFailed: "Impossible de lancer le sélecteur d’élément", }, annotate: { - title: "Annoter l'élément", - placeholder: "Message à l'agent concernant cet élément…", + title: "Annoter l’élément", + placeholder: "Message à l’agent à propos de cet élément…", submit: "Joindre", cancel: "Annuler", }, devices: { - label: "Taille de l'appareil", + label: "Taille de l’appareil", responsive: "Adaptatif", }, errors: { - failedToLoad: "Échec du chargement de la page", - invalidUrl: "NavigateurURLinvalide", - unsupportedProtocol: "Navigateur non pris en charge bloquéURL:{{protocol}}", + failedToLoad: "Impossible de charger la page", + invalidUrl: "URL du navigateur invalide", + unsupportedProtocol: "URL bloquée, protocole non pris en charge : {{protocol}}", }, }, terminal: { - hostDisconnected: "Hostn'est pas connecté", - updateHost: "Mettez à jour l’hôte pour utiliser le terminal natif.", - unableToSubscribe: "Impossible de s'abonner au terminal", + hostDisconnected: "L’hôte n’est pas connecté", + updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.", + unableToSubscribe: "Impossible de s’abonner au terminal", }, tabs: { - loading: "Chargement...", + loading: "Chargement…", modified: "Modifications non enregistrées", - loadingAgentTitle: "Titre d'agent de chargement", + loadingAgentTitle: "Chargement du titre de l’agent…", fallback: { newAgent: "Nouvel agent", - setup: "Installation", - workspaceSetup: "ConfigurationWorkspace", + setup: "Configuration", + workspaceSetup: "Configuration de l’espace de travail", terminal: "Terminal", browser: "Navigateur", agent: "Agent", - workspace: "Workspace", + workspace: "Espace de travail", }, switcher: { - trigger: "Changer d'onglet ({{count}}ouvert)", - title: "Changer d'onglet", - searchPlaceholder: "Onglets de recherche", + trigger: "Changer d’onglet ({{count}} ouverts)", + title: "Changer d’onglet", + searchPlaceholder: "Rechercher des onglets", }, menu: { - openFor: "Ouvrir le menu pour{{label}}", + openFor: "Ouvrir le menu de {{label}}", copyResumeCommand: "Copier la commande de reprise", - copyAgentId: "Copier l'identifiant de l'agent", - copyTerminalId: "Copier l'identifiant du terminal", - copyFilePath: "Copy file path", - rename: "Rebaptiser", - closeAbove: "Fermer les onglets ci-dessus", - closeBelow: "Fermer les onglets ci-dessous", - closeLeft: "Près de la gauche", - closeRight: "Près de la droite", + copyAgentId: "Copier l’ID de l’agent", + copyTerminalId: "Copier l’ID du terminal", + copyFilePath: "Copier le chemin du fichier", + rename: "Renommer", + closeAbove: "Fermer les onglets au-dessus", + closeBelow: "Fermer les onglets en dessous", + closeLeft: "Fermer les onglets à gauche", + closeRight: "Fermer les onglets à droite", closeOthers: "Fermer les autres onglets", moveToMain: "Déplacer vers le panneau principal", - reloadAgent: "Agent de rechargement", + reloadAgent: "Recharger l’agent", reloadAgentTooltip: - "Rechargez l'agent pour mettre à jour les compétences, les MCP ou le statut de connexion.", + "Rechargez l’agent pour mettre à jour les skills, les MCP ou l’état de connexion.", close: "Fermer", renameTerminal: "Renommer le terminal", - renameAgent: "Renommer l'agent", + renameAgent: "Renommer l’agent", }, actions: { newTab: "Nouvel onglet", newAgent: "Nouvel agent", newTerminal: "Nouveau terminal", - preparingTerminal: "Préparation de l'onglet du terminal", - preparingTerminalTooltip: "Préparation du terminal...", + preparingTerminal: "Préparation de l’onglet de terminal…", + preparingTerminalTooltip: "Préparation du terminal…", newBrowser: "Nouveau navigateur", - maximizePane: "Agrandir le volet", - restorePane: "Restaurer le volet", - closePane: "Fermer le volet", + maximizePane: "Agrandir le panneau", + restorePane: "Restaurer le panneau", + closePane: "Fermer le panneau", exitFocusMode: "Quitter le mode concentration", - splitRight: "Volet divisé à droite", - splitDown: "Diviser le volet vers le bas", + splitRight: "Diviser le panneau à droite", + splitDown: "Diviser le panneau vers le bas", changes: "Modifications", files: "Fichiers", - pullRequest: "Demande de fusion", - terminalProfilesMenu: "Terminal profiles", - editTerminalProfiles: "Edit profiles", + pullRequest: "Pull request", + terminalProfilesMenu: "Profils de terminal", + editTerminalProfiles: "Modifier les profils", }, explorerSidebar: { - open: "Ouvrir le panneau latéral", - close: "Fermer le panneau latéral", - toggle: "Basculer le panneau latéral", - changes: "Changements", + open: "Ouvrir la barre latérale de l’explorateur", + close: "Fermer la barre latérale de l’explorateur", + toggle: "Afficher/masquer la barre latérale de l’explorateur", + changes: "Modifications", files: "Fichiers", }, toasts: { - copyFailed: "Échec de la copie", - agentIdCopiedLabel: "AgentID", - terminalIdCopiedLabel: "Identifiant du terminal", - resumeCommandCopiedLabel: "reprendre la commande", - filePathCopiedLabel: "File path", - resumeIdUnavailable: "ReprendreIDnon disponible", - resumeCommandUnavailable: "Commande de reprise non disponible", - reloadingAgent: "Agent de rechargement...", + copyFailed: "Impossible de copier", + agentIdCopiedLabel: "ID de l’agent", + terminalIdCopiedLabel: "ID du terminal", + resumeCommandCopiedLabel: "Commande de reprise", + filePathCopiedLabel: "Chemin du fichier", + resumeIdUnavailable: "ID de reprise indisponible", + resumeCommandUnavailable: "Commande de reprise indisponible", + reloadingAgent: "Rechargement de l’agent…", reloadedAgent: "Agent rechargé", - failedToReloadAgent: "Échec du rechargement de l'agent", - failedToCloseAgent: "Échec de la fermeture de l'agent", + failedToReloadAgent: "Impossible de recharger l’agent", + failedToCloseAgent: "Impossible de fermer l’agent", }, confirmations: { unsavedTitle: "Modifications non enregistrées", unsavedMessage: "Cet onglet contient des modifications non enregistrées. Le fermer supprimera le brouillon.", closeWithoutSaving: "Fermer sans enregistrer", - closePaneTitle: "Fermer le volet?", + closePaneTitle: "Fermer le panneau ?", bulkUnsaved: "{{count}} onglet(s) contiennent des modifications non enregistrées. Les fermer supprimera ces brouillons.", close: "Fermer", cancel: "Annuler", - archive: "Archive", - closeTerminalTitle: "Fermer le terminal?", - closeTerminalMessage: - "Tout processus en cours d’exécution dans ce terminal sera immédiatement arrêté.", - archiveRunningAgentTitle: "Archiver l'agent en cours d'exécution?", + archive: "Archiver", + closeTerminalTitle: "Fermer le terminal ?", + closeTerminalMessage: "Tout processus en cours dans ce terminal sera arrêté immédiatement.", + archiveRunningAgentTitle: "Archiver l’agent en cours ?", archiveRunningAgentMessage: - "Cet agent est toujours en cours d'exécution. L'archiver arrêtera l'agent et fermera l'onglet.", - closeTabsLeftTitle: "Fermer les onglets à gauche?", - closeTabsRightTitle: "Fermer les onglets à droite?", - closeOtherTabsTitle: "Fermer les autres onglets?", + "Cet agent est toujours en cours d’exécution. L’archiver arrêtera l’agent et fermera l’onglet.", + closeTabsLeftTitle: "Fermer les onglets à gauche ?", + closeTabsRightTitle: "Fermer les onglets à droite ?", + closeOtherTabsTitle: "Fermer les autres onglets ?", bulk: { - all: "Cela archivera les agents{{agents}}, fermera les terminaux{{terminals}}et fermera les onglets{{tabs}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", + all: "Cette action archivera {{agents}} agent(s), fermera {{terminals}} terminal(s) et fermera {{tabs}} onglet(s). Tout processus en cours dans un terminal fermé sera arrêté immédiatement.", agentsAndTerminals: - "Cela archivera les agents{{agents}}et fermera les terminaux{{terminals}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", + "Cette action archivera {{agents}} agent(s) et fermera {{terminals}} terminal(s). Tout processus en cours dans un terminal fermé sera arrêté immédiatement.", terminalsAndTabs: - "Cela fermera le(s) terminal(s){{terminals}}et fermera le(s) onglet(s){{tabs}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", - agentsAndTabs: "Cela archivera les agents{{agents}}et fermera les onglets{{tabs}}.", + "Cette action fermera {{terminals}} terminal(s) et {{tabs}} onglet(s). Tout processus en cours dans un terminal fermé sera arrêté immédiatement.", + agentsAndTabs: + "Cette action archivera {{agents}} agent(s) et fermera {{tabs}} onglet(s).", terminals: - "Cela fermera le(s) terminal(s){{terminals}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", - tabs: "Cela fermera les onglets{{tabs}}.", - agents: "Cela archivera les agents{{agents}}.", + "Cette action fermera {{terminals}} terminal(s). Tout processus en cours dans un terminal fermé sera arrêté immédiatement.", + tabs: "Cette action fermera {{tabs}} onglet(s).", + agents: "Cette action archivera {{agents}} agent(s).", }, }, }, header: { actions: { - workspaceActions: "ActionsWorkspace", + workspaceActions: "Actions de l’espace de travail", newAgent: "Nouvel agent", - newTerminal: "Nouvelle borne", - newBrowser: "Nouvel onglet du navigateur", - importSession: "Session d'importation", - copyPath: "Copier le chemin de l'espace de travail", + newTerminal: "Nouveau terminal", + newBrowser: "Nouvel onglet de navigateur", + importSession: "Importer une session", + copyPath: "Copier le chemin de l’espace de travail", copyBranchName: "Copier le nom de la branche", showSetup: "Afficher la configuration", }, toasts: { - workspacePathUnavailable: "Le cheminWorkspacen'est pas encore disponible", - branchNameUnavailable: "Nom de la succursale non disponible", + workspacePathUnavailable: "Le chemin de l’espace de travail n’est pas encore disponible", + branchNameUnavailable: "Nom de la branche indisponible", terminalQueued: - "Préparation de l'espace de travail, ouverture du terminal lorsque vous êtes prêt...", - workspacePathCopiedLabel: "CheminWorkspace", - branchNameCopiedLabel: "Nom de la succursale", + "Préparation de l’espace de travail, le terminal s’ouvrira dès qu’il sera prêt…", + workspacePathCopiedLabel: "Chemin de l’espace de travail", + branchNameCopiedLabel: "Nom de la branche", }, }, scripts: { @@ -751,231 +753,224 @@ export const fr: TranslationResources = { copyUrl: "Copier l’URL", openService: "Voir le service", restart: "Redémarrer", - run: "Courir", + run: "Lancer", stop: "Arrêter", view: "Voir le terminal", }, accessibility: { - trigger: "ScriptsWorkspace", + trigger: "Scripts de l’espace de travail", openService: "Voir le service {{scriptName}}", - viewTerminal: "Voir le terminal{{scriptName}}", - runScript: "Exécuter le script{{scriptName}}", - stopScript: "Arrêter{{scriptName}}", - restartScript: "Redémarrer{{scriptName}}", - copyUrl: "Copier l'URL de{{scriptName}}", - chooseUrl: "Choisir l’URL pour {{scriptName}}", - script: "Script{{scriptName}}", + viewTerminal: "Voir le terminal de {{scriptName}}", + runScript: "Lancer le script {{scriptName}}", + stopScript: "Arrêter {{scriptName}}", + restartScript: "Redémarrer {{scriptName}}", + copyUrl: "Copier l’URL de {{scriptName}}", + chooseUrl: "Choisir l’URL de {{scriptName}}", + script: "Script {{scriptName}}", }, routes: { public: "Proxy inverse", - paseo: "Memorable", + paseo: "Mémorable", direct: "Directe", }, states: { - exitCode: "quitter{{code}}", - startFailed: "Échec du démarrage de{{scriptName}}", - stopFailed: "Échec de l'arrêt de{{scriptName}}", + exitCode: "sortie {{code}}", + startFailed: "Impossible de démarrer {{scriptName}}", + stopFailed: "Impossible d’arrêter {{scriptName}}", }, }, tree: { - showFolderTree: "Afficher l'arborescence des dossiers", - hideFolderTree: "Masquer l'arborescence des dossiers", + showFolderTree: "Afficher l’arborescence des dossiers", + hideFolderTree: "Masquer l’arborescence des dossiers", }, git: { actions: { - moreOptions: "Plus d'options", - moreActions: "Plus de propositions", + moreOptions: "Plus d’options", + moreActions: "Plus d’actions", commit: { - label: "Commettre", - pending: "S'engager...", - success: "Engagé", + label: "Commiter", + pending: "Commit en cours…", + success: "Commité", }, pull: { - label: "Tirer", - pending: "Tirer...", - success: "Tiré", + label: "Pull", + pending: "Pull en cours…", + success: "Pull effectué", }, push: { - label: "Pousser", - pending: "Pousser...", - success: "Poussé", + label: "Push", + pending: "Push en cours…", + success: "Push effectué", }, pullAndPush: { - label: "Tirez et poussez", - pending: "Tirer et pousser...", - success: "Tiré et poussé", + label: "Pull et push", + pending: "Pull et push en cours…", + success: "Pull et push effectués", }, - viewPr: "VoirPR", + viewPr: "Voir la PR", createPr: { - label: "CréerPR", - pending: "Création dePR...", - success: "PRcréé", - label_mr: "Créer MR", - pending_mr: "Création de MR...", - success_mr: "MR créé", + label: "Créer une PR", + pending: "Création de la PR…", + success: "PR créée", + label_mr: "Créer une MR", + pending_mr: "Création de la MR…", + success_mr: "MR créée", }, mergeBranch: { - label: "Fusionner localement", - pending: "Fusion...", + label: "Fusionner en local", + pending: "Fusion…", success: "Fusionné", }, mergeFromBase: { - label: "Mise à jour de{{baseRef}}", - pending: "Mise à jour...", + label: "Mettre à jour depuis {{baseRef}}", + pending: "Mise à jour…", success: "Mis à jour", }, archive: { label: "Archiver l’espace de travail", - pending: "Archivage...", + pending: "Archivage…", success: "Archivé", }, mergePr: { - squash: "Fusionner PR (squash)", - merge: "Fusionner PR (merge)", - rebase: "Fusionner PR (rebase)", - pending: "Fusion de PR...", - success: "PR fusionné", - squash_mr: "Fusionner MR (squash)", - merge_mr: "Fusionner MR (merge)", - rebase_mr: "Fusionner MR (rebase)", - pending_mr: "Fusion de MR...", - success_mr: "MR fusionné", + squash: "Fusionner la PR (squash)", + merge: "Fusionner la PR (merge)", + rebase: "Fusionner la PR (rebase)", + pending: "Fusion de la PR…", + success: "PR fusionnée", + squash_mr: "Fusionner la MR (squash)", + merge_mr: "Fusionner la MR (merge)", + rebase_mr: "Fusionner la MR (rebase)", + pending_mr: "Fusion de la MR…", + success_mr: "MR fusionnée", }, - viewPr_mr: "Voir MR", + viewPr_mr: "Voir la MR", autoMerge: { enableSquash: "Fusion automatique (squash)", enableMerge: "Fusion automatique (merge)", enableRebase: "Fusion automatique (rebase)", enabled: "Fusion automatique activée", - enabling: "Activation de la fusion automatique...", - disabling: "Désactivation de la fusion automatique...", + enabling: "Activation de la fusion automatique…", + disabling: "Désactivation de la fusion automatique…", disabled: "Fusion automatique désactivée", }, unavailable: { viewPrNoForge: - "Voir {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté", + "Impossible de voir la {{noun}} pour l’instant : {{brand}} n’est pas connecté", pullNoRemote: - "Pull n'est pas disponible ici car cette branche n'est pas encore connectée à une télécommande", + "Pull indisponible : cette branche n’est pas encore liée à un dépôt distant", pullDirty: - "Pull n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", - pullUpToDate: "Pull n'est pas disponible car cette branche est déjà à jour", + "Pull indisponible tant que vous avez des modifications locales : commitez-les ou remisez-les d’abord", + pullUpToDate: "Pull indisponible : cette branche est déjà à jour", pushNoRemote: - "Push n'est pas disponible ici car cette branche n'est pas encore connectée à une télécommande", + "Push indisponible : cette branche n’est pas encore liée à un dépôt distant", pushBehind: - "Push n'est pas encore disponible, car de nouvelles modifications doivent être apportées en premier.", - pushNothing: "Push n'est pas disponible car il n'y a rien de nouveau à envoyer", + "Push indisponible pour l’instant : des modifications plus récentes doivent d’abord être récupérées", + pushNothing: "Push indisponible : il n’y a rien de nouveau à pousser", pullAndPushNoRemote: - "Le pull et le push ne sont pas disponibles ici car cette branche n'est pas encore connectée à une télécommande", + "Pull et push indisponibles : cette branche n’est pas encore liée à un dépôt distant", pullAndPushDirty: - "Pull et push ne sont pas disponibles tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + "Pull et push indisponibles tant que vous avez des modifications locales : commitez-les ou remisez-les d’abord", pullAndPushNoIncoming: - "Pull et push ne sont pas disponibles car il n'y a aucun changement entrant à récupérer d'abord", - pullAndPushInSync: - "Les fonctions Pull et Push ne sont pas disponibles car cette branche est déjà synchronisée", + "Pull et push indisponibles : aucune modification entrante à récupérer", + pullAndPushInSync: "Pull et push indisponibles : cette branche est déjà synchronisée", pullAndPushNothingToPush: - "Pull et push ne sont pas disponibles car il n'y a rien de nouveau à envoyer après le pull", + "Pull et push indisponibles : il n’y aurait rien de nouveau à pousser après le pull", createPrNoForge: - "Créer {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté", + "Impossible de créer une {{noun}} pour l’instant : {{brand}} n’est pas connecté", createPrNoCommits: - "CréerPRn'est pas disponible car cette branche n'a pas encore de nouveaux commits", - mergeNoBase: - "La fusion n'est pas disponible car nous n'avons pas pu déterminer la branche de base", + "Impossible de créer une PR : cette branche n’a pas encore de nouveau commit", + mergeNoBase: "Fusion indisponible : impossible de déterminer la branche de base", mergeDirty: - "La fusion n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + "Fusion indisponible tant que vous avez des modifications locales : commitez-les ou remisez-les d’abord", mergeNothing: - "La fusion n'est pas disponible car cette branche n'a encore rien de nouveau à fusionner", - updateNoBase: - "La mise à jour n'est pas disponible car nous n'avons pas pu déterminer la branche de base", + "Fusion indisponible : cette branche n’a encore rien de nouveau à fusionner", + updateNoBase: "Mise à jour indisponible : impossible de déterminer la branche de base", updateDirty: - "La mise à jour n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + "Mise à jour indisponible tant que vous avez des modifications locales : commitez-les ou remisez-les d’abord", updateCurrent: - "La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}", + "Mise à jour indisponible : cette branche est déjà à jour avec {{baseRef}}", mergePrNoGithub: - "La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté", + "Impossible de fusionner la PR pour l’instant : GitHub n’est pas connecté", archiveNotWorktree: - "L'archive n'est pas disponible ici car cet espace de travail n'a pas été créé en tant qu'arbre de travailPaseo", + "Archivage indisponible : cet espace de travail n’a pas été créé comme worktree Paseo", mergePrNoForge: - "La fusion {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté", - mergePrMissing: - "La fusionPRn'est pas disponible car il n'y a pas encore de demande d'extraction", - mergePrDraft: - "La fusionPRn'est pas disponible car la demande d'extraction est encore un brouillon", - mergePrMerged: - "La fusionPRn'est pas disponible car la demande d'extraction est déjà fusionnée", - mergePrClosed: "La fusionPRn'est pas disponible car la demande d'extraction est fermée", - mergePrConflicts: - "La fusionPRn'est pas disponible car la demande d'extraction présente des conflits", + "Impossible de fusionner la {{noun}} pour l’instant : {{brand}} n’est pas connecté", + mergePrMissing: "Impossible de fusionner la PR : il n’y a pas encore de pull request", + mergePrDraft: "Impossible de fusionner la PR : la pull request est encore un brouillon", + mergePrMerged: "Impossible de fusionner la PR : la pull request est déjà fusionnée", + mergePrClosed: "Impossible de fusionner la PR : la pull request est fermée", + mergePrConflicts: "Impossible de fusionner la PR : la pull request a des conflits", mergePrQueue: - "MergePRn'est pas disponible ici car ce référentiel utilise une file d'attente de fusion", + "Impossible de fusionner la PR ici : ce dépôt utilise une file d’attente de fusion", mergePrNotReady: - "La fusion {{noun}} n'est pas disponible jusqu'à ce que {{brand}} signale que {{noun}} est prête à fusionner", + "Impossible de fusionner la {{noun}} tant que {{brand}} n’indique pas que la {{noun}} est prête à être fusionnée", autoMergeCannotDisable: "La fusion automatique est activée, mais ce compte ne peut pas la désactiver", }, toasts: { - failedCommit: "Échec de la validation", - failedPull: "Échec de l'extraction", - failedPush: "Échec de la poussée", - failedPullAndPush: "Impossible de tirer et de pousser", - failedCreatePr: "Échec de la création dePR", - failedMergePr: "Échec de la fusion dePR", - failedEnableAutoMerge: "Échec de l'activation de la fusion automatique", - failedDisableAutoMerge: "Échec de la désactivation de la fusion automatique", - baseRefUnavailable: "Réf de base indisponible", + failedCommit: "Impossible de commiter", + failedPull: "Échec du pull", + failedPush: "Échec du push", + failedPullAndPush: "Échec du pull et du push", + failedCreatePr: "Impossible de créer la PR", + failedMergePr: "Impossible de fusionner la PR", + failedEnableAutoMerge: "Impossible d’activer la fusion automatique", + failedDisableAutoMerge: "Impossible de désactiver la fusion automatique", + baseRefUnavailable: "Référence de base indisponible", failedMerge: "Échec de la fusion", - failedMergeFromBase: "Échec de la fusion à partir de la base", + failedMergeFromBase: "Échec de la fusion depuis la base", }, archiveWarning: { - title: "Archiver «{{workspaceName}}»?", - confirm: "Archive", + title: "Archiver « {{workspaceName}} » ?", + confirm: "Archiver", cancel: "Annuler", - uncommittedChanges: "Modifications non validées", - uncommittedChangesWithDiff: "Modifications non validées ({{diffStat}})", - addedLine: "Ligne ajoutée{{count}}", - addedLines: "{{count}}lignes ajoutées", - deletedLine: "Ligne supprimée{{count}}", - deletedLines: "{{count}}lignes supprimées", - unpushedCommit: "Validation non poussée{{count}}", - unpushedCommits: "Validations non poussées{{count}}", + uncommittedChanges: "Modifications non commitées", + uncommittedChangesWithDiff: "Modifications non commitées ({{diffStat}})", + addedLine: "{{count}} ligne ajoutée", + addedLines: "{{count}} lignes ajoutées", + deletedLine: "{{count}} ligne supprimée", + deletedLines: "{{count}} lignes supprimées", + unpushedCommit: "{{count}} commit non poussé", + unpushedCommits: "{{count}} commits non poussés", }, }, diff: { - openChangesTab: "Ouvrir l'onglet Modifications", - openDiffTab: "Ouvrir l'onglet Diff", - closeChangesTab: "Fermer l'onglet Modifications", + openChangesTab: "Ouvrir l’onglet Modifications", + openDiffTab: "Ouvrir l’onglet Diff", + closeChangesTab: "Fermer l’onglet Modifications", jumpToFile: { title: "Aller au fichier", }, binaryFile: "Fichier binaire", - tooLarge: "Diff trop grand pour être affiché", + tooLarge: "Diff trop volumineux pour être affiché", previewTooLargeTitle: "Ce diff est trop volumineux pour être prévisualisé", - previewTooLargeDescription: "Réduisez la comparaison pour la prévisualiser ici", - unified: "Différentiel unifié", - split: "Différent côte à côte", + previewTooLargeDescription: "Réduisez la comparaison pour le prévisualiser ici", + unified: "Diff unifié", + split: "Diff côte à côte", switchToUnified: "Passer au diff unifié", switchToSplit: "Passer au diff côte à côte", options: "Options du diff", - inlineDiff: "Diff intégré", + inlineDiff: "Diff en ligne", hideWhitespace: "Masquer les espaces", showWhitespace: "Afficher les espaces", scrollLongLines: "Faire défiler les longues lignes", - wrapLongLines: "Enroulez les longues lignes", - collapseAllFolders: "Réduire tous les dossiers", - expandAllFolders: "Développer tous les dossiers", - collapseAllFiles: "Réduire tous les fichiers", - expandAllFiles: "Développer tous les fichiers", - refreshing: "Rafraîchissant", - refresh: "Rafraîchir", - refreshState: "Actualiser l'état de git et de {{brand}}", - failedRefresh: "Échec de l'actualisation de l'état git.", - seeUncommittedChanges: "Voir les modifications non validées", - seeCommittedChanges: "Voir les modifications validées", - checkingRepository: "Vérification du référentiel...", - notRepository: "Pas un dépôt git", - diffMode: "Mode différentiel", - uncommitted: "Non engagé", - committed: "Engagé", - branchUnknown: "Inconnu", + wrapLongLines: "Renvoyer les longues lignes à la ligne", + collapseAllFolders: "Replier tous les dossiers", + expandAllFolders: "Déplier tous les dossiers", + collapseAllFiles: "Replier tous les fichiers", + expandAllFiles: "Déplier tous les fichiers", + refreshing: "Actualisation…", + refresh: "Actualiser", + refreshState: "Actualiser l’état de git et de {{brand}}", + failedRefresh: "Impossible d’actualiser l’état git.", + seeUncommittedChanges: "Voir les modifications non commitées", + seeCommittedChanges: "Voir les modifications commitées", + checkingRepository: "Vérification du dépôt…", + notRepository: "Ce n’est pas un dépôt git", + diffMode: "Mode de diff", + uncommitted: "Non commité", + committed: "Commité", + branchUnknown: "Inconnue", base: "base", newFile: "Nouveau", deletedFile: "Supprimé", @@ -983,81 +978,128 @@ export const fr: TranslationResources = { commits: { title: "Commits", countLabel: "{{count}} commits de l’espace de travail", - noneAhead: "Aucun commit en avance sur {{baseRef}} pour le moment", + noneAhead: "Aucun commit en avance sur {{baseRef}} pour l’instant", fileDiffEmpty: "Aucune modification à afficher", - fileDiffError: "Échec du chargement du diff du fichier", + fileDiffError: "Impossible de charger le diff du fichier", loading: "Chargement des commits…", - loadError: "Échec du chargement des commits", - empty: "Aucun commit pour le moment", + loadError: "Impossible de charger les commits", + empty: "Aucun commit pour l’instant", }, }, openInEditor: { open: "Ouvrir", - chooseEditor: "Choisir l'éditeur", - openIn: "Espace de travail ouvert dans{{target}}", - openFileIn: "Open {{fileName}} in {{target}}", - failedOpen: "Échec de l'ouverture de l'espace de travail", + chooseEditor: "Choisir l’éditeur", + openIn: "Ouvrir l’espace de travail dans {{target}}", + openFileIn: "Ouvrir {{fileName}} dans {{target}}", + failedOpen: "Impossible d’ouvrir l’espace de travail", }, pr: { actions: { viewPullRequest: "Voir", openOn: "Ouvrir sur {{brand}}", + addToChat: "Ajouter à la conversation", + addAllToChat: "Tout ajouter à la conversation", + addingToChat: "Ajout…", + }, + checksOverview: { + headline: { + actionRequired: "Certaines vérifications demandent votre attention", + failure: "Certaines vérifications ont échoué", + pending: "Certaines vérifications ne sont pas terminées", + success: "Toutes les vérifications ont réussi", + none: "Aucune vérification", + }, + count: { + actionRequired: "{{count}} à traiter", + warning: "{{count}} avec avertissement", + failure: "{{count}} en échec", + pending: "{{count}} en cours", + manual: "{{count}} manuelle(s)", + success: "{{count}} réussie(s)", + ignored: "{{count}} ignorée(s)", + }, + detailOne: "Vérification : {{parts}}", + detailMany: "Vérifications : {{parts}}", + groupOne: { + actionRequired: "{{count}} vérification à traiter", + warning: "{{count}} vérification avec avertissement", + failure: "{{count}} vérification en échec", + pending: "{{count}} vérification en cours", + manual: "{{count}} vérification manuelle", + success: "{{count}} vérification réussie", + ignored: "{{count}} vérification ignorée", + }, + groupMany: { + actionRequired: "{{count}} vérifications à traiter", + warning: "{{count}} vérifications avec avertissement", + failure: "{{count}} vérifications en échec", + pending: "{{count}} vérifications en cours", + manual: "{{count}} vérifications manuelles", + success: "{{count}} vérifications réussies", + ignored: "{{count}} vérifications ignorées", + }, }, checksSummary: { - passedLabel: "succès", - failedLabel: "échec", + passedLabel: "réussies", + failedLabel: "en échec", runningLabel: "en cours", passedAccessible: "Vérifications réussies", failedAccessible: "Vérifications en échec", runningAccessible: "Vérifications en cours", }, sections: { - checks: "Chèques", + checks: "Vérifications", pipeline: "Pipeline", - reviews: "Avis", + reviews: "Revues", + activity: "Activité", }, empty: { - noJobs: "Aucune tâche", - loadingPipeline: "Chargement du pipeline...", - pipelineJobsLoadFailed: "Impossible de charger les tâches du pipeline", - allowedToFail: "autorisé à échouer", + noJobs: "Aucun job", + loadingPipeline: "Chargement du pipeline…", + pipelineJobsLoadFailed: "Impossible de charger les jobs du pipeline", + allowedToFail: "échec autorisé", + noActivity: "Aucune activité pour le moment", }, approvals: "{{given}} sur {{required}} approbations", accessibility: { - pullRequest: "Demande de tirage #{{number}}", - pullRequest_mr: "Demande de fusion !{{number}}", + pullRequest: "Pull request #{{number}}", + pullRequest_mr: "Merge request !{{number}}", + commentActions: "Actions du commentaire", + threadActions: "Actions du fil", checkStatus: { - passed: "Réussi", + passed: "Réussie", failed: "Échec", warning: "Avertissement", actionRequired: "Action requise", - manual: "Manuel", + manual: "Manuelle", pending: "En attente", - skipped: "Ignoré", - cancelled: "Annulé", + skipped: "Ignorée", + cancelled: "Annulée", }, }, states: { draft: "Brouillon", - merged: "Fusionné", - closed: "Fermé", - open: "Ouvrir", + merged: "Fusionnée", + closed: "Fermée", + open: "Ouverte", }, activity: { - commented: "Commenté", - approved: "Approuvé", - requestedChanges: "Modifications demandées", - reviewed: "Révisé", + commented: "A commenté", + approved: "A approuvé", + requestedChanges: "A demandé des modifications", + reviewed: "A relu", }, time: { - justNow: "tout à l' heure", + justNow: "à l’instant", }, thread: { discussion: "Fil de discussion", + resolved: "Résolu", + outdated: "Obsolète", }, errors: { - statusLoadFailed: "Impossible de charger le statut de la demande d'extraction", - activityLoadFailed: "Impossible de charger l'activité de demande d'extraction", + statusLoadFailed: "Impossible de charger le statut de la pull request", + activityLoadFailed: "Impossible de charger l’activité de la pull request", }, }, forgeSetup: { @@ -1086,7 +1128,7 @@ export const fr: TranslationResources = { orange: "Orange", pink: "Rose", indigo: "Indigo", - teal: "Sarcelle", + teal: "Bleu canard", red: "Rouge", amber: "Ambre", blue: "Bleu", @@ -1105,7 +1147,7 @@ export const fr: TranslationResources = { color: "Couleur", save: "Enregistrer", delete: "Supprimer", - deleteTitle: "Supprimer {{name}} ?", + deleteTitle: "Supprimer {{name}} ?", deleteMessage_one: "Cela retire l’étiquette de {{count}} espace de travail sur cet hôte.", deleteMessage_other: "Cela retire l’étiquette de {{count}} espaces de travail sur cet hôte.", offline: "Cet hôte est hors ligne.", @@ -1123,8 +1165,16 @@ export const fr: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Groupe {{label}}", + statusBucket: { + needsInput: "Attend une réponse", + failed: "Échec", + readyToReview: "À relire", + working: "En cours", + done: "Terminé", + }, display: { - trigger: "Préférences d'affichage", + trigger: "Préférences d’affichage", heading: "Affichage", grouping: { label: "Regroupement", @@ -1146,7 +1196,7 @@ export const fr: TranslationResources = { checks: "Vérifications", services: "Services", labels: "Libellés", - diff: "Statistiques de diff", + diff: "Statistiques du diff", timestamp: "Dernière activité", }, checks: { @@ -1174,8 +1224,8 @@ export const fr: TranslationResources = { }, host: { noHost: "Aucun hôte", - switchTitle: "Changer d'hôte", - searchPlaceholder: "Rechercher des hôtes...", + switchTitle: "Changer d’hôte", + searchPlaceholder: "Rechercher des hôtes…", }, actions: { addProject: "Ajouter un projet", @@ -1200,93 +1250,93 @@ export const fr: TranslationResources = { }, sections: { sessions: "Historique", - search: "Rechercher", + search: "Recherche", schedules: "Planifications", }, worktreeSetup: { - title: "Configurer les scripts d'arbre de travail", + title: "Configurer les scripts de worktree", description: - "Ajoutez des commandes de configuration pour que les nouveaux arbres de travail puissent installer des dépendances et se préparer automatiquement.", + "Ajoutez des commandes de configuration pour que les nouveaux worktrees installent leurs dépendances et se préparent automatiquement.", openProjectSettings: "Ouvrir les paramètres du projet", }, project: { actions: { menu: "Actions du projet", openSettings: "Ouvrir les paramètres du projet", - openNewWindow: "Open in new window", - openNewWindowFailed: "Couldn't open a new window", - openFolder: "Open in file manager", - openFolderFailed: "Couldn't open folder", - remove: "Supprimer le projet", - removing: "Suppression...", + openNewWindow: "Ouvrir dans une nouvelle fenêtre", + openNewWindowFailed: "Impossible d’ouvrir une nouvelle fenêtre", + openFolder: "Ouvrir dans le gestionnaire de fichiers", + openFolderFailed: "Impossible d’ouvrir le dossier", + remove: "Retirer le projet", + removing: "Retrait…", }, confirmations: { - removeTitle: "Supprimer le projet?", + removeTitle: "Retirer le projet ?", removeMessage: - "Supprimer «{{projectName}}» de la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.", + "Retirer « {{projectName}} » de la barre latérale ?\n\nLes fichiers sur le disque ne seront pas modifiés.", removeConfirm: "Retirer", cancel: "Annuler", }, toasts: { - hostDisconnected: "Hostn'est pas connecté", - removeFailed: "Échec de la suppression de certains espaces de travail", - updateHostToRemove: "Mettez à jour le host pour supprimer des projets.", + hostDisconnected: "L’hôte n’est pas connecté", + removeFailed: "Impossible de retirer certains espaces de travail", + updateHostToRemove: "Mettez à jour l’hôte pour retirer des projets.", }, empty: { - title: "Aucun projet pour l'instant", + title: "Aucun projet pour l’instant", description: "Ajoutez un projet pour commencer", }, }, workspace: { status: { - serviceRunning: "Service {{name}} en cours", + serviceRunning: "Service {{name}} en cours d’exécution", serviceUnhealthy: "Service {{name}} en échec", - creating: "Création...", + creating: "Création…", }, checks: { - passed: "Réussis : {{count}}", - failed: "Échecs : {{count}}", - warning: "Avertissements : {{count}}", - actionRequired: "Action requise : {{count}}", - manual: "Manuels : {{count}}", - pending: "En attente : {{count}}", + passed: "Réussies : {{count}}", + failed: "Échouées : {{count}}", + warning: "Avertissements : {{count}}", + actionRequired: "Action requise : {{count}}", + manual: "Manuelles : {{count}}", + pending: "En attente : {{count}}", }, actions: { - menu: "ActionsWorkspace", + menu: "Actions de l’espace de travail", newWorkspace: "Nouvel espace de travail", showMore: "Afficher plus", showLess: "Afficher moins", - createWorkspaceFor: "Créer un nouvel espace de travail pour{{projectName}}", + createWorkspaceFor: "Créer un espace de travail pour {{projectName}}", copyPath: "Copier le chemin", copyBranchName: "Copier le nom de la branche", - rename: "Renommer l'espace de travail", + rename: "Renommer l’espace de travail", pin: "Épingler en haut", unpin: "Désépingler", - archive: "Archive", + archive: "Archiver", archiveWorkspace: "Archiver l’espace de travail", hideFromSidebar: "Masquer de la barre latérale", - archiving: "Archivage...", - hiding: "Dissimulation...", + archiving: "Archivage…", + hiding: "Masquage…", }, confirmations: { - hideTitle: "Masquer l'espace de travail?", + hideTitle: "Masquer l’espace de travail ?", hideMessage: - "Masquer «{{workspaceName}}» dans la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.", - hideConfirm: "Cacher", + "Masquer « {{workspaceName}} » de la barre latérale ?\n\nLes fichiers sur le disque ne seront pas modifiés.", + hideConfirm: "Masquer", cancel: "Annuler", }, rename: { - title: "Renommer l'espace de travail", - submit: "Rebaptiser", - invalidBranchName: "Nom de succursale invalide", + title: "Renommer l’espace de travail", + submit: "Renommer", + invalidBranchName: "Nom de branche invalide", }, toasts: { - workspacePathUnavailable: "CheminWorkspacenon disponible", + workspacePathUnavailable: "Chemin de l’espace de travail indisponible", pathCopied: "Chemin copié", - branchNameCopied: "Nom de la succursale copié", - hostDisconnected: "Hostn'est pas connecté", - hideFailed: "Échec du masquage de l'espace de travail", - archiveFailed: "Échec de l'archivage de l'espace de travail", + branchNameCopied: "Nom de branche copié", + hostDisconnected: "L’hôte n’est pas connecté", + hideFailed: "Impossible de masquer l’espace de travail", + archiveFailed: "Impossible d’archiver l’espace de travail", }, }, }, @@ -1305,34 +1355,34 @@ export const fr: TranslationResources = { }, titlePlaceholder: "Titre (facultatif)", errors: { - hostDisconnected: "Hostn'est pas connecté", - createWorktreeFailed: "Échec de la création de l'arbre de travail", - composerStateRequired: "L'état du compositeur est requis", + hostDisconnected: "L’hôte n’est pas connecté", + createWorktreeFailed: "Impossible de créer le worktree", + composerStateRequired: "L’état de la zone de saisie est requis", selectModel: "Sélectionnez un modèle", }, tooltips: { - project: "Choose the project", - host: "Choose the host", - isolation: "Choose the isolation level", - startingRef: "Choisissez par où commencer", - launch: "Choose what to launch", + project: "Choisir le projet", + host: "Choisir l’hôte", + isolation: "Choisir le niveau d’isolation", + startingRef: "Choisir le point de départ", + launch: "Choisir quoi lancer", }, refPicker: { - startingRef: "Réf de départ", - intoBase: "dans {{baseRef}}", - searching: "Recherche...", + startingRef: "Réf. de départ", + intoBase: "vers {{baseRef}}", + searching: "Recherche…", noMatchingRefs: "Aucune référence correspondante.", - searchPlaceholder: "Rechercher des succursales et des PR", - title: "Commencer à partir de", + searchPlaceholder: "Rechercher des branches et des PR", + title: "Partir de", }, launch: { - title: "What to launch", + title: "Que lancer ?", chat: "Chat", terminal: "Terminal", - manageProfiles: "Manage profiles", - submit: "Launch", - promptPlaceholder: "Prompt {{name}}", - commandPlaceholder: "Run a command, or leave empty for a blank terminal", + manageProfiles: "Gérer les profils", + submit: "Lancer", + promptPlaceholder: "Prompt pour {{name}}", + commandPlaceholder: "Saisissez une commande, ou laissez vide pour un terminal vierge", }, }, desktop: { @@ -1343,181 +1393,180 @@ export const fr: TranslationResources = { close: "Fermer la fenêtre", }, quitting: { - title: "QuitterPaseo...", - detail: "Arrêt du démon local.", + title: "Fermeture de Paseo…", + detail: "Arrêt du daemon local.", }, daemon: { lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Statut", - builtInOnly: "Seul le démon de bureau intégré est affiché ici", - running: "en cours d'exécution", - notRunning: "ne fonctionne pas", - pid: "PID{{pid}}", + builtInOnly: "Seul le daemon intégré à l’application de bureau est affiché ici", + running: "en cours d’exécution", + notRunning: "arrêté", + pid: "PID {{pid}}", }, management: { - title: "Gérer le démon intégré", - hint: "LaissezPaseodémarrer et arrêter le démon intégré", - pauseTitle: "Suspendre le démon intégré", + title: "Gérer le daemon intégré", + hint: "Laisser Paseo démarrer et arrêter le daemon intégré", + pauseTitle: "Suspendre le daemon intégré", pauseMessage: - "Cela arrêtera immédiatement le démon intégré. Les agents en cours d'exécution et les terminaux connectés au démon intégré seront arrêtés.", - pauseAndStop: "Pause et arrêt", + "Le daemon intégré sera arrêté immédiatement. Les agents en cours et les terminaux connectés au daemon intégré seront arrêtés.", + pauseAndStop: "Suspendre et arrêter", registrationFailed: - "Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.", + "Le daemon intégré a démarré, mais Paseo n’a pas pu enregistrer la connexion localhost. Désactivez puis réactivez la gestion du daemon, ou ajoutez localhost manuellement.", pausedStopFailed: - "La gestion du démon intégré a été suspendue, maisPaseon'a ​​pas pu arrêter le démon.", - updateFailed: "Impossible de mettre à jour la gestion des démons intégrés.", + "La gestion du daemon intégré a été suspendue, mais Paseo n’a pas pu arrêter le daemon.", + updateFailed: "Impossible de mettre à jour la gestion du daemon intégré.", }, keepRunning: { - title: "Laisser le démon fonctionner après avoir quitté", - hint: "Daemoncontinue de fonctionner lorsque vous quittezPaseo", + title: "Laisser le daemon tourner après la fermeture", + hint: "Le daemon continue de tourner quand vous quittez Paseo", }, logs: { title: "Fichier journal", - modalTitle: "JournauxDaemon", + modalTitle: "Journaux du daemon", unavailable: "Chemin du journal indisponible", empty: "(le fichier journal est vide)", copied: "Chemin du journal copié.", copyFailed: "Impossible de copier le chemin du journal.", - open: "Journaux ouverts", + open: "Ouvrir les journaux", copyPath: "Copier le chemin", }, fullStatus: { title: "Statut complet", - modalTitle: "StatutDaemon", - hint: "Exécute`paseo daemon status`et affiche la sortie", - view: "Afficher l'état", + modalTitle: "Statut du daemon", + hint: "Exécute `paseo daemon status` et affiche la sortie", + view: "Afficher le statut", copied: "Statut copié dans le presse-papiers.", - fetchFailed: "Échec de la récupération de l'état du démon:{{message}}", + fetchFailed: "Impossible de récupérer le statut du daemon : {{message}}", }, advancedSettings: "Paramètres avancés", - openAdvancedSettings: "Ouvrir les paramètres avancés du démon", + openAdvancedSettings: "Ouvrir les paramètres avancés du daemon", versionMismatch: - "Les versions de l'application et du démon ne correspondent pas. Mettez à jour les deux vers la même version pour une meilleure expérience.", - loadFailed: "Impossible de charger l'état du démon de bureau.", + "Les versions de l’application et du daemon ne correspondent pas. Mettez-les à jour vers la même version pour une meilleure expérience.", + loadFailed: "Impossible de charger le statut du daemon de l’application de bureau.", }, updates: { status: { - checking: "Vérification des mises à jour de l'application...", - installing: "Installation de la mise à jour de l'application...", - upToDate: "L'application est à jour.", - upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", - pending: "Nous vous informerons lorsque la mise à jour sera prête.", + checking: "Recherche de mises à jour de l’application…", + installing: "Installation de la mise à jour de l’application…", + upToDate: "L’application est à jour.", + upToDateWithLastChecked: "À jour. Dernière vérification à {{time}}.", + pending: "Nous vous préviendrons quand la mise à jour sera prête.", pendingWithLastChecked: - "Nous vous informerons lorsque la mise à jour sera prête. Dernière vérification à {{time}}.", - pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement...", + "Nous vous préviendrons quand la mise à jour sera prête. Dernière vérification à {{time}}.", + pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement…", pendingWithVersionAndLastChecked: - "Mise à jour trouvée : {{version}}. Téléchargement... Dernière vérification à {{time}}.", - availableWithVersion: "Mise à jour prête:{{version}}", + "Mise à jour trouvée : {{version}}. Téléchargement… Dernière vérification à {{time}}.", + availableWithVersion: "Mise à jour prête : {{version}}", availableWithVersionAndLastChecked: - "Mise à jour prête:{{version}}. Dernière vérification à {{time}}.", - available: "Une mise à jour de l'application est prête à être installée.", + "Mise à jour prête : {{version}}. Dernière vérification à {{time}}.", + available: "Une mise à jour de l’application est prête à être installée.", availableWithLastChecked: - "Une mise à jour de l'application est prête à être installée. Dernière vérification à {{time}}.", - installed: "Mise à jour de l'application installée. Redémarrage requis.", - failed: "Échec de la mise à jour de l'application.", - idle: "L'état de la mise à jour n'a pas encore été vérifié.", + "Une mise à jour de l’application est prête à être installée. Dernière vérification à {{time}}.", + installed: "Mise à jour de l’application installée. Redémarrage requis.", + failed: "Échec de la mise à jour de l’application.", + idle: "Les mises à jour n’ont pas encore été vérifiées.", }, - installError: "Impossible d'installer la mise à jour de l'application de bureau.", + installError: "Impossible d’installer la mise à jour de l’application de bureau.", callout: { installingTitle: "Installation de la mise à jour", - failedTitle: "La mise à jour a échoué", + failedTitle: "Échec de la mise à jour", availableTitle: "Mise à jour disponible", - genericError: "Quelque chose s'est mal passé.", - whatsNew: "Quoi de neuf", - installingAction: "Installation...", + genericError: "Une erreur s’est produite.", + whatsNew: "Nouveautés", + installingAction: "Installation…", installAndRestart: "Installer et redémarrer", - installingDescription: "Installation et redémarrage...", - versionReady: "{{version}}est prêt à être installé.", + installingDescription: "Installation et redémarrage…", + versionReady: "La version {{version}} est prête à être installée.", newVersionReady: "Une nouvelle version est prête à être installée.", restartWarning: - "La mise à niveau de l'application arrêtera l'exécution des agents et fermera les sessions de terminal.", + "La mise à jour de l’application arrêtera les agents en cours et fermera les sessions de terminal.", }, }, settings: { - loadFailed: "Impossible de charger les paramètres du bureau.", - saveFailed: "Impossible d'enregistrer les paramètres du bureau.", + loadFailed: "Impossible de charger les paramètres de l’application de bureau.", + saveFailed: "Impossible d’enregistrer les paramètres de l’application de bureau.", }, rosetta: { - title: "Téléchargez la versionApple Silicon", - runningIntel: "Vous exécutez la versionInteldePaseosousRosettasurApple Silicon.", + title: "Téléchargez la version Apple Silicon", + runningIntel: "Vous utilisez la version Intel de Paseo via Rosetta sur Apple Silicon.", highCpu: - "Cela entraîne une utilisation élevée du processeur. Téléchargez la versionApple Siliconpour le réparer.", + "Cela entraîne une forte utilisation du processeur. Téléchargez la version Apple Silicon pour corriger le problème.", download: "Télécharger", }, permissions: { notifications: { - allowed: "Les notifications sont autorisées par le système d'exploitation.", + allowed: "Les notifications sont autorisées par le système.", denied: "Les notifications sont refusées dans les paramètres système.", - notGranted: "Les notifications n'ont pas encore été accordées.", + notGranted: "Les notifications n’ont pas encore été autorisées.", webOnly: - "L’état des notifications sur le bureau est uniquement disponible sur l’exécution Web.", + "L’état des notifications de bureau n’est disponible que dans l’environnement web.", supported: "Les notifications de bureau sont prises en charge.", unsupported: "Les notifications de bureau ne sont pas prises en charge sur cette plateforme.", - apiUnavailable: "L'API de notification Web n'est pas disponible dans cet environnement.", + apiUnavailable: "L’API Web Notification n’est pas disponible dans cet environnement.", requestsWebOnly: - "Les demandes de notification sur le bureau sont uniquement disponibles sur l'exécution Web.", - requestUnavailable: "L'API de notification Web requestPermission() n'est pas disponible.", - requestFailed: "Échec de la demande d'autorisation de notification:{{message}}", - unexpectedState: "État d'autorisation de notification inattendu:{{state}}", + "Les demandes de notification de bureau ne sont disponibles que dans l’environnement web.", + requestUnavailable: + "La méthode requestPermission() de l’API Web Notification n’est pas disponible.", + requestFailed: "Échec de la demande d’autorisation de notification : {{message}}", + unexpectedState: "État d’autorisation de notification inattendu : {{state}}", }, microphone: { - webOnly: - "L’état du microphone de bureau est uniquement disponible dans l’environnement d’exécution Web.", - navigatorUnavailable: "Le navigateur n'est pas disponible dans cet environnement.", - granted: "L’accès au microphone est accordé.", - denied: "L'accès au microphone est refusé dans les paramètres système.", - notGranted: "L'autorisation du microphone n'a pas encore été accordée.", - unexpectedState: "État d'autorisation inattendu du microphone:{{state}}", + webOnly: "L’état du microphone n’est disponible que dans l’environnement web.", + navigatorUnavailable: "L’objet navigator n’est pas disponible dans cet environnement.", + granted: "L’accès au microphone est autorisé.", + denied: "L’accès au microphone est refusé dans les paramètres système.", + notGranted: "L’accès au microphone n’a pas encore été autorisé.", + unexpectedState: "État d’autorisation du microphone inattendu : {{state}}", statusApiUnavailable: - "L’API d’état du microphone n’est pas disponible dans ce runtime. Utilisez Request pour vérifier l’accès.", - queryFailed: "Échec de l'interrogation de l'état du microphone:{{message}}", - captureUnavailable: "La capture du microphone n'est pas disponible dans cet environnement.", + "L’API d’état du microphone n’est pas disponible dans cet environnement. Utilisez « Demander » pour vérifier l’accès.", + queryFailed: "Impossible de consulter l’état du microphone : {{message}}", + captureUnavailable: "La capture du microphone n’est pas disponible dans cet environnement.", permissionApiUnavailable: - "L'API d'état des autorisations n'est pas disponible. Utilisez Request pour vérifier l’accès.", + "L’API d’état des autorisations n’est pas disponible. Utilisez « Demander » pour vérifier l’accès.", requestsWebOnly: - "Les demandes de microphone de bureau sont uniquement disponibles sur le runtime Web.", + "Les demandes d’accès au microphone ne sont disponibles que dans l’environnement web.", captureApiUnavailable: - "L'API de capture de microphone n'est pas disponible dans cet environnement.", - requestDenied: - "L'autorisation du microphone a été refusée par l'utilisateur ou le système.", - noDevice: "Aucun microphone n'a été trouvé.", - requestFailed: "Échec de la demande d'autorisation du microphone:{{message}}", + "L’API de capture du microphone n’est pas disponible dans cet environnement.", + requestDenied: "L’accès au microphone a été refusé par l’utilisateur ou le système.", + noDevice: "Aucun microphone trouvé.", + requestFailed: "Échec de la demande d’accès au microphone : {{message}}", }, empty: { - notifications: "L'état de la notification n'a pas encore été vérifié.", - microphone: "L'état du microphone n'a pas encore été vérifié.", + notifications: "L’état des notifications n’a pas encore été vérifié.", + microphone: "L’état du microphone n’a pas encore été vérifié.", }, testNotification: { - title: "Test de notificationPaseo", - body: "Si vous pouvez voir cela, les notifications sur le bureau fonctionnent.", + title: "Test de notification Paseo", + body: "Si vous voyez ceci, les notifications de bureau fonctionnent.", notDelivered: - "La notification n'a pas été délivrée. Vérifiez Paramètres système > Notifications.", - failed: "Échec de l'envoi de la notification.", + "La notification n’a pas été délivrée. Vérifiez Réglages système > Notifications.", + failed: "Échec de l’envoi de la notification.", }, }, integrations: { cli: { - statusFailed: "Impossible de vérifier l'état de l'installation deCLI.", - installFailed: "Impossible d'installer lePaseoCLI.", + statusFailed: "Impossible de vérifier l’installation de la CLI.", + installFailed: "Impossible d’installer la CLI Paseo.", }, }, }, rootError: { title: "Paseo a rencontré un problème.", - body: "Réessayez pour recharger l'application. Si cela continue, joignez les détails ci-dessous au signalement.", + body: "Réessayez pour recharger l’application. Si le problème persiste, joignez les détails ci-dessous à votre signalement.", details: "Détails", }, startup: { - errorTitle: "Quelque chose s'est mal passé", + errorTitle: "Une erreur s’est produite", errorDescription: - "Le serveur local n'a pas pu démarrer. Si cela continue, veuillez signaler le problème surGitHubet inclure les journaux ci-dessous.", + "Le serveur local n’a pas pu démarrer. Si le problème persiste, signalez-le sur GitHub en joignant les journaux ci-dessous.", logs: { - loading: "Chargement des journaux du démon...", - unavailable: "Aucun journal de démon disponible.", - loadFailed: "Impossible de charger les journaux du démon:{{message}}", + loading: "Chargement des journaux du daemon…", + unavailable: "Aucun journal du daemon disponible.", + loadFailed: "Impossible de charger les journaux du daemon : {{message}}", }, }, openProject: { @@ -1527,24 +1576,24 @@ export const fr: TranslationResources = { description: "Ouvrez un dossier sur votre machine", }, importSession: { - title: "Session d'importation", - description: "Apportez des sessionsCLIexternes récentes", + title: "Importer une session", + description: "Ouvrez une session Claude Code, Codex ou autre lancée dans un terminal", }, setupProviders: { - title: "Fournisseurs d'installation", - description: "ConfigurezClaude Code,Codexet plus", + title: "Configurer les fournisseurs", + description: "Configurez Claude Code, Codex et plus encore", }, pairDevice: { title: "Associer un appareil", - description: "Connectez votre téléphone à ce démon", + description: "Connectez votre téléphone à ce daemon", }, }, }, projectPicker: { - placeholder: "Tapez pour rechercher...", + placeholder: "Tapez pour rechercher…", browse: "Parcourir…", - opening: "Projet d'ouverture...", - searching: "Recherche en cours...", + opening: "Ouverture du projet…", + searching: "Recherche…", empty: "Commencez à taper un chemin", errors: { directory_not_found: "Répertoire introuvable.", @@ -1554,76 +1603,77 @@ export const fr: TranslationResources = { }, branchSwitcher: { triggerTooltip: "Changer la branche de l’espace de travail", - currentBranch: "Branche actuelle:{{branchName}}. Appuyez pour changer de branche.", - placeholder: "Changer de branche...", - searchPlaceholder: "Filtrer les branches...", + currentBranch: "Branche actuelle : {{branchName}}. Appuyez pour changer de branche.", + placeholder: "Changer de branche…", + searchPlaceholder: "Filtrer les branches…", empty: "Aucune branche trouvée.", title: "Changer de branche", - uncommittedTitle: "Modifications non validées", + uncommittedTitle: "Modifications non commitées", uncommittedMessage: - "Vous avez des modifications non validées. Les ranger avant de changer de branche?", - stashAndSwitch: "Cachette et changement", - failedToStash: "Échec de la sauvegarde des modifications", - failedToSwitch: "Échec du changement de branche", - restoreStashTitle: "Restaurer les modifications cachées?", + "Vous avez des modifications non commitées. Les remiser avant de changer de branche ?", + stashAndSwitch: "Remiser et changer", + failedToStash: "Impossible de remiser les modifications", + failedToSwitch: "Impossible de changer de branche", + restoreStashTitle: "Restaurer les modifications remisées ?", restoreStashMessage: - "Cette branche a caché les modifications d'une session précédente. Souhaitez-vous les restaurer?", + "Cette branche contient des modifications remisées lors d’une session précédente. Voulez-vous les restaurer ?", restore: "Restaurer", later: "Plus tard", - stashRestored: "Modifications cachées restaurées", + stashRestored: "Modifications remisées restaurées", }, agentAutocomplete: { - searchingWorkspace: "Recherche dans l'espace de travail...", - loadingCommands: "Chargement des commandes...", + searchingWorkspace: "Recherche dans l’espace de travail…", + loadingCommands: "Chargement des commandes…", noFiles: "Aucun fichier ou répertoire trouvé", noCommands: "Aucune commande trouvée", failedToLoad: "Échec du chargement", - chooseProjectForCommands: "Choisissez un projet pour voir les commandes", - chooseModelForCommands: "Sélectionnez un modèle pour voir les commandes", + chooseProjectForCommands: "Choisissez un projet pour afficher les commandes", + chooseModelForCommands: "Sélectionnez un modèle pour afficher les commandes", }, loadOlderHistory: { - failed: "Impossible de charger l'ancien historique", + failed: "Impossible de charger l’historique plus ancien", }, imageAttachmentPicker: { permissionTitle: "Autorisation requise", - permissionMessage: "Veuillez autoriser l'accès à votre photothèque pour joindre des images.", + permissionMessage: "Autorisez l’accès à votre photothèque pour joindre des images.", errorTitle: "Erreur", - failedToSelect: "Échec de la sélection de l'image", + failedToSelect: "Impossible de sélectionner l’image", dialogTitle: "Joindre des images", dialogFilterName: "Images", }, workspaceSetup: { title: "Créer un espace de travail", errors: { - failedCreateWorktree: "Échec de la création de l'arbre de travail", - failedOpenProject: "Échec de l'ouverture du projet", + failedCreateWorktree: "Impossible de créer le worktree", + failedOpenProject: "Impossible d’ouvrir le projet", selectModel: "Sélectionnez un modèle", - hostDisconnected: "Hostn'est pas connecté", - pendingRequired: "Aucune configuration d'espace de travail n'est en attente", - composerStateRequired: "L'état du compositeur de configurationWorkspaceest requis", + hostDisconnected: "L’hôte n’est pas connecté", + pendingRequired: "Aucune configuration d’espace de travail en attente", + composerStateRequired: + "L’état de la zone de saisie de configuration de l’espace de travail est requis", }, }, onboarding: { - title: "Bienvenue surPaseo", + title: "Bienvenue dans Paseo", subtitle: "Connectez votre ordinateur pour commencer", actions: { settings: "Paramètres", }, }, modelSelector: { - title: "Sélectionnez le fournisseur", - selectModel: "Sélectionnez le modèle", - selectedModel: "Sélectionnez le modèle ({{model}})", - loading: "Chargement...", + title: "Sélectionner le fournisseur", + selectModel: "Sélectionner le modèle", + selectedModel: "Sélectionner le modèle ({{model}})", + loading: "Chargement…", loadingShort: "Chargement", - loadingSelector: "Chargement du sélecteur de modèle...", + loadingSelector: "Chargement du sélecteur de modèle…", error: "Erreur", - defaultModel: "Défaut", + defaultModel: "Par défaut", profiles: "Profils", providers: "Fournisseurs", model: "Modèle", editProfiles: "Modifier", - editProfilesLabel: "Modifier les profils d'agent", + editProfilesLabel: "Modifier les profils d’agent", createProfile: "Créer un profil", createProfileFromModel: "Créer un profil à partir de ce modèle", editProfileLabel: "Modifier {{name}}", @@ -1631,43 +1681,43 @@ export const fr: TranslationResources = { modelCount: "{{count}} modèle", modelCountPlural: "{{count}} modèles", retry: "Réessayer", - retrying: "Nouvelle tentative...", + 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}}", + noMatchesForQuery: "Aucun modèle ne correspond à « {{query}} »", + searchAllPlaceholder: "Rechercher dans tous les modèles…", + searchPlaceholder: "Rechercher des modèles…", + openProviderSettings: "Ouvrir les paramètres de {{provider}}", }, providerCatalog: { title: "Ajouter un fournisseur", - search: "Fournisseurs de recherche", + search: "Rechercher des fournisseurs", noProviders: "Aucun fournisseur trouvé", actions: { add: "Ajouter", - adding: "Ajout", + adding: "Ajout…", installed: "Installé", cancel: "Annuler", - installInstructions: "Instructions d'installation", - installInstructionsFor: "Instructions d'installation{{provider}}", + installInstructions: "Instructions d’installation", + installInstructionsFor: "Instructions d’installation de {{provider}}", }, errors: { - unableToInstall: "Impossible d'installer le fournisseur", + unableToInstall: "Impossible d’installer le fournisseur", }, }, providerSelection: { - defaultModel: "Défaut", - selectModel: "Sélectionnez le modèle", - loading: "Chargement...", + defaultModel: "Par défaut", + selectModel: "Sélectionner le modèle", + loading: "Chargement…", error: "Erreur", unavailable: "Indisponible", unknownError: "Erreur inconnue", readiness: { - initialPromptRequired: "Une invite initiale est requise", - noProviders: "Aucun fournisseur disponible sur l'hébergeur sélectionné", - modelDefaultsLoading: "Les valeurs par défaut du modèle sont toujours en cours de chargement", - noModelAvailable: "Aucun modèle n'est disponible pour le fournisseur sélectionné", - workspaceDirectoryNotFound: "RépertoireWorkspaceintrouvable", - hostDisconnected: "Hostn'est pas connecté", + initialPromptRequired: "Un prompt initial est requis", + noProviders: "Aucun fournisseur disponible sur l’hôte sélectionné", + modelDefaultsLoading: "Les valeurs par défaut du modèle sont en cours de chargement", + noModelAvailable: "Aucun modèle disponible pour le fournisseur sélectionné", + workspaceDirectoryNotFound: "Répertoire de l’espace de travail introuvable", + hostDisconnected: "L’hôte n’est pas connecté", }, }, pairing: { @@ -1676,11 +1726,11 @@ export const fr: TranslationResources = { label: "Mot de passe de l’hôte", }, hostConfirmation: { - title: "Se connecter à cet hôte ?", + title: "Se connecter à cet hôte ?", description: - "Cet hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.", + "Cet hôte pourra exécuter du code dans cette app et accéder à vos autres hôtes connectés. Ne vous connectez que si vous le reconnaissez.", descriptionChanged: - "Ce lien modifie la façon dont vous vous connectez à cet hôte. L'hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.", + "Ce lien modifie la façon dont vous vous connectez à cet hôte. L’hôte pourra exécuter du code dans cette app et accéder à vos autres hôtes connectés. Ne vous connectez que si vous le reconnaissez.", hostLabel: "Hôte", fingerprintLabel: "Empreinte de la clé", relayLabel: "Relais", @@ -1694,22 +1744,22 @@ export const fr: TranslationResources = { }, remoteSsh: { title: "SSH distant", - description: "Connectez-vous avec le client SSH de bureau.", + description: "Connexion via le client SSH de bureau.", }, scanQr: { - title: "Scanner le codeQR", - description: "Connexion relais cryptée.", + title: "Scanner le code QR", + description: "Connexion chiffrée via le relais.", }, pasteLink: { - title: "Coller le lien d'association", - description: "Connexion relais cryptée.", + title: "Coller le lien d’association", + description: "Connexion chiffrée via le relais.", }, }, direct: { title: "Connexion directe", - helper: "Saisissez l'adresse d'un serveurPaseo.", + helper: "Saisissez l’adresse d’un serveur Paseo.", fields: { - host: "Host", + host: "Hôte", port: "Port", password: "Mot de passe", optional: "Facultatif", @@ -1717,9 +1767,9 @@ export const fr: TranslationResources = { connectionUri: "URI de connexion", }, advanced: { - label: "Avancé", - show: "Afficher avancé", - hide: "Masquer avancé", + label: "Options avancées", + show: "Afficher les options avancées", + hide: "Masquer les options avancées", }, passwordVisibility: { show: "Afficher le mot de passe", @@ -1727,33 +1777,33 @@ export const fr: TranslationResources = { }, actions: { cancel: "Annuler", - connect: "Connecter", - connecting: "De liaison...", + connect: "Se connecter", + connecting: "Connexion…", }, errors: { - hostRequired: "Hostest requis", + hostRequired: "L’hôte est requis", invalidPort: "Le port doit être compris entre 1 et 65535", invalidConnection: "Connexion invalide", - failedTitle: "La connexion a échoué", - failedToConnect: "Nous n'avons pas réussi à nous connecter à{{endpoint}}.", - noAdditionalDetails: "{{detail}}(aucun détail supplémentaire fourni)", - timedOut: "La connexion a expiré. Vérifiez l'hôte/portet votre réseau.", - refused: "Connexion rejetée. Le serveur fonctionne-t-il à cette adresse?", - hostNotFound: "Hostintrouvable. Vérifiez le nom d'hôte et réessayez.", - hostUnreachable: "Hostest inaccessible. Vérifiez votre réseau et votre pare-feu.", + failedTitle: "Échec de la connexion", + failedToConnect: "Impossible de se connecter à {{endpoint}}.", + noAdditionalDetails: "{{detail}} (aucun détail supplémentaire)", + timedOut: "Délai de connexion dépassé. Vérifiez l’hôte, le port et votre réseau.", + refused: "Connexion refusée. Le serveur est-il bien lancé à cette adresse ?", + hostNotFound: "Hôte introuvable. Vérifiez le nom d’hôte et réessayez.", + hostUnreachable: "Hôte inaccessible. Vérifiez votre réseau et votre pare-feu.", tlsError: - "Erreur TLS. Les connexions directes utilisent SSL uniquement lorsqu'un terminateur TLS se trouve devant le démon.", + "Erreur TLS. Les connexions directes n’utilisent SSL que si un terminateur TLS est placé devant le daemon.", unableToConnect: - "Impossible de se connecter. Vérifiez l'hôte/portet que le démon est accessible.", - details: "Détails:{{detail}}", + "Impossible de se connecter. Vérifiez l’hôte, le port et que le daemon est joignable.", + details: "Détails : {{detail}}", }, }, remoteSsh: { title: "SSH distant", - helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.", + helper: "Connectez-vous à un daemon Paseo qui tourne sur l’hôte distant.", fields: { target: "Hôte SSH", - password: "Mot de passe du démon", + password: "Mot de passe du daemon", optional: "Facultatif", }, passwordVisibility: { @@ -1762,72 +1812,72 @@ export const fr: TranslationResources = { }, actions: { cancel: "Annuler", - connect: "Connecter", - connecting: "Connexion...", + connect: "Se connecter", + connecting: "Connexion…", }, errors: { targetRequired: "L’hôte SSH est requis", invalidTarget: "Saisissez un hôte ssh:// valide", - failedToConnect: "Connexion SSH impossible. {{detail}}", + failedToConnect: "Impossible de se connecter en SSH. {{detail}}", }, }, link: { - title: "Coller le lien d'association", - helper: "Collez le lien d'appairage depuis votre serveur.", - label: "Lien d'appariement", + title: "Coller le lien d’association", + helper: "Collez le lien d’association fourni par votre serveur.", + label: "Lien d’association", errors: { - required: "Collez un lien d'appairage (.../#offer=...)", - missingOffer: "Le lien doit inclure#offer=...", - emptyOffer: "La charge utile de l'offre est vide", - invalid: "Lien d'association invalide", - unableToPair: "Impossible de coupler l'hôte", + required: "Collez un lien d’association (.../#offer=...)", + missingOffer: "Le lien doit contenir #offer=...", + emptyOffer: "Le contenu de l’offre est vide", + invalid: "Lien d’association invalide", + unableToPair: "Impossible d’associer l’hôte", }, alert: { - failedTitle: "Échec du couplage", + failedTitle: "Échec de l’association", }, actions: { cancel: "Annuler", - pair: "Paire", - pairing: "L'appariement...", + pair: "Associer", + pairing: "Association…", }, }, scan: { - title: "ScannerQR", - webUnavailableTitle: "Non disponible sur le Web", + title: "Scanner le code QR", + webUnavailableTitle: "Indisponible sur le web", webUnavailableBody: - "L'analyseQRn'est pas prise en charge dans la version Web. Utilisez plutôt \"Coller le lien\".", + "Le scan de code QR n’est pas pris en charge dans la version web. Utilisez plutôt « Coller le lien ».", backToSettings: "Retour aux paramètres", cameraPermissionTitle: "Autorisation de la caméra", cameraPermissionBody: - "Autorisez l'accès à la caméra pour scanner le code d'appairageQRà partir de votre démon.", - grantPermission: "Accorder l'autorisation", - pairing: "L'appariement...", - unableToPair: "Impossible de coupler l'hôte", + "Autorisez l’accès à la caméra pour scanner le code QR d’association affiché par votre daemon.", + grantPermission: "Accorder l’autorisation", + pairing: "Association…", + unableToPair: "Impossible d’associer l’hôte", errorTitle: "Erreur", }, device: { - loadingOffer: "Chargement de l'offre d'association...", - failedToLoadOffer: "Échec du chargement de l'offre d'association.", - relayDisabled: "Le relais n'est pas activé. Activer le relais pour coupler un appareil.", - enableTitle: "Activer le relais ?", + loadingOffer: "Chargement de l’offre d’association…", + failedToLoadOffer: "Impossible de charger l’offre d’association.", + relayDisabled: "Le relais n’est pas activé. Activez-le pour associer un appareil.", + enableTitle: "Activer le relais ?", enableDescription: - "Le relais permet à cet appareil de se connecter depuis n’importe où. Le trafic de couplage est chiffré de bout en bout.", + "Le relais permet à cet appareil de se connecter depuis n’importe où. Le trafic d’association est chiffré de bout en bout.", relayDocs: "Fonctionnement du relais", - relayDocsAccessibility: "Lire comment fonctionne le relais Paseo", + relayDocsAccessibility: "Découvrir le fonctionnement du relais Paseo", enableRelay: "Activer le relais", - enablingRelay: "Activation...", + enablingRelay: "Activation…", notNow: "Pas maintenant", directConnectionHint: "Sans relais, connectez-vous directement via TCP, Tailscale ou un autre VPN. Aucun code QR n’est créé.", updateRequired: "Mettez à jour l’hôte pour activer le relais depuis Paseo Desktop.", - unavailable: "Offre de jumelage indisponible.", - hint: "Scannez ce codeQRavecPaseosur votre téléphone ou copiez le lien ci-dessous.", + unavailable: "Offre d’association indisponible.", + hint: "Scannez ce code QR avec Paseo sur votre téléphone, ou copiez le lien ci-dessous.", securityWarning: "Traitez ce lien d’association comme un mot de passe. Toute personne qui le possède peut accéder à ce daemon.", - qrUnavailable: "CodeQRindisponible.", - qrAccessibility: "Code QR de couplage", + qrUnavailable: "Code QR indisponible.", + qrAccessibility: "Code QR d’association", retry: "Réessayer", - copy: "Copie", + copy: "Copier", copied: "Copié", }, }, @@ -1835,41 +1885,41 @@ export const fr: TranslationResources = { actions: { mute: "Couper la voix en temps réel", unmute: "Réactiver la voix en temps réel", - stop: "Arrêtez la voix en temps réel et interrompez le tour", + stop: "Arrêter la voix en temps réel et interrompre le tour", }, }, rewind: { - tooltip: "Revenez à ce message", - warning: "Cette action ne peut pas être annulée", + tooltip: "Revenir à ce message", + warning: "Cette action est irréversible", actions: { - conversation: "Rembobiner la conversation", - files: "Rembobiner les fichiers", - both: "Rembobiner la conversation et les fichiers", + conversation: "Revenir en arrière sur la conversation", + files: "Revenir en arrière sur les fichiers", + both: "Revenir en arrière sur la conversation et les fichiers", }, errors: { - failed: "Échec du rembobinage de l'agent", + failed: "Impossible de faire revenir l’agent en arrière", }, }, diffViewer: { - empty: "Aucun changement à afficher", + empty: "Aucune modification à afficher", }, serviceUrl: { - title: "Service ouvertURL", - message: "Ouvrir{{url}}?", - inPaseo: "DansPaseo", + title: "Ouvrir l’URL du service", + message: "Ouvrir {{url}} ?", + inPaseo: "Dans Paseo", externalBrowser: "Navigateur externe", - dontAskAgain: "Ne demande plus", + dontAskAgain: "Ne plus demander", }, downloads: { - requestTokenFailed: "Échec de la demande du jeton de téléchargement.", - hostUnavailable: "L'hôte de téléchargement n'est pas disponible.", - cancelled: "Le téléchargement a été annulé.", - failed: "Échec du téléchargement du fichier.", - shareFile: "Partager un fichier", - shareFileNamed: "Partager{{fileName}}", + requestTokenFailed: "Impossible d’obtenir le token de téléchargement.", + hostUnavailable: "L’hôte de téléchargement est indisponible.", + cancelled: "Téléchargement annulé.", + failed: "Impossible de télécharger le fichier.", + shareFile: "Partager le fichier", + shareFileNamed: "Partager {{fileName}}", }, menu: { - backdrop: "Toile de fond du menu", + backdrop: "Arrière-plan du menu", }, subagents: { title: "Sous-agents", @@ -1880,32 +1930,32 @@ export const fr: TranslationResources = { pillLabelNeedsInputOne: "1 attend une réponse", pillLabelNeedsInputMany: "{{count}} attendent une réponse", pillLabelReadyToReview: "{{count}} à relire", - detachAction: "Detacher {{label}}", - detachTooltip: "Detacher le sous-agent", - archiveAction: "Archiver{{label}}", - archiveTooltip: "Sous-agent d'archivage", + detachAction: "Détacher {{label}}", + detachTooltip: "Détacher le sous-agent", + archiveAction: "Archiver {{label}}", + archiveTooltip: "Archiver le sous-agent", archiveFinishedAction: "Archiver les sous-agents terminés", archiveFinishedRetry: "Réessayer ({{failed}}/{{total}})", }, panels: { draft: { newAgent: "Nouvel agent", - creatingAgent: "Agent créateur", + creatingAgent: "Création de l’agent", }, file: { - directoryMissing: "Répertoire Workspace introuvable.", - loading: "Chargement du fichier...", + directoryMissing: "Répertoire de l’espace de travail introuvable.", + loading: "Chargement du fichier…", noPreview: "Aucun aperçu disponible", - binaryPreviewUnavailable: "Aperçu binaire indisponible", + binaryPreviewUnavailable: "Aperçu indisponible pour un fichier binaire", tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché", - failedToLoad: "Échec du chargement du fichier", - failedToLoadPreview: "Échec du chargement de l'aperçu du fichier", + failedToLoad: "Impossible de charger le fichier", + failedToLoadPreview: "Impossible de charger l’aperçu du fichier", editor: { - fileSize: "Taille {{size}}", + fileSize: "Taille du fichier : {{size}}", lines: "{{count}} lignes", - editorStatus: "État de l’éditeur : {{status}}", + editorStatus: "État de l’éditeur : {{status}}", unsavedChanges: "Modifications non enregistrées", - saving: "Enregistrement...", + saving: "Enregistrement…", saveFailed: "Échec de l’enregistrement", changedOnDisk: "Modifié sur le disque", vimMode: "Mode Vim {{mode}}", @@ -1918,7 +1968,7 @@ export const fr: TranslationResources = { conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.", overwrite: "Écraser", reload: "Recharger", - reloadTitle: "Recharger depuis le disque ?", + reloadTitle: "Recharger depuis le disque ?", reloadMessage: "Vos modifications locales seront perdues.", }, }, @@ -1929,108 +1979,108 @@ export const fr: TranslationResources = { chooseFile: "Choisissez un fichier", }, pullRequest: { - label: "Demande de fusion", - subtitle: "Détails de la demande de fusion", - emptyTitle: "Aucune demande de fusion pour le moment", + label: "Pull request", + subtitle: "Détails de la pull request", + emptyTitle: "Aucune pull request pour l’instant", emptyDescription: - "Créez une demande de fusion pour cette copie de travail afin d’afficher ses détails ici.", + "Créez une pull request pour cette copie de travail afin d’en voir les détails ici.", }, diff: { changesLabel: "Modifications", diffLabel: "Diff", - changesSubtitle: "Différences de l'arbre de travail", - commitSubtitle: "Différences du commit", - uncommittedSubtitle: "Modifications non validées", + changesSubtitle: "Diff de l’arbre de travail", + commitSubtitle: "Diff du commit", + uncommittedSubtitle: "Modifications non commitées", baseSubtitle: "Comparé à {{baseRef}}", - directoryMissing: "Répertoire Workspace introuvable.", + directoryMissing: "Répertoire de l’espace de travail introuvable.", empty: "Aucune modification", - loadError: "Échec du chargement des différences", - capabilityMissing: "Mettez à jour l'hôte pour voir les différences des commits.", + loadError: "Impossible de charger le diff", + capabilityMissing: "Mettez à jour l’hôte pour afficher le diff des commits.", }, }, toolCallDetails: { error: "Erreur", empty: "Aucun détail supplémentaire disponible", subAgentActivity: "Activité du sous-agent", - input: "Saisir", - output: "Sortir", + input: "Entrée", + output: "Sortie", }, toolCallGroup: { editedFiles: { - one: "a modifié {{count}} fichier", - other: "a modifié {{count}} fichiers", + one: "{{count}} fichier modifié", + other: "{{count}} fichiers modifiés", }, commands: { - one: "a exécuté {{count}} commande", - other: "a exécuté {{count}} commandes", + one: "{{count}} commande exécutée", + other: "{{count}} commandes exécutées", }, readFiles: { - one: "a lu {{count}} fichier", - other: "a lu {{count}} fichiers", + one: "{{count}} fichier lu", + other: "{{count}} fichiers lus", }, searches: { - one: "a effectué {{count}} recherche", - other: "a effectué {{count}} recherches", + one: "{{count}} recherche effectuée", + other: "{{count}} recherches effectuées", }, otherTools: { - one: "a utilisé {{count}} autre outil", - other: "a utilisé {{count}} autres outils", + one: "{{count}} autre outil utilisé", + other: "{{count}} autres outils utilisés", }, paseoCalls: { - one: "a appelé Paseo {{count}} fois", - other: "a appelé Paseo {{count}} fois", + one: "{{count}} appel à Paseo", + other: "{{count}} appels à Paseo", }, and: "et", }, renameModal: { - rename: "Rebaptiser", - saving: "Sauvegarde...", + rename: "Renommer", + saving: "Enregistrement…", }, sidebarCallout: { - dismiss: "Rejeter", + dismiss: "Ignorer", }, contextWindow: { noData: "Aucune donnée de contexte", - accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte", - title: "Fenêtre contextuelle", - used: "{{percentage}}% utilisé", - tokens: "Jetons{{used}}/{{max}}", - sessionCost: "Coût de la séance{{cost}}", - accessibility: "Fenêtre contextuelle{{percentage}}% utilisé", + accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte", + title: "Fenêtre de contexte", + used: "{{percentage}} % utilisés", + tokens: "{{used}} / {{max}} tokens", + sessionCost: "Coût de la session : {{cost}}", + accessibility: "Fenêtre de contexte : {{percentage}} % utilisés", }, review: { comment: { - add: "Ajouter un commentaire", - edit: "Modifier le commentaire de l'avis", - delete: "Supprimer le commentaire de l'avis", - label: "Revoir le commentaire", + add: "Ajouter un commentaire de revue", + edit: "Modifier le commentaire de revue", + delete: "Supprimer le commentaire de revue", + label: "Commentaire de revue", placeholder: "Laisser un commentaire", cancel: "Annuler", - cancelAccessibility: "Annuler le commentaire de révision", - save: "Commentaire", - saveAccessibility: "Enregistrer le commentaire de l'avis", + cancelAccessibility: "Annuler le commentaire de revue", + save: "Commenter", + saveAccessibility: "Enregistrer le commentaire de revue", }, }, settings: { title: "Paramètres", - loading: "Chargement des paramètres...", + loading: "Chargement des paramètres…", groups: { app: "Application", - host: "Host", + host: "Hôte", }, hostPicker: { - switchHost: "Changer d'hôte", - local: "Locale", + switchHost: "Changer d’hôte", + local: "Local", }, - backToWorkspace: "Dos", + backToWorkspace: "Retour", addHost: "Ajouter un hôte", - enableBuiltInDaemon: "Activer le démon intégré", + enableBuiltInDaemon: "Activer le daemon intégré", projects: "Projets", projectList: { - hostLoadFailed: "Impossible de charger les projets depuis l'hôte{{hostName}}:{{message}}", - editProject: "Modifier{{projectName}}", + hostLoadFailed: "Impossible de charger les projets depuis l’hôte {{hostName}} : {{message}}", + editProject: "Modifier {{projectName}}", }, - groupInfo: "À propos de{{title}}", + groupInfo: "À propos de {{title}}", sections: { general: "Général", chat: "Discussion", @@ -2050,32 +2100,32 @@ export const fr: TranslationResources = { editor: { title: "Éditeur", vimKeybindings: "Raccourcis Vim", - vimHint: "S’applique aux fichiers source sur le web et le bureau.", + vimHint: "S’applique aux fichiers source dans l’app web et l’app de bureau.", }, notifications: { title: "Notifications", permission: "Autorisation des notifications", refreshAccessibility: "Actualiser l’autorisation des notifications", playSound: "Émettre un son", - playSoundHint: "Émet un son lorsqu’une notification de bureau arrive", + playSoundHint: "Émet un son à l’arrivée d’une notification de bureau", test: "Tester la notification", testHint: "Envoie une notification avec ces réglages", - permissionRequired: "Autorisez l’accès aux notifications avant le test", + permissionRequired: "Autorisez les notifications avant de tester", send: "Envoyer", - sending: "Envoi...", + sending: "Envoi…", sentTitle: "Notification de test envoyée", sentDescription: "Paseo a transmis la notification au système d’exploitation.", sendFailedTitle: "Impossible d’envoyer la notification de test", }, hostSections: { projects: "Projets", - connections: "Relations", + connections: "Connexions", agents: "Agents", metadata: "Métadonnées", - workspaces: "Workspaces", + workspaces: "Espaces de travail", providers: "Fournisseurs", usage: "Utilisation", - terminals: "Terminals", + terminals: "Terminaux", plugins: "Plugins", host: "Aperçu", }, @@ -2103,43 +2153,43 @@ export const fr: TranslationResources = { description: "Les onglets du navigateur partagent les connexions et les données des sites dans Paseo.", clear: "Effacer les données du navigateur", - clearing: "Effacement...", - confirmTitle: "Effacer les données du navigateur ?", + clearing: "Effacement…", + confirmTitle: "Effacer les données du navigateur ?", confirmMessage: "Vous serez déconnecté des sites et les onglets ouverts seront rechargés.", success: "Données du navigateur effacées.", - error: "Impossible d'effacer les données du navigateur.", + error: "Impossible d’effacer les données du navigateur.", }, defaultSend: { label: "Envoi par défaut", descriptions: { interrupt: - "Lorsque l'agent est en cours d'exécution, Entrée interrompt. Commande/Ctrl+Entrée met en file d'attente.", + "Quand l’agent est en cours d’exécution, Entrée l’interrompt. Cmd/Ctrl+Entrée met en file d’attente.", steer: - "Lorsque l’agent est en cours d’exécution, Entrée oriente le tour actif. Commande/Ctrl+Entrée met en file d’attente.", + "Quand l’agent est en cours d’exécution, Entrée réoriente le tour en cours. Cmd/Ctrl+Entrée met en file d’attente.", queue: - "Lorsque l'agent est en cours d'exécution, Entrée met en file d'attente. Commande/Ctrl+Entrée envoie.", + "Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.", }, options: { interrupt: "Interrompre", - steer: "Orienter", - queue: "File d'attente", + steer: "Réorienter", + queue: "File d’attente", }, }, serviceUrls: { options: { ask: "Demander", - inApp: "DansPaseo", + inApp: "Dans Paseo", external: "Navigateur externe", }, }, terminalScrollback: { - label: "DéfilementTerminal", + label: "Historique du terminal", description: "Lignes conservées dans le tampon du terminal intégré", - accessibilityLabel: "Lignes de défilementTerminal", + accessibilityLabel: "Lignes d’historique du terminal", }, autoExpandReasoning: { label: "Toujours afficher le raisonnement", - description: "Afficher le raisonnement de l'agent entièrement développé par défaut", + description: "Afficher par défaut le raisonnement de l’agent entièrement déplié", }, toolCallDetail: { label: "Affichage des appels d’outils", @@ -2151,7 +2201,7 @@ export const fr: TranslationResources = { }, language: { label: "Langue", - description: "Langue de l'application", + description: "Langue de l’application", options: { system: "Système", ar: "العربية", @@ -2174,67 +2224,68 @@ export const fr: TranslationResources = { accessibilityLabel: "Utiliser l’ancien rendu du terminal", }, testAudio: "Tester le son", - playTest: "Jouer à l'essai", - playing: "Jouant...", - playbackFailed: "Échec de la lecture:{{message}}", + playTest: "Lancer le test", + playing: "Lecture…", + playbackFailed: "Échec de la lecture : {{message}}", app: { - title: "App diagnostic", - rowTitle: "App diagnostic", - rowHint: "Collect connection, daemon, provider, desktop, and log details", - run: "Run", - running: "Running diagnostic...", + title: "Diagnostic de l’app", + rowTitle: "Diagnostic de l’app", + rowHint: + "Collecter les informations sur la connexion, le daemon, les fournisseurs, l’app de bureau et les journaux", + run: "Lancer", + running: "Diagnostic en cours…", copyLabel: "diagnostic", - copyAccessibility: "Copy diagnostic", - copyFailed: "Failed to copy diagnostic", - refreshAccessibility: "Refresh diagnostic", - refreshingAccessibility: "Refreshing diagnostic", + copyAccessibility: "Copier le diagnostic", + copyFailed: "Impossible de copier le diagnostic", + refreshAccessibility: "Actualiser le diagnostic", + refreshingAccessibility: "Actualisation du diagnostic", progress: { client: "Client", - desktop: "Desktop", + desktop: "App de bureau", }, }, }, about: { title: "À propos", - appVersion: "Version de l'application", - whatsNewHint: "Notes de version pour chaque release", + appVersion: "Version de l’application", + whatsNewHint: "Les nouveautés de chaque version", thisDevice: "Cet appareil", connectedHosts: "Hôtes connectés", offline: "Hors ligne", - versionDiffers: "La version diffère de cet appareil", + versionDiffers: "Version différente de celle de cet appareil", releaseChannel: { - label: "Canal de sortie", + label: "Canal de publication", description: - "Passez àBetapour obtenir des mises à jour plus tôt et contribuer à les façonner", + "Passez à la Bêta pour recevoir les mises à jour plus tôt et contribuer à les façonner", stable: "Stable", - beta: "Beta", + beta: "Bêta", }, updates: { - label: "Mises à jour de l'application", - readyToInstall: "Prêt à installer:{{version}}", - installTitle: "Installer la mise à jour du bureau", - installMessage: "Cela met à jourPaseosur cet ordinateur", + label: "Mises à jour de l’application", + readyToInstall: "Prête à installer : {{version}}", + installTitle: "Installer la mise à jour de l’app de bureau", + installMessage: "Paseo sera mis à jour sur cet ordinateur", installConfirm: "Installer la mise à jour", - update: "Mise à jour", - updateTo: "Mise à jour vers{{version}}", - installing: "Installation...", + update: "Mettre à jour", + updateTo: "Mettre à jour vers {{version}}", + installing: "Installation…", check: "Vérifier", - checking: "Vérification...", + checking: "Vérification…", alertTitle: "Erreur", - alertMessage: "Impossible d'ouvrir la boîte de dialogue de confirmation de mise à jour.", + alertMessage: "Impossible d’ouvrir la boîte de dialogue de confirmation de mise à jour.", }, }, appearance: { theme: { title: "Thème", - accessibilityLabel: "Thème:{{value}}", + accessibilityLabel: "Thème : {{value}}", options: { - light: "Lumière", + light: "Clair", dark: "Sombre", zinc: "Zinc", midnight: "Minuit", claude: "Claude", - ghostty: "Fantôme", + ghostty: "Ghostty", pureBlack: "Noir pur", auto: "Système", }, @@ -2244,7 +2295,7 @@ export const fr: TranslationResources = { }, chatOutline: { title: "Plan de la discussion", - description: "Afficher un plan pour passer d’une requête à l’autre", + description: "Afficher un plan pour naviguer entre les prompts", }, sidebar: { header: { @@ -2253,64 +2304,65 @@ export const fr: TranslationResources = { "Choisissez les éléments affichés en haut de la barre latérale et leur ordre", }, footer: { - title: "Pied", + title: "Pied de page", description: - "Choisissez les lignes affichées en bas de la barre latérale et leur ordre. Ajouter un projet et la rangée d’icônes restent toujours visibles", + "Choisissez les lignes affichées en bas de la barre latérale et leur ordre. « Ajouter un projet » et la rangée d’icônes restent toujours visibles", }, - moveUp: "Déplacer vers le haut", - moveDown: "Déplacer vers le bas", + moveUp: "Monter", + moveDown: "Descendre", }, fonts: { title: "Polices", - systemDefault: "Valeur par défaut du système", - interfaceFont: "Police d'interface", + systemDefault: "Police par défaut du système", + interfaceFont: "Police de l’interface", interfaceFontHint: - "Utilisé dans toute l'application. Laisser vide pour la valeur par défaut du système", - interfaceFontAccessibility: "Famille de polices d'interface", - interfaceSize: "Taille de l'interface", + "Utilisée dans toute l’application. Laissez vide pour utiliser la police par défaut du système", + interfaceFontAccessibility: "Famille de police de l’interface", + interfaceSize: "Taille de l’interface", interfaceSizeHint: "Utilisée pour la navigation, les contrôles et les libellés", - interfaceSizeAccessibility: "Taille de police de l'interface", + interfaceSizeAccessibility: "Taille de police de l’interface", contentSize: "Taille du contenu", - contentSizeHint: "Utilisée pour le texte du chat et le Markdown rendu", + contentSizeHint: "Utilisée pour le texte de la discussion et le Markdown rendu", contentSizeAccessibility: "Taille de police du contenu", - codeFont: "Police de code", + codeFont: "Police du code", codeFontHint: - "Utilisé dans le code, les différences et la sortie du terminal. Laisser vide pour la valeur par défaut du système", - codeFontAccessibility: "Famille de polices de code", + "Utilisée pour le code, les diffs et la sortie du terminal. Laissez vide pour utiliser la police par défaut du système", + codeFontAccessibility: "Famille de police du code", codeSize: "Taille du code", codeSizeHint: "Utilisée pour le code, les diffs et la sortie du terminal", - codeSizeAccessibility: "Taille de la police du code", + codeSizeAccessibility: "Taille de police du code", }, layout: { title: "Mise en page", contentWidth: "Largeur du contenu", - contentWidthHint: "Largeur maximale du chat et des fichiers Markdown sur les grands écrans", + contentWidthHint: + "Largeur maximale de la discussion et des fichiers Markdown sur les grands écrans", contentWidthAccessibility: "Largeur du contenu en pixels", reset: "Réinitialiser", resetAccessibility: "Réinitialiser la largeur du contenu", }, syntax: { title: "Syntaxe", - highlightTheme: "Thème de surbrillance", - highlightThemeHint: "Couleurs du code, indépendamment du thème de l'application", - highlightThemeAccessibility: "Thème phare:{{value}}", + highlightTheme: "Thème de coloration", + highlightThemeHint: "Couleurs du code, indépendantes du thème de l’application", + highlightThemeAccessibility: "Thème de coloration : {{value}}", previewAccessibility: - "Aperçu en direct de la typographie du contenu, du thème de syntaxe et de la police de code", + "Aperçu en direct de la typographie du contenu, du thème de syntaxe et de la police du code", previewContent: "Aperçu du contenu et du code", }, }, shortcuts: { dialogTitle: "Raccourcis", searchPlaceholder: "Rechercher des raccourcis", - unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur le bureau", - capturePrompt: "Appuyez sur le raccourci...", - unassigned: "Non défini", + unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur l’app de bureau", + capturePrompt: "Appuyez sur le raccourci…", + unassigned: "Non attribué", actions: { menu: "Actions pour {{name}}", - done: "Fait", + done: "Terminé", cancel: "Annuler", bind: "Attribuer", - rebind: "Relier", + rebind: "Réattribuer", clear: "Effacer", reset: "Rétablir la valeur par défaut", resetAll: "Tout réinitialiser", @@ -2318,85 +2370,85 @@ export const fr: TranslationResources = { sections: { general: "Général", workspaces: "Projets et espaces de travail", - tabsPanes: "Onglets et volets", + tabsPanes: "Onglets et panneaux", layout: "Disposition", - agentInput: "EntréeAgent", + agentInput: "Zone de saisie", }, help: { - openProject: "Projet ouvert", + openProject: "Ouvrir un projet", newWorkspace: "Nouvel espace de travail", - newWorktree: "Nouvel arbre de travail", + newWorktree: "Nouveau worktree", archiveWorkspace: "Archiver l’espace de travail", newTab: "Nouvel onglet", - closeCurrentTab: "Fermer l'onglet actuel", - jumpToWorkspace: "Accéder à l'espace de travail", - jumpToTab: "Aller à l'onglet", + closeCurrentTab: "Fermer l’onglet actuel", + jumpToWorkspace: "Aller à l’espace de travail", + jumpToTab: "Aller à l’onglet", previousWorkspace: "Espace de travail précédent", nextWorkspace: "Espace de travail suivant", previousTab: "Onglet précédent", nextTab: "Onglet suivant", - splitPaneRight: "Volet divisé à droite", - splitPaneDown: "Diviser le volet vers le bas", - focusPaneLeft: "Volet de mise au point à gauche", - focusPaneRight: "Volet de mise au point à droite", - focusPaneUp: "Volet de mise au point vers le haut", - focusPaneDown: "Volet de mise au point vers le bas", - moveTabLeft: "Déplacer l'onglet vers la gauche", - moveTabRight: "Déplacer l'onglet vers la droite", - moveTabUp: "Déplacer l'onglet vers le haut", - moveTabDown: "Déplacer l'onglet vers le bas", - closePane: "Fermer le volet", - newTerminal: "Nouvelle borne", + splitPaneRight: "Diviser le panneau vers la droite", + splitPaneDown: "Diviser le panneau vers le bas", + focusPaneLeft: "Activer le panneau de gauche", + focusPaneRight: "Activer le panneau de droite", + focusPaneUp: "Activer le panneau du haut", + focusPaneDown: "Activer le panneau du bas", + moveTabLeft: "Déplacer l’onglet vers la gauche", + moveTabRight: "Déplacer l’onglet vers la droite", + moveTabUp: "Déplacer l’onglet vers le haut", + moveTabDown: "Déplacer l’onglet vers le bas", + closePane: "Fermer le panneau", + newTerminal: "Nouveau terminal", searchFiles: "Rechercher des fichiers", - toggleCommandCenter: "Basculer le centre de commande", + toggleCommandCenter: "Afficher/masquer la palette de commandes", showKeyboardShortcuts: "Afficher les raccourcis clavier", - toggleLeftSidebar: "Basculer la barre latérale gauche", - toggleRightSidebar: "Basculer le panneau latéral", - toggleBothSidebars: "Basculer les deux barres latérales", - toggleSettings: "Basculer les paramètres", - toggleFocusMode: "Basculer le mode de mise au point", - cycleTheme: "Thème du cycle", - focusMessageInput: "Saisie du message de focus", - cycleAgentMode: "Parcourir les modes de l'agent", - toggleVoiceMode: "Changer le mode vocal", - startStopDictation: "Démarrer la dictée/stop", - interruptAgent: "Agent d'interruption", - sendMessage: "Envoyer un message", - queueMessage: "Message de file d'attente", - muteUnmuteVoiceMode: "Mode vocal/unmutemuet", + toggleLeftSidebar: "Afficher/masquer la barre latérale gauche", + toggleRightSidebar: "Afficher/masquer la barre latérale de l’explorateur", + toggleBothSidebars: "Afficher/masquer les deux barres latérales", + toggleSettings: "Afficher/masquer les paramètres", + toggleFocusMode: "Activer/désactiver le mode focus", + cycleTheme: "Parcourir les thèmes", + focusMessageInput: "Aller à la zone de saisie", + cycleAgentMode: "Parcourir les modes de l’agent", + toggleVoiceMode: "Activer/désactiver le mode vocal", + startStopDictation: "Démarrer/arrêter la dictée", + interruptAgent: "Interrompre l’agent", + sendMessage: "Envoyer le message", + queueMessage: "Mettre le message en file d’attente", + muteUnmuteVoiceMode: "Couper/réactiver le micro en mode vocal", switchProject: "Changer de projet", }, helpNotes: { showKeyboardShortcuts: - "Disponible lorsque le focus n’est pas dans un champ de texte ou un terminal.", + "Disponible quand le focus n’est ni dans un champ de texte ni dans un terminal.", }, }, integrations: { title: "Intégrations", docs: { - cli: "DocumentsCLI", - openCli: "Ouvrir la documentationCLI", + cli: "Documentation CLI", + openCli: "Ouvrir la documentation CLI", }, commandLine: { title: "Ligne de commande", - description: "Agents de contrôle et de script depuis votre terminal", + description: "Pilotez et scriptez vos agents depuis votre terminal", }, actions: { install: "Installer", - installing: "Installation...", + installing: "Installation…", installed: "Installé", }, }, permissions: { title: "Autorisations", microphone: "Microphone", - refresh: "Rafraîchir", - refreshing: "Rafraîchissant...", + refresh: "Actualiser", + refreshing: "Actualisation…", refreshAccessibility: "Actualiser les autorisations du bureau", actions: { - granted: "Accordé", - request: "Demande", - requesting: "Demander...", + granted: "Accordée", + request: "Demander", + requesting: "Demande…", }, }, host: { @@ -2415,12 +2467,12 @@ export const fr: TranslationResources = { options: { none: "Par défaut", violet: "Violet", - sky: "Ciel", + sky: "Bleu ciel", emerald: "Émeraude", orange: "Orange", pink: "Rose", indigo: "Indigo", - teal: "Sarcelle", + teal: "Bleu canard", red: "Rouge", amber: "Ambre", blue: "Bleu", @@ -2439,250 +2491,252 @@ export const fr: TranslationResources = { workspaceName: "my-workspace", }, }, - notFound: "Hostintrouvable", + notFound: "Hôte introuvable", badges: { relay: "Relais", - local: "Locale", + local: "Local", remoteSsh: "SSH distant", }, connections: { - title: "Relations", + title: "Connexions", removeTitle: "Supprimer la connexion", - removeMessage: "Supprimer{{name}}? Cela ne peut pas être annulé.", - removeAction: "Retirer", + removeMessage: "Supprimer {{name}} ? Cette action est irréversible.", + removeAction: "Supprimer", removeErrorTitle: "Erreur", removeErrorMessage: "Impossible de supprimer la connexion", - timeout: "Temps mort", + timeout: "Délai dépassé", }, pairDevices: { title: "Associer des appareils", rowTitle: "Associer un appareil", - rowHint: "Scannez un codeQRou copiez un lien pour connecter votre téléphone à cet hôte", + rowHint: "Scannez un QR code ou copiez un lien pour connecter votre téléphone à cet hôte", }, skills: { ...en.settings.host.skills, - sectionTitle: "Compétences en orchestration", - title: "Compétences en orchestration", - description: "Apprenez à vos agents à orchestrer via leCLI", + sectionTitle: "Skills d’orchestration", + title: "Skills d’orchestration", + description: "Apprenez à vos agents à orchestrer via la CLI", updateAvailable: "Mise à jour disponible", - updateTitle: "Mettre à jour les compétencesPaseo?", - updateFallback: "Synchronisez les compétences regroupées sur votre machine.", - uninstallTitle: "Désinstaller les compétencesPaseo?", + updateTitle: "Mettre à jour les skills Paseo ?", + updateFallback: "Synchronisez les skills fournis sur cet hôte.", + uninstallTitle: "Désinstaller les skills Paseo ?", uninstallMessage: - "Supprime toutes les compétences d'orchestrationPaseode ~/.agents, ~/.claude, ~/.codex.", - choose: "Choisir les compétences", - chooseAll: "Toutes les compétences", + "Supprime tous les skills d’orchestration Paseo de ~/.agents, ~/.claude et ~/.codex sur cet hôte.", + choose: "Choisir les skills", + chooseAll: "Tous les skills", chooseAllHint: - "Gardez installées toutes les compétences fournies, y compris celles ajoutées plus tard.", - chooseList: "Compétences fournies", - chooseEmpty: "Cette version ne fournit aucune compétence.", - removeTitle: "Supprimer les compétences décochées ?", + "Garder installés tous les skills fournis, y compris ceux ajoutés plus tard.", + chooseList: "Skills fournis", + chooseEmpty: "Cet hôte ne fournit aucun skill.", + removeTitle: "Supprimer les skills décochés ?", removeMessage: - "{{skills}} seront supprimées de ~/.agents, ~/.claude et ~/.codex. Tout ce que vous avez ajouté dans ces dossiers de compétences est également supprimé.", - saveFailed: "Impossible d'enregistrer votre sélection de compétences.", - statusFailed: "Impossible de vérifier l'état des compétences d'orchestration.", - updateFailed: "Impossible de mettre à jour les compétences d'orchestration.", - uninstallFailed: "Impossible de désinstaller les compétences d'orchestration.", - saveSelectionFailed: - "Impossible d'enregistrer la sélection des compétences d'orchestration.", + "{{skills}} seront supprimés de ~/.agents, ~/.claude et ~/.codex sur cet hôte. Tout ce que vous avez ajouté dans ces dossiers de skills sera également supprimé.", + saveFailed: "Impossible d’enregistrer votre sélection de skills.", + statusFailed: "Impossible de vérifier l’état des skills d’orchestration.", + updateFailed: "Impossible de mettre à jour les skills d’orchestration.", + uninstallFailed: "Impossible de désinstaller les skills d’orchestration.", + saveSelectionFailed: "Impossible d’enregistrer la sélection des skills d’orchestration.", actions: { install: "Installer", - installing: "Installation...", + installing: "Installation…", installed: "Installé", - update: "Mise à jour", - working: "Fonctionnement...", + update: "Mettre à jour", + working: "En cours…", remove: "Supprimer", uninstall: "Désinstaller", save: "Enregistrer", - saving: "Enregistrement...", + saving: "Enregistrement…", cancel: "Annuler", }, operations: { - add: "Ajouter une compétence", - update: "Mettre à jour la compétence", - delete: "Supprimer la compétence", + add: "Ajouter un skill", + update: "Mettre à jour le skill", + delete: "Supprimer le skill", }, }, orchestration: { title: "Orchestration", - unavailable: "Connectez-vous à cet hôte pour gérer l'orchestration", + unavailable: "Connectez-vous à cet hôte pour gérer l’orchestration", enableTools: { - title: "Activer les outilsPaseo", - hint: "Les agents pourront gérer les arbres de travail, les agents et les horaires", - accessibilityLabel: "Injecter les outilsPaseo", + title: "Activer les outils Paseo", + hint: "Les agents pourront gérer les worktrees, les agents et les planifications", + accessibilityLabel: "Injecter les outils Paseo", }, systemPrompt: { - title: "Invite système", - hint: "Ajoute une invite système à tous les agents", - sheetTitle: "Ajouter une invite système", - accessibilityLabel: "Ajouter une invite système", - placeholder: "Gardez toujours des réponses concises.", + title: "Prompt système", + hint: "Ajoute un prompt système à tous les agents", + sheetTitle: "Ajouter un prompt système", + accessibilityLabel: "Ajouter un prompt système", + placeholder: "Toujours répondre de façon concise.", edit: "Modifier", reset: "Réinitialiser", - save: "Sauvegarder", - saving: "Sauvegarde...", + save: "Enregistrer", + saving: "Enregistrement…", }, }, agents: { - unavailable: "Connect to this host to manage agents", + unavailable: "Connectez-vous à cet hôte pour gérer les agents", }, workspaces: { - unavailable: "Connect to this host to manage workspaces", + unavailable: "Connectez-vous à cet hôte pour gérer les espaces de travail", }, terminalProfiles: { - unavailable: "Connect to this host to manage terminal profiles", - sectionTitle: "Terminal profiles", - editProfile: "Edit profile", - addProfileTitle: "Add terminal profile", - editProfileTitle: "Edit terminal profile", + unavailable: "Connectez-vous à cet hôte pour gérer les profils de terminal", + sectionTitle: "Profils de terminal", + editProfile: "Modifier le profil", + addProfileTitle: "Ajouter un profil de terminal", + editProfileTitle: "Modifier le profil de terminal", namePlaceholder: "Claude Code", commandPlaceholder: "claude", argsPlaceholder: "--dangerously-skip-permissions", - nameLabel: "Name", - commandLabel: "Command", + nameLabel: "Nom", + commandLabel: "Commande", argsLabel: "Arguments", - nameRequired: "Name is required", - commandRequired: "Command is required", - argsHint: "Space-separated arguments passed to the command", - saving: "Saving...", - remove: "Remove", - removeConfirmTitle: "Remove profile?", - removeConfirmMessage: 'Remove "{{name}}"?', - moveUp: "Move up", - moveDown: "Move down", - save: "Save", - emptyState: "No profiles yet. Add one to launch terminals with a specific command.", + nameRequired: "Le nom est obligatoire", + commandRequired: "La commande est obligatoire", + argsHint: "Arguments séparés par des espaces, transmis à la commande", + saving: "Enregistrement…", + remove: "Supprimer", + removeConfirmTitle: "Supprimer le profil ?", + removeConfirmMessage: "Supprimer « {{name}} » ?", + moveUp: "Monter", + moveDown: "Descendre", + save: "Enregistrer", + emptyState: + "Aucun profil pour le moment. Ajoutez-en un pour lancer des terminaux avec une commande précise.", }, 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", + sectionTitle: "Profils d’agent", + unavailable: "Connectez-vous à cet hôte pour gérer les profils d’agent", + unsupported: "Le daemon de cet hôte 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", + "Aucun profil pour le moment. Ajoutez-en un pour démarrer des agents avec un fournisseur et un modèle enregistrés.", + addProfileTitle: "Ajouter un profil d’agent", newProfile: "Nouveau profil", editProfile: "Modifier le profil", - editProfileTitle: "Modifier le profil d'agent", + editProfileTitle: "Modifier le profil d’agent", nameLabel: "Nom", - namePlaceholder: "Travail sur l'UI", + 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", + noProviders: "Aucun fournisseur disponible sur cet hôte", modelLabel: "Modèle", - noModels: "Ce fournisseur n'a aucun modèle à choisir", + noModels: "Ce fournisseur ne propose aucun modèle", 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", + noModes: "Ce fournisseur ne propose aucun mode", + thinkingLabel: "Réflexion", + noThinkingOptions: "Ce modèle n’a aucun niveau de réflexion", + featuresLabel: "Fonctionnalités", + featureCount: "{{count}} fonctionnalités", + featureCountOne: "{{count}} fonctionnalité", notesLabel: "Quand l’utiliser", notesPlaceholder: - "À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.", + "À utiliser pour le travail sur l’UI : composants, mise en page et design tokens.", notesHint: "Aide les agents à choisir ce profil lorsqu’ils démarrent un autre agent.", save: "Enregistrer", - saving: "Enregistrement...", + saving: "Enregistrement…", remove: "Supprimer", - removeConfirmTitle: "Supprimer le profil ?", - removeConfirmMessage: "Supprimer « {{name}} » ?", - moveUp: "Déplacer vers le haut", - moveDown: "Déplacer vers le bas", + removeConfirmTitle: "Supprimer le profil ?", + removeConfirmMessage: "Supprimer « {{name}} » ?", + moveUp: "Monter", + moveDown: "Descendre", }, daemon: { rename: { - editLabel: "Modifier l'étiquette", - title: "Renommer l'hôte", - placeholder: "MonHost", + editLabel: "Modifier le libellé", + title: "Renommer l’hôte", + placeholder: "Mon hôte", submit: "Renommer", }, restart: { - title: "Redémarrer le démon", - hint: "Redémarre le processus démon. L'application se reconnectera automatiquement", + title: "Redémarrer le daemon", + hint: "Redémarre le processus du daemon. L’application se reconnectera automatiquement", confirm: "Redémarrer", - confirmTitle: "Redémarrer{{name}}", + confirmTitle: "Redémarrer {{name}}", confirmMessage: - "Cela redémarrera le démon. Les agents qui s'y exécutent continueront à fonctionner; l'application se reconnectera automatiquement.", - restarting: "Redémarrage...", + "Le daemon va redémarrer. Les agents qui y tournent continueront de s’exécuter et l’application se reconnectera automatiquement.", + restarting: "Redémarrage…", unableToReconnectTitle: "Impossible de se reconnecter", unableToReconnectMessage: - "{{name}}n'est pas revenu en ligne. Veuillez vérifier qu'il a redémarré.", - unavailableTitle: "Hostindisponible", + "{{name}} n’est pas revenu en ligne. Vérifiez qu’il a bien redémarré.", + unavailableTitle: "Hôte indisponible", unavailableMessage: - "Cet hôte n'est pas connecté. Attendez qu'il soit en ligne avant de redémarrer.", - offlineTitle: "Hosthors ligne", + "Cet hôte n’est pas connecté. Attendez qu’il soit en ligne avant de redémarrer.", + offlineTitle: "Hôte hors ligne", offlineMessage: - "Cet hôte est hors ligne.Paseose reconnecte automatiquement: attendez qu'il soit de nouveau en ligne avant de redémarrer.", + "Cet hôte est hors ligne. Paseo se reconnecte automatiquement : attendez qu’il soit de nouveau en ligne avant de redémarrer.", requestFailedTitle: "Erreur", requestFailedMessage: - "Échec de l'envoi de la demande de redémarrage.Paseose reconnecte automatiquement - réessayez une fois que l'hôte apparaît comme en ligne.", + "Impossible d’envoyer la demande de redémarrage. Paseo se reconnecte automatiquement : réessayez quand l’hôte apparaît en ligne.", dialogFailedMessage: - "Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.", + "Impossible d’ouvrir la boîte de dialogue de confirmation du redémarrage.", }, update: { desktopManagedHint: "Ce daemon est géré par Paseo Desktop. Mettez à jour Paseo Desktop sur l’hôte.", - title: "Update daemon", - hint: "Update the daemon to the latest version and restart it", - confirm: "Update", - confirmTitle: "Update {{name}}", + title: "Mettre à jour le daemon", + hint: "Met à jour le daemon vers la dernière version puis le redémarre", + confirm: "Mettre à jour", + confirmTitle: "Mettre à jour {{name}}", confirmMessage: - "This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.", - updating: "Updating...", - phaseStarting: "Preparing update...", - phaseDownloading: "Downloading packages...", - phaseInstalling: "Installing...", - phaseComplete: "Update complete, restarting...", - unableToReconnectTitle: "Unable to reconnect", + "Le daemon va être mis à jour vers la dernière version puis redémarré. Les agents en cours seront brièvement interrompus.", + updating: "Mise à jour…", + phaseStarting: "Préparation de la mise à jour…", + phaseDownloading: "Téléchargement des paquets…", + phaseInstalling: "Installation…", + phaseComplete: "Mise à jour terminée, redémarrage…", + unableToReconnectTitle: "Impossible de se reconnecter", unableToReconnectMessage: - "{{name}} did not come back online after updating. Please verify the daemon restarted.", - unavailableTitle: "Host unavailable", + "{{name}} n’est pas revenu en ligne après la mise à jour. Vérifiez que le daemon a bien redémarré.", + unavailableTitle: "Hôte indisponible", unavailableMessage: - "This host is not connected. Wait for it to come online before updating.", - offlineTitle: "Host offline", - offlineMessage: "This host is offline. Wait until it is back online before updating.", - requestFailedTitle: "Update failed", - requestFailedMessage: "Failed to update the daemon: {{error}}", - dialogFailedMessage: "Unable to open the update confirmation dialog.", + "Cet hôte n’est pas connecté. Attendez qu’il soit en ligne avant de lancer la mise à jour.", + offlineTitle: "Hôte hors ligne", + offlineMessage: + "Cet hôte est hors ligne. Attendez qu’il soit de nouveau en ligne avant de lancer la mise à jour.", + requestFailedTitle: "Échec de la mise à jour", + requestFailedMessage: "Impossible de mettre à jour le daemon : {{error}}", + dialogFailedMessage: + "Impossible d’ouvrir la boîte de dialogue de confirmation de la mise à jour.", }, dangerZone: "Zone dangereuse", remove: { - title: "Supprimer l'hôte", - localTitle: "Remove localhost connection", + title: "Supprimer l’hôte", + localTitle: "Supprimer la connexion localhost", hint: "Supprime cet hôte et ses connexions enregistrées de cet appareil", - localHint: "Removes localhost from this device and stops the built-in daemon", - localConfirmTitle: "Remove localhost connection and stop daemon?", - confirmMessage: "Supprimer{{name}}? Cela supprimera ses connexions enregistrées.", + localHint: "Supprime localhost de cet appareil et arrête le daemon intégré", + localConfirmTitle: "Supprimer la connexion localhost et arrêter le daemon ?", + confirmMessage: "Supprimer {{name}} ? Ses connexions enregistrées seront supprimées.", localConfirmMessage: - "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected.", + "La connexion localhost sera supprimée, la gestion du daemon intégré désactivée et le daemon géré arrêté. Les hôtes distants restent connectés.", errorTitle: "Erreur", - errorMessage: "Impossible de supprimer l'hôte", - localErrorMessage: "Unable to remove localhost connection", + errorMessage: "Impossible de supprimer l’hôte", + localErrorMessage: "Impossible de supprimer la connexion localhost", }, }, }, providers: { title: "Fournisseurs", addProvider: "Ajouter un fournisseur", - providerDetails: "Détails du fournisseur{{name}}", - enableProvider: "Activer{{name}}", + providerDetails: "Détails du fournisseur {{name}}", + enableProvider: "Activer {{name}}", unavailable: "Connectez-vous à cet hôte pour voir les fournisseurs", - loading: "Chargement...", - addErrorTitle: "Unable to add provider", + loading: "Chargement…", + addErrorTitle: "Impossible d’ajouter le fournisseur", updateErrorTitle: "Impossible de mettre à jour le fournisseur", actions: { - menu: "{{name}} actions", - remove: "Remove provider", - removing: "Removing...", + menu: "Actions de {{name}}", + remove: "Supprimer le fournisseur", + removing: "Suppression…", }, remove: { - confirmTitle: "Remove {{name}}?", - confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.", - confirm: "Remove", - errorTitle: "Unable to remove provider", + confirmTitle: "Supprimer {{name}} ?", + confirmMessage: + "L’entrée du fournisseur sera supprimée de config.json. Cette action est irréversible.", + confirm: "Supprimer", + errorTitle: "Impossible de supprimer le fournisseur", }, statuses: { disabled: "Désactivé", @@ -2696,40 +2750,40 @@ export const fr: TranslationResources = { many: "{{count}} modèles", addModel: "Ajouter un modèle", addCustomTitle: "Ajouter un modèle personnalisé", - modelId: "ModèleID", - modelIdPlaceholder: "par ex. ouvert/gpt-5", + modelId: "ID du modèle", + modelIdPlaceholder: "ex. openai/gpt-5", add: "Ajouter", - adding: "Ajout...", - failedToSave: "Échec de l'enregistrement du modèle", - removeModel: "Supprimer{{id}}", + adding: "Ajout…", + failedToSave: "Impossible d’enregistrer le modèle", + removeModel: "Supprimer {{id}}", searchPlaceholder: "Rechercher des modèles", - loading: "Chargement des modèles...", + loading: "Chargement des modèles…", retry: "Réessayer", - retrying: "Nouvelle tentative...", + retrying: "Nouvelle tentative…", noSearchMatches: "Aucun modèle ne correspond à votre recherche", noneDetected: "Aucun modèle détecté", - discovered: "Découvert", + discovered: "Détectés", custom: "Modèles personnalisés", - updated: "{{time}}mis à jour", + updated: "Mis à jour {{time}}", }, diagnostic: { - title: "Diagnostique", - button: "Diagnostique", - refresh: "Rafraîchir", - refreshing: "Rafraîchissant...", + title: "Diagnostic", + button: "Diagnostic", + refresh: "Actualiser", + refreshing: "Actualisation…", copyLabel: "diagnostic", copyAccessibility: "Copier le diagnostic", - copyFailed: "Échec de la copie du diagnostic", + copyFailed: "Impossible de copier le diagnostic", refreshAccessibility: "Actualiser le diagnostic", - refreshingAccessibility: "Diagnostic rafraîchissant", - running: "Exécution du diagnostic...", + refreshingAccessibility: "Actualisation du diagnostic", + running: "Exécution du diagnostic…", none: "Aucun diagnostic disponible", - failedToFetch: "Échec de la récupération du diagnostic", + failedToFetch: "Impossible de récupérer le diagnostic", unknownError: "Erreur inconnue", }, }, project: { - noEditableTarget: "Ce projet n'est pas modifiable sur cet hôte.", + noEditableTarget: "Ce projet n’est pas modifiable sur cet hôte.", backToProjects: "Retour aux projets", edit: { title: "Modifier le projet", @@ -2737,85 +2791,86 @@ export const fr: TranslationResources = { nameLabel: "Nom du projet", icon: "Icône", chooseImage: "Choisir une image", - useAutomatic: "Utiliser l'automatique", - imageUrl: "URL d'image ou de site", - save: "Enregistrer", + useAutomatic: "Icône automatique", + imageUrl: "URL d’une image ou d’un site", + save: "Enregistrer les modifications", savedToast: "Projet mis à jour", }, readFailures: { - invalidTitle: "paseo.json n'a pas pu être analysé", + invalidTitle: "Impossible d’analyser paseo.json", invalidDescription: "Corrigez le fichier sur le disque, puis rechargez.", - missingTitle: "Cet hôte n'a pas ce projet", - missingSingleHost: "L'hôte sélectionné n'a aucune trace de ce projet.", + missingTitle: "Ce projet n’existe pas sur cet hôte", + missingSingleHost: "L’hôte sélectionné n’a aucune trace de ce projet.", transportTitle: "Impossible de charger paseo.json", - transportFallback: "L'hôte n'a pas répondu.", + transportFallback: "L’hôte n’a pas répondu.", failedTitle: "Impossible de charger paseo.json", failedDescription: "Rechargez pour réessayer.", }, worktree: { - title: "Crochets de cycle de vie Worktree", - info: "Commandes exécutées lorsqu'un arbre de travail est créé ou supprimé pour ce projet", - docs: "Documents", + title: "Hooks de cycle de vie des worktrees", + info: "Commandes exécutées à la création ou à la suppression d’un worktree de ce projet", + docs: "Documentation", docsTooltip: - "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", + "Consultez la documentation pour plus de détails et la liste des variables d’environnement disponibles pour ces commandes", + setup: "Configuration", + setupAccessibility: "Commandes de configuration du worktree", + uncommittedTitle: "Commitez 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", + teardown: "Démontage", + teardownAccessibility: "Commandes de démontage du worktree", }, scripts: { title: "Scripts", - info: "Services de longue durée et commandes ponctuelles que vous pouvez lancer à partir de n'importe quel agent de ce projet", - empty: "Pas encore de scripts.", + info: "Services de longue durée et commandes ponctuelles que vous pouvez lancer depuis n’importe quel agent de ce projet", + empty: "Aucun script pour le moment.", untitled: "Script sans titre", - port: "port{{port}}", - menuAccessibility: "Ouvrir le menu des scripts", - removeTitle: "Supprimer le script?", - removeMessage: "Supprimer{{name}}?", - removeFallbackName: "ce scénario", + port: "port {{port}}", + menuAccessibility: "Ouvrir le menu du script", + removeTitle: "Supprimer le script ?", + removeMessage: "Supprimer {{name}} ?", + removeFallbackName: "ce script", name: "Nom", command: "Commande", nameAccessibility: "Nom du script", - commandAccessibility: "Commande de script", - nameRequired: "Le nom est requis", - commandRequired: "La commande est requise", - newScript: "Nouveau scénario", - editScript: "Modifier{{name}}", - runAsService: "Exécuter en tant que service", - serviceHint: "Paseosupervise le processus et attribue un port via $PASEO_PORT", + commandAccessibility: "Commande du script", + nameRequired: "Le nom est obligatoire", + commandRequired: "La commande est obligatoire", + newScript: "Nouveau script", + editScript: "Modifier {{name}}", + runAsService: "Exécuter comme service", + serviceHint: "Paseo supervise le processus et lui attribue un port via $PASEO_PORT", actions: { add: "Ajouter un script", edit: "Modifier", - remove: "Retirer", + remove: "Supprimer", }, }, metadata: { - title: "Génération de métadonnées", - info: "Instructions spécifiques au projet injectées dans les invites de l'IA quePaseoutilise pour générer des métadonnées: utilisez-les pour appliquer les conventions de votre équipe telles que la dénomination des branches, le style de validation ou le formatPR.", - branchName: "Noms des succursales", + title: "Génération des métadonnées", + info: "Instructions propres au projet, injectées dans les prompts IA que Paseo utilise pour générer les métadonnées : servez-vous-en pour imposer les conventions de votre équipe (nommage des branches, style de commit, format des PR).", + branchName: "Noms de branches", branchNamePlaceholder: - "Préfixez les branches avec feat/ ou fix/, mb/ pour les branches personnelles", - commitMessage: "Valider les messages", - commitMessagePlaceholder: "Utiliser des commits conventionnels avec une portée", - pullRequest: "Demandes de tirage", + "Préfixez les branches par feat/ ou fix/, et par mb/ pour les branches personnelles", + commitMessage: "Messages de commit", + commitMessagePlaceholder: "Utilisez les Conventional Commits avec un scope", + pullRequest: "Pull requests", pullRequestPlaceholder: - "Commencez avec un résumé d'un paragraphe, incluez une section sur le plan de test", + "Commencez par un résumé d’un paragraphe et incluez une section « Test plan »", }, writeFailures: { staleTitle: "Configuration modifiée sur le disque", - staleDescription: "Rechargez pour récupérer le dernier paseo.json avant de sauvegarder.", - failedTitle: "Impossible d'enregistrer paseo.json", - failedDescription: "Réessayez ou rechargez la dernière version à partir du disque.", + staleDescription: + "Rechargez pour récupérer la dernière version de paseo.json avant d’enregistrer.", + failedTitle: "Impossible d’enregistrer paseo.json", + failedDescription: "Réessayez ou rechargez la dernière version depuis le disque.", }, actions: { reload: "Recharger", - tryAgain: "Essayer à nouveau", - save: "Sauvegarder", + tryAgain: "Réessayer", + save: "Enregistrer", saved: "Projet enregistré", - saving: "Sauvegarde...", + saving: "Enregistrement…", cancel: "Annuler", }, }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 59ac1cc600..30931c642b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -387,6 +387,9 @@ export const ja: TranslationResources = { completed: "完了", }, }, + turnFooter: { + workedFor: "作業時間 {{duration}}", + }, compaction: { loading: "コンテキストを圧縮中...", auto: "コンテキストが自動的に圧縮されました", @@ -983,6 +986,47 @@ export const ja: TranslationResources = { actions: { viewPullRequest: "表示", openOn: "{{brand}}で開く", + addToChat: "チャットに追加", + addAllToChat: "すべてチャットに追加", + addingToChat: "追加中...", + }, + checksOverview: { + headline: { + actionRequired: "対応が必要なチェックがあります", + failure: "失敗したチェックがあります", + pending: "完了していないチェックがあります", + success: "すべてのチェックに合格しました", + none: "チェックなし", + }, + count: { + actionRequired: "{{count}} 件要対応", + warning: "{{count}} 件警告", + failure: "{{count}} 件失敗", + pending: "{{count}} 件実行中", + manual: "{{count}} 件手動", + success: "{{count}} 件成功", + ignored: "{{count}} 件スキップ", + }, + detailOne: "チェック: {{parts}}", + detailMany: "チェック: {{parts}}", + groupOne: { + actionRequired: "要対応のチェック {{count}} 件", + warning: "警告のチェック {{count}} 件", + failure: "失敗したチェック {{count}} 件", + pending: "実行中のチェック {{count}} 件", + manual: "手動のチェック {{count}} 件", + success: "成功したチェック {{count}} 件", + ignored: "スキップされたチェック {{count}} 件", + }, + groupMany: { + actionRequired: "要対応のチェック {{count}} 件", + warning: "警告のチェック {{count}} 件", + failure: "失敗したチェック {{count}} 件", + pending: "実行中のチェック {{count}} 件", + manual: "手動のチェック {{count}} 件", + success: "成功したチェック {{count}} 件", + ignored: "スキップされたチェック {{count}} 件", + }, }, checksSummary: { passedLabel: "成功", @@ -996,17 +1040,21 @@ export const ja: TranslationResources = { checks: "チェック", pipeline: "パイプライン", reviews: "レビュー", + activity: "アクティビティ", }, empty: { noJobs: "ジョブなし", loadingPipeline: "パイプラインを読み込み中...", pipelineJobsLoadFailed: "パイプラインのジョブを読み込めませんでした", allowedToFail: "失敗を許可", + noActivity: "まだアクティビティはありません", }, approvals: "{{given}} / {{required}} 承認", accessibility: { pullRequest: "プルリクエスト#{{number}}", pullRequest_mr: "マージリクエスト !{{number}}", + commentActions: "コメントの操作", + threadActions: "スレッドの操作", checkStatus: { passed: "成功", failed: "失敗", @@ -1035,6 +1083,8 @@ export const ja: TranslationResources = { }, thread: { discussion: "ディスカッションスレッド", + resolved: "解決済み", + outdated: "古い", }, errors: { statusLoadFailed: "プルリクエストのステータスを読み込めません", @@ -1101,6 +1151,14 @@ export const ja: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} グループ", + statusBucket: { + needsInput: "入力待ち", + failed: "失敗", + readyToReview: "レビュー待ち", + working: "実行中", + done: "完了", + }, display: { trigger: "表示設定", heading: "表示", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 7528bd26c6..d3b9688c44 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -383,6 +383,9 @@ export const ko: TranslationResources = { completed: "완료됨", }, }, + turnFooter: { + workedFor: "작업 시간 {{duration}}", + }, compaction: { loading: "압축하는 중...", auto: "컨텍스트가 자동으로 압축되었습니다", @@ -977,6 +980,47 @@ export const ko: TranslationResources = { actions: { viewPullRequest: "보기", openOn: "{{brand}}에서 열기", + addToChat: "채팅에 추가", + addAllToChat: "모두 채팅에 추가", + addingToChat: "추가 중...", + }, + checksOverview: { + headline: { + actionRequired: "확인이 필요한 검사가 있습니다", + failure: "실패한 검사가 있습니다", + pending: "아직 완료되지 않은 검사가 있습니다", + success: "모든 검사를 통과했습니다", + none: "검사 없음", + }, + count: { + actionRequired: "{{count}}개 조치 필요", + warning: "{{count}}개 경고", + failure: "{{count}}개 실패", + pending: "{{count}}개 진행 중", + manual: "{{count}}개 수동", + success: "{{count}}개 성공", + ignored: "{{count}}개 건너뜀", + }, + detailOne: "검사: {{parts}}", + detailMany: "검사: {{parts}}", + groupOne: { + actionRequired: "조치 필요 검사 {{count}}개", + warning: "경고 검사 {{count}}개", + failure: "실패한 검사 {{count}}개", + pending: "진행 중인 검사 {{count}}개", + manual: "수동 검사 {{count}}개", + success: "성공한 검사 {{count}}개", + ignored: "건너뛴 검사 {{count}}개", + }, + groupMany: { + actionRequired: "조치 필요 검사 {{count}}개", + warning: "경고 검사 {{count}}개", + failure: "실패한 검사 {{count}}개", + pending: "진행 중인 검사 {{count}}개", + manual: "수동 검사 {{count}}개", + success: "성공한 검사 {{count}}개", + ignored: "건너뛴 검사 {{count}}개", + }, }, checksSummary: { passedLabel: "통과", @@ -990,17 +1034,21 @@ export const ko: TranslationResources = { checks: "검사", pipeline: "파이프라인", reviews: "리뷰", + activity: "활동", }, empty: { noJobs: "작업 없음", loadingPipeline: "파이프라인 로드 중…", pipelineJobsLoadFailed: "파이프라인 작업을 로드할 수 없습니다.", allowedToFail: "실패가 허용됨", + noActivity: "아직 활동이 없습니다", }, approvals: "{{required}} 중 {{given}} 승인", accessibility: { pullRequest: "풀 리퀘스트 #{{number}}", pullRequest_mr: "병합 요청 !{{number}}", + commentActions: "댓글 작업", + threadActions: "스레드 작업", checkStatus: { passed: "통과", failed: "실패", @@ -1029,6 +1077,8 @@ export const ko: TranslationResources = { }, thread: { discussion: "토론 스레드", + resolved: "해결됨", + outdated: "오래됨", }, errors: { statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다", @@ -1095,6 +1145,14 @@ export const ko: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} 그룹", + statusBucket: { + needsInput: "입력 필요", + failed: "실패", + readyToReview: "검토 대기", + working: "실행 중", + done: "완료", + }, display: { trigger: "표시 설정", heading: "표시", diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts index 144d35c5b4..8ac518f7ff 100644 --- a/packages/app/src/i18n/resources/plugin-settings.ts +++ b/packages/app/src/i18n/resources/plugin-settings.ts @@ -27,7 +27,7 @@ export const pluginSettings = { globalTitle: "تفعيل الإضافات", globalHint: "مفتاح عام لكل الإضافات المضبوطة", sourceLabel: "مصدر الإضافة", - sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm", + sourcePlaceholder: "owner/slug, مجلد أو رابط Git أو حزمة npm", docs: "الوثائق", install: "تثبيت الإضافة", installing: "جارٍ التثبيت…", @@ -82,7 +82,7 @@ export const pluginSettings = { globalTitle: "Activar plugins", globalHint: "Interruptor global para todos los plugins configurados", sourceLabel: "Fuente del plugin", - sourcePlaceholder: "Directorio, URL de Git o paquete npm", + sourcePlaceholder: "owner/slug, Directorio, URL de Git o paquete npm", docs: "Documentación", install: "Instalar plugin", installing: "Instalando…", @@ -137,7 +137,7 @@ export const pluginSettings = { globalTitle: "Activer les plugins", globalHint: "Interrupteur global pour tous les plugins configurés", sourceLabel: "Source du plugin", - sourcePlaceholder: "Dossier, URL Git ou paquet npm", + sourcePlaceholder: "owner/slug, Dossier, URL Git ou paquet npm", docs: "Documentation", install: "Installer le plugin", installing: "Installation…", @@ -163,7 +163,7 @@ export const pluginSettings = { globalEnabled: "Plugins activés", globalDisabled: "Plugins désactivés", }, - removeConfirmTitle: "Supprimer {{id}} ?", + removeConfirmTitle: "Supprimer {{id}} ?", removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.", states: { offlineTitle: "L’hôte des plugins est hors ligne", @@ -192,7 +192,7 @@ export const pluginSettings = { globalTitle: "プラグインを有効化", globalHint: "設定済みプラグインすべての共通スイッチ", sourceLabel: "プラグインソース", - sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ", + sourcePlaceholder: "owner/slug, ディレクトリ、Git URL、npm パッケージ", docs: "ドキュメント", install: "プラグインをインストール", installing: "インストール中…", @@ -246,7 +246,7 @@ export const pluginSettings = { globalTitle: "플러그인 활성화", globalHint: "설정된 모든 플러그인의 전역 스위치", sourceLabel: "플러그인 소스", - sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지", + sourcePlaceholder: "owner/slug, 디렉터리, Git URL 또는 npm 패키지", docs: "문서", install: "플러그인 설치", installing: "설치 중…", @@ -301,7 +301,7 @@ export const pluginSettings = { globalTitle: "Ativar plugins", globalHint: "Controle global para todos os plugins configurados", sourceLabel: "Fonte do plugin", - sourcePlaceholder: "Diretório, URL Git ou pacote npm", + sourcePlaceholder: "owner/slug, Diretório, URL Git ou pacote npm", docs: "Documentação", install: "Instalar plugin", installing: "Instalando…", @@ -356,7 +356,7 @@ export const pluginSettings = { globalTitle: "Включить плагины", globalHint: "Общий переключатель для всех настроенных плагинов", sourceLabel: "Источник плагина", - sourcePlaceholder: "Каталог, URL Git или пакет npm", + sourcePlaceholder: "owner/slug, Каталог, URL Git или пакет npm", docs: "Документация", install: "Установить плагин", installing: "Установка…", @@ -418,7 +418,7 @@ export const pluginSettings = { globalTitle: "启用插件", globalHint: "所有已配置插件的全局开关", sourceLabel: "插件来源", - sourcePlaceholder: "目录、Git URL 或 npm 包", + sourcePlaceholder: "owner/slug, 目录、Git URL 或 npm 包", docs: "文档", install: "安装插件", installing: "正在安装…", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5726d75c8b..aa426a4ce6 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -386,6 +386,9 @@ export const ptBR: TranslationResources = { completed: "Concluída", }, }, + turnFooter: { + workedFor: "Trabalhou por {{duration}}", + }, compaction: { loading: "Compactando...", auto: "Contexto compactado automaticamente", @@ -993,6 +996,47 @@ export const ptBR: TranslationResources = { actions: { viewPullRequest: "Ver", openOn: "Abrir no {{brand}}", + addToChat: "Adicionar ao chat", + addAllToChat: "Adicionar tudo ao chat", + addingToChat: "Adicionando...", + }, + checksOverview: { + headline: { + actionRequired: "Algumas verificações precisam da sua atenção", + failure: "Algumas verificações não foram bem-sucedidas", + pending: "Algumas verificações ainda não terminaram", + success: "Todas as verificações foram aprovadas", + none: "Nenhuma verificação", + }, + count: { + actionRequired: "{{count}} com ação pendente", + warning: "{{count}} com aviso", + failure: "{{count}} com falha", + pending: "{{count}} em andamento", + manual: "{{count}} manual(is)", + success: "{{count}} aprovada(s)", + ignored: "{{count}} ignorada(s)", + }, + detailOne: "Verificação: {{parts}}", + detailMany: "Verificações: {{parts}}", + groupOne: { + actionRequired: "{{count}} verificação com ação pendente", + warning: "{{count}} verificação com aviso", + failure: "{{count}} verificação com falha", + pending: "{{count}} verificação em andamento", + manual: "{{count}} verificação manual", + success: "{{count}} verificação aprovada", + ignored: "{{count}} verificação ignorada", + }, + groupMany: { + actionRequired: "{{count}} verificações com ação pendente", + warning: "{{count}} verificações com aviso", + failure: "{{count}} verificações com falha", + pending: "{{count}} verificações em andamento", + manual: "{{count}} verificações manuais", + success: "{{count}} verificações aprovadas", + ignored: "{{count}} verificações ignoradas", + }, }, checksSummary: { passedLabel: "passou", @@ -1006,17 +1050,21 @@ export const ptBR: TranslationResources = { checks: "Verificações", pipeline: "Pipeline", reviews: "Revisões", + activity: "Atividade", }, empty: { noJobs: "Sem jobs", loadingPipeline: "Carregando pipeline...", pipelineJobsLoadFailed: "Não foi possível carregar os jobs do pipeline", allowedToFail: "permitido falhar", + noActivity: "Nenhuma atividade ainda", }, approvals: "{{given}} de {{required}} aprovações", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + commentActions: "Ações do comentário", + threadActions: "Ações da conversa", checkStatus: { passed: "Aprovado", failed: "Falhou", @@ -1036,15 +1084,17 @@ export const ptBR: TranslationResources = { }, activity: { commented: "Comentou", - approved: "Aprovado", + approved: "Aprovou", requestedChanges: "Solicitou alterações", - reviewed: "Revisado", + reviewed: "Revisou", }, time: { justNow: "agora mesmo", }, thread: { discussion: "Tópico de discussão", + resolved: "Resolvido", + outdated: "Desatualizado", }, errors: { statusLoadFailed: "Não foi possível carregar o status da pull request", @@ -1114,6 +1164,14 @@ export const ptBR: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Grupo {{label}}", + statusBucket: { + needsInput: "Precisa de resposta", + failed: "Com falha", + readyToReview: "Para revisar", + working: "Em execução", + done: "Concluído", + }, display: { trigger: "Preferências de exibição", heading: "Exibição", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 4f558ca79f..a49fe1f9cb 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -386,6 +386,9 @@ export const ru: TranslationResources = { completed: "Завершена", }, }, + turnFooter: { + workedFor: "Время работы: {{duration}}", + }, compaction: { loading: "Сжатие контекста...", auto: "Контекст сжат автоматически", @@ -987,6 +990,47 @@ export const ru: TranslationResources = { actions: { viewPullRequest: "Просмотреть", openOn: "Открыть на {{brand}}", + addToChat: "Добавить в чат", + addAllToChat: "Добавить всё в чат", + addingToChat: "Добавление...", + }, + checksOverview: { + headline: { + actionRequired: "Некоторые проверки требуют внимания", + failure: "Некоторые проверки не пройдены", + pending: "Некоторые проверки ещё не завершены", + success: "Все проверки пройдены", + none: "Нет проверок", + }, + count: { + actionRequired: "требует действий: {{count}}", + warning: "с предупреждением: {{count}}", + failure: "не пройдено: {{count}}", + pending: "выполняется: {{count}}", + manual: "вручную: {{count}}", + success: "пройдено: {{count}}", + ignored: "пропущено: {{count}}", + }, + detailOne: "Проверка ({{parts}})", + detailMany: "Проверки ({{parts}})", + groupOne: { + actionRequired: "Требует действий: {{count}}", + warning: "С предупреждением: {{count}}", + failure: "Не пройдена: {{count}}", + pending: "Выполняется: {{count}}", + manual: "Ручная: {{count}}", + success: "Пройдена: {{count}}", + ignored: "Пропущена: {{count}}", + }, + groupMany: { + actionRequired: "Требуют действий: {{count}}", + warning: "С предупреждением: {{count}}", + failure: "Не пройдены: {{count}}", + pending: "Выполняются: {{count}}", + manual: "Ручные: {{count}}", + success: "Пройдены: {{count}}", + ignored: "Пропущены: {{count}}", + }, }, checksSummary: { passedLabel: "успешно", @@ -1000,17 +1044,21 @@ export const ru: TranslationResources = { checks: "Проверки", pipeline: "Пайплайн", reviews: "Ревью", + activity: "Активность", }, empty: { noJobs: "Нет заданий", loadingPipeline: "Загрузка пайплайна...", pipelineJobsLoadFailed: "Не удалось загрузить задания пайплайна", allowedToFail: "допускается сбой", + noActivity: "Активности пока нет", }, approvals: "Одобрено: {{given}} из {{required}}", accessibility: { pullRequest: "PR #{{number}}", pullRequest_mr: "MR !{{number}}", + commentActions: "Действия с комментарием", + threadActions: "Действия с обсуждением", checkStatus: { passed: "Успешно", failed: "Ошибка", @@ -1039,6 +1087,8 @@ export const ru: TranslationResources = { }, thread: { discussion: "Ветка обсуждения", + resolved: "Решено", + outdated: "Устарело", }, errors: { statusLoadFailed: "Не удалось загрузить статус PR", @@ -1105,6 +1155,14 @@ export const ru: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "Группа «{{label}}»", + statusBucket: { + needsInput: "Ожидает ввода", + failed: "Ошибка", + readyToReview: "На проверку", + working: "В работе", + done: "Готово", + }, display: { trigger: "Настройки отображения", heading: "Отображение", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 32e9c3d4b4..51fb965e68 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -382,6 +382,9 @@ export const zhCN: TranslationResources = { completed: "已完成", }, }, + turnFooter: { + workedFor: "工作了 {{duration}}", + }, compaction: { loading: "正在压缩...", auto: "上下文已自动压缩", @@ -961,6 +964,47 @@ export const zhCN: TranslationResources = { actions: { viewPullRequest: "查看", openOn: "在 {{brand}} 上打开", + addToChat: "添加到聊天", + addAllToChat: "全部添加到聊天", + addingToChat: "正在添加...", + }, + checksOverview: { + headline: { + actionRequired: "部分检查需要你处理", + failure: "部分检查未通过", + pending: "部分检查尚未完成", + success: "所有检查均已通过", + none: "没有检查", + }, + count: { + actionRequired: "{{count}} 个需处理", + warning: "{{count}} 个警告", + failure: "{{count}} 个失败", + pending: "{{count}} 个进行中", + manual: "{{count}} 个手动", + success: "{{count}} 个成功", + ignored: "{{count}} 个已跳过", + }, + detailOne: "检查:{{parts}}", + detailMany: "检查:{{parts}}", + groupOne: { + actionRequired: "{{count}} 个需处理的检查", + warning: "{{count}} 个有警告的检查", + failure: "{{count}} 个失败的检查", + pending: "{{count}} 个进行中的检查", + manual: "{{count}} 个手动检查", + success: "{{count}} 个成功的检查", + ignored: "{{count}} 个已跳过的检查", + }, + groupMany: { + actionRequired: "{{count}} 个需处理的检查", + warning: "{{count}} 个有警告的检查", + failure: "{{count}} 个失败的检查", + pending: "{{count}} 个进行中的检查", + manual: "{{count}} 个手动检查", + success: "{{count}} 个成功的检查", + ignored: "{{count}} 个已跳过的检查", + }, }, checksSummary: { passedLabel: "通过", @@ -974,17 +1018,21 @@ export const zhCN: TranslationResources = { checks: "检查", pipeline: "流水线", reviews: "审查", + activity: "动态", }, empty: { noJobs: "无作业", loadingPipeline: "正在加载流水线...", pipelineJobsLoadFailed: "无法加载流水线作业", allowedToFail: "允许失败", + noActivity: "暂无动态", }, approvals: "{{given}} / {{required}} 批准", accessibility: { pullRequest: "拉取请求 #{{number}}", pullRequest_mr: "合并请求 !{{number}}", + commentActions: "评论操作", + threadActions: "讨论串操作", checkStatus: { passed: "成功", failed: "失败", @@ -1013,6 +1061,8 @@ export const zhCN: TranslationResources = { }, thread: { discussion: "讨论主题", + resolved: "已解决", + outdated: "已过时", }, errors: { statusLoadFailed: "无法加载拉取请求状态", @@ -1079,6 +1129,14 @@ export const zhCN: TranslationResources = { }, }, sidebar: { + statusGroupAccessibility: "{{label}} 分组", + statusBucket: { + needsInput: "需要输入", + failed: "失败", + readyToReview: "待查看", + working: "运行中", + done: "已完成", + }, display: { trigger: "显示偏好", heading: "显示", diff --git a/packages/app/src/usage/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx index a31ae89478..0199c08ff2 100644 --- a/packages/app/src/usage/agent-usage.tsx +++ b/packages/app/src/usage/agent-usage.tsx @@ -27,7 +27,12 @@ export function AgentUsage({ <> {view.kind === "ready" ? ( - + ) : ( {view.kind === "loading" ? usageCopy.loading : view.message} @@ -39,10 +44,12 @@ export function AgentUsage({ function AgentUsageCards({ serverId, + agentId, reports, refreshable, }: { serverId: string; + agentId: string; reports: readonly UsageReportEntry[]; refreshable: boolean; }) { @@ -51,6 +58,7 @@ function AgentUsageCards({ `${login.harness}: ${reportContent(login.report).message}`, + ); + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. + return [ + entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem), + ]; +} + const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); export function UsageCard({ serverId, + agentId, entry, display, pinnable, @@ -52,6 +65,7 @@ export function UsageCard({ compact = false, }: { serverId: string; + agentId?: string; entry: UsageReportEntry; display: UsageDisplay; /** Whether each window row pins the window to the sidebar. */ @@ -61,13 +75,14 @@ export function UsageCard({ compact?: boolean; }) { const isCompact = useIsCompactFormFactor(); - const { refresh, refreshState } = useReportRefresh(serverId, entry.id); + const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId); // Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip. const showsFreshnessInline = isNative || isCompact || !refreshable; const usage = entry.report; const status = statusText(usage); const footer = entry.account.label ?? null; - const { windows, balances, details, message } = reportContent(usage); + const { windows, balances, details } = reportContent(usage); + const messages = reportMessages(entry); const containerStyle = useMemo( () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], @@ -110,9 +125,9 @@ export function UsageCard({ ) : null} - {message ? ( - - {message} + {messages.length > 0 ? ( + + {messages.join("\n")} ) : null} diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index d8e2ce74d1..020faf61ec 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -65,8 +65,7 @@ const styles = StyleSheet.create((theme) => ({ })); /** - * One host's usage and the title-row controls that go with it, for the Usage screen and the - * compact usage sheet. + * One host's usage and the title-row controls that go with it, for the Usage modal. */ export function useHostUsageWithControls(hostSelection: UsageHostSelection): { view: UsageView; diff --git a/packages/app/src/usage/hosts.ts b/packages/app/src/usage/hosts.ts index 450e0a6157..dba21dc316 100644 --- a/packages/app/src/usage/hosts.ts +++ b/packages/app/src/usage/hosts.ts @@ -6,7 +6,7 @@ import { } from "@/stores/navigation-active-workspace-store"; import { resolveUsageHostId, - resolveUsageScreenHostId, + resolveUsageModalHostId, type UsageHost, type UsageHostChoice, } from "./model"; @@ -35,7 +35,7 @@ export function useUsageHostId(): string | null { } /** - * The host the Usage screen or the compact usage sheet shows, and the hosts to pick from. A pick + * The host the Usage modal shows, and the hosts to pick from. A pick * is saved on the device, so the sidebar Usage row and later visits show the same host. */ export function useUsageHostSelection(): { @@ -55,5 +55,5 @@ export function useUsageHostSelection(): { }, [updateSettings], ); - return { serverId: resolveUsageScreenHostId(choice), connectedHosts, select }; + return { serverId: resolveUsageModalHostId(choice), connectedHosts, select }; } diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts index c9ae924f10..45599aba2d 100644 --- a/packages/app/src/usage/index.ts +++ b/packages/app/src/usage/index.ts @@ -1,7 +1,6 @@ export { AgentUsage } from "./agent-usage"; export { HostUsageSection } from "./host-usage-section"; export { useHostReportsUsage } from "./queries"; -export { UsageScreen } from "./usage-screen"; export { UsageSidebarItem, UsageSidebarRoot, diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index 1276157120..ad293bb23e 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -6,7 +6,7 @@ import { replaceReport, resolveUsageRefresh, resolveUsageHostId, - resolveUsageScreenHostId, + resolveUsageModalHostId, resolveUsageView, settleReports, upsertReport, @@ -117,35 +117,35 @@ describe("resolveUsageHostId", () => { }); }); -describe("resolveUsageScreenHostId", () => { +describe("resolveUsageModalHostId", () => { it("shows the picked host while it stays connected, even one that cannot report usage", () => { - expect(resolveUsageScreenHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe( + expect(resolveUsageModalHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe( "old", ); - expect( - resolveUsageScreenHostId({ pickedServerId: "offline", activeServerId: "b", hosts }), - ).toBe("b"); + expect(resolveUsageModalHostId({ pickedServerId: "offline", activeServerId: "b", hosts })).toBe( + "b", + ); }); it("otherwise shows the host the sidebar row reads", () => { for (const pickedServerId of [null, "a", "offline"]) { for (const activeServerId of ["b", null, "old", "offline"]) { const choice = { pickedServerId, activeServerId, hosts }; - expect(resolveUsageScreenHostId(choice)).toBe(resolveUsageHostId(choice)); + expect(resolveUsageModalHostId(choice)).toBe(resolveUsageHostId(choice)); } } }); - it("shows the first connected host when none reports usage, so the screen says to update it", () => { + it("shows the first connected host when none reports usage, so the modal says to update it", () => { expect( - resolveUsageScreenHostId({ + resolveUsageModalHostId({ pickedServerId: null, activeServerId: null, hosts: hosts.slice(0, 2), }), ).toBe("old"); expect( - resolveUsageScreenHostId({ pickedServerId: null, activeServerId: null, hosts: [] }), + resolveUsageModalHostId({ pickedServerId: null, activeServerId: null, hosts: [] }), ).toBeNull(); }); }); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 2b2e4c54cf..a32da7ea4d 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -176,11 +176,11 @@ export function resolveUsageHostId(choice: UsageHostChoice): string | null { } /** - * The host the Usage screen shows: the picked host while it is connected, even one that cannot - * report usage so the screen says to update it; else the sidebar row's host; else the first + * The host the Usage modal shows: the picked host while it is connected, even one that cannot + * report usage so the modal says to update it; else the sidebar row's host; else the first * connected host. */ -export function resolveUsageScreenHostId(choice: UsageHostChoice): string | null { +export function resolveUsageModalHostId(choice: UsageHostChoice): string | null { const connected = choice.hosts.filter((host) => host.isConnected); const picked = connected.find((host) => host.serverId === choice.pickedServerId); return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null; diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index f054d6d082..7b6f210543 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -98,10 +98,14 @@ async function getReport( serverId: string, reportId: string, forceRefresh = false, + agentId?: string, ): Promise { return ( - (await requireClient(serverId).listUsageReports({ reportIds: [reportId], forceRefresh })) - .reports[0] ?? null + ( + await requireClient(serverId).listUsageReports( + agentId === undefined ? { reportIds: [reportId], forceRefresh } : { agentId, forceRefresh }, + ) + ).reports.find((report) => report.id === reportId) ?? null ); } @@ -223,20 +227,22 @@ export function useUsageHosts(): UsageHost[] { } /** - * Forces the source to fetch one report, and only that report. The result replaces the report - * in every list of its host that holds it, the host's and each agent's, so every surface showing - * it moves together; until then the previous report stays on screen. + * Refreshes a host report, or the agent's session reports. The result replaces the card + * in the requesting view. Host and agent views can use different logins of the same account. */ export function useReportRefresh( serverId: string, reportId: string, + agentId?: string, ): { refresh: () => void; refreshState: UsageRefresh } { const queryClient = useQueryClient(); const mutation = useMutation({ - mutationFn: () => getReport(serverId, reportId, true), + mutationFn: () => getReport(serverId, reportId, true, agentId), onSuccess: (report) => { - queryClient.setQueriesData( - { queryKey: hostUsageQueryKey(serverId) }, + queryClient.setQueryData( + agentId === undefined + ? usageReportsQueryKey(serverId) + : agentUsageQueryKey(serverId, agentId), (reports) => (reports ? replaceReport(reports, reportId, report) : reports), ); }, diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index fc8f28c1c0..d90013d8bf 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -1,4 +1,3 @@ -import { router } from "expo-router"; import { createContext, Fragment, @@ -17,13 +16,9 @@ import { type PressableStateCallbackType, } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; -import { buildUsageRoute } from "@/utils/host-routes"; -import { useHostUsageWithControls } from "./controls"; -import { useUsagePreferences, type UsageDisplay } from "./display"; -import { useUsageHostId, useUsageHostSelection } from "./hosts"; +import { useUsagePreferences } from "./display"; +import { useUsageHostId } from "./hosts"; import { useUsageHostReports } from "./queries"; import { UsageSourceIcon } from "./source-icon"; import { UsageMeter } from "./meter"; @@ -35,8 +30,7 @@ import { type PinnedUsageLayout, type PinnedUsageSource, } from "./pinned"; -import type { UsageHost } from "./model"; -import { UsageBody } from "./usage-section"; +import { UsageModal } from "./usage-modal"; /** Each summary window with data on the usage host, under its source; empty while none has. */ function useUsageSummary(): readonly PinnedUsageSource[] { @@ -52,8 +46,8 @@ export function useHasUsageSummary(): boolean { /** * The sidebar footer's usage entry: each summary window's source icon and percent, and nothing - * while no summary window has data, since the footer's Usage icon already opens the screen. - * Pressing it opens the Usage screen; on compact layouts it opens the usage sheet instead. + * while no summary window has data, since the footer's Usage icon already opens Usage. + * Pressing it opens the Usage modal. */ export function UsageSidebarItem() { const { t } = useTranslation(); @@ -66,30 +60,25 @@ export function UsageSidebarItem() { const OpenSidebarUsageContext = createContext<(() => void) | null>(null); -/** One owner for the footer icon and summary: a compact sheet or the wide Usage screen. */ +/** + * One owner for the footer icon and summary: both open the Usage modal over the current screen. + * The modal mounts on first open and unmounts once it has finished closing, so its host's reports + * load only while it is shown. + */ export function UsageSidebarRoot({ children }: { children: ReactNode }) { - const { t } = useTranslation(); - const { display } = useUsagePreferences(); - const isCompact = useIsCompactFormFactor(); const [open, setOpen] = useState(false); - const [sheetMounted, setSheetMounted] = useState(false); + const [mounted, setMounted] = useState(false); const openUsage = useCallback(() => { - if (isCompact) { - setSheetMounted(true); - setOpen(true); - return; - } - router.push(buildUsageRoute()); - }, [isCompact]); + setMounted(true); + setOpen(true); + }, []); + const closeUsage = useCallback(() => setOpen(false), []); + const unmountUsage = useCallback(() => setMounted(false), []); return ( - - {children} - {isCompact && sheetMounted ? ( - - ) : null} - + {children} + {mounted ? : null} ); } @@ -101,57 +90,6 @@ export function useOpenSidebarUsage(): () => void { return openUsage; } -/** - * The compact usage sheet: the Usage screen's host, reports with pins, and controls, the controls - * in its title row. - */ -function UsageSheet({ title, display }: { title: string; display: UsageDisplay }) { - const { serverId, connectedHosts, select } = useUsageHostSelection(); - if (!serverId) return null; - return ( - - ); -} - -function HostUsageSheet({ - title, - serverId, - hosts, - onSelectHost, - display, -}: { - title: string; - serverId: string; - hosts: UsageHost[]; - onSelectHost: (serverId: string) => void; - display: UsageDisplay; -}) { - const hostSelection = useMemo( - () => ({ hosts, serverId, onSelect: onSelectHost }), - [hosts, onSelectHost, serverId], - ); - const { view, refresh, controls } = useHostUsageWithControls(hostSelection); - return ( - - - - - - ); -} - function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string { const windows = sources.flatMap((source) => source.windows); return `${label}: ${windows.map((window) => window.label).join(", ")}`; @@ -343,8 +281,4 @@ const styles = StyleSheet.create((theme) => ({ windowLabel: { color: theme.colors.foregroundMuted, }, - sheetBody: { - padding: theme.spacing[3], - gap: theme.spacing[3], - }, })); diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx new file mode 100644 index 0000000000..8305c3f878 --- /dev/null +++ b/packages/app/src/usage/usage-modal.tsx @@ -0,0 +1,93 @@ +import { useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; +import { useHostUsageWithControls } from "./controls"; +import { usageCopy } from "./copy"; +import { useUsagePreferences } from "./display"; +import { useUsageHostSelection } from "./hosts"; +import type { UsageHost } from "./model"; +import { UsageBody, UsageMessage } from "./usage-section"; + +interface UsageModalProps { + visible: boolean; + onClose: () => void; + onDismiss: () => void; +} + +/** + * Usage over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. It + * shows the usage host's reports with pins, and the host filter, Refresh all, and Settings in its + * title row. + */ +export function UsageModal(props: UsageModalProps) { + const { serverId, connectedHosts, select } = useUsageHostSelection(); + if (!serverId) { + return ( + + + + ); + } + // Keyed by host so a refresh in flight on one host never shows on another's cards. + return ( + + ); +} + +function HostUsageModal({ + serverId, + hosts, + onSelectHost, + ...props +}: UsageModalProps & { + serverId: string; + hosts: UsageHost[]; + onSelectHost: (serverId: string) => void; +}) { + const { display } = useUsagePreferences(); + const hostSelection = useMemo( + () => ({ hosts, serverId, onSelect: onSelectHost }), + [hosts, onSelectHost, serverId], + ); + const { view, refresh, controls } = useHostUsageWithControls(hostSelection); + return ( + + + + + + ); +} + +function UsageModalFrame({ + visible, + onClose, + onDismiss, + actions, + children, +}: UsageModalProps & { actions?: ReactNode; children: ReactNode }) { + const { t } = useTranslation(); + const title = t(builtinSidebarNavLabelKey("usage")); + const header = useMemo(() => ({ title, actions }), [actions, title]); + return ( + + {children} + + ); +} diff --git a/packages/app/src/usage/usage-screen.tsx b/packages/app/src/usage/usage-screen.tsx deleted file mode 100644 index 559d15095e..0000000000 --- a/packages/app/src/usage/usage-screen.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useIsFocused } from "@react-navigation/native"; -import { router } from "expo-router"; -import { useMemo, type ReactNode } from "react"; -import { View } from "react-native"; -import { PageLayout } from "@/components/page-layout"; -import { useHostUsageWithControls } from "./controls"; -import { usageCopy } from "./copy"; -import { useUsagePreferences } from "./display"; -import { useUsageHostSelection } from "./hosts"; -import type { UsageHost } from "./model"; -import { UsageBody, UsageMessage } from "./usage-section"; - -// The screen is reachable by URL, so there may be no history to go back to. -function leaveUsage(): void { - if (router.canGoBack()) { - router.back(); - return; - } - router.replace("/"); -} - -export function UsageScreen() { - const isFocused = useIsFocused(); - if (!isFocused) return {null}; - return ; -} - -function UsagePage({ actions, children }: { actions?: ReactNode; children: ReactNode }) { - return ( - - {children} - - ); -} - -function FocusedUsageScreen() { - const { serverId, connectedHosts, select } = useUsageHostSelection(); - if (!serverId) { - return ( - - - - ); - } - return ( - - ); -} - -function HostUsage({ - serverId, - hosts, - onSelectHost, -}: { - serverId: string; - hosts: UsageHost[]; - onSelectHost: (serverId: string) => void; -}) { - const { display } = useUsagePreferences(); - const hostSelection = useMemo( - () => ({ hosts, serverId, onSelect: onSelectHost }), - [hosts, onSelectHost, serverId], - ); - const { view, refresh, controls } = useHostUsageWithControls(hostSelection); - return ( - - - - - - ); -} diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 1dc3ff48bb..c13d6f25f9 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -428,10 +428,6 @@ export function buildSchedulesRoute() { return "/schedules" as const; } -export function buildUsageRoute() { - return "/usage" as const; -} - export function buildOpenProjectRoute() { return "/open-project" as const; } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 6b008e3dc6..ac9663c6da 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -1,3 +1,4 @@ +import { i18n } from "@/i18n/i18next"; import { describe, expect, it } from "vitest"; import type { SidebarProjectEntry, @@ -211,6 +212,7 @@ describe("buildStatusSidebarShortcutModel", () => { ["p1", "Project 1"], ["p2", "Project 2"], ]), + i18n.t, ), }); @@ -247,7 +249,7 @@ describe("buildStatusSidebarShortcutModel", () => { ]; const model = buildStatusSidebarShortcutModel({ - groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]])), + groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]]), i18n.t), collapsedStatusGroupKeys: new Set(["needs_input"]), }); diff --git a/packages/cli/package.json b/packages/cli/package.json index 438ad4064e..08525ae169 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "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.11.0-beta.4", - "@getpaseo/protocol": "0.11.0-beta.4", - "@getpaseo/server": "0.11.0-beta.4", + "@getpaseo/client": "0.11.0-beta.5", + "@getpaseo/protocol": "0.11.0-beta.5", + "@getpaseo/server": "0.11.0-beta.5", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts index cb42c0b0a5..fd7e845ecd 100644 --- a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -358,14 +358,21 @@ test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])( stdio: "ignore", }); const exited = new Promise((resolve) => child!.once("exit", resolve)); + // The lock file exists before its contents identify the supervisor. + let supervisorPid: number | undefined; await expect - .poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 }) - .toBe(true); - const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8")); + .poll( + async () => { + supervisorPid = (await readDaemonInstance(home))?.pid; + return supervisorPid; + }, + { timeout: 10_000 }, + ) + .toBeTypeOf("number"); await f.ok(["status", "--home", home]); child.kill("SIGINT"); await exited; - expect(() => process.kill(lock.pid, 0)).toThrow(); + expect(() => process.kill(supervisorPid!, 0)).toThrow(); expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped"); } finally { child?.kill("SIGTERM"); diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts index bad3bc0436..8ea6de9dfb 100644 --- a/packages/cli/src/commands/plugin/index.test.ts +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -255,12 +255,12 @@ describe("plugin management commands", () => { const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const command = createPluginCommand(); - await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], { + await command.parseAsync(["install", "git:owner/monorepo", "--path", "plugins/review"], { from: "user", }); expect(installPluginSource).toHaveBeenCalledWith({ - source: "owner/monorepo:plugins/review", + source: "git:owner/monorepo:plugins/review", }); stderr.mockRestore(); }); diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 993c690f68..86dc5889b8 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -1,9 +1,12 @@ +import path from "node:path"; +import { expandUserPath } from "../../classify.js"; import { createInterface } from "node:readline/promises"; import { reviewPluginUpdates, type UpdateOutcome } from "./update.js"; import { Command } from "commander"; import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages"; import { formatPluginSourceReference, + parsePluginSourceReference, formatPluginIdentity, } from "@getpaseo/protocol/plugin-source-reference"; import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js"; @@ -125,7 +128,18 @@ export async function runPluginInstallCommand( process.stderr.write( "Trusting plugin code: server code and preparation commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n", ); - const sourceReference = formatPluginSourceReference(source, options.path); + const reference = parsePluginSourceReference(source); + let resolvedSource = source; + if (reference.kind === "directory") { + const directory = expandUserPath(reference.source); + // An absolute path can target a daemon on a different operating system. + const absoluteDirectory = + path.posix.isAbsolute(directory) || path.win32.isAbsolute(directory) + ? directory + : path.resolve(directory); + resolvedSource = formatPluginSourceReference(absoluteDirectory, reference.pluginPath); + } + const sourceReference = formatPluginSourceReference(resolvedSource, options.path); const data = await withPluginSourceClient(options.daemonTarget, (client) => client.installPluginSource({ source: sourceReference, @@ -215,10 +229,12 @@ export function createPluginCommand(): Command { plugin .command("install") .alias("add") - .description("Trust and install a plugin from a directory, Git repository, or npm package") + .description( + "Trust and install a plugin from a registry, directory, Git repository, or npm package", + ) .argument( "", - "Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path", + "Registry owner/slug or host/owner/slug, host directory, explicit git:owner/repo or Git URL, or npm: source", ) .option("--id ", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref ", "Git branch, tag, or commit") diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index df0c13295b..a3350ecb8c 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { exec, execFile } from "node:child_process"; import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; @@ -9,6 +9,7 @@ import { scaffoldPluginDirectory } from "./scaffold.js"; const directories: string[] = []; const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc"); async function typecheckPlugin(directory: string): Promise { @@ -28,6 +29,47 @@ afterEach(async () => { }); describe("plugin scaffold", () => { + it("creates an author overview beside the manifest", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "hello-plugin"); + await scaffoldPluginDirectory(directory); + + const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8"); + const comments = overview.match(//g) ?? []; + expect(comments).toHaveLength(1); + expect(overview.replace(//g, "").trim()).toBe(""); + expect(overview).toContain("required to list"); + expect(overview).toContain("README"); + expect(overview).toContain("choosing a plugin in Paseo"); + expect(overview).toContain("Length follows complexity"); + expect(overview).toContain("1. Describe what your plugin is and does"); + expect(overview).toContain("2. Explain how it works only when it is not obvious"); + expect(overview).toContain("3. Explain setup when needed"); + expect(overview).toContain("4. Explain capabilities and settings"); + expect(overview).toContain("permissions"); + expect(overview).toContain("known limits"); + expect(overview).not.toContain("This plugin entry was imported"); + const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); + expect(packageJson.files).toContain("OVERVIEW.md"); + expect(await readdir(directory)).toContain("paseo-plugin.json"); + }); + + it("includes the author overview in the packed npm artifact", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "packed-plugin"); + await scaffoldPluginDirectory(directory); + + const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", { + cwd: directory, + }); + const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[]; + expect(artifact.files.map((file) => file.path)).toEqual( + expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]), + ); + }); + it( "creates a standalone split-runtime project that typechecks", { timeout: 15_000 }, @@ -51,6 +93,8 @@ describe("plugin scaffold", () => { expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual( { id: "hello-plugin", + $comment: expect.stringContaining('"icon": "assets/icon.png"'), + media: [], requirements: { paseo: `>=${cliPackageJson.version}` }, }, ); @@ -60,6 +104,7 @@ describe("plugin scaffold", () => { version: "0.0.0", files: [ "paseo-plugin.json", + "OVERVIEW.md", "index.client.ts", "index.client.tsx", "index.server.ts", diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index 448aa956fa..b1eb53a6ef 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -3,6 +3,34 @@ import path from "node:path"; import { PluginIdSchema } from "@getpaseo/protocol/messages"; import { resolveCliVersion } from "../../version.js"; +const OVERVIEW = ` +`; + const TSCONFIG = { compilerOptions: { target: "ES2020", @@ -155,6 +183,7 @@ export async function scaffoldPluginDirectory( version: "0.0.0", files: [ "paseo-plugin.json", + "OVERVIEW.md", "index.client.ts", "index.client.tsx", "index.server.ts", @@ -177,8 +206,19 @@ export async function scaffoldPluginDirectory( const files = new Map([ [ "paseo-plugin.json", - `${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`, + `${JSON.stringify( + { + id, + $comment: + 'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.', + media: [], + requirements: { paseo: `>=${version}` }, + }, + null, + 2, + )}\n`, ], + ["OVERVIEW.md", OVERVIEW], ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], ["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`], ["index.client.tsx", CLIENT_ENTRY], diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index ce509af8e1..3e71b6b608 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -8,11 +8,13 @@ import { } from "../../../../scripts/test-support/npm-registry.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import { once } from "node:events"; +import { WebSocket, WebSocketServer } from "ws"; import { connectToDaemon } from "../../src/utils/client.ts"; import { createE2ETestContext } from "../helpers/test-daemon.ts"; @@ -45,6 +47,81 @@ async function main(): Promise { ); await writeFile(path.join(directory, "index.server.ts"), pluginSource); + // Observe real CLI requests while forwarding them to the isolated daemon. + const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const sources: string[] = []; + proxy.on("connection", (socket, request) => { + const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, { + headers: { authorization: request.headers.authorization ?? "" }, + }); + const ready = once(upstream, "open"); + socket.on("message", async (data, isBinary) => { + const message = JSON.parse(data.toString()); + if ( + message.type === "session" && + message.message.type === "plugin.source.install.request" + ) { + sources.push(message.message.source); + } + await ready; + upstream.send(data, { binary: isBinary }); + }); + upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary })); + socket.on("close", () => upstream.close()); + upstream.on("close", () => socket.close()); + }); + await once(proxy, "listening"); + const address = proxy.address(); + assert.ok(address && typeof address !== "string"); + const sibling = path.join(context.workDir, "x"); + const caller = path.join(context.workDir, "caller"); + await mkdir(caller); + await mkdir(path.join(sibling, "sub"), { recursive: true }); + for (const target of [caller, path.join(sibling, "sub"), context.paseoHome]) { + await writeFile( + path.join(target, "paseo-plugin.json"), + JSON.stringify({ + id: "relative-cli", + requirements: { paseo: `>=${resolveCliVersion()}` }, + }), + ); + await writeFile(path.join(target, "index.server.ts"), pluginSource); + } + assert.notEqual(caller, process.cwd()); // The daemon inherits the test runner's cwd. + try { + for (const [source, expectedSource, expectedDirectory, extraArgs] of [ + [".", caller, caller, []], + ["../x:sub", `${sibling}:sub`, path.join(sibling, "sub"), []], + ["../x", `${sibling}:sub`, path.join(sibling, "sub"), ["--path", "sub"]], + ["~", context.paseoHome, context.paseoHome, []], + ["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []], + ] as const) { + sources.length = 0; + const result = await context.paseo( + ["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"], + { cwd: caller }, + ); + assert.deepEqual(sources, [expectedSource]); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).path, expectedDirectory); + const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]); + assert.equal(removed.exitCode, 0, removed.stderr); + } + // An absolute path belongs to the daemon's platform, which can differ from the CLI's. + const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`; + sources.length = 0; + const missing = await context.paseo( + ["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"], + { cwd: caller }, + ); + assert.deepEqual(sources, [windowsSource]); + assert.equal(missing.exitCode, 1); + assert.match(missing.stderr, /Plugin directory does not exist/); + } finally { + for (const socket of proxy.clients) socket.close(); + await promisify(proxy.close.bind(proxy))(); + } + const install = await context.paseo(["plugin", "install", directory, "--json"]); assert.equal(install.exitCode, 0, install.stderr); assert.equal(JSON.parse(install.stdout).id, "cli-e2e"); diff --git a/packages/client/package.json b/packages/client/package.json index c23045beb9..05e4b208d4 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.11.0-beta.4", - "@getpaseo/relay": "0.11.0-beta.4", + "@getpaseo/protocol": "0.11.0-beta.5", + "@getpaseo/relay": "0.11.0-beta.5", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index 4beed26643..5501ff8ba4 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -1574,9 +1574,8 @@ async function automationDrag(guest, sourceRef, targetRef) { } async function automationType(guest, refEntry, text) { - const send = await attachAutomationDebugger(guest); await automationClick(guest, refEntry); - await send("Input.insertText", { text }); + await guest.insertText(text); } function sendContainedEnter(guest) { @@ -2641,13 +2640,25 @@ async function runBrowserProfileGroup() { async function main() { ensureDirSync(OUT_DIR); if ( - !["all", "existing", "permanent-parking", "automation", "browser-profile"].includes( - HARNESS_GROUP, - ) + ![ + "all", + "existing", + "permanent-parking", + "automation", + "browser-profile", + "titlebar-drag", + ].includes(HARNESS_GROUP) ) { fail(`unknown harness group ${HARNESS_GROUP}`); } + if (HARNESS_GROUP === "titlebar-drag") { + const { runTitlebarDragGroup } = require("./titlebar-drag.js"); + await runTitlebarDragGroup(OUT_DIR); + pass(`capture harness titlebar-drag complete output=${OUT_DIR}`); + return; + } + if (HARNESS_GROUP === "browser-profile") { const browserProfileResults = await runBrowserProfileGroup(); await fsp.writeFile( diff --git a/packages/desktop/capture-harness/titlebar-drag-mouse.m b/packages/desktop/capture-harness/titlebar-drag-mouse.m new file mode 100644 index 0000000000..ac8894bc52 --- /dev/null +++ b/packages/desktop/capture-harness/titlebar-drag-mouse.m @@ -0,0 +1,52 @@ +// titlebar-drag-mouse.m — native mouse helper for the titlebar-drag harness. +// Posts real CGEvents (kCGHIDEventTap) so a drag moves the actual window; pure +// CDP input would never exercise Electron's draggable-region pipeline. +// Usage: titlebar-drag-mouse drag x1 y1 x2 y2 | click x y +// Every event carries flags 0 so a user-held Cmd/Opt cannot leak into the drag. +// Exit 2 when this process lacks Accessibility trust: the harness fails loudly +// instead of posting nothing and reading as a pass. +#import +#import +#import +#import +#import + +static void post(CGEventType type, CGPoint p, useconds_t delay) { + CGEventRef event = CGEventCreateMouseEvent(NULL, type, p, kCGMouseButtonLeft); + if (!event) { + fprintf(stderr, "mouse: CGEventCreateMouseEvent failed\n"); + exit(3); + } + CGEventSetFlags(event, (CGEventFlags)0); + CGEventPost(kCGHIDEventTap, event); + CFRelease(event); + usleep(delay); +} + +int main(int argc, char **argv) { + if (!AXIsProcessTrusted()) { + fprintf(stderr, "mouse: not accessibility-trusted\n"); + return 2; + } + if (argc == 6 && strcmp(argv[1], "drag") == 0) { + CGPoint a = {atof(argv[2]), atof(argv[3])}; + CGPoint b = {atof(argv[4]), atof(argv[5])}; + post(kCGEventMouseMoved, a, 150000); + post(kCGEventLeftMouseDown, a, 150000); + for (int i = 1; i <= 20; i++) { + CGPoint step = {a.x + (b.x - a.x) * i / 20, a.y + (b.y - a.y) * i / 20}; + post(kCGEventLeftMouseDragged, step, 25000); + } + post(kCGEventLeftMouseUp, b, 25000); + return 0; + } + if (argc == 4 && strcmp(argv[1], "click") == 0) { + CGPoint p = {atof(argv[2]), atof(argv[3])}; + post(kCGEventMouseMoved, p, 150000); + post(kCGEventLeftMouseDown, p, 150000); + post(kCGEventLeftMouseUp, p, 25000); + return 0; + } + fprintf(stderr, "usage: %s drag x1 y1 x2 y2 | click x y\n", argv[0]); + return 1; +} diff --git a/packages/desktop/capture-harness/titlebar-drag.js b/packages/desktop/capture-harness/titlebar-drag.js new file mode 100644 index 0000000000..fe4a35d06c --- /dev/null +++ b/packages/desktop/capture-harness/titlebar-drag.js @@ -0,0 +1,272 @@ +// Native mouse input is required: CDP mouse events do not move Electron windows. +// Load production CSS so restoring the global no-drag rule reproduces the failure. +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const path = require("node:path"); +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); +const { BrowserWindow } = require("electron"); + +const execFileAsync = promisify(execFile); +const PACKAGE_ROOT = path.join(__dirname, "..", ".."); +const INDEX_HTML = path.join(PACKAGE_ROOT, "app", "public", "index.html"); +const MOUSE_SOURCE = path.join(__dirname, "titlebar-drag-mouse.m"); +const BASE = { x: 100, y: 150 }; +const DRAG_DELTA = { x: 80, y: 50 }; +const DRAG_POINTS = [ + { check: "header-overlay-blank", x: 625, y: 18 }, + { check: "tab-row-overlay-blank", x: 625, y: 50 }, + { check: "sidebar-overlay-blank", x: 125, y: 18 }, + { check: "direct-rail-blank", x: 125, y: 50 }, +]; +const CLICK_POINTS = [ + { check: "header-button-click", id: "header-btn", x: 955, y: 18 }, + { check: "window-owned-toggle-click", id: "window-toggle", x: 40, y: 18 }, + { check: "window-owned-focusable-click", id: "focusable", x: 320, y: 18 }, + { check: "window-owned-negative-tabindex-button-click", id: "negative-button", x: 400, y: 18 }, + { check: "sidebar-tab-click", id: "sidebar-btn", x: 205, y: 18 }, + { check: "rail-tab-click", id: "rail-btn", x: 205, y: 50 }, + { check: "portal-button-click", id: "portal-btn", x: 490, y: 18 }, + { check: "floating-panel-button-click", id: "floating-btn", x: 785, y: 18 }, +]; + +/** Static overlays and a sibling window control mirror the app shell ownership. */ +function buildFixtureHtml(appStyles) { + const dragOverlay = + 'style="position:absolute;top:0;left:0;display:block;width:100%;height:100%;-webkit-app-region:drag"'; + const button = (id, style) => + ``; + return ` + + + +
+
+
+ +${button("window-toggle", "position:absolute;left:10px;top:6px;width:60px;height:24px;z-index:2")} +
Focusable
+
Button
+
+
+
+ Chat link + ${button("chat-code", "position:absolute;left:250px;top:320px;width:180px;height:24px")} +
file.ts
+
+
+
+
+ ${button("portal-btn", "position:fixed;left:460px;top:6px;width:60px;height:24px;pointer-events:auto")} +
+
+ ${button("floating-btn", "position:fixed;left:755px;top:6px;width:60px;height:24px;pointer-events:auto")} +
+`; +} + +function readRealAppStyles() { + const html = fs.readFileSync(INDEX_HTML, "utf8"); + const styles = [...html.matchAll(/]*>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join("\n"); + if (!styles.trim()) throw new Error(`no ", + ].join("\n"); + + const tokens = highlightCode(code, "Counter.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "let", style: "keyword" }, + { text: "number", style: "type" }, + { text: "template", style: "tag" }, + { text: "v-if", style: "attribute" }, + { text: "count", style: "definition" }, + { text: "1", style: "number" }, + { text: "'a}}b'", style: "string" }, + { text: "inner", style: "definition" }, + { text: "2", style: "number" }, + { text: "badge", style: "class" }, + { text: "color", style: "property" }, + ]), + ); + }); + + it("highlights a Vue SFC that contains an empty interpolation", () => { + const code = [""].join("\n"); + + const tokens = highlightCode(code, "Empty.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "template", style: "tag" }, + { text: "count", style: "variable" }, + ]), + ); + }); + + it("highlights quoted Vue directive values as code", () => { + const code = ""; + + const tokens = highlightCode(code, "Directive.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "count", style: "variable" }, + { text: "0", style: "number" }, + { text: "badge", style: "variable" }, + ]), + ); + expect(tokens).not.toContainEqual({ text: '"count > 0"', style: "string" }); + }); + + it("keeps a Vue interpolation open through nested template strings", () => { + const code = ""; + + const tokens = highlightCode(code, "Nested.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "`}}`", style: "string" }, + { text: " b`", style: "string" }, + ]), + ); + }); + it("highlights TSX code with correct dialect", () => { const code = 'const el =
hello
;'; const result = highlightCode(code, "test.tsx"); diff --git a/packages/highlight/src/__tests__/parsers.test.ts b/packages/highlight/src/__tests__/parsers.test.ts index 1d0d0ebcdd..b24872eb94 100644 --- a/packages/highlight/src/__tests__/parsers.test.ts +++ b/packages/highlight/src/__tests__/parsers.test.ts @@ -25,6 +25,7 @@ describe("isLanguageSupported", () => { expect(isLanguageSupported("test.ex")).toBe(true); expect(isLanguageSupported("Counter.svelte")).toBe(true); expect(isLanguageSupported("Page.astro")).toBe(true); + expect(isLanguageSupported("App.vue")).toBe(true); }); it("returns false for unsupported file extensions", () => { @@ -67,6 +68,7 @@ describe("getSupportedExtensions", () => { expect(extensions).toContain("json"); expect(extensions).toContain("svelte"); expect(extensions).toContain("astro"); + expect(extensions).toContain("vue"); }); }); diff --git a/packages/highlight/src/astro/parser.ts b/packages/highlight/src/astro/parser.ts index 444235488f..202c921252 100644 --- a/packages/highlight/src/astro/parser.ts +++ b/packages/highlight/src/astro/parser.ts @@ -14,6 +14,7 @@ import { import { parser as cssParser } from "@lezer/css"; import { parser as htmlParser } from "@lezer/html"; import { parser as jsParser } from "@lezer/javascript"; +import { findClosingBrace } from "../js-scanner.js"; interface Range { from: number; @@ -23,88 +24,11 @@ interface Range { const jsxParser = jsParser.configure({ dialect: "ts jsx" }); const typescriptParser = jsParser.configure({ dialect: "ts" }); -function isSpace(code: number): boolean { - return code === 32 || code === 9 || code === 10 || code === 13; -} - -function isRegexStart(text: string, position: number): boolean { - let previous = position - 1; - while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--; - if (previous < 0) return true; - - const character = text[previous]; - if (/[)\]}<"'`\d]/.test(character)) return false; - const code = text.charCodeAt(previous); - if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61; - if (!/[A-Za-z_$]/.test(character)) return true; - - let start = previous; - while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--; - const keyword = text.slice(start + 1, previous + 1); - return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test( - keyword, - ); -} - -function skipQuotedText(text: string, opening: number): number { - const quote = text.charCodeAt(opening); - for (let position = opening + 1; position < text.length; position++) { - const code = text.charCodeAt(position); - if (code === 92) position++; - else if (code === quote) return position; - } - return text.length - 1; -} - -function skipLineComment(text: string, opening: number): number { - const newline = text.indexOf("\n", opening + 2); - return newline >= 0 ? newline : text.length - 1; -} - -function skipBlockComment(text: string, opening: number): number { - const closing = text.indexOf("*/", opening + 2); - return closing >= 0 ? closing + 1 : text.length - 1; -} - -function skipRegex(text: string, opening: number): number { - let isInCharacterClass = false; - for (let position = opening + 1; position < text.length; position++) { - const code = text.charCodeAt(position); - if (code === 10 || code === 13) return position; - if (code === 92) position++; - else if (isInCharacterClass && code === 93) isInCharacterClass = false; - else if (!isInCharacterClass && code === 91) isInCharacterClass = true; - else if (!isInCharacterClass && code === 47) return position; - } - return text.length - 1; -} - -function findClosingBrace(text: string, opening: number): number { - let depth = 0; - for (let position = opening; position < text.length; position++) { - const code = text.charCodeAt(position); - if (code === 47 && text.charCodeAt(position + 1) === 47) { - position = skipLineComment(text, position); - } else if (code === 47 && text.charCodeAt(position + 1) === 42) { - position = skipBlockComment(text, position); - } else if (code === 47 && isRegexStart(text, position)) { - position = skipRegex(text, position); - } else if (code === 34 || code === 39 || code === 96) { - position = skipQuotedText(text, position); - } else if (code === 123) { - depth++; - } else if (code === 125 && --depth === 0) { - return position; - } - } - return -1; -} - function findExpressions(text: string): Range[] { const ranges: Range[] = []; for (let position = 0; position < text.length; position++) { if (text.charCodeAt(position) !== 123) continue; - const closing = findClosingBrace(text, position); + const closing = findClosingBrace(text, position + 1); if (closing < 0) break; ranges.push({ from: position, to: closing }); position = closing; diff --git a/packages/highlight/src/js-scanner.ts b/packages/highlight/src/js-scanner.ts new file mode 100644 index 0000000000..fb7f70ed77 --- /dev/null +++ b/packages/highlight/src/js-scanner.ts @@ -0,0 +1,100 @@ +// Scans JavaScript embedded in a template (Astro `{ }` expressions, Vue `{{ }}` +// interpolations) for the brace that closes it, without parsing the code. + +function isSpace(code: number): boolean { + return code === 32 || code === 9 || code === 10 || code === 13; +} + +function isRegexStart(text: string, position: number): boolean { + let previous = position - 1; + while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--; + if (previous < 0) return true; + + const character = text[previous]; + if (/[)\]}<"'`\d]/.test(character)) return false; + const code = text.charCodeAt(previous); + if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61; + if (!/[A-Za-z_$]/.test(character)) return true; + + let start = previous; + while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--; + const keyword = text.slice(start + 1, previous + 1); + return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test( + keyword, + ); +} + +// Template strings nested deeper than this are treated as running to the end. +const MAX_TEMPLATE_NESTING = 32; + +function skipQuotedText(text: string, opening: number, nesting = 0): number { + const quote = text.charCodeAt(opening); + for (let position = opening + 1; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 92) position++; + else if (code === quote) return position; + else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) { + position = skipTemplateSubstitution(text, position + 2, nesting); + } + } + return text.length - 1; +} + +function skipTemplateSubstitution(text: string, start: number, nesting: number): number { + if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1; + const closing = findClosingBrace(text, start, () => true, nesting + 1); + return closing >= 0 ? closing : text.length - 1; +} + +function skipLineComment(text: string, opening: number): number { + const newline = text.indexOf("\n", opening + 2); + return newline >= 0 ? newline : text.length - 1; +} + +function skipBlockComment(text: string, opening: number): number { + const closing = text.indexOf("*/", opening + 2); + return closing >= 0 ? closing + 1 : text.length - 1; +} + +function skipRegex(text: string, opening: number): number { + let isInCharacterClass = false; + for (let position = opening + 1; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 10 || code === 13) return position; + if (code === 92) position++; + else if (isInCharacterClass && code === 93) isInCharacterClass = false; + else if (!isInCharacterClass && code === 91) isInCharacterClass = true; + else if (!isInCharacterClass && code === 47) return position; + } + return text.length - 1; +} + +// Finds the first `}` outside nested braces, strings, comments, and regexes +// for which `isEnd` holds. +export function findClosingBrace( + text: string, + start: number, + isEnd: (position: number) => boolean = () => true, + nesting = 0, +): number { + let depth = 0; + for (let position = start; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 47 && text.charCodeAt(position + 1) === 47) { + position = skipLineComment(text, position); + } else if (code === 47 && text.charCodeAt(position + 1) === 42) { + position = skipBlockComment(text, position); + } else if (code === 47 && isRegexStart(text, position)) { + position = skipRegex(text, position); + } else if (code === 34 || code === 39 || code === 96) { + position = skipQuotedText(text, position, nesting); + } else if (code === 123) { + depth++; + } else if (code === 125 && depth === 0) { + if (isEnd(position)) return position; + } else if (code === 125) { + depth--; + } + } + return -1; +} diff --git a/packages/highlight/src/parsers.ts b/packages/highlight/src/parsers.ts index 9ccb3129be..06c3129733 100644 --- a/packages/highlight/src/parsers.ts +++ b/packages/highlight/src/parsers.ts @@ -18,6 +18,7 @@ import { parser as elixirParser } from "lezer-elixir"; import type { Parser } from "@lezer/common"; import { csharpLanguage } from "./csharp/language.js"; import { astroParser } from "./astro/parser.js"; +import { vueParser } from "./vue/parser.js"; import { nixLanguage } from "./nix/language.js"; import { parser as svelteBaseParser } from "./svelte/parser.js"; import { configureNesting, defaultNesting } from "./svelte/nesting.js"; @@ -56,6 +57,8 @@ const languagesByExtension: Record = { svelte: language(svelteBaseParser.configure({ wrap: configureNesting(defaultNesting) })), // Astro astro: language(astroParser), + // Vue + vue: language(vueParser), // XML xml: language(xmlParser), // Java diff --git a/packages/highlight/src/vue/parser.ts b/packages/highlight/src/vue/parser.ts new file mode 100644 index 0000000000..5c54825b61 --- /dev/null +++ b/packages/highlight/src/vue/parser.ts @@ -0,0 +1,136 @@ +import { + type Input, + type NestedParse, + type SyntaxNode, + type SyntaxNodeRef, + parseMixed, +} from "@lezer/common"; +import { parser as cssParser } from "@lezer/css"; +import { parser as htmlParser } from "@lezer/html"; +import { parser as jsParser } from "@lezer/javascript"; +import { findClosingBrace } from "../js-scanner.js"; + +interface Range { + from: number; + to: number; +} + +const typescriptParser = jsParser.configure({ dialect: "ts" }); +const expressionParser = jsParser.configure({ top: "SingleExpression" }); + +function findInterpolationEnd(text: string, start: number): number { + return findClosingBrace(text, start, (position) => text.charCodeAt(position + 1) === 125); +} + +function findExpressions(text: string): Range[] { + const ranges: Range[] = []; + for (let position = 0; position < text.length; position++) { + if (text.charCodeAt(position) !== 123) continue; + if (text.charCodeAt(position + 1) !== 123) continue; + const closing = findInterpolationEnd(text, position + 2); + if (closing < 0) break; + // Lezer rejects empty inner ranges, so `{{}}` gets no nested parse. + if (closing > position + 2) ranges.push({ from: position + 2, to: closing }); + position = closing + 1; + } + return ranges; +} + +function findDirectiveExpression(value: string): Range | null { + const quote = value[0]; + const isQuoted = (quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote); + const inner = isQuoted ? value.slice(1, -1) : value; + const trimmed = inner.trim(); + if (!trimmed) return null; + const from = (isQuoted ? 1 : 0) + inner.indexOf(trimmed); + return { from, to: from + trimmed.length }; +} + +function getOpenTagAttributes(node: SyntaxNode, input: Input): Record { + const attributes: Record = Object.create(null); + const openTag = node.getChild("OpenTag"); + if (!openTag) return attributes; + + for (const attribute of openTag.getChildren("Attribute")) { + const name = attribute.getChild("AttributeName"); + if (!name) continue; + const value = + attribute.getChild("AttributeValue") || attribute.getChild("UnquotedAttributeValue"); + const key = input.read(name.from, name.to).toLowerCase(); + attributes[key] = value ? input.read(value.from, value.to).replace(/^["']|["']$/g, "") : ""; + } + return attributes; +} + +function isDirectiveAttribute(attribute: string): boolean { + return ( + attribute.startsWith("v-") || + attribute.startsWith(":") || + attribute.startsWith("@") || + attribute.startsWith(".") + ); +} + +function directiveOverlay( + node: SyntaxNodeRef, + parent: SyntaxNode, + input: Input, +): NestedParse | null { + const attributeName = parent.getChild("AttributeName"); + if (!attributeName) return null; + if (!isDirectiveAttribute(input.read(attributeName.from, attributeName.to).toLowerCase())) { + return null; + } + + const range = findDirectiveExpression(input.read(node.from, node.to)); + if (!range) return null; + return { + parser: expressionParser, + overlay: [{ from: node.from + range.from, to: node.from + range.to }], + }; +} + +function textOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({ + from: node.from + from, + to: node.from + to, + })); + return overlays.length > 0 ? { parser: expressionParser, overlay: overlays } : null; +} + +function templateOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const parent = node.node.parent; + if (parent?.name === "Attribute") return directiveOverlay(node, parent, input); + if (node.name !== "Text") return null; + return textOverlay(node, input); +} + +function scriptLanguage(attributes: Record): NestedParse | null { + if (attributes.src) return null; + + const language = (attributes.lang || attributes.type || "").toLowerCase(); + if (language.includes("tsx")) return { parser: jsParser.configure({ dialect: "ts jsx" }) }; + if (language.includes("typescript") || language === "ts") return { parser: typescriptParser }; + if (language.includes("jsx")) return { parser: jsParser.configure({ dialect: "jsx" }) }; + return { parser: jsParser }; +} + +function scriptOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const parent = node.node.parent; + if (!parent) return null; + return scriptLanguage(getOpenTagAttributes(parent, input)); +} + +function nestedLanguage(node: SyntaxNodeRef, input: Input): NestedParse | null { + const name = node.name; + if (name === "Text" || name === "UnquotedAttributeValue" || name === "AttributeValue") { + return templateOverlay(node, input); + } + if (name === "StyleText") return { parser: cssParser }; + if (name === "ScriptText") return scriptOverlay(node, input); + return null; +} + +export const vueParser = htmlParser.configure({ + wrap: parseMixed(nestedLanguage), +}); diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 8e82c124d7..306a636512 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -70,8 +70,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.11.0-beta.4", - "@getpaseo/protocol": "0.11.0-beta.4", + "@getpaseo/client": "0.11.0-beta.5", + "@getpaseo/protocol": "0.11.0-beta.5", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -81,8 +81,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.11.0-beta.4", - "@getpaseo/protocol": "0.11.0-beta.4", + "@getpaseo/client": "0.11.0-beta.5", + "@getpaseo/protocol": "0.11.0-beta.5", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 38197e4cb6..5b1ce4f3dc 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -4,6 +4,7 @@ import { isBuiltin } from "node:module"; import { fileURLToPath } from "node:url"; import ts from "typescript"; import { describe, expect, it } from "vitest"; +import * as server from "./server/index.js"; const sourceDirectory = path.dirname(fileURLToPath(import.meta.url)); const entries = { @@ -64,6 +65,10 @@ function boundaryViolations(entry: string, runtime: "shared" | "server" | "clien } describe("plugin SDK import boundaries", () => { + it("publishes process capabilities without their Windows implementation helpers", () => { + expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]); + }); + it("classifies every published entry", () => { const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); expect(Object.keys(manifest.exports).sort()).toEqual(Object.keys(entries).sort()); diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index a71f2ca696..fc78d2a8c4 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -1,4 +1,5 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawnProcess, terminateProcess } from "../process.js"; import { Readable, Writable } from "node:stream"; import { ClientSideConnection, @@ -400,9 +401,9 @@ class AcpRuntime { args: options.options.command.slice(1), env: process.env, }; - child = spawn(launch.command, launch.args, { + child = spawnProcess(launch.command, launch.args, { env: { ...launch.env, ...options.env }, - stdio: ["pipe", "pipe", "pipe"], + stdio: "pipe", }); spawnFailure = new Promise((_resolve, reject) => child!.once("error", reject)); child.stderr.on("data", () => undefined); @@ -734,9 +735,9 @@ class AcpRuntime { if (!child || this.processFailed) return; if (child.exitCode !== null || child.signalCode !== null) return; const closed = new Promise((resolve) => child.once("close", () => resolve())); - child.kill("SIGTERM"); + await terminateProcess(child, "SIGTERM"); if (await settlesWithin(closed, 1_000)) return; - child.kill("SIGKILL"); + await terminateProcess(child); if (!(await settlesWithin(closed, 1_000))) { throw new Error(`ACP provider ${this.options.options.id} did not terminate after SIGKILL`); } diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts index dc211f61bf..9180023d19 100644 --- a/packages/plugin/src/server/index.ts +++ b/packages/plugin/src/server/index.ts @@ -22,3 +22,5 @@ export type { PluginBeforeRequests, PluginLifecycleRegistration, } from "./lifecycle.js"; + +export { spawnProcess, execCommand, terminateProcess } from "./process.js"; diff --git a/packages/plugin/src/server/process-internal/windows-command.test.ts b/packages/plugin/src/server/process-internal/windows-command.test.ts new file mode 100644 index 0000000000..3eb5e2e63b --- /dev/null +++ b/packages/plugin/src/server/process-internal/windows-command.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { quoteWindowsArgument, quoteWindowsCommand } from "./windows-command.js"; + +describe("quoteWindowsCommand", () => { + const originalPlatform = process.platform; + + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } + + afterEach(() => { + setPlatform(originalPlatform); + }); + + test("quotes a Windows path with spaces", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe( + '"C:\\Program Files\\Anthropic\\claude.exe"', + ); + }); + + test("does not double-quote an already-quoted path", () => { + setPlatform("win32"); + expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe( + '"C:\\Program Files\\Anthropic\\claude.exe"', + ); + }); + + test("returns the command unchanged when there are no spaces", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); + }); + + test("escapes ampersands", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix"); + }); + + test("escapes pipes", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix"); + }); + + test("does not double percent signs", () => { + setPlatform("win32"); + // cmd.exe only collapses %% → % inside batch files; on the command line + // it stays literal, which corrupts git --format atoms etc. + expect(quoteWindowsCommand("100%")).toBe("100%"); + }); + + test("preserves git --format atoms verbatim", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe( + "--format=%^(refname^)%09%^(committerdate:unix^)", + ); + }); + + test("escapes carets", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix"); + }); + + test("escapes multiple metacharacters", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("build&(test|deploy)!")).toBe( + "build^&^(test^|deploy^)^!^", + ); + }); + + test("quotes commands with spaces after escaping metacharacters", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe( + '"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"', + ); + }); + + test("returns the command unchanged on non-Windows platforms", () => { + setPlatform("darwin"); + expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); + }); +}); + +describe("quoteWindowsArgument", () => { + const originalPlatform = process.platform; + + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } + + afterEach(() => { + setPlatform(originalPlatform); + }); + + test("quotes a Windows argument with spaces", () => { + setPlatform("win32"); + expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe( + '"C:\\Program Files\\Anthropic\\cli.js"', + ); + }); + + test("does not double-quote an already-quoted argument", () => { + setPlatform("win32"); + expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe( + '"C:\\Program Files\\Anthropic\\cli.js"', + ); + }); + + test("returns the argument unchanged when there are no spaces", () => { + setPlatform("win32"); + expect(quoteWindowsArgument("--version")).toBe("--version"); + }); + + test("returns the argument unchanged on non-Windows platforms", () => { + setPlatform("darwin"); + expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); + }); +}); diff --git a/packages/server/src/utils/windows-command.ts b/packages/plugin/src/server/process-internal/windows-command.ts similarity index 100% rename from packages/server/src/utils/windows-command.ts rename to packages/plugin/src/server/process-internal/windows-command.ts diff --git a/packages/plugin/src/server/process.test.ts b/packages/plugin/src/server/process.test.ts new file mode 100644 index 0000000000..e9b3000da5 --- /dev/null +++ b/packages/plugin/src/server/process.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { execCommand, spawnProcess } from "./index.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function writeArgumentReader(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "plugin process arguments ")); + roots.push(root); + const script = path.join(root, "read arguments.mjs"); + await writeFile(script, "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n"); + return script; +} + +const argumentsToPreserve = [ + "argument with spaces", + "--format=%(refname)%09%(committerdate:unix)", + "100%", + "feature&bugfix", + "feature|bugfix", + "feature^bugfix", +]; + +// A bare extensionless command selects the Windows shell; an absolute node.exe path would +// skip that path. The real child checks quoting through the public process capabilities. +test("execCommand delivers shell arguments to the real child unchanged", async () => { + const script = await writeArgumentReader(); + const { stdout } = await execCommand("node", [script, ...argumentsToPreserve]); + expect(JSON.parse(stdout)).toEqual(argumentsToPreserve); +}); + +test("spawnProcess delivers shell arguments to the real child unchanged", async () => { + const script = await writeArgumentReader(); + const child = spawnProcess("node", [script, ...argumentsToPreserve], { stdio: "pipe" }); + let stdout = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`Argument reader exited with ${code}`)); + }); + }); + expect(JSON.parse(stdout)).toEqual(argumentsToPreserve); +}); diff --git a/packages/plugin/src/server/process.ts b/packages/plugin/src/server/process.ts new file mode 100644 index 0000000000..b2ba70ac12 --- /dev/null +++ b/packages/plugin/src/server/process.ts @@ -0,0 +1,119 @@ +import { + execFile, + spawn, + type ChildProcess, + type ChildProcessWithoutNullStreams, + type SpawnOptions, +} from "node:child_process"; +import { extname } from "node:path"; +import { promisify } from "node:util"; +import { + isWindowsCommandScript, + quoteWindowsArgument, + quoteWindowsCommand, +} from "./process-internal/windows-command.js"; +const execFileAsync = promisify(execFile); +export interface ExecCommandOptions { + env?: NodeJS.ProcessEnv; + cwd?: string; + encoding?: BufferEncoding; + killSignal?: NodeJS.Signals; + timeout?: number; + maxBuffer?: number; + shell?: boolean | string; + signal?: AbortSignal; +} +interface ExecCommandResult { + stdout: string; + stderr: string; +} +function hasPathSeparator(value: string): boolean { + return value.includes("/") || value.includes("\\"); +} + +function shouldUseWindowsShell( + command: string, + requestedShell?: boolean | string, +): boolean | string { + if (isWindowsCommandScript(command)) { + return true; + } + if (requestedShell !== undefined) { + return requestedShell; + } + return process.platform === "win32" && !hasPathSeparator(command) && !extname(command); +} + +/** Launch a CLI with Windows command-script handling and argv quoting. The caller owns env. */ +export function spawnProcess( + command: string, + args: string[], + options: SpawnOptions & { stdio: "pipe" }, +): ChildProcessWithoutNullStreams; +export function spawnProcess(command: string, args: string[], options?: SpawnOptions): ChildProcess; +export function spawnProcess( + command: string, + args: string[], + options?: SpawnOptions, +): ChildProcess { + const spawnOptions = options ?? {}; + const isWindows = process.platform === "win32"; + const shell = shouldUseWindowsShell(command, spawnOptions.shell); + + const shouldQuoteForShell = isWindows && shell !== false; + const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; + const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; + + return spawn(resolvedCommand, resolvedArgs, { + ...spawnOptions, + env: options?.env, + shell, + signal: options?.signal, + windowsHide: true, + }); +} + +export async function execCommand( + command: string, + args: string[], + options?: ExecCommandOptions, +): Promise { + const isWindows = process.platform === "win32"; + const shell = shouldUseWindowsShell(command, options?.shell); + const shouldQuoteForShell = isWindows && shell !== false; + const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; + const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; + + return execFileAsync(resolvedCommand, resolvedArgs, { + cwd: options?.cwd, + env: options?.env, + encoding: options?.encoding ?? "utf8", + signal: options?.signal, + killSignal: options?.killSignal, + timeout: options?.timeout, + maxBuffer: options?.maxBuffer, + shell, + windowsHide: true, + }) as Promise; +} + +/** Force-stop a CLI, including descendants behind Windows command-script launchers. */ +export async function terminateProcess( + child: ChildProcess, + signal: NodeJS.Signals = "SIGKILL", +): Promise { + if (process.platform !== "win32") { + child.kill(signal); + return; + } + if (child.pid === undefined) return; + try { + await execCommand("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { + shell: false, + timeout: 5000, + }); + } catch (error) { + // The process can finish between its close check and taskkill. + if (!(error instanceof Error && "code" in error && error.code === 128)) throw error; + } +} diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index 4ad2661d67..fa23ca9c91 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -58,6 +58,8 @@ export interface UsageAccount { /** Stable across token rotation; [A-Za-z0-9._-]{1,128}. Never a credential or raw email. */ key: string; label?: string; + /** Harness owning this login, e.g. Codex, OpenCode, Pi or OMP. */ + harness?: string; /** Store locator, opaque to the daemon. */ input: JsonValue; } diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 1bf3d7719c..b969849cb1 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index 5c62e90669..f266b65988 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => { expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); }, ); + it("preserves manifest metadata in plugin list responses", () => { + const message = { + type: "plugin.list.response", + payload: { + requestId: "metadata", + plugins: [ + { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + path: "/plugins/review", + enabled: false, + status: "disabled", + }, + ], + }, + }; + expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); + }); + it("parses plugin timeline append messages and advertises the capability", () => { expect( SessionInboundMessageSchema.parse({ diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 0214502263..6f5e4abd3f 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6289,6 +6289,17 @@ export const UsageReportEntrySchema = z.object({ sourceLabel: z.string(), icon: z.string().optional(), report: UsageReportSchema, + loginErrors: z + .array( + z.object({ + harness: z.string(), + report: z.discriminatedUnion("status", [ + z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }), + z.object({ status: z.literal("error"), error: z.string() }), + ]), + }), + ) + .optional(), }); export const UsageListReportsUpdateMessageSchema = z.object({ type: z.literal("usage.list_reports.update"), @@ -6621,6 +6632,9 @@ export const PluginNpmInstallationSchema = z.object({ export const PluginListItemSchema = z.object({ id: PluginIdSchema, + name: z.string().optional(), + icon: z.string().optional(), + media: z.array(z.string()).optional(), description: z.string().optional(), path: z.string(), enabled: z.boolean(), diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index fe2a79916f..2298d680a4 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -389,3 +389,50 @@ test("blocked setup preserves the legacy failed shape and optional provenance", legacySnapshot.parse(failed), ); }); + +test("usage login errors are additive and older reports still parse", () => { + const entry = { + id: "codex:account", + account: {}, + fetchedAt: "2026-10-05T00:00:00.000Z", + sourceId: "codex", + sourceLabel: "Codex", + report: { status: "error", error: "Usage API returned 500" }, + }; + const legacy = z.object({ + type: z.literal("usage.list_reports.update"), + payload: z.object({ + requestId: z.string(), + report: z.object({ + id: z.string(), + account: z.object({ label: z.string().optional() }), + fetchedAt: z.string(), + sourceId: z.string(), + sourceLabel: z.string(), + icon: z.string().optional(), + report: z.discriminatedUnion("status", [ + z.object({ status: z.literal("available"), windows: z.array(z.unknown()) }), + z.object({ status: z.literal("unavailable"), problem: z.unknown() }), + z.object({ status: z.literal("error"), error: z.string() }), + ]), + }), + }), + }); + const oldMessage = { + type: "usage.list_reports.update", + payload: { requestId: "usage", report: entry }, + }; + const newMessage = { + ...oldMessage, + payload: { + ...oldMessage.payload, + report: { + ...entry, + loginErrors: [{ harness: "Codex", report: entry.report }], + }, + }, + }; + expect(SessionOutboundMessageSchema.parse(oldMessage)).toEqual(oldMessage); + expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage); + expect(legacy.parse(newMessage)).toEqual(oldMessage); +}); diff --git a/packages/protocol/src/plugin-overview.test.ts b/packages/protocol/src/plugin-overview.test.ts new file mode 100644 index 0000000000..c4023965da --- /dev/null +++ b/packages/protocol/src/plugin-overview.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { pluginOverviewUrl } from "./plugin-overview.js"; + +describe("plugin overview destinations", () => { + it.each([ + undefined, + "", + "./docs", + "../docs", + "/login", + "//example.com", + "#section", + "?login", + "http://example.com", + "mailto:hello@example.com", + "ftp://example.com", + "javascript:alert(1)", + "data:image/png;base64,AAAA", + "vbscript:msgbox(1)", + "javascript:alert(1)", + "javascript%3Aalert(1)", + "javascript%253Aalert(1)", + "java\tscript:alert(1)", + " https://example.com", + "https://example.com\n", + "https:example.com", + "https:///example.com", + "https://paseo.sh@evil.example", + "https://example.com\\@evil.example", + "https://", + ])("leaves %s as text", (url) => { + expect(pluginOverviewUrl(url)).toBeUndefined(); + }); + + it.each([ + "https://example.com/path?q=a&b=c#section", + "HTTPS://example.com/path", + "https://example.com/%3Cscript%3E", + ])("allows %s without decoding it again", (url) => { + expect(pluginOverviewUrl(url)).toBe(url); + }); +}); diff --git a/packages/protocol/src/plugin-overview.ts b/packages/protocol/src/plugin-overview.ts new file mode 100644 index 0000000000..2507b511b0 --- /dev/null +++ b/packages/protocol/src/plugin-overview.ts @@ -0,0 +1,49 @@ +/** Presentation contract for author-controlled registry content, independent of the UI runtime. */ +export const pluginOverviewPolicy = { + rawHtml: false, + elements: [ + "p", + "br", + "hr", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "ul", + "ol", + "li", + "strong", + "em", + "del", + "pre", + "code", + "a", + "img", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + ], + link: { rel: "noopener noreferrer nofollow", target: "_blank" }, +} as const; + +/** Validate decoded Markdown destinations; never resolve against the reader's page or repository. */ +export function pluginOverviewUrl(value: string | undefined): string | undefined { + // URL parsers discard controls and normalize backslashes; reject those ambiguous spellings. + // eslint-disable-next-line no-control-regex + if (!value || !/^https:\/\/[^/?#]/i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value)) + return undefined; + try { + const url = new URL(value); + if (url.protocol !== "https:" || !url.hostname || url.username || url.password) + return undefined; + return value; + } catch { + return undefined; + } +} diff --git a/packages/protocol/src/plugin-registry.test.ts b/packages/protocol/src/plugin-registry.test.ts new file mode 100644 index 0000000000..5692c4c56e --- /dev/null +++ b/packages/protocol/src/plugin-registry.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { pluginMediaKind } from "./plugin-registry.js"; + +describe("plugin media kind", () => { + it.each([ + ["https://cdn.example.com/a/preview.png", "image"], + ["https://cdn.example.com/a/preview.JPG", "image"], + ["https://cdn.example.com/a/preview.jpeg", "image"], + ["https://cdn.example.com/a/preview.webp", "image"], + ["https://cdn.example.com/a/preview.gif", "image"], + ["https://cdn.example.com/a/demo.mp4", "video"], + ["https://cdn.example.com/a/demo.WebM", "video"], + ["https://cdn.example.com/a/demo.mp4?raw=true#t=2", "video"], + ["https://cdn.example.com/mp4/preview.png?format=mp4", "image"], + ] as const)("reads %s as %s from its path extension", (url, kind) => { + expect(pluginMediaKind(url)).toBe(kind); + }); +}); diff --git a/packages/protocol/src/plugin-registry.ts b/packages/protocol/src/plugin-registry.ts index 36c5354c88..90854962ba 100644 --- a/packages/protocol/src/plugin-registry.ts +++ b/packages/protocol/src/plugin-registry.ts @@ -39,13 +39,21 @@ export const PublishedPluginSchema = z.object({ repository: z.object({ url: z.string().url(), commit: z.string().optional() }), artifact: PluginRegistryArtifactSchema, icon: z.string().url().optional(), - screenshots: z.array(z.string().url()), + /** Images and videos in display order; see `pluginMediaKind`. */ + media: z.array(z.string().url()), submittedAt: z.string(), reviewedAt: z.string(), updatedAt: z.string(), publishedAt: z.string(), installs: z.number().int().nonnegative().optional(), }); +const VIDEO_EXTENSIONS = new Set(["mp4", "webm"]); + +/** Whether a registry media entry is a video or an image, from its path extension. */ +export function pluginMediaKind(url: string): "image" | "video" { + const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? ""; + return VIDEO_EXTENSIONS.has(extension) ? "video" : "image"; +} export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() }); export const PluginRegistryIndexSchema = z.object({ schemaVersion: z.literal(1), @@ -54,6 +62,8 @@ export const PluginRegistryIndexSchema = z.object({ z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }), ), plugins: z.array(PublishedPluginSchema), + /** Plugin IDs the maintainer features, in display order. An ID may name a plugin that is not listed. */ + featured: z.array(z.string()), generatedAt: z.string(), }); export type PluginRegistryIdentity = z.infer; diff --git a/packages/protocol/src/plugin-source-reference.test.ts b/packages/protocol/src/plugin-source-reference.test.ts index b8ce6e7346..83f41ce3df 100644 --- a/packages/protocol/src/plugin-source-reference.test.ts +++ b/packages/protocol/src/plugin-source-reference.test.ts @@ -3,6 +3,35 @@ import { describe, expect, it } from "vitest"; import { parsePluginSourceReference } from "./plugin-source-reference.js"; describe("plugin source references", () => { + it.each([ + ".", + "..", + "./owner/slug", + "../owner/slug", + "/owner/slug", + "~/owner/slug", + "~", + "C:\\", + "C:/", + "D:\\plugins\\repository", + "D:/plugins/repository", + "\\\\server\\share", + "\\\\server\\share\\plugins\\review", + ".\\plugins\\review", + "..\\plugins\\review", + ])("recognizes the explicit directory %s by shape", (source) => { + expect(parsePluginSourceReference(source)).toEqual({ + kind: "directory", + source, + pluginPath: undefined, + }); + expect(parsePluginSourceReference(`${source}:plugins/review`)).toEqual({ + kind: "directory", + source, + pluginPath: "plugins/review", + }); + }); + it.each([ ["npm:review@1.2.0:nested", "npm:review@1.2.0", "nested"], ["npm:review@^1.2.0", "npm:review@^1.2.0", undefined], @@ -16,6 +45,7 @@ describe("plugin source references", () => { ["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"], ["plugins.example.com:8443/owner/slug", "plugins.example.com:8443/owner/slug", undefined], ["owner/repository", "owner/repository", undefined], + ["plugins/foo", "plugins/foo", undefined], ["owner/repository:plugins/review", "owner/repository", "plugins/review"], [ "https://example.test:8443/owner/repository.git", @@ -39,10 +69,8 @@ describe("plugin source references", () => { "file:///D:/plugins/repository", "plugins/review", ], - ["D:\\plugins\\repository", "D:\\plugins\\repository", undefined], - ["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"], ])("parses %s", (reference, source, pluginPath) => { - expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath }); + expect(parsePluginSourceReference(reference)).toEqual({ kind: "managed", source, pluginPath }); }); }); diff --git a/packages/protocol/src/plugin-source-reference.ts b/packages/protocol/src/plugin-source-reference.ts index 229dc1df88..15171531f0 100644 --- a/packages/protocol/src/plugin-source-reference.ts +++ b/packages/protocol/src/plugin-source-reference.ts @@ -1,19 +1,27 @@ import { parsePluginRegistryReference } from "./plugin-registry.js"; import type { PluginInstallation } from "./messages.js"; export interface PluginSourceReference { + kind: "directory" | "managed"; source: string; pluginPath: string | undefined; } export function parsePluginSourceReference(reference: string): PluginSourceReference { - if (parsePluginRegistryReference(reference)) return { source: reference, pluginPath: undefined }; const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0]; const source = prefix ? reference.slice(prefix.length) : reference; const parsed = splitPluginPath(source, { scp: prefix !== "npm:" }); - return { ...parsed, source: `${prefix ?? ""}${parsed.source}` }; + if (!prefix && /^(\.\.?(?:[/\\]|$)|\/|~|[A-Za-z]:[/\\]|\\\\)/.test(parsed.source)) { + return { kind: "directory", ...parsed }; + } + if (parsePluginRegistryReference(reference)) + return { kind: "managed", source: reference, pluginPath: undefined }; + return { kind: "managed", ...parsed, source: `${prefix ?? ""}${parsed.source}` }; } -function splitPluginPath(reference: string, options: { scp: boolean }): PluginSourceReference { +function splitPluginPath( + reference: string, + options: { scp: boolean }, +): Pick { const separator = reference.lastIndexOf(":"); if (separator === -1) return { source: reference, pluginPath: undefined }; diff --git a/packages/protocol/tests/fixtures/plugin-overview.json b/packages/protocol/tests/fixtures/plugin-overview.json new file mode 100644 index 0000000000..bbe8575ac8 --- /dev/null +++ b/packages/protocol/tests/fixtures/plugin-overview.json @@ -0,0 +1,1202 @@ +[ + { + "name": "script", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "image event", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "svg event", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "iframe", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTML javascript link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "HTML block", + "markdown": "
\n\n
", + "links": [], + "images": [] + }, + { + "name": "comment", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "meta refresh", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "base", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "form", + "markdown": "
", + "links": [], + "images": [] + }, + { + "name": "style expression", + "markdown": "

click

", + "links": [], + "images": [] + }, + { + "name": "style block", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "closing script", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTML entities", + "markdown": "<img src=x onerror=alert(1)>", + "links": [], + "images": [] + }, + { + "name": "nested HTML entities", + "markdown": "&lt;script&gt;alert(1)&lt;/script&gt;", + "links": [], + "images": [] + }, + { + "name": "javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "javascript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript link", + "markdown": "[click](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript image", + "markdown": "![picture](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference link", + "markdown": "[click][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data HTML link", + "markdown": "[click](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML image", + "markdown": "![picture](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML reference link", + "markdown": "[click][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML reference image", + "markdown": "![picture][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data image link", + "markdown": "[click](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image image", + "markdown": "![picture](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image reference link", + "markdown": "[click][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image reference image", + "markdown": "![picture][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "data image HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "vbscript link", + "markdown": "[click](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript image", + "markdown": "![picture](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript reference link", + "markdown": "[click][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript reference image", + "markdown": "![picture][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "http link", + "markdown": "[click](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http image", + "markdown": "![picture](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http reference link", + "markdown": "[click][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http reference image", + "markdown": "![picture][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "http HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "ftp link", + "markdown": "[click](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp image", + "markdown": "![picture](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp reference link", + "markdown": "[click][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp reference image", + "markdown": "![picture][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "ftp HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mailto link", + "markdown": "[click](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto image", + "markdown": "![picture](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto reference link", + "markdown": "[click][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto reference image", + "markdown": "![picture][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "mailto HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "protocol relative link", + "markdown": "[click](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative image", + "markdown": "![picture](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference link", + "markdown": "[click][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference image", + "markdown": "![picture][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "relative link", + "markdown": "[click](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative image", + "markdown": "![picture](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative reference link", + "markdown": "[click][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "parent relative link", + "markdown": "[click](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative image", + "markdown": "![picture](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative reference link", + "markdown": "[click][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "root relative link", + "markdown": "[click](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative image", + "markdown": "![picture](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative reference link", + "markdown": "[click][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative reference image", + "markdown": "![picture][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "root relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "fragment link", + "markdown": "[click](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment image", + "markdown": "![picture](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment reference link", + "markdown": "[click][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment reference image", + "markdown": "![picture][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "fragment HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "query link", + "markdown": "[click](?login)", + "links": [], + "images": [] + }, + { + "name": "query image", + "markdown": "![picture](?login)", + "links": [], + "images": [] + }, + { + "name": "query reference link", + "markdown": "[click][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query reference image", + "markdown": "![picture][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "query HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "entity javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "entity letter link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "nested entity link", + "markdown": "[click](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity image", + "markdown": "![picture](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity reference link", + "markdown": "[click][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "percent encoded link", + "markdown": "[click](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded image", + "markdown": "![picture](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded link", + "markdown": "[click](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded image", + "markdown": "![picture](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "tab scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "newline scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials link", + "markdown": "[click](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials image", + "markdown": "![picture](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference link", + "markdown": "[click][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference image", + "markdown": "![picture][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "backslash link", + "markdown": "[click](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash image", + "markdown": "![picture](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash reference link", + "markdown": "[click][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash reference image", + "markdown": "![picture][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "backslash HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "missing authority link", + "markdown": "[click](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority image", + "markdown": "![picture](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority reference link", + "markdown": "[click][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority reference image", + "markdown": "![picture][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "javascript autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data HTML autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data image autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "vbscript autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "http autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "ftp autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mailto autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTTPS link", + "markdown": "[click](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "HTTPS image", + "markdown": "![picture](https://example.com/image.png)", + "links": [], + "images": ["https://example.com/image.png"] + }, + { + "name": "HTTPS autolink", + "markdown": "", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTPS", + "markdown": "https://example.com/path", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTP", + "markdown": "http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "deceptive RTL text", + "markdown": "[paseo.sh‮evil](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "title injection", + "markdown": "[click](https://example.com/path \"\\\" onmouseover=alert(1)\")", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "table alignment", + "markdown": "| A |\n| :-: |\n| safe |", + "links": [], + "images": [] + }, + { + "name": "heading", + "markdown": "## Heading", + "links": [], + "images": [] + }, + { + "name": "IRC link", + "markdown": "[click](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC image", + "markdown": "![picture](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "IRCS link", + "markdown": "[click](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS image", + "markdown": "![picture](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "XMPP link", + "markdown": "[click](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP image", + "markdown": "![picture](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "tel link", + "markdown": "[click](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel image", + "markdown": "![picture](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "file link", + "markdown": "[click](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file image", + "markdown": "![picture](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "blob link", + "markdown": "[click](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob image", + "markdown": "![picture](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "named entity link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "double encoded entity link", + "markdown": "[click](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity image", + "markdown": "![picture](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity autolink", + "markdown": "", + "links": [], + "images": [] + } +] diff --git a/packages/relay/package.json b/packages/relay/package.json index 0dbed362e1..58c0f442cd 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 42e80a5fec..c11efb3571 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "description": "Paseo backend server", "files": [ "dist/server", @@ -107,11 +107,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.11.0-beta.4", - "@getpaseo/highlight": "0.11.0-beta.4", - "@getpaseo/plugin": "0.11.0-beta.4", - "@getpaseo/protocol": "0.11.0-beta.4", - "@getpaseo/relay": "0.11.0-beta.4", + "@getpaseo/client": "0.11.0-beta.5", + "@getpaseo/highlight": "0.11.0-beta.5", + "@getpaseo/plugin": "0.11.0-beta.5", + "@getpaseo/protocol": "0.11.0-beta.5", + "@getpaseo/relay": "0.11.0-beta.5", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index bdee5f6f7d..7589cb92a1 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -27,6 +27,8 @@ async function runSupervisorFixture(options: { fileSizeLimitBlocks?: number; /** Occupy the log path with a directory so the supervisor cannot open daemon.log. */ blockLogPath?: boolean; + /** Close the reading end of the supervisor's stdout and stderr as soon as it starts. */ + closeOutput?: boolean; }): Promise<{ code: number | null; signal: NodeJS.Signals | null; @@ -86,6 +88,11 @@ async function runSupervisorFixture(options: { stdio: ["ignore", "pipe", "pipe"], }); + if (options.closeOutput) { + child.stdout.destroy(); + child.stderr.destroy(); + } + let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); @@ -116,10 +123,19 @@ async function runSupervisorFixture(options: { }); }); - const log = await readFile(logPath, "utf8"); + const log = await readLogIfWritten(logPath); return { code, signal, elapsedMs: Date.now() - startedAt, log, stdout, stderr }; } +async function readLogIfWritten(logPath: string): Promise { + try { + return await readFile(logPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } +} + describe("supervisor durable logging", () => { test("resolves rotation defaults", () => { const paseoHome = path.join(path.sep, "tmp", "paseo-home"); @@ -189,6 +205,30 @@ describe("supervisor durable logging", () => { expect(result.stderr).toContain('"worker-json-stderr"'); }); + test("keeps supervising the worker when nobody reads its stdout or stderr", async () => { + const result = await runSupervisorFixture({ + closeOutput: true, + workerSource: ` + process.on("message", (message) => { + if (message?.type === "paseo:graceful-shutdown") process.exit(0); + }); + process.stdout.write("first stdout line\\n"); + process.stderr.write("first stderr line\\n"); + setTimeout(() => { + process.stdout.write("later stdout line\\n"); + process.send?.({ type: "paseo:shutdown", reason: "closed_output_probe" }); + }, 500); + setInterval(() => {}, 1000); + `, + }); + + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.log).toContain("first stderr line\n"); + expect(result.log).toContain("later stdout line\n"); + expect(result.log).toContain('"reason":"closed_output_probe"'); + }); + test("preserves raw non-JSON stdout and stderr lines", async () => { const result = await runSupervisorFixture({ workerSource: ` diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index 1f8c1374c6..ec56822619 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -166,6 +166,15 @@ function createDurableLog( }; } +// The program that started the daemon can close its end of stdout or stderr, as +// `paseo daemon run | head -1` does. Drop the output nobody reads and keep supervising +// the worker; daemon.log still records it. +function ignoreClosedOutput(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE") { + throw error; + } +} + export function runSupervisor(options: SupervisorOptions): SupervisorController { const restartOnCrash = options.restartOnCrash ?? false; const workerArgs = options.workerArgs ?? process.argv.slice(2); @@ -482,6 +491,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController process.on("SIGINT", () => forwardSignal("SIGINT")); process.on("SIGTERM", () => forwardSignal("SIGTERM")); + process.stdout.on("error", ignoreClosedOutput); + process.stderr.on("error", ignoreClosedOutput); process.stdout.write(`[${options.name}] ${options.startupMessage}\n`); writeLifecycleLog(options.startupMessage); diff --git a/packages/server/src/executable-resolution/executable-resolution.test.ts b/packages/server/src/executable-resolution/executable-resolution.test.ts index 4434089e98..2ee0bd90df 100644 --- a/packages/server/src/executable-resolution/executable-resolution.test.ts +++ b/packages/server/src/executable-resolution/executable-resolution.test.ts @@ -3,12 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { - executableExists, - findExecutable, - quoteWindowsArgument, - quoteWindowsCommand, -} from "./executable-resolution.js"; +import { executableExists, findExecutable } from "./executable-resolution.js"; import { windowsExecutableResolution } from "./windows.js"; import { isPlatform } from "../test-utils/platform.js"; @@ -217,118 +212,3 @@ describe("executableExists", () => { expect(executableExists("/missing/codex", () => false)).toBeNull(); }); }); - -describe("quoteWindowsCommand", () => { - const originalPlatform = process.platform; - - function setPlatform(value: string) { - Object.defineProperty(process, "platform", { value, writable: true }); - } - - afterEach(() => { - setPlatform(originalPlatform); - }); - - test("quotes a Windows path with spaces", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe( - '"C:\\Program Files\\Anthropic\\claude.exe"', - ); - }); - - test("does not double-quote an already-quoted path", () => { - setPlatform("win32"); - expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe( - '"C:\\Program Files\\Anthropic\\claude.exe"', - ); - }); - - test("returns the command unchanged when there are no spaces", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); - }); - - test("escapes ampersands", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix"); - }); - - test("escapes pipes", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix"); - }); - - test("does not double percent signs", () => { - setPlatform("win32"); - // cmd.exe only collapses %% → % inside batch files; on the command line - // it stays literal, which corrupts git --format atoms etc. - expect(quoteWindowsCommand("100%")).toBe("100%"); - }); - - test("preserves git --format atoms verbatim", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe( - "--format=%^(refname^)%09%^(committerdate:unix^)", - ); - }); - - test("escapes carets", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix"); - }); - - test("escapes multiple metacharacters", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("build&(test|deploy)!")).toBe( - "build^&^(test^|deploy^)^!^", - ); - }); - - test("quotes commands with spaces after escaping metacharacters", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe( - '"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"', - ); - }); - - test("returns the command unchanged on non-Windows platforms", () => { - setPlatform("darwin"); - expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); - }); -}); - -describe("quoteWindowsArgument", () => { - const originalPlatform = process.platform; - - function setPlatform(value: string) { - Object.defineProperty(process, "platform", { value, writable: true }); - } - - afterEach(() => { - setPlatform(originalPlatform); - }); - - test("quotes a Windows argument with spaces", () => { - setPlatform("win32"); - expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe( - '"C:\\Program Files\\Anthropic\\cli.js"', - ); - }); - - test("does not double-quote an already-quoted argument", () => { - setPlatform("win32"); - expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe( - '"C:\\Program Files\\Anthropic\\cli.js"', - ); - }); - - test("returns the argument unchanged when there are no spaces", () => { - setPlatform("win32"); - expect(quoteWindowsArgument("--version")).toBe("--version"); - }); - - test("returns the argument unchanged on non-Windows platforms", () => { - setPlatform("darwin"); - expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); - }); -}); diff --git a/packages/server/src/executable-resolution/executable-resolution.ts b/packages/server/src/executable-resolution/executable-resolution.ts index 75f230409f..59c218511b 100644 --- a/packages/server/src/executable-resolution/executable-resolution.ts +++ b/packages/server/src/executable-resolution/executable-resolution.ts @@ -2,11 +2,8 @@ import { createRequire } from "node:module"; import { existsSync } from "node:fs"; import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { execCommand } from "../utils/spawn.js"; -import { isWindowsCommandScript } from "../utils/windows-command.js"; import { windowsExecutableResolution } from "./windows.js"; -export { quoteWindowsArgument, quoteWindowsCommand } from "../utils/windows-command.js"; - type Which = ( command: string, options: { all: true; path?: string; pathExt?: string }, @@ -82,7 +79,6 @@ export async function probeExecutable( timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 64 * 1024, - shell: isWindowsCommandScript(executablePath), }); return true; } catch (error) { diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 41e2d52014..0bcd46636a 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -309,18 +309,30 @@ export async function sendPromptToAgent( const unarchive = params.unarchive ?? true; const record = await params.agentStorage.get(params.agentId); + let archivedAtToRestore: string | null = null; if (record?.archivedAt) { if (!unarchive) { return { disposition: "turn_started" }; } - await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId); + if (await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId)) { + archivedAtToRestore = record.archivedAt; + } } - await ensureAgentLoaded(params.agentId, { - agentManager: params.agentManager, - agentStorage: params.agentStorage, - logger: params.logger, - }); + try { + await ensureAgentLoaded(params.agentId, { + agentManager: params.agentManager, + agentStorage: params.agentStorage, + logger: params.logger, + }); + } catch (error) { + // A send that could not load the agent leaves it where it was: still archived. + // Concurrent sends share this load, so none of them holds a live session. + if (archivedAtToRestore) { + await params.agentManager.archiveSnapshot(params.agentId, archivedAtToRestore); + } + throw error; + } if (params.sessionMode) { await params.agentManager.setAgentMode(params.agentId, params.sessionMode); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 3e5e739577..34d36c9e16 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -628,6 +628,11 @@ export interface AgentCreateSessionOptions { * Defaults to true. Providers that cannot honor false should no-op. */ persistSession?: boolean; + /** + * Model ids added by provider configuration (`models` / `additionalModels`). + * Providers that validate against runtime-advertised models must accept these. + */ + configuredModelIds?: readonly string[]; } /** What a resumed session is for: driving the agent, or reading what it already did. */ @@ -637,6 +642,8 @@ export type AgentResumePurpose = "interactive" | "history"; export interface AgentResumeSessionOptions { /** Defaults to interactive. History loading may be read-only for archived native sessions. */ purpose?: AgentResumePurpose; + /** See AgentCreateSessionOptions.configuredModelIds. */ + configuredModelIds?: readonly string[]; } /** diff --git a/packages/server/src/server/agent/agent-stream-coalescer.test.ts b/packages/server/src/server/agent/agent-stream-coalescer.test.ts index c48a5ec8e4..1cdf4bf375 100644 --- a/packages/server/src/server/agent/agent-stream-coalescer.test.ts +++ b/packages/server/src/server/agent/agent-stream-coalescer.test.ts @@ -723,6 +723,57 @@ describe("AgentStreamCoalescer", () => { ]); }); + test("keeps a known tool call detail when a later update in the window has none", () => { + const { coalescer, flushes } = createHarness(); + primeLeadingEdge(coalescer, flushes); + + coalescer.handle( + "agent-1", + timeline({ + type: "tool_call", + callId: "agent-call", + name: "Task", + status: "running", + error: null, + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "List files", + log: "", + actions: [], + }, + }), + ); + coalescer.handle( + "agent-1", + timeline({ + type: "tool_call", + callId: "agent-call", + name: "Agent", + status: "completed", + error: null, + detail: { type: "unknown", input: { description: "List files" }, output: null }, + }), + ); + + expect(flushes.map((flush) => flush.item)).toEqual([ + { + type: "tool_call", + callId: "agent-call", + name: "Agent", + status: "completed", + error: null, + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "List files", + log: "", + actions: [], + }, + }, + ]); + }); + test("preserves mixed text and tool call arrival order within a flush", async () => { const { coalescer, flushes } = createHarness(); primeLeadingEdge(coalescer, flushes); diff --git a/packages/server/src/server/agent/agent-stream-coalescer.ts b/packages/server/src/server/agent/agent-stream-coalescer.ts index ca52a5479e..c11d968228 100644 --- a/packages/server/src/server/agent/agent-stream-coalescer.ts +++ b/packages/server/src/server/agent/agent-stream-coalescer.ts @@ -1,4 +1,5 @@ import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "./agent-sdk-types.js"; +import { mergeToolCallItems } from "./timeline-projection.js"; export const AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS = 60; @@ -183,9 +184,16 @@ export class AgentStreamCoalescer { } const existingIndex = buffer.toolCallEntryIndexes.get(event.item.callId); + const existing = existingIndex !== undefined ? buffer.entries[existingIndex] : undefined; + // Merge rather than replace, so an update that carries less (an unknown detail after a + // labeled one) cannot erase what the timeline would have kept had both been flushed. + let item = event.item; + if (existing?.kind === "tool_call") { + item = mergeToolCallItems(existing.item, event.item); + } const entry: PendingToolCallEntry = { kind: "tool_call", - item: event.item, + item, provider: event.provider, ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), }; diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index bd22d37df3..2698a3b2ac 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -492,6 +492,7 @@ function wrapClientProvider( const listFeatures = inner.listFeatures?.bind(inner); const archiveNativeSession = inner.archiveNativeSession?.bind(inner); const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner); + const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id); return { provider, @@ -509,7 +510,7 @@ function wrapClientProvider( providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), }, launchContext, - options, + { ...options, configuredModelIds }, ), ), resumeSession: async (handle, overrides, launchContext, options) => @@ -526,7 +527,7 @@ function wrapClientProvider( providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions), }, launchContext, - options, + { ...options, configuredModelIds }, ), ), fetchCatalog: async (options, context) => { diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index f847745d69..33c9696900 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1,9 +1,11 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { EventEmitter } from "node:events"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { pathToFileURL } from "node:url"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AgentSideConnection, ClientSideConnection, @@ -52,8 +54,10 @@ import type { AgentStreamEvent } from "../agent-sdk-types.js"; import type { AgentCapabilityFlags, AgentPersistenceHandle, + AgentSession, ProviderRefreshContext, } from "../agent-sdk-types.js"; +import { buildProviderRegistry } from "../provider-registry.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js"; import { asInternals } from "../../test-utils/class-mocks.js"; @@ -3436,6 +3440,8 @@ interface ACPCloseInternals { child: ChildProcess | null; connection: unknown; sessionId: string | null; + activeForegroundTurnId: string | null; + agentCapabilities: { sessionCapabilities?: { close?: unknown } } | null; } async function startTerminal( @@ -3502,6 +3508,38 @@ describe("ACPAgentSession close() tree-kill", () => { expect(child.kill).not.toHaveBeenCalled(); }); + test("close() terminates the provider when cancel and closeSession never settle", async () => { + vi.useFakeTimers(); + try { + const terminator = new FakeTerminator(); + const session = createSession({ terminateProcess: terminator.terminate }); + const child = createTerminalChildStub(); + const cancel = vi.fn(() => new Promise(() => undefined)); + const unstableCloseSession = vi.fn(() => new Promise(() => undefined)); + const internals = asInternals(session); + internals.child = child; + internals.sessionId = "session-1"; + internals.activeForegroundTurnId = "turn-1"; + internals.agentCapabilities = { sessionCapabilities: { close: {} } }; + internals.connection = { cancel, unstable_closeSession: unstableCloseSession }; + + let settled = false; + const closing = (async () => { + await session.close(); + settled = true; + })(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(settled).toBe(true); + expect(cancel).toHaveBeenCalledWith({ sessionId: "session-1" }); + expect(unstableCloseSession).toHaveBeenCalledWith({ sessionId: "session-1" }); + expect(terminator.terminated).toContain(child); + await closing; + } finally { + vi.useRealTimers(); + } + }); + test("close() terminates running terminal child processes", async () => { const terminator = new FakeTerminator(); const session = createSession({ terminateProcess: terminator.terminate }); @@ -4220,3 +4258,265 @@ describe("ACP session/load invariant — cwd and mcpServers always passed", () = }); }); }); + +const SILENT_CLOSE_ACP_AGENT = ` +import { readFileSync, writeFileSync } from "node:fs"; +import { Readable, Writable } from "node:stream"; +const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL); +writeFileSync(process.env.ACP_PID_FILE, String(process.pid)); +new AgentSideConnection( + () => ({ + async initialize() { + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { sessionCapabilities: { close: {} } }, + }; + }, + async newSession() { + return { sessionId: "silent-close-session" }; + }, + async authenticate() {}, + async cancel() {}, + unstable_closeSession() { + return new Promise(() => {}); + }, + }), + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); +`; + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function settlesWithin(promise: Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }); + try { + return await Promise.race([promise.then(() => true), timedOut]); + } finally { + clearTimeout(timer); + } +} + +interface SilentCloseProvider { + session: ACPAgentSession; + readPid(): Promise; + dispose(): Promise; +} + +async function startSilentCloseProvider(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-silent-close-")); + const agentScript = path.join(dir, "agent.mjs"); + const pidFile = path.join(dir, "agent.pid"); + await writeFile(agentScript, SILENT_CLOSE_ACP_AGENT); + const session = new ACPAgentSession( + { provider: "silent-close-acp", cwd: dir }, + { + provider: "silent-close-acp", + logger: createTestLogger(), + defaultCommand: [process.execPath, agentScript], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + launchEnv: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + ACP_PID_FILE: pidFile, + }, + }, + ); + const readPid = async () => Number(await readFile(pidFile, "utf8")); + return { + session, + readPid, + async dispose() { + const pid = await readPid().catch(() => null); + if (pid !== null && isProcessAlive(pid)) { + process.kill(pid, "SIGKILL"); + } + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +describe("ACPAgentSession close() with an unresponsive provider", () => { + let provider: SilentCloseProvider | null = null; + + afterEach(async () => { + await provider?.dispose(); + provider = null; + }); + + test("terminates a provider that never answers session/close", async () => { + provider = await startSilentCloseProvider(); + await provider.session.initializeNewSession(); + const pid = await provider.readPid(); + expect(isProcessAlive(pid)).toBe(true); + + expect(await settlesWithin(provider.session.close(), 8_000)).toBe(true); + expect(isProcessAlive(pid)).toBe(false); + }, 15_000); +}); + +// An ACP agent that advertises two models (or none, with ACP_ADVERTISE_MODELS=none) but, +// like Cline, runs any model id it is given. +const PERMISSIVE_MODEL_ACP_AGENT = ` +import { Readable, Writable } from "node:stream"; +const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL); +let currentModelId = "advertised-default"; +const modelState = () => + process.env.ACP_ADVERTISE_MODELS === "none" + ? undefined + : { + currentModelId, + availableModels: [ + { modelId: "advertised-default", name: "Advertised default" }, + { modelId: "advertised-other", name: "Advertised other" }, + ], + }; +new AgentSideConnection( + () => ({ + async initialize() { + return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: true } }; + }, + async newSession() { + return { sessionId: "permissive-session", models: modelState() }; + }, + async loadSession() { + return { models: modelState() }; + }, + async unstable_setSessionModel({ modelId }) { + currentModelId = modelId; + return {}; + }, + async authenticate() {}, + async cancel() {}, + async prompt() { + return { stopReason: "end_turn" }; + }, + }), + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); +`; + +describe("custom ACP provider with configured models", () => { + let dir: string; + let agentScript: string; + let session: AgentSession | null = null; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-configured-models-")); + agentScript = path.join(dir, "agent.mjs"); + await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT); + }); + + afterEach(async () => { + await session?.close(); + session = null; + await rm(dir, { recursive: true, force: true }); + }); + + function buildRegistry( + override: { + models?: Array<{ id: string; label: string }>; + additionalModels?: Array<{ id: string; label: string }>; + }, + agentEnv: Record = {}, + ) { + return buildProviderRegistry(createTestLogger(), { + providerOverrides: { + permissive: { + extends: "acp", + label: "Permissive", + command: [process.execPath, agentScript], + env: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + ...agentEnv, + }, + ...override, + }, + }, + }); + } + + async function createSessionOnModel( + registry: ReturnType, + model: string, + ): Promise { + const client = registry.permissive.createClient(createTestLogger()); + session = await client.createSession({ provider: "permissive", cwd: dir, model }); + return session; + } + + test("runs on a model added through additionalModels that the agent does not advertise", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("runs on a model from a replacement models list that the agent does not advertise", async () => { + const registry = buildRegistry({ + models: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("runs on a model from additionalModels when the agent advertises no models", async () => { + const registry = buildRegistry( + { additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }] }, + { ACP_ADVERTISE_MODELS: "none" }, + ); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("resumes on a model added through additionalModels that the agent does not advertise", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + const client = registry.permissive.createClient(createTestLogger()); + + session = await client.resumeSession( + { provider: "permissive", sessionId: "permissive-session", metadata: { cwd: dir } }, + { model: "stealth/pixel-canary" }, + ); + + expect((await session.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("keeps the agent's model when the requested model is neither advertised nor configured", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stale/removed-model"); + + expect((await created.getRuntimeInfo()).model).toBe("advertised-default"); + }); +}); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 1b47ccaccb..6bded49feb 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -76,6 +76,9 @@ import { type AgentPersistenceHandle, type AgentPromptContentBlock, type AgentPromptInput, + type AgentResumePurpose, + type AgentCreateSessionOptions, + type AgentResumeSessionOptions, type AgentRunOptions, type AgentRunResult, type AgentRuntimeInfo, @@ -148,6 +151,18 @@ function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promis }); } +async function isDirectory(target: string): Promise { + try { + return (await fs.stat(target)).isDirectory(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + return false; + } + throw error; + } +} + function isRecord(value: unknown): value is Record { return value != null && typeof value === "object" && !Array.isArray(value); } @@ -301,6 +316,10 @@ export function buildACPClientCapabilities( const PROBE_ENV: Record = { NO_BROWSER: "true" }; const ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS = 20_000; const ACP_PROBE_CLOSE_TIMEOUT_MS = 2_000; +// Archive and delete await close(). The ACP SDK does not reject a pending +// request when the provider never answers, so these calls must be bounded +// or the provider process is never terminated. +const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2_000; const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000; const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000; @@ -493,6 +512,8 @@ interface ACPAgentSessionOptions { capabilities: AgentCapabilityFlags; extensionCommandsParser?: ACPExtensionCommandsParser; handle?: AgentPersistenceHandle; + resumePurpose?: AgentResumePurpose; + configuredModelIds?: readonly string[]; agentId?: string; launchEnv?: Record; waitForInitialCommands?: boolean; @@ -960,6 +981,7 @@ export class ACPAgentClient implements AgentClient { async createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, ): Promise { this.assertProvider(config); const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {}); @@ -987,6 +1009,7 @@ export class ACPAgentClient implements AgentClient { supportsMcpServers: providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, }, + configuredModelIds: options?.configuredModelIds, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1002,6 +1025,7 @@ export class ACPAgentClient implements AgentClient { handle: AgentPersistenceHandle, overrides?: Partial, launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, ): Promise { if (handle.provider !== this.provider) { throw new Error(`Cannot resume ${handle.provider} handle with ${this.provider} provider`); @@ -1043,6 +1067,8 @@ export class ACPAgentClient implements AgentClient { providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, }, handle, + resumePurpose: options?.purpose, + configuredModelIds: options?.configuredModelIds, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -1671,6 +1697,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { configOptions: SessionConfigOption[], ) => SessionConfigOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[]; + private readonly configuredModelIds: ReadonlySet; private readonly clientCapabilities?: ACPClientCapabilities; private readonly clientCapabilityMeta?: ACPClientCapabilityMeta; private readonly modeIdTransformer?: (modeId: string) => string | null; @@ -1696,6 +1723,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly terminalEntries = new Map(); private readonly persistedHistory: AgentTimelineItem[] = []; private readonly initialHandle?: AgentPersistenceHandle; + private readonly resumePurpose: AgentResumePurpose; private readonly config: AgentSessionConfig; private child: ChildProcessWithoutNullStreams | null = null; @@ -1738,6 +1766,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; + this.configuredModelIds = new Set(options.configuredModelIds); this.clientCapabilities = options.clientCapabilities; this.clientCapabilityMeta = options.clientCapabilityMeta; this.modeIdTransformer = options.modeIdTransformer; @@ -1749,6 +1778,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.agentId = options.agentId; this.launchEnv = options.launchEnv; this.initialHandle = options.handle; + this.resumePurpose = options.resumePurpose ?? "interactive"; this.config = { ...config, provider: options.provider }; this.currentMode = config.modeId ?? null; this.currentModel = config.model ?? null; @@ -2171,12 +2201,20 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } + await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) }); + } + + private resolveModelSelection(modelId: string): ACPModelSelection { const selection = resolveACPModelSelection({ modelId, availableModels: this.availableModels, configOptions: this.configOptions, }); - await this.setModelWithSelection({ modelId, selection }); + if (selection.availableModel || !this.configuredModelIds.has(modelId)) { + return selection; + } + // A model the user added in provider config is applied even when the agent does not advertise it. + return { ...selection, availableModel: { modelId, name: modelId } }; } private async setModelWithSelection({ @@ -2190,21 +2228,20 @@ export class ACPAgentSession implements AgentSession, ACPClient { throw new Error("ACP session not initialized"); } - if (selection.hasAvailableModels) { - if (!selection.availableModel) { - this.warnInvalidSelection( - modelId, - `is not a valid ${this.provider} model. Available options: ${this.availableModels - ?.map((model) => model.modelId) - .join(", ")}`, - ); - return; - } - - if (typeof this.connection.unstable_setSessionModel !== "function") { - throw new Error(this.modelSelectionUnavailableMessage()); - } + if (selection.hasAvailableModels && !selection.availableModel) { + this.warnInvalidSelection( + modelId, + `is not a valid ${this.provider} model. Available options: ${this.availableModels + ?.map((model) => model.modelId) + .join(", ")}`, + ); + return; + } + if ( + selection.availableModel && + typeof this.connection.unstable_setSessionModel === "function" + ) { try { await this.connection.unstable_setSessionModel({ sessionId: this.sessionId, @@ -2476,13 +2513,21 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (this.connection && this.sessionId) { try { if (this.activeForegroundTurnId) { - await this.connection.cancel({ sessionId: this.sessionId }); + await withTimeout( + this.connection.cancel({ sessionId: this.sessionId }), + ACP_CLOSE_REQUEST_TIMEOUT_MS, + `ACP cancel timed out after ${ACP_CLOSE_REQUEST_TIMEOUT_MS}ms`, + ); } } catch {} try { if (this.agentCapabilities?.sessionCapabilities?.close) { - await this.connection.unstable_closeSession({ sessionId: this.sessionId }); + await withTimeout( + this.connection.unstable_closeSession({ sessionId: this.sessionId }), + ACP_CLOSE_REQUEST_TIMEOUT_MS, + `ACP closeSession timed out after ${ACP_CLOSE_REQUEST_TIMEOUT_MS}ms`, + ); } } catch (error) { this.logger.debug({ err: error }, "ACP closeSession failed during shutdown"); @@ -2758,6 +2803,16 @@ export class ACPAgentSession implements AgentSession, ACPClient { return {}; } + // Loading an archived agent's history runs nothing in its working directory, which + // may have been removed with its worktree. session/load still names the original + // directory; only the process starts from the home directory. + private async resolveProcessCwd(): Promise { + if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) { + return this.config.cwd; + } + return homedir(); + } + private async spawnProcess(): Promise { const prefix = await resolveProviderLaunch({ commandConfig: this.runtimeSettings?.command, @@ -2771,7 +2826,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { const command = prefix.command; const args = [...prefix.args, ...this.defaultCommand.slice(1)]; const child = spawnProcess(command, args, { - cwd: this.config.cwd, + cwd: await this.resolveProcessCwd(), ...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings, overlays: [this.launchEnv], @@ -2881,13 +2936,11 @@ export class ACPAgentSession implements AgentSession, ACPClient { const configuredModelId = this.config.model; let switchedModel = false; if (configuredModelId && configuredModelId !== this.currentModel) { - const selection = resolveACPModelSelection({ - modelId: configuredModelId, - availableModels: this.availableModels, - configOptions: this.configOptions, - }); try { - await this.setModelWithSelection({ modelId: configuredModelId, selection }); + await this.setModelWithSelection({ + modelId: configuredModelId, + selection: this.resolveModelSelection(configuredModelId), + }); switchedModel = true; } catch (error) { if (!this.isModelSelectionUnavailableError(error)) { diff --git a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index 5069c3653f..747a466c32 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -327,6 +327,57 @@ test("fails an auto mode turn when Claude Code uses Vertex", async () => { } }); +test("switches an auto mode session to another mode when Claude Code uses Bedrock", async () => { + const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK; + process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + sdkQueryFactory.mockImplementation(() => queryMock); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdkQueryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "auto", + }); + + try { + await session.setMode("acceptEdits"); + + expect(await session.getCurrentMode()).toBe("acceptEdits"); + expect(sdkQueryFactory).toHaveBeenCalledTimes(1); + expect(sdkQueryFactory.mock.calls[0]?.[0]?.options?.permissionMode).toBe("acceptEdits"); + } finally { + restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock); + await session.close(); + } +}); + +test("does not keep a query started in a mode that Claude Code rejected", async () => { + const launchedModes: Array = []; + sdkQueryFactory.mockImplementation(({ options }: { options: { permissionMode?: string } }) => { + launchedModes.push(options.permissionMode); + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + if (launchedModes.length === 1) { + queryMock.setPermissionMode.mockRejectedValue(new Error("bypass refused")); + } + return queryMock; + }); + const session = await createSession(); + + try { + await expect(session.setMode("bypassPermissions")).rejects.toThrow("bypass refused"); + await session.listCommands(); + + expect(await session.getCurrentMode()).toBe("default"); + expect(launchedModes).toEqual(["bypassPermissions", "default"]); + } finally { + await session.close(); + } +}); + test("logs redacted query summary and never leaks sentinel secrets", async () => { const envSecret = "PASEO_ENV_SENTINEL_SECRET"; const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET"; @@ -1037,11 +1088,11 @@ test("preserves bypass capability across query restarts triggered by thinking ch expect(capturedOptions).toHaveLength(2); expect(capturedOptions[0]).toMatchObject({ - permissionMode: "default", + permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, }); expect(capturedOptions[1]).toMatchObject({ - permissionMode: "acceptEdits", + permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, effort: "high", }); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 8701ab87ec..48ddab5973 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -712,4 +712,56 @@ describe("ClaudeAgentSession persisted subagent replay", () => { expect(timeline.length).toBeGreaterThan(0); expect(timeline[0]).toMatchObject({ id: TOOL_USE_ID }); }); + + test("replays a SubagentHandback report as the subagent's final message", async () => { + const handbackId = "toolu_handback"; + const report = "## Verdict\n\n- **Coherent**"; + writeSession({ + parentLines: [taskToolUse(), taskToolResult()], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: handbackId, + name: "SubagentHandback", + input: { message: report }, + }, + ], + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: handbackId, + content: [ + { + type: "text", + text: '{"success":true,"message":"Report delivered to your caller."}', + }, + ], + }, + ], + }, + }), + ], + }); + + const items = (await replayDescriptors()).flatMap((event) => + event.event.type === "timeline" ? [event.event.item] : [], + ); + expect(items).toEqual([{ type: "assistant_message", text: report }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts new file mode 100644 index 0000000000..e5e8d117a4 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import pino from "pino"; +import { expect, test } from "vitest"; + +import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; + +const ROOT_PROMPT = `Use Claude Code's native Agent tool exactly once, never Paseo tools, with this complete task: + +Do not use any tools. Reply with exactly this markdown report and nothing else: +## HANDBACK_HEADING +- **HANDBACK_BOLD** item + +Then reply exactly ROOT_DONE.`; + +function subagentItems(events: AgentStreamEvent[]): AgentTimelineItem[] { + return events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event.item] : [], + ); +} + +function expectHandbackAsReport(items: AgentTimelineItem[]): void { + expect(items.filter((item) => item.type === "tool_call")).toEqual([]); + expect( + items.flatMap((item) => (item.type === "assistant_message" ? [item.text] : [])).join(""), + ).toContain("## HANDBACK_HEADING\n- **HANDBACK_BOLD** item"); +} + +test("an auto-mode Claude subagent's handback report shows as its final message", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-subagent-handback-")); + const client = new ClaudeAgentClient({ logger: pino({ level: "warn" }) }); + const session = await client.createSession({ provider: "claude", cwd, modeId: "auto" }); + try { + const events = await collectSessionTurnEvents(session, ROOT_PROMPT); + expectHandbackAsReport(subagentItems(events)); + + const persistence = session.describePersistence(); + expect(persistence).not.toBeNull(); + await session.close(); + const restored = await client.resumeSession(persistence!, { cwd }); + try { + const replayed: AgentStreamEvent[] = []; + for await (const event of restored.streamHistory()) replayed.push(event); + expectHandbackAsReport(subagentItems(replayed)); + } finally { + await restored.close(); + } + } finally { + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 240_000); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index aa60e40462..ddac56a0eb 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 { readClaudeSubagentHandback } from "./subagent-handback.js"; import { ClaudeTaskState } from "./task-state.js"; import { ClaudeTaskProtocolSource, @@ -2084,6 +2085,7 @@ class ClaudeAgentSession implements AgentSession { private toolUseCache = new Map(); private toolUseIndexToId = new Map(); private toolUseInputBuffers = new Map(); + private subagentHandbackIds = new Set(); private pendingPermissions = new Map(); private activeForegroundTurnId: string | null = null; private autonomousTurn: AutonomousTurnState | null = null; @@ -2439,8 +2441,15 @@ class ClaudeAgentSession implements AgentSession { const normalized = isPermissionMode(modeId) ? modeId : "default"; assertClaudeModeCanRun(normalized, this.harnessEnvironment); const previousMode = this.currentMode; - const activeQuery = await this.ensureQuery(); - await activeQuery.setPermissionMode(normalized); + const launchesQuery = !this.query || this.queryRestartNeeded; + const activeQuery = await this.ensureQuery(normalized); + try { + await activeQuery.setPermissionMode(normalized); + } catch (error) { + // The query was launched in the rejected mode; relaunch in the current mode next time. + if (launchesQuery) this.queryRestartNeeded = true; + throw error; + } if (normalized === "plan") { if (previousMode !== "plan") { this.planResumeMode = previousMode; @@ -3126,7 +3135,7 @@ class ClaudeAgentSession implements AgentSession { return { kind: "fresh-session" }; } - private async ensureQuery(): Promise { + private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise { if (this.query && !this.queryRestartNeeded) { return this.query; } @@ -3170,7 +3179,7 @@ class ClaudeAgentSession implements AgentSession { this.persistence = null; const input = createAsyncMessageInput(); - const options = await this.buildOptions(); + const options = await this.buildOptions(launchMode); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; this.query = claudeQuery( @@ -3296,7 +3305,7 @@ class ClaudeAgentSession implements AgentSession { }); } - private async buildOptions(): Promise { + private async buildOptions(permissionMode: PermissionMode): Promise { const { thinking, effort, ultracode } = this.resolveThinkingConfig(); const appendedSystemPrompt = this.buildAppendedSystemPrompt(); const providerOptions = applyClaudeToolPolicy( @@ -3305,7 +3314,7 @@ class ClaudeAgentSession implements AgentSession { ); const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode }); const sdkEnv = this.harnessEnvironment; - assertClaudeModeCanRun(this.currentMode, sdkEnv); + assertClaudeModeCanRun(permissionMode, sdkEnv); const claudeBinary = await this.resolveBinary(); this.logger.debug( @@ -3328,7 +3337,7 @@ class ClaudeAgentSession implements AgentSession { const base: ClaudeOptions = { cwd: this.config.cwd, includePartialMessages: true, - permissionMode: this.currentMode, + permissionMode, // Dynamic mode switching can recreate the underlying Claude query. Keep the // bypass launch capability available so later setPermissionMode("bypassPermissions") // calls do not fail after a model/thinking/rewind-driven restart. @@ -5184,7 +5193,7 @@ class ClaudeAgentSession implements AgentSession { // User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message. const userTextParts: string[] = []; for (const block of content) { - if (!isClaudeContentChunk(block)) { + if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) { continue; } this.mapBlockToTimeline(block, { @@ -5272,6 +5281,24 @@ class ClaudeAgentSession implements AgentSession { } } + // A subagent's handback call is its final message, and the call's result is only an + // acknowledgement, so neither becomes a tool call. + private mapSubagentHandbackBlock(block: ClaudeContentChunk, items: AgentTimelineItem[]): boolean { + if (typeof block.tool_use_id === "string" && this.subagentHandbackIds.has(block.tool_use_id)) { + this.subagentHandbackIds.delete(block.tool_use_id); + return true; + } + const handback = readClaudeSubagentHandback(block); + if (!handback) { + return false; + } + this.subagentHandbackIds.add(handback.callId); + if (handback.report) { + items.push({ type: "assistant_message", text: handback.report }); + } + return true; + } + private handleToolUseStart(block: ClaudeContentChunk, items: AgentTimelineItem[]): void { const entry = this.upsertToolUseEntry(block); if (!entry) { diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts index 615ca81f3a..f762557879 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -35,4 +35,64 @@ describe("ClaudeSidechainTracker", () => { }, }); }); + + it("shows a SubagentHandback report as the subagent's final message", () => { + const tracker = new ClaudeSidechainTracker({ getToolInput: () => null }); + const report = " indented code\n\n## Verdict\n\n- **Coherent**"; + const frames = [ + { + type: "stream_event", + parent_tool_use_id: "task-1", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "handback-1", + name: "SubagentHandback", + input: {}, + }, + }, + }, + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { + id: "msg-1", + content: [ + { + type: "tool_use", + id: "handback-1", + name: "SubagentHandback", + input: { message: report }, + }, + ], + }, + }, + { + type: "user", + parent_tool_use_id: "task-1", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "handback-1", + tool_name: "SubagentHandback", + content: [{ type: "text", text: '{"success":true}' }], + }, + ], + }, + }, + ]; + + const childItems = frames + .flatMap((frame) => tracker.handleMessage(frame as unknown as SDKMessage, "task-1")) + .flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" + ? [event.event.item] + : [], + ); + + expect(childItems).toEqual([{ type: "assistant_message", text: report, messageId: "msg-1" }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index 6973d1db5d..195e21f857 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -6,6 +6,10 @@ import { mapClaudeRunningToolCall, } from "./tool-call-mapper.js"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; +import { + isClaudeSubagentHandbackToolName, + readClaudeSubagentHandback, +} from "./subagent-handback.js"; import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; @@ -91,7 +95,7 @@ export class ClaudeSidechainTracker { const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId); const actionCandidates = this.extractSubAgentActionCandidates(message); const childTimelineItems = [ - ...this.extractSubAgentTimelineItems(message), + ...this.extractSubAgentTimelineItems(message, state), ...this.extractSubAgentToolResults(message, state), ]; let actionUpdated = false; @@ -233,7 +237,10 @@ export class ClaudeSidechainTracker { this.activeSidechains.clear(); } - private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] { + private extractSubAgentTimelineItems( + message: SDKMessage, + state: SubAgentActivityState, + ): AgentTimelineItem[] { if (message.type !== "assistant" || !Array.isArray(message.message?.content)) { return []; } @@ -253,6 +260,18 @@ export class ClaudeSidechainTracker { } else if (block.type === "thinking") { const text = readTrimmedString(block.thinking); if (text) items.push({ type: "reasoning", text }); + } else { + const handback = readClaudeSubagentHandback(block); + if (!handback) continue; + // The handback's result is only an acknowledgement, never a tool card. + state.completedActionKeys.add(handback.callId); + if (handback.report) { + items.push({ + type: "assistant_message", + text: handback.report, + ...(messageId ? { messageId } : {}), + }); + } } } return items; @@ -391,6 +410,12 @@ export class ClaudeSidechainTracker { } private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { + return this.extractToolActionCandidates(message).filter( + (action) => !isClaudeSubagentHandbackToolName(action.toolName), + ); + } + + private extractToolActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { if (message.type === "assistant") { return this.extractAssistantMessageActions(message); } diff --git a/packages/server/src/server/agent/providers/claude/subagent-handback.ts b/packages/server/src/server/agent/providers/claude/subagent-handback.ts new file mode 100644 index 0000000000..2a8613ed31 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagent-handback.ts @@ -0,0 +1,27 @@ +/** + * Since Claude Code 2.1.271, a subagent in auto mode delivers its report to its caller through a + * `SubagentHandback` tool call instead of its final text. The report is the subagent's final + * message, so Paseo shows it as one and hides the call and its acknowledgement. + */ +const SUBAGENT_HANDBACK_TOOL_NAME = "SubagentHandback"; + +export interface ClaudeSubagentHandback { + callId: string; + /** Null while the call is still streaming its input. */ + report: string | null; +} + +export function isClaudeSubagentHandbackToolName(name: unknown): boolean { + return name === SUBAGENT_HANDBACK_TOOL_NAME; +} + +export function readClaudeSubagentHandback(block: { + type: string; + [key: string]: unknown; +}): ClaudeSubagentHandback | null { + if (block.type !== "tool_use" || !isClaudeSubagentHandbackToolName(block.name)) return null; + if (typeof block.id !== "string" || block.id.length === 0) return null; + const input = block.input as { message?: unknown } | null | undefined; + const report = typeof input?.message === "string" ? input.message : ""; + return { callId: block.id, report: report.trim().length > 0 ? report : null }; +} diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts new file mode 100644 index 0000000000..ec17dd7ecf --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts @@ -0,0 +1,154 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +describe("GenericACPAgentClient archived history", () => { + test("replays an archived session whose working directory was removed", async () => { + await withFakeACPAgent(async ({ command, removedCwd, loadRequestsPath }) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "history" }, + ); + try { + expect(await collectTimelineText(session.streamHistory())).toEqual([ + "user_message:What is in the README?", + "assistant_message:It describes the project.", + ]); + const loadRequests = JSON.parse(await readFile(loadRequestsPath, "utf8")); + expect(loadRequests).toEqual([{ sessionId: "archived-session", cwd: removedCwd }]); + } finally { + await session.close(); + } + }); + }); + + test("replays an archived session whose working directory path is now a file", async () => { + await withFakeACPAgent(async ({ command, removedCwd }) => { + await writeFile(removedCwd, "not a directory", "utf8"); + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "history" }, + ); + try { + expect(await collectTimelineText(session.streamHistory())).toEqual([ + "user_message:What is in the README?", + "assistant_message:It describes the project.", + ]); + } finally { + await session.close(); + } + }); + }); + + test("still requires the working directory to resume a session that will run", async () => { + await withFakeACPAgent(async ({ command, removedCwd }) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + await expect( + client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "interactive" }, + ), + ).rejects.toThrow("ENOENT"); + }); + }); +}); + +async function collectTimelineText(events: AsyncGenerator): Promise { + const text: string[] = []; + for await (const event of events) { + if (event.type !== "timeline") { + continue; + } + if (event.item.type === "user_message" || event.item.type === "assistant_message") { + text.push(`${event.item.type}:${event.item.text}`); + } + } + return text; +} + +async function withFakeACPAgent( + run: (input: { + command: [string, ...string[]]; + removedCwd: string; + loadRequestsPath: string; + }) => Promise, +): Promise { + const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-")); + try { + const scriptPath = path.join(testDir, "fake-acp-agent.cjs"); + const loadRequestsPath = path.join(testDir, "load-requests.json"); + await writeFile(scriptPath, fakeACPAgentScript, "utf8"); + await run({ + command: [process.execPath, scriptPath, loadRequestsPath], + removedCwd: path.join(testDir, "removed-worktree"), + loadRequestsPath, + }); + } finally { + await rm(testDir, { recursive: true, force: true }); + } +} + +const fakeACPAgentScript = ` +const fs = require("node:fs"); +const readline = require("node:readline"); + +const loadRequestsPath = process.argv[2]; +const loadRequests = []; +const rl = readline.createInterface({ input: process.stdin }); + +function write(message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +} + +function replay(sessionId, sessionUpdate, text) { + write({ + method: "session/update", + params: { sessionId, update: { sessionUpdate, content: { type: "text", text } } }, + }); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + write({ + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? 1, + agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} } }, + }, + }); + return; + } + + if (message.method === "session/load") { + const { sessionId, cwd } = message.params; + loadRequests.push({ sessionId, cwd }); + fs.writeFileSync(loadRequestsPath, JSON.stringify(loadRequests)); + replay(sessionId, "user_message_chunk", "What is in the README?"); + replay(sessionId, "agent_message_chunk", "It describes the project."); + write({ id: message.id, result: {} }); + return; + } + + if (message.id !== undefined) { + write({ id: message.id, result: {} }); + } +}); +`; 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 62b6c9ead9..7dfbb6abb8 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 @@ -154,6 +154,28 @@ describe("JsonlRpcProcess", () => { } }); + test("publishes unmatched responses without publishing the response that settles a request", async () => { + const child = createInMemoryChildProcess(); + const transport = startProcess({ child }); + const messages: Record[] = []; + transport.onMessage((message) => messages.push(message)); + + try { + const { id, promise } = transport.startRequest({ type: "prompt" }); + const response = { id, type: "response", command: "prompt" }; + const rejection = { ...response, success: false, error: "rejected after acknowledgement" }; + const unknownResponse = { ...response, id: "unknown-request", success: true }; + child.stdout.write(`${JSON.stringify({ ...response, success: true })}\n`); + child.stdout.write(`${JSON.stringify(rejection)}\n`); + child.stdout.write(`${JSON.stringify(unknownResponse)}\n`); + + await expect(promise).resolves.toBeUndefined(); + expect(messages).toEqual([rejection, unknownResponse]); + } finally { + await transport.close(); + } + }); + test("rejects unsuccessful responses", async () => { const transport = startProcess(); 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 d58e2235ca..9d8b9af623 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts @@ -258,25 +258,27 @@ export class JsonlRpcProcess { this.frameDecoder.write(chunk); } - /** Route a complete logical frame to its consumer (response or subscriber). */ + /** Route a complete logical frame to its consumer (pending request or subscriber). */ private dispatchFrame(message: Record): void { - if (message.type === "response") { - this.handleResponse(message as unknown as JsonlRpcResponse); + const pending = + message.type === "response" && typeof message.id === "string" + ? this.pending.get(message.id) + : undefined; + if (pending) { + this.settleRequest(pending, message as unknown as JsonlRpcResponse & { id: string }); return; } + // A response nothing is waiting for is published like any other frame, so a provider + // can read it as an event, such as OMP 18.3 rejecting a prompt after acknowledging it. for (const subscriber of this.messageSubscribers) { subscriber(message); } } - private handleResponse(response: JsonlRpcResponse): void { - if (!response.id) { - return; - } - const pending = this.pending.get(response.id); - if (!pending) { - return; - } + private settleRequest( + pending: PendingRequest, + response: JsonlRpcResponse & { id: string }, + ): void { if (pending.timer) { clearTimeout(pending.timer); } diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index f06bb62f57..c0e6193bb5 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -459,7 +459,9 @@ describe("OMP agent client and session", () => { test("fails a turn when the provider idle gate passes its deadline", async () => { const scheduler = new ManualIdleScheduler(); - const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 1 }); + // Long enough that the gate's first check, made right after the turn ends, cannot already + // be past it on a slow runner. + const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 50 }); await omp.start(); const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", { isStreaming: true, @@ -473,7 +475,7 @@ describe("OMP agent client and session", () => { args: { command: "sleep 30" }, }); expect(omp.runningToolCallIds()).toEqual(["tool-at-deadline"]); - await new Promise((resolve) => setTimeout(resolve, 2)); + await new Promise((resolve) => setTimeout(resolve, 60)); scheduler.retry(); await expect(completion).rejects.toThrow(/provider idle/i); expect(omp.runningToolCallIds()).toEqual([]); @@ -927,6 +929,24 @@ describe("OMP agent client and session", () => { expect(omp.completedTurnCount()).toBe(1); }); + test.each(["result after ack", "result before ack"] as const)( + "fails a prompt OMP rejects before its agent runs (%s)", + async (order) => { + const omp = new OmpHarness(); + await omp.start(); + + await expect( + omp.runPromptRejectedBeforeAgentRuns( + "Reply with ok.", + "No API key found for anthropic.", + order, + ), + ).rejects.toThrow("No API key found for anthropic."); + expect(omp.turnFailures()).toEqual(["No API key found for anthropic."]); + expect(omp.completedTurnCount()).toBe(0); + }, + ); + test("completes a no-turn notify with one notification and no assistant text", async () => { const scheduler = new ManualNoTurnScheduler(); const omp = new OmpHarness({ noTurnScheduler: scheduler }); @@ -1218,6 +1238,25 @@ describe("OMP agent client and session", () => { expect(omp.runtime().prompts).toEqual([{ message: "continue", imageCount: 0 }]); }); + test("ignores a prompt rejection a previous OMP process left for a reused request id", async () => { + const omp = new OmpHarness(); + await omp.start(); + omp.emit({ + type: "prompt_result", + id: "req_1", + agentInvoked: false, + status: "error", + error: { message: "No API key found for anthropic." }, + }); + omp.processExit("OMP RPC process exited with code 1 and signal null"); + + await omp.runPromptWithoutTurnOnNextRuntime("/session", "req_1"); + + expect(omp.runtimeLaunches()).toHaveLength(2); + expect(omp.turnFailures()).toEqual(["OMP RPC process exited with code 1 and signal null"]); + expect(omp.completedTurnCount()).toBe(1); + }); + test("reports an immediate relaunch failure without retrying in a loop", async () => { const omp = new OmpHarness(); await omp.start(); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 4c97014fe8..2dc6c58dea 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -172,6 +172,21 @@ interface OmpPersistenceMetadata { bridgedTools: Map; } +/** OMP's outcome for one prompt, correlated by the prompt's request id. */ +interface OmpPromptResult { + agentInvoked: boolean; + /** Why OMP rejected the prompt before its agent ran, such as a missing API key. */ + error: string | null; +} + +function promptResultError(result: { + status?: string; + error?: { message: string }; +}): string | null { + if (result.status !== "error") return null; + return result.error?.message || "OMP rejected the prompt"; +} + interface StartTurnResult { turnId: string; } @@ -695,7 +710,8 @@ export class OmpAgentSession implements AgentSession { private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = []; private activePromptRequestId: string | null = null; private activePromptAgentInvoked: boolean | null = null; - private readonly pendingPromptResults = new Map(); + private activePromptError: string | null = null; + private readonly pendingPromptResults = new Map(); private pendingNoTurnCompletionAbort: AbortController | null = null; private lastKnownThinkingOptionId: string | null; private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; @@ -865,12 +881,13 @@ export class OmpAgentSession implements AgentSession { if (ack.requestId) { this.pendingPromptResults.delete(ack.requestId); } - this.activePromptAgentInvoked = correlatedResult ?? ack.agentInvoked ?? null; - if (correlatedResult === false) { + this.activePromptAgentInvoked = correlatedResult?.agentInvoked ?? ack.agentInvoked ?? null; + this.activePromptError = correlatedResult?.error ?? null; + if (correlatedResult?.agentInvoked === false) { this.scheduleNoTurnPromptCompletion(turnId); return; } - if (correlatedResult !== true && ack.agentInvoked === false) { + if (correlatedResult?.agentInvoked !== true && ack.agentInvoked === false) { await this.completeNoTurnPrompt(turnId); return; } @@ -1028,6 +1045,9 @@ export class OmpAgentSession implements AgentSession { const state = await next.getState(); if (this.closed) throw new Error("OMP session is closed"); this.unsubscribeRuntime?.(); + // Request ids restart with each OMP process, so results held for the old one cannot + // correlate with prompts sent to the new one. + this.pendingPromptResults.clear(); this.runtimeSession = next; this.hostTools = restarted.hostTools; this.state = state; @@ -1309,7 +1329,14 @@ export class OmpAgentSession implements AgentSession { ) { return; } + const error = this.activePromptError; this.emitBufferedNoTurnOutputs(turnId); + if (error) { + this.usagePoller.stopTurn(); + this.resetActiveTurn({ terminalizeWork: true }); + this.emit({ type: "turn_failed", provider: this.provider, turnId, error }); + return; + } this.completeTurn(turnId, []); } @@ -1318,6 +1345,7 @@ export class OmpAgentSession implements AgentSession { this.activeNoTurnPromptText = null; this.activePromptRequestId = null; this.activePromptAgentInvoked = null; + this.activePromptError = null; this.pendingNoTurnOutputs.splice(0, this.pendingNoTurnOutputs.length); } @@ -1715,15 +1743,17 @@ export class OmpAgentSession implements AgentSession { ? event.agentInvoked : undefined; if (requestId && agentInvoked !== undefined) { + const result = { agentInvoked, error: promptResultError(event) }; if (requestId === this.activePromptRequestId && this.activeTurnId) { this.activePromptAgentInvoked = agentInvoked; + this.activePromptError = result.error; if (agentInvoked === false) { this.scheduleNoTurnPromptCompletion(this.activeTurnId); } else { this.cancelNoTurnPromptCompletion(); } } else if (this.activePromptRequestId === null) { - this.pendingPromptResults.set(requestId, agentInvoked); + this.pendingPromptResults.set(requestId, result); } } 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 ebe99ead03..2deae45850 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 @@ -227,6 +227,95 @@ describe("OMP CLI runtime", () => { expect(eventTypes).toEqual(["notice"]); }); + test("emits agent_end for a run that contains an OMP developer message", async () => { + const child = createOmpChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const eventTypes: string[] = []; + session.onEvent((event) => eventTypes.push(event.type)); + + // OMP 18.5 injects a developer reminder mid-run when a tool call matches a rule. + const reminder = { + role: "developer", + content: [ + { type: "text", text: '…' }, + ], + attribution: "agent", + timestamp: 2, + }; + child.stdout.write(`${JSON.stringify({ type: "message_end", message: reminder })}\n`); + child.stdout.write( + `${JSON.stringify({ + type: "agent_end", + messages: [ + { role: "user", content: [{ type: "text", text: "Write tiny.ts" }], timestamp: 1 }, + reminder, + { + role: "assistant", + content: [{ type: "text", text: "Done" }], + stopReason: "stop", + timestamp: 3, + }, + ], + })}\n`, + ); + + expect(eventTypes).toEqual(["message_end", "agent_end"]); + }); + + test("emits agent_end for a run that contains every OMP message role Paseo does not render", async () => { + const child = createOmpChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const eventTypes: string[] = []; + session.onEvent((event) => eventTypes.push(event.type)); + + // A prompt that @-mentions a file makes OMP add a fileMention message to the run. + const fileMention = { + role: "fileMention", + files: [{ path: "notes.md", content: "# Notes\n", lineCount: 1 }], + timestamp: 2, + }; + child.stdout.write(`${JSON.stringify({ type: "message_end", message: fileMention })}\n`); + child.stdout.write( + `${JSON.stringify({ + type: "agent_end", + messages: [ + { role: "user", content: [{ type: "text", text: "Read @notes.md" }], timestamp: 1 }, + fileMention, + { + role: "pythonExecution", + code: "print(1)", + output: "1\n", + cancelled: false, + truncated: false, + timestamp: 3, + }, + { + role: "hookMessage", + customType: "hook", + content: "hook output", + display: false, + timestamp: 4, + }, + { role: "branchSummary", summary: "Earlier branch", fromId: "entry-1", timestamp: 5 }, + { + role: "compactionSummary", + summary: "Earlier turns", + tokensBefore: 1000, + timestamp: 6, + }, + { + role: "assistant", + content: [{ type: "text", text: "Done" }], + stopReason: "stop", + timestamp: 7, + }, + ], + })}\n`, + ); + + expect(eventTypes).toEqual(["message_end", "agent_end"]); + }); + test("lists commands through get_available_commands", async () => { const child = createOmpChild(); const commandTypes: string[] = []; @@ -352,6 +441,31 @@ describe("OMP CLI runtime", () => { await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" }); }); + test("reports OMP 18.3's late prompt rejection as a failed prompt result", async () => { + const child = createOmpChild(); + onOmpCommand(child, (command) => { + if (command.type !== "prompt") return; + const response = { id: command.id, type: "response", command: "prompt" }; + const rejection = { ...response, success: false, error: "No API key found for anthropic." }; + child.stdout.write( + `${JSON.stringify({ ...response, success: true })}\n${JSON.stringify(rejection)}\n`, + ); + }); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const events: unknown[] = []; + session.onEvent((event) => events.push(event)); + + await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" }); + + expect(events).toContainEqual({ + type: "prompt_result", + id: "req_1", + agentInvoked: false, + status: "error", + error: { message: "No API key found for anthropic." }, + }); + }); + test("negotiates RPC protocol v2 when OMP advertises it", async () => { const child = createOmpChild({ supportedProtocolVersions: [1, 2] }); const commands: Record[] = []; 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 51d340ab2c..75bd79f5f0 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -27,7 +27,7 @@ import { OmpModelsResultSchema, OmpPromptAckSchema, OmpRpcCommandSchema, - OmpRuntimeEventSchema, + parseOmpRuntimeEvent, OmpSessionStateSchema, OmpSessionStatsSchema, type OmpThinkingLevel, @@ -121,9 +121,9 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { private readonly launchEnvironment: Record, ) { process.onMessage((message) => { - const event = OmpRuntimeEventSchema.safeParse(message); - if (event.success) { - this.emit(event.data); + const event = parseOmpRuntimeEvent(message); + if (event) { + this.emit(event); } }); process.onExit(({ error }) => { diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index a747d3bee0..e64a93fd64 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -252,6 +252,90 @@ describe("OMP history mapper", () => { ]); }); + test("replays a rule-violation run with the same rows the live timeline showed", async () => { + const dir = mkdtempSync(join(tmpdir(), "omp-rule-reminder-history-")); + const sessionFile = join(dir, "session.jsonl"); + writeFileSync( + sessionFile, + [ + { type: "session", id: "root" }, + { + type: "message", + id: "prompt", + parentId: "root", + message: { + role: "user", + content: "Read @notes.md, then write tiny.ts", + }, + }, + { + type: "message", + id: "mention", + parentId: "prompt", + message: { + role: "fileMention", + files: [ + { + path: "notes.md", + content: "[notes.md#468D]\n1:# notes", + lineCount: 1, + }, + ], + }, + }, + { + type: "ttsr_injection", + id: "injection", + parentId: "mention", + injectedRules: ["ts-no-tiny-functions"], + }, + { + type: "message", + id: "rule-reminder", + parentId: "injection", + message: { + role: "developer", + content: [ + { + type: "text", + text: '\nAvoid tiny functions.\n', + }, + ], + }, + }, + { + type: "message", + id: "answer", + parentId: "rule-reminder", + message: { + role: "assistant", + content: [{ type: "text", text: "FINAL_ANSWER" }], + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + const events: AgentStreamEvent[] = []; + for await (const event of streamOmpHistory({ + sessionFile, + provider: "omp", + })) + events.push(event); + expect(events.map((event) => event.item)).toEqual([ + { + type: "user_message", + text: "Read @notes.md, then write tiny.ts", + messageId: "prompt", + }, + { + type: "assistant_message", + text: "FINAL_ANSWER", + messageId: "omp-history-assistant-1", + }, + ]); + }); + test("coalesces replayed subagent poll calls by target set", async () => { const events = await collectHistory([ { @@ -745,6 +829,64 @@ describe("OMP history mapper", () => { ]); }); + test("replays a compaction entry as a completed compaction row", async () => { + const dir = mkdtempSync(join(tmpdir(), "omp-compaction-history-")); + const sessionFile = join(dir, "session.jsonl"); + writeFileSync( + sessionFile, + [ + { type: "session", id: "root", parentId: null }, + { + type: "message", + id: "answer-1", + parentId: "root", + message: { + role: "assistant", + content: [{ type: "text", text: "Before." }], + responseId: "resp-1", + }, + }, + { + type: "compaction", + id: "compaction-1", + parentId: "answer-1", + timestamp: "2026-10-05T04:31:19.537Z", + summary: "## Goal\nSummary", + shortSummary: "Summary", + firstKeptEntryId: "answer-1", + tokensBefore: 77611, + tokensAfter: 37526, + method: "snapcompact", + details: { readFiles: [], modifiedFiles: [] }, + fromExtension: false, + }, + { + type: "message", + id: "answer-2", + parentId: "compaction-1", + message: { + role: "assistant", + content: [{ type: "text", text: "After." }], + responseId: "resp-2", + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + + const events: AgentStreamEvent[] = []; + for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) { + events.push(event); + } + expect(events.map((event) => event.item)).toEqual([ + { type: "assistant_message", text: "Before.", messageId: "resp-1" }, + { type: "compaction", status: "completed", preTokens: 77611 }, + { type: "assistant_message", text: "After.", messageId: "resp-2" }, + ]); + expect(events[1]).toMatchObject({ type: "timeline", timestamp: "2026-10-05T04:31:19.537Z" }); + }); + test("maps omp 18.1 custom_message entries like live custom messages", async () => { const dir = mkdtempSync(join(tmpdir(), "omp-custom-message-history-")); const sessionFile = join(dir, "session.jsonl"); diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts index adca06a45d..2c818dfc5a 100644 --- a/packages/server/src/server/agent/providers/omp/history.ts +++ b/packages/server/src/server/agent/providers/omp/history.ts @@ -20,6 +20,10 @@ interface OmpSessionEntry { [key: string]: unknown; } +type OmpReplayEntry = + | { entry: OmpSessionEntry; message: OmpAgentMessage } + | { entry: OmpSessionEntry; item: AgentTimelineItem }; + export async function readOmpHistoryTodoState( sessionFile: string, ): Promise { @@ -90,13 +94,17 @@ export async function* streamOmpHistory(input: { throw error; } const messages: OmpAgentMessage[] = []; - const messageEntries: OmpSessionEntry[] = []; + const replayEntries: OmpReplayEntry[] = []; const userEntries: OmpCapturedUserMessageEntry[] = []; for (const entry of entries) { + if (entry.type === "compaction") { + replayEntries.push({ entry, item: mapCompactionEntry(entry) }); + continue; + } const mapped = mapEntryMessage(entry); if (!mapped) continue; messages.push(mapped); - messageEntries.push(entry); + replayEntries.push({ entry, message: mapped }); if (mapped.role === "user" && entry.id) { userEntries.push({ id: entry.id, text: textOf(mapped.content) }); } @@ -107,9 +115,13 @@ export async function* streamOmpHistory(input: { OMP_HISTORY_MAPPER_HOOKS, input.bridgedTools, ); - for (let index = 0; index < messages.length; index += 1) { - const timestamp = normalizeProviderReplayTimestamp(messageEntries[index]?.timestamp); - for (const event of mapper.mapMessages([messages[index]!])) { + for (const replayEntry of replayEntries) { + const timestamp = normalizeProviderReplayTimestamp(replayEntry.entry.timestamp); + const events: AgentStreamEvent[] = + "item" in replayEntry + ? [{ type: "timeline", provider: input.provider, item: replayEntry.item }] + : mapper.mapMessages([replayEntry.message]); + for (const event of events) { yield timestamp && event.type === "timeline" ? { ...event, timestamp } : event; } } @@ -360,8 +372,7 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { content.length === 1 && content[0]?.type === "text" && typeof content[0].text === "string" && - content[0].text.trimStart().startsWith("") && - content[0].text.trimEnd().endsWith("") + isSystemReminder(content[0].text) ) { return null; } @@ -370,6 +381,19 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { if (["user", "assistant", "toolResult", "custom", "bashExecution"].includes(message.role)) { return message as unknown as OmpAgentMessage; } + // OMP roles the live timeline does not render either, such as the fileMention that carries + // an @-mentioned file's content. + if ( + [ + "pythonExecution", + "hookMessage", + "branchSummary", + "compactionSummary", + "fileMention", + ].includes(message.role) + ) { + return null; + } return visibleFallback(message.role, message); } if (entry.type === "custom_message") { @@ -381,6 +405,16 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { return visibleFallback(entry.type, entry); } +// OMP persists a compaction as a top-level entry; the live timeline shows it as a compaction row. +// The entry does not record whether a manual /compact or auto-compaction produced it. +function mapCompactionEntry(entry: OmpSessionEntry): AgentTimelineItem { + return { + type: "compaction", + status: "completed", + ...(typeof entry.tokensBefore === "number" ? { preTokens: entry.tokensBefore } : {}), + }; +} + // omp 18.1+ persists injected rows (skill prompts, hub messages, job notices) as top-level // custom_message entries without a message object; replay them as live custom messages function mapCustomMessageEntry(entry: OmpSessionEntry): OmpAgentMessage | null { @@ -399,6 +433,13 @@ function mapCustomMessageEntry(entry: OmpSessionEntry): OmpAgentMessage | null { } as OmpAgentMessage; } +// OMP's rule-violation reminder carries attributes: +function isSystemReminder(text: string): boolean { + return ( + /^]/.test(text.trimStart()) && text.trimEnd().endsWith("") + ); +} + function isControlEntryType(type: string): boolean { return ( type === "session" || @@ -410,6 +451,7 @@ function isControlEntryType(type: string): boolean { type === "system_prompt" || type === "model_change" || type === "thinking_level_change" || + type === "ttsr_injection" || type === "tool_execution" || type.startsWith("tool_execution_") ); diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index 4c69f2fbeb..b0514c7863 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -41,6 +41,13 @@ const OmpUserMessageSchema = z content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]), }) .passthrough(); +// OMP injects hidden developer messages, such as rule-violation reminders, into a run. +const OmpDeveloperMessageSchema = z + .object({ + role: z.literal("developer"), + content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]), + }) + .passthrough(); const OmpCustomMessageSchema = z .object({ role: z.literal("custom"), @@ -79,13 +86,30 @@ const OmpBashExecutionMessageSchema = z timestamp: z.number(), }) .passthrough(); +// Roles Paseo does not render. They are listed so that a frame carrying one, such as +// agent_end, still parses; every role in OMP's RPC AgentMessage must appear in the union. +const OmpPythonExecutionMessageSchema = z + .object({ role: z.literal("pythonExecution") }) + .passthrough(); +const OmpHookMessageSchema = z.object({ role: z.literal("hookMessage") }).passthrough(); +const OmpBranchSummaryMessageSchema = z.object({ role: z.literal("branchSummary") }).passthrough(); +const OmpCompactionSummaryMessageSchema = z + .object({ role: z.literal("compactionSummary") }) + .passthrough(); +const OmpFileMentionMessageSchema = z.object({ role: z.literal("fileMention") }).passthrough(); export const OmpAgentMessageSchema = z.discriminatedUnion("role", [ OmpUserMessageSchema, + OmpDeveloperMessageSchema, OmpCustomMessageSchema, OmpAssistantMessageSchema, OmpToolResultMessageSchema, OmpBashExecutionMessageSchema, + OmpPythonExecutionMessageSchema, + OmpHookMessageSchema, + OmpBranchSummaryMessageSchema, + OmpCompactionSummaryMessageSchema, + OmpFileMentionMessageSchema, ]); export const OmpModelThinkingSchema = z @@ -480,6 +504,8 @@ export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [ type: z.literal("prompt_result"), id: z.string().optional(), agentInvoked: z.boolean().optional(), + status: z.string().optional(), + error: z.object({ message: z.string() }).passthrough().optional(), }) .passthrough(), z.object({ type: z.literal("process_exit"), error: z.string() }).passthrough(), @@ -609,6 +635,36 @@ export type OmpSubagentEventPayload = z.infer; export type OmpAgentSessionEvent = z.infer; export type OmpRuntimeEvent = z.infer; + +/** + * COMPAT(ompLatePromptRejection): added in v0.11.0, remove after 2027-04-05 once the + * minimum supported OMP version emits prompt_result errors. + * OMP 18.3 reports a prompt it rejects after acknowledging it, such as one whose model has + * no API key, only as a second failed `response` frame for the prompt id. Later releases + * report the same outcome as a `prompt_result` with `status: "error"`. + */ +const OmpLatePromptRejectionSchema = z.object({ + type: z.literal("response"), + command: z.literal("prompt"), + id: z.string(), + success: z.literal(false), + error: z.string(), +}); + +export function parseOmpRuntimeEvent(message: unknown): OmpRuntimeEvent | null { + const rejection = OmpLatePromptRejectionSchema.safeParse(message); + if (rejection.success) { + return { + type: "prompt_result", + id: rejection.data.id, + agentInvoked: false, + status: "error", + error: { message: rejection.data.error }, + }; + } + const event = OmpRuntimeEventSchema.safeParse(message); + return event.success ? event.data : null; +} export type OmpTodoItem = z.infer; export type OmpTodoPhase = z.infer; export type OmpTodoReminderEvent = z.infer; diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index f7f0039257..1820c187c9 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -294,6 +294,14 @@ export class OmpHarness { return await run; } + async runPromptWithoutTurnOnNextRuntime(input: string, requestId: string): Promise { + const session = this.requireSession(); + this.omp.queueSessionSetup((runtime) => { + runtime.promptAck = { requestId, agentInvoked: false }; + }); + return await session.run(input); + } + async runPromptWithoutTurn(input: string): Promise { const session = this.requireSession(); this.omp.latestSession().promptAck = { agentInvoked: false }; @@ -329,6 +337,31 @@ export class OmpHarness { return { completed: () => isCompleted, completion }; } + async runPromptRejectedBeforeAgentRuns( + input: string, + error: string, + order: "result after ack" | "result before ack", + ): Promise { + const session = this.requireSession(); + const runtime = this.omp.latestSession(); + runtime.promptAck = { requestId: "prompt-rejected" }; + const rejection = { + type: "prompt_result", + id: "prompt-rejected", + agentInvoked: false, + status: "error", + sessionSettled: true, + error: { message: error, retryable: false }, + } as const; + const promptStarted = runtime.nextPrompt(); + const run = session.run(input); + if (order === "result before ack") runtime.emit(rejection); + await promptStarted; + await waitForImmediate(); + if (order === "result after ack") runtime.emit(rejection); + return await run; + } + async runPromptAfterCorrelatedTrueResult( input: string, output: string, diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index 8c91854fb8..26cb65885d 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -1,10 +1,11 @@ -import type { ModelInfo, SessionMessageInfo } from "@opencode/client"; +import type { FormInfo, ModelInfo, SessionMessageInfo } from "@opencode/client"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../../../test-utils/test-logger.js"; import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; import { OpenCodeV2AgentClient } from "./agent.js"; import { V2Harness } from "../test-utils/v2-harness.js"; +import type { V2Api } from "./api.js"; function terminalEvents(events: AgentStreamEvent[]) { return events.filter( @@ -210,6 +211,172 @@ describe("OpenCode v2 session lifecycle", () => { } }); + test("ends the turn when the user dismisses a question", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + let form: FormInfo | null = null; + harness.api.session.form.list = async () => (form ? [form] : []); + harness.api.session.form.cancel = async () => { + form = null; + harness.active = false; + // OpenCode 2.0.20 reports a dismissed question as an interruption without a user reason. + harness.push({ + id: "dismissed", + created: 3, + type: "session.execution.interrupted", + durable: { aggregateID: "session", seq: 3, version: 1 }, + data: { sessionID: "session", reason: "shutdown" }, + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("ask me something"); + await expect.poll(() => harness.prompts).toHaveLength(1); + form = { + id: "question", + sessionID: "session", + title: "Color", + fields: [{ key: "color", type: "string", options: [{ label: "Red", value: "red" }] }], + }; + harness.push({ + id: "form", + created: 2, + type: "form.created", + data: { form }, + }); + await expect.poll(() => session.getPendingPermissions()).toHaveLength(1); + + await session.respondToPermission("question", { behavior: "deny" }); + + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_canceled" }); + + await session.startTurn("next message"); + await expect.poll(() => harness.prompts).toHaveLength(2); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(2); + expect(terminalEvents(events)[1]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + + test("ends the turn when the user denies a tool permission", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + let permission: Awaited>[number] | null = null; + harness.api.permission.list = async () => (permission ? [permission] : []); + harness.api.permission.reply = async () => { + permission = null; + harness.active = false; + // OpenCode 2.0.20 reports a denied tool call as an interruption without a user reason. + harness.push({ + id: "denied", + created: 3, + type: "session.execution.interrupted", + durable: { aggregateID: "session", seq: 3, version: 1 }, + data: { sessionID: "session", reason: "shutdown" }, + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("run printf"); + await expect.poll(() => harness.prompts).toHaveLength(1); + permission = { + id: "approval", + sessionID: "session", + action: "shell", + resources: ["printf TOOL_PERMISSION_QA"], + }; + harness.push({ + id: "asked", + created: 2, + type: "permission.asked", + data: permission, + }); + await expect.poll(() => session.getPendingPermissions()).toHaveLength(1); + + await session.respondToPermission("approval", { behavior: "deny" }); + + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_canceled" }); + + await session.startTurn("next message"); + await expect.poll(() => harness.prompts).toHaveLength(2); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(2); + expect(terminalEvents(events)[1]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + + test("keeps the turn resumable after the user denies a child session's permission", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + const child = { ...harness.info, id: "child", parentID: "session" }; + harness.api.session.list = async (input) => ({ + data: input?.parentID === "session" ? [child] : [], + cursor: {}, + }); + let permission: Awaited>[number] | null = { + id: "child-approval", + sessionID: "child", + action: "shell", + resources: ["pwd"], + }; + harness.api.permission.list = async (input) => + permission && input.sessionID === "child" ? [permission] : []; + harness.api.permission.reply = async () => { + permission = null; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("delegate work"); + await expect.poll(() => harness.prompts).toHaveLength(1); + expect(session.getPendingPermissions()).toHaveLength(1); + + await session.respondToPermission("child-approval", { behavior: "deny" }); + + harness.active = false; + harness.push({ + id: "shutdown", + created: 3, + type: "session.execution.interrupted", + durable: { aggregateID: "session", seq: 3, version: 1 }, + data: { sessionID: "session", reason: "shutdown" }, + }); + const reads = harness.activeReads; + harness.push({ id: "reconnect", created: 4, type: "server.connected", data: {} }); + await expect.poll(() => harness.activeReads).toBeGreaterThan(reads); + expect(terminalEvents(events)).toEqual([]); + harness.startExecution(); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + test("recovers the latest execution from the durable log when shutdown and resume events were missed", async () => { const harness = new V2Harness(); harness.autoComplete = false; diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts index 176fc455be..cc13b99e4b 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -33,6 +33,12 @@ export class SessionPermissions { list() { return [...this.pending.values()]; } + isOwnedBy(requestId: string, sessionID: string) { + return ( + this.forms.get(requestId)?.sessionID === sessionID || + this.permissionOwners.get(requestId) === sessionID + ); + } async respondToPermission(requestId: string, response: AgentPermissionResponse) { const form = this.forms.get(requestId); if (form) { diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index cb71ff7212..5cd45b1ff7 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -405,7 +405,10 @@ export class OpenCodeV2Session implements AgentSession { } async respondToPermission(requestId: string, response: AgentPermissionResponse) { await this.reconnectIfExited(); + const deniesOwnRequest = + response.behavior === "deny" && this.permissions.isOwnedBy(requestId, this.id); await this.permissions.respondToPermission(requestId, response); + if (deniesOwnRequest) this.turns.requestDenied(); } private async reconcileSnapshot() { const [info, history] = await Promise.all([ diff --git a/packages/server/src/server/agent/providers/opencode/v2/turns.ts b/packages/server/src/server/agent/providers/opencode/v2/turns.ts index 5c8765ec02..2b2c21d0c5 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/turns.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/turns.ts @@ -60,6 +60,7 @@ interface Turn { completion: Promise; settle(): void; accepted: boolean; + requestDenied: boolean; } export class SessionTurns { @@ -112,6 +113,7 @@ export class SessionTurns { settle, accepted, output, + requestDenied: false, }; this.turn = turn; this.execution = null; @@ -162,6 +164,13 @@ export class SessionTurns { this.sequence = event.durable.seq; this.requestReconciliation(); } + // OpenCode ends the execution when the user denies its permission or dismisses its question, + // but reports the interruption as "shutdown", the same reason as an execution it will resume. + requestDenied() { + if (!this.turn) return; + this.turn.requestDenied = true; + this.requestReconciliation(); + } private requestReconciliation() { void this.reconcile().catch((error: unknown) => this.options.reportReconciliationError(error)); } @@ -216,7 +225,9 @@ export class SessionTurns { this.sequence = execution.durable.seq; if ( execution.type === "session.execution.started" || - (execution.type === "session.execution.interrupted" && execution.data.reason === "shutdown") + (execution.type === "session.execution.interrupted" && + execution.data.reason === "shutdown" && + !turn.requestDenied) ) return; // Read the final history only after observing idle. Never turn an observation transport error into an execution failure. diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index d8128a6a2b..1f72cdc498 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -87,7 +87,8 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail) return incoming; } -function mergeToolCallItems( +/** Fold a later update for the same tool call into the one it follows. */ +export function mergeToolCallItems( existing: Extract, incoming: Extract, ): Extract { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e63ce89fb3..0ec792e871 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -126,6 +126,7 @@ import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-wo import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { createPaseoWorktreeWorkflow } from "./worktree-session.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; +import { formatAttachmentContentDisposition } from "./file-download/content-disposition.js"; import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js"; import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { RequestedSpeechProviders } from "./speech/speech-types.js"; @@ -414,7 +415,6 @@ export interface PaseoDaemonConfig { plugins?: Record; pluginRegistries?: PluginRegistries; pluginRegistryUrl?: string; - pluginRegistryEnabled?: boolean; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -625,7 +625,6 @@ export async function createPaseoDaemon( usageSession: (id) => agentManager.usageSession(id), }, managedSources: new ManagedPluginSources(config.paseoHome, { - enabled: config.pluginRegistryEnabled ?? false, registries: config.pluginRegistries, defaultUrl: config.pluginRegistryUrl, }), @@ -838,9 +837,8 @@ export async function createPaseoDaemon( return; } - const safeFileName = entry.fileName.replace(/["\r\n]/g, "_"); res.setHeader("Content-Type", entry.mimeType); - res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`); + res.setHeader("Content-Disposition", formatAttachmentContentDisposition(entry.fileName)); res.setHeader("Content-Length", fileStats.size.toString()); const stream = fileHandle.createReadStream(); diff --git a/packages/server/src/server/config-environment.ts b/packages/server/src/server/config-environment.ts index 2442ee5dc5..24134035de 100644 --- a/packages/server/src/server/config-environment.ts +++ b/packages/server/src/server/config-environment.ts @@ -30,7 +30,6 @@ export const DAEMON_SETTING_ENV_KEYS = [ "PASEO_LOG_ROTATE_SIZE", "PASEO_PASSWORD", "PASEO_PLUGIN_REGISTRY", - "PASEO_PLUGIN_REGISTRY_ENABLED", "PASEO_RELAY_ENABLED", "PASEO_RELAY_ENDPOINT", "PASEO_RELAY_PUBLIC_ENDPOINT", diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 02a315c9a5..a71b5621e8 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -211,15 +211,6 @@ test("loads private plugin registry settings through the configuration boundary" }); expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal"); expect(config.pluginRegistries).toEqual(pluginRegistries); - expect(config.pluginRegistryEnabled).toBe(false); - await writeFile( - path.join(home, "config.json"), - JSON.stringify({ pluginRegistries, pluginRegistryEnabled: true }), - ); - expect(loadConfig(home, { env: {} }).pluginRegistryEnabled).toBe(true); - expect( - loadConfig(home, { env: { PASEO_PLUGIN_REGISTRY_ENABLED: "false" } }).pluginRegistryEnabled, - ).toBe(false); } finally { await rm(home, { recursive: true, force: true }); } diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 61481ce1f9..7b2d06560e 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -628,10 +628,6 @@ export function resolveConfigFromPersisted( plugins: persisted.plugins, pluginRegistries: persisted.pluginRegistries, pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY, - pluginRegistryEnabled: - parseBooleanEnv(env.PASEO_PLUGIN_REGISTRY_ENABLED) ?? - persisted.pluginRegistryEnabled ?? - false, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index f429cb672c..0bc3310b41 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -831,6 +831,29 @@ test("send_agent_message auto-unarchives archived agents", async () => { } }, 180000); +test("send_agent_message leaves an archived agent archived when its directory is gone", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + const archived = await ctx.client.archiveAgent(created.id); + rmSync(cwd, { recursive: true, force: true }); + + await expect(ctx.client.sendMessage(created.id, "hello")).rejects.toThrow( + "Working directory does not exist", + ); + + const afterSend = await ctx.client.fetchAgent({ agentId: created.id }); + expect(afterSend?.agent.archivedAt).toBe(archived.archivedAt); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + test("refresh_agent auto-unarchives archived agents", async () => { const cwd = tmpCwd(); try { diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts index f9d7fce7b4..ac138a8c01 100644 --- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -24,42 +24,77 @@ describe("daemon E2E", () => { }, 60000); describe("file download tokens", () => { - test("issues token over WS and downloads via HTTP", async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "download.txt"); - const fileContents = "download test payload"; - writeFileSync(filePath, fileContents, "utf-8"); + test.each([ + { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "報告書.md", + disposition: + "attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md", + }, + { + fileName: "보고서.txt", + disposition: + "attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + ])( + "issues token over WS and downloads $fileName via HTTP", + async ({ fileName, disposition }) => { + const cwd = tmpCwd(); + try { + const filePath = path.join(cwd, fileName); + const fileContents = "download test payload"; + writeFileSync(filePath, fileContents, "utf-8"); - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Download Token Test Agent", - }); + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Download Token Test Agent", + }); - expect(agent.id).toBeTruthy(); + expect(agent.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); - const tokenResponse = await ctx.client.requestDownloadToken(cwd, "download.txt"); + const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName); - expect(tokenResponse.error).toBeNull(); - expect(tokenResponse.token).toBeTruthy(); - expect(tokenResponse.fileName).toBe("download.txt"); + expect(tokenResponse.error).toBeNull(); + expect(tokenResponse.token).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(tokenResponse.fileName).toBe(fileName); - const response = await fetch( - `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, - ); + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, + ); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType); - const disposition = response.headers.get("content-disposition") ?? ""; - expect(disposition).toContain("download.txt"); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType); + expect(response.headers.get("content-disposition")).toBe(disposition); - const body = await response.text(); - expect(body).toBe(fileContents); - - rmSync(cwd, { recursive: true, force: true }); - }, 60000); + const body = await response.text(); + expect(body).toBe(fileContents); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60000, + ); test("rejects invalid token", async () => { const response = await fetch( diff --git a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts index f336f325bf..7149b16864 100644 --- a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -10,6 +10,7 @@ import { afterEach, beforeAll, describe, expect, test } from "vitest"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import { MIN_SUPPORTED_OMP_VERSION, + OmpAgentClient, formatOmpVersionSupport, } from "../agent/providers/omp/agent.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; @@ -48,14 +49,53 @@ async function preflight(): Promise { } } -async function createHarness(): Promise { +// OMP exits at startup when no model is usable at all, so the home offers one offline +// model, and an agent on any other provider's model has no credentials. +function createOfflineOmpHome(): string { + const home = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-offline-")); + roots.add(home); + const agentDir = path.join(home, ".omp", "agent"); + mkdirSync(agentDir, { recursive: true }); + writeFileSync( + path.join(agentDir, "models.yml"), + [ + "providers:", + " offline:", + " baseUrl: http://127.0.0.1:9/v1", + " api: openai-completions", + " auth: none", + " models:", + " - id: offline-1", + " name: Offline", + " api: openai-completions", + " reasoning: false", + " input: [text]", + " contextWindow: 128000", + " maxTokens: 4096", + " cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0}", + "", + ].join("\n"), + ); + return home; +} + +async function createHarness(options: { ompHome?: string } = {}): Promise { const cwd = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-")); const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-home-")); const staticDir = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-static-")); for (const root of [cwd, paseoHomeRoot, staticDir]) roots.add(root); const logger = pino({ level: process.env.OMP_E2E_LOG_LEVEL ?? "silent" }); const daemon = await createTestPaseoDaemon({ - agentClients: createRealProviderClients(["omp"], logger), + agentClients: options.ompHome + ? { + omp: new OmpAgentClient({ + logger, + runtimeSettings: { + env: { HOME: options.ompHome, ANTHROPIC_API_KEY: "", ANTHROPIC_OAUTH_TOKEN: "" }, + }, + }), + } + : createRealProviderClients(["omp"], logger), providerOverrides: { omp: { enabled: true } }, logger, paseoHomeRoot, @@ -91,6 +131,15 @@ async function timeline(client: DaemonClient, agentId: string): Promise entry.item); } +function compactionFiller(): string { + const teas = ["assam", "darjeeling", "sencha", "oolong"]; + return Array.from( + { length: 1200 }, + (_, index) => + `Record ${String(index).padStart(5, "0")}: tea=${teas[index % teas.length]} origin=region-${index % 37} notes=lorem ipsum dolor sit amet`, + ).join("\n"); +} + function latestTools(items: AgentTimelineItem[]) { const latest = new Map>(); for (const item of items) if (item.type === "tool_call") latest.set(item.callId, item); @@ -186,6 +235,29 @@ afterEach(() => { }); describe("daemon E2E (real OMP)", () => { + test( + "a prompt OMP rejects before its agent runs fails the turn with OMP's error", + async () => { + const harness = await createHarness({ ompHome: createOfflineOmpHome() }); + try { + const agent = await harness.client.createAgent({ + cwd: harness.cwd, + title: "no-credentials", + provider: "omp", + model: "anthropic/claude-haiku-4-5", + modeId: "full", + }); + await harness.client.sendMessage(agent.id, "Reply with ok."); + const finish = await harness.client.waitForFinish(agent.id, 60_000); + expect(finish.status).toBe("error"); + expect(finish.error).toContain("No API key found for anthropic"); + } finally { + await closeHarness(harness); + } + }, + TIMEOUT_MS, + ); + test( "prompt, native tool, and resumed follow-up", async () => { @@ -447,6 +519,56 @@ describe("daemon E2E (real OMP)", () => { TIMEOUT_MS, ); + test( + "a compaction shows as a compaction row after a daemon restart", + async () => { + const harness = await createHarness(); + let restarted: TestPaseoDaemon | null = null; + let restartedClient: DaemonClient | null = null; + try { + const agent = await createAgent(harness, "compact-restart"); + await promptAndFinish( + harness, + agent.id, + `${compactionFiller()}\n\nReply exactly OMP_COMPACT_FIRST`, + ); + await promptAndFinish(harness, agent.id, "Reply exactly OMP_COMPACT_SECOND"); + await harness.client.sendMessage(agent.id, "/compact"); + await harness.client.waitForFinish(agent.id, TIMEOUT_MS); + await promptAndFinish(harness, agent.id, "Reply exactly OMP_COMPACT_THIRD"); + + await harness.client.close(); + await harness.daemon.close(); + restarted = await createTestPaseoDaemon({ + agentClients: createRealProviderClients(["omp"], pino({ level: "silent" })), + providerOverrides: { omp: { enabled: true } }, + logger: pino({ level: "silent" }), + paseoHomeRoot: harness.paseoHomeRoot, + staticDir: harness.staticDir, + cleanup: false, + }); + restartedClient = new DaemonClient({ url: `ws://127.0.0.1:${restarted.port}/ws` }); + await restartedClient.connect(); + await restartedClient.fetchAgents({ subscribe: {} }); + const replayed = await timeline(restartedClient, agent.id); + + expect( + latestTools(replayed) + .map(toolResult) + .filter((text) => text.includes("Unsupported history record")), + ).toEqual([]); + expect(replayed.filter((item) => item.type === "compaction")).toEqual([ + expect.objectContaining({ type: "compaction", status: "completed" }), + ]); + } finally { + await restartedClient?.close().catch(() => undefined); + await restarted?.close().catch(() => undefined); + await closeHarness(harness); + } + }, + TIMEOUT_MS, + ); + test( "native branch rewinds conversation history and keeps the session usable", async () => { diff --git a/packages/server/src/server/daemon-instance.test.ts b/packages/server/src/server/daemon-instance.test.ts index 9d7ea66c5a..ee428b2e58 100644 --- a/packages/server/src/server/daemon-instance.test.ts +++ b/packages/server/src/server/daemon-instance.test.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir, uptime } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { readDaemonInstance, stopDaemonInstance } from "./daemon-instance.js"; import { acquirePidLock, getPidLockInfo, isLocked, type PidLockInfo } from "./pid-lock.js"; @@ -94,3 +94,42 @@ describe("daemon instance identity across a reboot", () => { await expect(readFile(join(paseoHome, "paseo.pid"), "utf-8")).rejects.toThrow(/ENOENT/); }); }); + +// Linux keeps a boot's id while a VM is paused for a host sleep, though the VM's wall clock +// runs ahead of its uptime once it resumes. A sleep longer than the time between boot and +// the supervisor's start puts the wall-clock boot instant after the lock's startedAt. +describe.runIf(process.platform === "linux")("daemon instance identity on Linux", () => { + let paseoHome: string; + + beforeEach(async () => { + paseoHome = await mkdtemp(join(tmpdir(), "paseo-daemon-instance-linux-")); + }); + + afterEach(async () => { + vi.useRealTimers(); + await rm(paseoHome, { recursive: true, force: true }); + }); + + test("a supervisor still holds the lock after its paused VM resumes", async () => { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid }); + + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + uptime() * 1000 + 12 * 60 * 60_000); + + expect(await readDaemonInstance(paseoHome)).toMatchObject({ pid: process.pid }); + expect(await isLocked(paseoHome)).toMatchObject({ locked: true }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Another Paseo daemon is already running"); + }); + + test("a lock written during another boot has no running owner", async () => { + await writeLock(paseoHome, { + ...lockFor(process.pid, new Date()), + bootId: "00000000-0000-0000-0000-000000000000", + }); + + expect(await readDaemonInstance(paseoHome)).toBeNull(); + expect(await isLocked(paseoHome)).toMatchObject({ locked: false }); + }); +}); diff --git a/packages/server/src/server/file-download/content-disposition.test.ts b/packages/server/src/server/file-download/content-disposition.test.ts new file mode 100644 index 0000000000..46748c21ef --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { formatAttachmentContentDisposition } from "./content-disposition.js"; + +describe("formatAttachmentContentDisposition", () => { + test.each([ + { fileName: "report.txt", disposition: 'attachment; filename="report.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "Résumé Ångström.pdf", + disposition: + "attachment; filename=\"R?sum? ?ngstr?m.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9%20%C3%85ngstr%C3%B6m.pdf", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + { + fileName: 'say "hi" \\ bye.txt', + disposition: 'attachment; filename="say \\"hi\\" \\\\ bye.txt"', + }, + { + fileName: "100%25 done.txt", + disposition: "attachment; filename=\"100%25 done.txt\"; filename*=UTF-8''100%2525%20done.txt", + }, + { + fileName: "l'été (1)*.txt", + disposition: + "attachment; filename=\"l'?t? (1)*.txt\"; filename*=UTF-8''l%27%C3%A9t%C3%A9%20%281%29%2A.txt", + }, + ])("$fileName", ({ fileName, disposition }) => { + expect(formatAttachmentContentDisposition(fileName)).toBe(disposition); + }); +}); diff --git a/packages/server/src/server/file-download/content-disposition.ts b/packages/server/src/server/file-download/content-disposition.ts new file mode 100644 index 0000000000..1ad7997b7c --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.ts @@ -0,0 +1,32 @@ +const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g; +const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/; +const QUOTED_STRING_SPECIALS = /[\\"]/g; +const NON_ATTR_CHARS_LEFT_BY_URI_ENCODING = /['()*]/g; + +/** + * Content-Disposition for a file download (RFC 6266). + * + * Any name that is not plain ASCII also gets `filename*=UTF-8''…`, because + * browsers do not read raw Latin-1 bytes in `filename` and fall back to a name + * taken from the URL. A name with a percent escape gets it too, so browsers do + * not decode the escape in `filename`. + */ +export function formatAttachmentContentDisposition(fileName: string): string { + const asciiFileName = fileName.replace(NON_PRINTABLE_ASCII, "?"); + const disposition = `attachment; filename=${quote(asciiFileName)}`; + if (asciiFileName === fileName && !PERCENT_ESCAPE.test(fileName)) { + return disposition; + } + return `${disposition}; filename*=UTF-8''${encodeExtendedValue(fileName)}`; +} + +function quote(value: string): string { + return `"${value.replace(QUOTED_STRING_SPECIALS, "\\$&")}"`; +} + +function encodeExtendedValue(value: string): string { + return encodeURIComponent(value).replace( + NON_ATTR_CHARS_LEFT_BY_URI_ENCODING, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index f924a88ebc..79e3d97218 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -308,7 +308,6 @@ export const PersistedConfigSchema = z providers: ProvidersSchema.optional(), pluginRegistries: PluginRegistriesSchema.optional(), - pluginRegistryEnabled: z.boolean().optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index db95d4786d..314b44ea47 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { open, readFile, stat, unlink, utimes } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import { ensurePrivateDirectory } from "./private-files.js"; @@ -15,6 +16,7 @@ export const pidLockInfoSchema = z.object({ serverId: z.string().nullable().optional(), desktopManaged: z.boolean().optional(), heartbeat: z.literal(true).optional(), + bootId: z.string().optional(), }); export interface PidLockInfo extends z.infer {} @@ -62,16 +64,38 @@ function precedesThisBoot(startedAt: string): boolean { return stamped < Date.now() - uptime() * 1000 - BOOT_INSTANT_TOLERANCE_MS; } +let cachedBootId: string | null | undefined; + +// Linux names each boot. The wall-clock boot instant is not enough there: a VM paused while +// its host sleeps keeps its uptime, so after it resumes the instant lands after locks its +// running supervisor wrote. +function currentBootId(): string | null { + if (cachedBootId === undefined) { + try { + cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null; + } catch { + cachedBootId = null; + } + } + return cachedBootId; +} + +function writtenDuringThisBoot(lock: PidLockInfo): boolean { + const thisBoot = currentBootId(); + if (lock.bootId && thisBoot) return lock.bootId === thisBoot; + return !precedesThisBoot(lock.startedAt); +} + /** * Whether the process that wrote this lock is still running. * * A PID alone does not identify the supervisor: the operating system hands the number to * something else once the supervisor is gone, and a reboot reassigns it freely. A process - * cannot predate the boot it runs under, so a lock stamped before this boot is abandoned + * cannot predate the boot it runs under, so a lock written during another boot is abandoned * however alive its PID looks. */ export function isPidLockOwnerRunning(lock: PidLockInfo): boolean { - if (precedesThisBoot(lock.startedAt)) return false; + if (!writtenDuringThisBoot(lock)) return false; return isPidRunning(lock.pid); } @@ -214,6 +238,7 @@ export async function acquirePidLock( } // Create new lock with exclusive flag + const bootId = currentBootId(); const lockInfo: PidLockInfo = { pid: lockOwnerPid, startedAt: new Date().toISOString(), @@ -221,6 +246,7 @@ export async function acquirePidLock( uid: process.getuid?.() ?? 0, listen, heartbeat: true, + ...(bootId ? { bootId } : {}), ...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}), }; diff --git a/packages/server/src/server/plugins/bundle-evaluator.ts b/packages/server/src/server/plugins/bundle-evaluator.ts index 2941d4c4ff..e80db885ff 100644 --- a/packages/server/src/server/plugins/bundle-evaluator.ts +++ b/packages/server/src/server/plugins/bundle-evaluator.ts @@ -3,6 +3,7 @@ import * as pluginSharedRuntime from "@getpaseo/plugin"; import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider"; import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp"; import * as pluginUsageRuntime from "@getpaseo/plugin/server/usage"; +import * as pluginServerRuntime from "@getpaseo/plugin/server"; import type { PluginServerContribution } from "@getpaseo/plugin/server"; import * as zod from "zod"; import { isPluginClientOnlySdkSpecifier } from "./plugin-sdk-specifiers.js"; @@ -14,7 +15,7 @@ function runtimeRequire(name: string): unknown { throw new Error(`${name} is available only in plugin client code`); } if (name === "@getpaseo/plugin") return pluginSharedRuntime; - if (name === "@getpaseo/plugin/server") return {}; + if (name === "@getpaseo/plugin/server") return pluginServerRuntime; if (name === "@getpaseo/plugin/server/provider") return pluginProviderRuntime; if (name === "@getpaseo/plugin/server/acp") return pluginAcpRuntime; if (name === "@getpaseo/plugin/server/usage") return pluginUsageRuntime; diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 544b609df5..71fe2b96e9 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -10,6 +10,7 @@ import { stat, writeFile, } from "node:fs/promises"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -418,7 +419,7 @@ describe("PluginService", () => { await service.stopAllPlugins(); }, 20_000); - it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => { + it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); const running = await createPlugin("running", "export default () => () => {};"); @@ -426,7 +427,13 @@ describe("PluginService", () => { const malformed = await createPlugin("malformed", "export default () => () => {};"); await writeFile( path.join(running, "paseo-plugin.json"), - JSON.stringify({ id: "running", description: "Runs checks" }), + JSON.stringify({ + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }), ); await writeFile( path.join(disabled, "paseo-plugin.json"), @@ -442,16 +449,123 @@ describe("PluginService", () => { await service.start(); expect( - (await service.listPlugins()).map(({ id, description }) => ({ id, description })), + (await service.listPlugins()).map(({ id, description, name, icon, media }) => ({ + id, + description, + name, + icon, + media, + })), ).toEqual([ { id: "disabled", description: "Waits until enabled" }, { id: "malformed", description: undefined }, - { id: "running", description: "Runs checks" }, + { + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }, ]); await service.stopAllPlugins(); }); - it("prefers an existing directory and installs its selected plugin subdirectory", async () => { + it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`); + await mkdir(owner); + roots.push(home, owner); + const source = `${path.basename(owner)}/example`; + const repository = await createPlugin("registry-example", "export default () => () => {};\n"); + await mkdir(path.join(owner, "example")); + await cp(repository, path.join(owner, "example"), { recursive: true }); + await runGitCommand(["init", "-b", "main"], { cwd: repository }); + await runGitCommand(["add", "-A"], { cwd: repository }); + await runGitCommand( + [ + "-c", + "user.name=Paseo Tests", + "-c", + "user.email=paseo@example.test", + "commit", + "-m", + "fixture", + ], + { cwd: repository }, + ); + const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }); + const requests: Array = []; + const server = createServer((request, response) => { + requests.push(request.url); + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + id: source, + name: "Example", + description: "Test", + categories: [], + author: { github: path.basename(owner) }, + repository: { url: "https://github.com/fixture/example" }, + artifact: { + kind: "git", + remote: pathToFileURL(repository).href, + commit: stdout.trim(), + pluginPath: ".", + }, + media: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + readme: "# Test", + }), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const url = `http://127.0.0.1:${address.port}`; + const store = createStore(home); + store.patch({ pluginsEnabled: false }); + const service = bindTestSessionHost( + new PluginService(pino({ level: "silent" }), store, "0.4.0", { + managedSources: new ManagedPluginSources(home, { defaultUrl: url }), + }), + ); + try { + await service.start(); + await expect(service.installSource({ source })).resolves.toMatchObject({ + id: "registry-example", + status: "disabled", + installation: { + identity: { kind: "git", registry: { url, id: source } }, + currentRevision: stdout.trim(), + }, + }); + expect(requests).toEqual([`/plugins/${source}.json`]); + await expect( + service.installSource({ source: `./${source}`, id: "local-example" }), + ).resolves.toMatchObject({ + installation: { identity: { kind: "directory", path: path.join(owner, "example") } }, + }); + expect(requests).toEqual([`/plugins/${source}.json`]); + } finally { + await service.stopAllPlugins(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, 30_000); + + it("names a missing explicit directory instead of trying a managed source", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = path.join(home, "missing"); + const service = createService(home); + await expect(service.installSource({ source: directory })).rejects.toThrow( + `Plugin directory does not exist: ${directory}`, + ); + }); + + it("installs an explicit directory and its selected plugin subdirectory", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-")); diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 7f74c847df..6c4af29e62 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -21,6 +21,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import type { DaemonConfigStore } from "../daemon-config-store.js"; import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js"; import { readPluginManifest } from "./manifest.js"; +import { expandTilde } from "../../utils/path.js"; import { runPluginBuild } from "./preparation.js"; import { PluginRuntime } from "./runtime.js"; import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js"; @@ -215,7 +216,12 @@ export class PluginService { }), }; const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null); - if (manifest?.description) item.description = manifest.description; + if (manifest) { + item.name = manifest.name; + item.description = manifest.description; + item.icon = manifest.icon; + item.media = manifest.media; + } item.installation = await this.managedSources ?.describe(id, source.path) .catch(() => undefined); @@ -286,16 +292,11 @@ export class PluginService { if (input.id && this.builtinPluginIds.has(input.id)) { throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`); } - const directDirectory = path.resolve(input.source); - const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source); - const directInfo = await stat(directDirectory).catch(() => null); - const reference = directInfo?.isDirectory() - ? { source: input.source, pluginPath: undefined } - : parsePluginSourceReference(input.source); - const directory = path.resolve(reference.source); - let info = directInfo; - if (!info?.isDirectory() && !explicit) info = await stat(directory).catch(() => null); - if (info?.isDirectory()) { + const reference = parsePluginSourceReference(input.source); + if (reference.kind === "directory") { + const directory = path.resolve(expandTilde(reference.source)); + const info = await stat(directory).catch(() => null); + if (!info?.isDirectory()) throw new Error(`Plugin directory does not exist: ${directory}`); if (input.ref) throw new Error("Plugin --ref is only valid for Git sources"); const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath); return this.installDirectory({ path: pluginDirectory, id: input.id }); diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 40878ae74e..466780b79a 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -280,7 +280,60 @@ describe("managed Git plugin sources", () => { }); describe("registry plugin sources", () => { - it("treats owner/repository as GitHub shorthand without contacting the registry while registry installs are off", async () => { + it("explains how to choose a revision when --ref is used with a registry id", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-ref-home-")); + roots.push(home); + const sources = new ManagedPluginSources(home); + await expect( + sources.prepareInstall({ source: "fixture/example", ref: "main" }), + ).rejects.toThrow( + "Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.", + ); + }); + + it("names the registry URL and recovery steps when the registry fetch fails", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-offline-home-")); + roots.push(home); + const server = createServer((request) => request.socket.destroy()); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const url = `http://127.0.0.1:${address.port}`; + try { + const sources = new ManagedPluginSources(home, { defaultUrl: url }); + await expect(sources.prepareInstall({ source: "fixture/example" })).rejects.toThrow( + `Could not reach plugin registry ${url}/plugins/fixture/example.json. Plugin fixture/example was not installed. Retry or use an explicit npm: or github: source.`, + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("explains explicit local and GitHub sources when a registry ID is missing", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-missing-home-")); + roots.push(home); + const requests: Array = []; + const server = createServer((request, response) => { + requests.push(request.url); + response.writeHead(404).end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + try { + const sources = new ManagedPluginSources(home, { + defaultUrl: `http://127.0.0.1:${address.port}`, + }); + await expect(sources.prepareInstall({ source: "fixture/missing" })).rejects.toThrow( + "If you intended a local directory, use ./fixture/missing. If you intended a GitHub source, use git:fixture/missing, or a full Git URL for another Git host.", + ); + expect(requests).toEqual(["/plugins/fixture/missing.json"]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("keeps explicit Git sources and persisted Git installs independent of the registry", async () => { const repository = await createRepository(); const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-")); roots.push(home); @@ -298,12 +351,34 @@ describe("registry plugin sources", () => { const sources = new ManagedPluginSources(home, { defaultUrl: `http://127.0.0.1:${address.port}`, }); - const candidate = await sources.prepareInstall({ source: "fixture/repository" }); - expect(candidate.record).toEqual({ - kind: "git", - remote: "https://github.com/fixture/repository.git", - }); - await sources.discard(candidate); + for (const source of [ + "git:fixture/repository", + "https://github.com/fixture/repository.git", + ]) { + const candidate = await sources.place( + "managed-example", + await sources.prepareInstall({ source }), + ); + expect(candidate.record).toEqual({ + kind: "git", + remote: "https://github.com/fixture/repository.git", + }); + sources.commit("managed-example", candidate.record); + const restarted = new ManagedPluginSources(home, { + defaultUrl: `http://127.0.0.1:${address.port}/changed`, + }); + expect((await restarted.preview("managed-example", candidate.directory)).outcome).toBe( + "current", + ); + expect( + (await restarted.describe("managed-example", candidate.directory)).identity, + ).toEqual({ + kind: "git", + remote: "https://github.com/fixture/repository.git", + pluginPath: ".", + }); + await sources.discard(candidate); + } }); expect(requests).toEqual([]); } finally { @@ -332,12 +407,17 @@ describe("registry plugin sources", () => { intent: string | string[] | undefined; authorization: string | undefined; }> = []; + let missing = false; const server = createServer((req, res) => { requests.push({ path: req.url, intent: req.headers["x-paseo-install"], authorization: req.headers.authorization, }); + if (missing) { + res.writeHead(404).end(); + return; + } res.setHeader("Content-Type", "application/json"); res.end( JSON.stringify({ @@ -353,7 +433,7 @@ describe("registry plugin sources", () => { commit, pluginPath: "packages/example", }, - screenshots: [], + media: [], submittedAt: "2026-10-03", reviewedAt: "2026-10-03", updatedAt: "2026-10-03", @@ -369,7 +449,6 @@ describe("registry plugin sources", () => { const url = `http://${host}/internal`; try { const sources = new ManagedPluginSources(home, { - enabled: true, defaultUrl: url, registries: { [host]: { authorization: "Bearer test" } }, }); @@ -380,7 +459,11 @@ describe("registry plugin sources", () => { ).toBe("managed-example"); await sources.verifyCandidate("managed-example", candidate); sources.commit("managed-example", candidate.record); - const preview = await sources.preview("managed-example", candidate.directory); + const restarted = new ManagedPluginSources(home, { + defaultUrl: "http://127.0.0.1:1/changed", + registries: { [host]: { authorization: "Bearer test" } }, + }); + const preview = await restarted.preview("managed-example", candidate.directory); expect(preview.outcome).toBe("current"); expect(preview.current?.identity).toEqual({ kind: "git", @@ -388,6 +471,10 @@ describe("registry plugin sources", () => { pluginPath: "packages/example", registry: { url, id: "acme/example" }, }); + missing = true; + await expect(restarted.preview("managed-example", candidate.directory)).rejects.toThrow( + "Check that the installed plugin is still published in this registry before updating", + ); expect(requests).toEqual([ { path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" }, { @@ -395,6 +482,11 @@ describe("registry plugin sources", () => { intent: undefined, authorization: "Bearer test", }, + { + path: "/internal/plugins/acme/example.json", + intent: undefined, + authorization: "Bearer test", + }, ]); } finally { await new Promise((resolve, reject) => @@ -424,27 +516,17 @@ it("installs and updates only the npm artifacts pinned by the plugin registry", const server = createServer((request, response) => { intents.push(request.headers["x-paseo-install"]); response.end( + // Install reads only the ID and artifact, so the document omits the directory's display fields. JSON.stringify({ id: "acme/example", - name: "Example", - description: "Test", - categories: [], - author: { github: "acme" }, - repository: { url: "https://github.com/acme/example" }, artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin }, - screenshots: [], - submittedAt: "2026-10-03", - reviewedAt: "2026-10-03", - updatedAt: "2026-10-03", - publishedAt: "2026-10-03", - readme: "# Example", }), ); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("Missing registry address"); - const options = { enabled: true, defaultUrl: `http://127.0.0.1:${address.port}` }; + const options = { defaultUrl: `http://127.0.0.1:${address.port}` }; try { const sources = new ManagedPluginSources(home, options); const candidate = await sources.place( diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index 8ee3b2b6f6..5373b7453a 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -147,11 +147,13 @@ export class ManagedPluginSources { input: InstallInput, target?: PluginUpdateTarget, ): Promise { - const registry = this.registryOptions.enabled - ? parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl) - : null; + const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl); if (registry) { - if (input.ref || input.pluginPath) + if (input.ref) + throw new Error( + "Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.", + ); + if (input.pluginPath) throw new Error("Registry sources use their reviewed artifact path and revision"); const resolved = await resolveRegistryPlugin(registry, this.registryOptions, true); const candidate = await this.prepareInstall(resolved.input, resolved.target); @@ -175,7 +177,7 @@ export class ManagedPluginSources { sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName); record = { kind: "npm" }; } else { - const remote = normalizeGitSource(input.source, !this.registryOptions.enabled); + const remote = normalizeGitSource(input.source); sourceRoot = path.join(versionRoot, "checkout"); await clone(remote, sourceRoot); const commit = await resolveRequestedRef(sourceRoot, input.ref); @@ -227,7 +229,7 @@ export class ManagedPluginSources { const location = this.locate(pluginId, configuredPath); let target: PluginUpdateTarget; let outcome: PluginUpdatePreview["outcome"]; - if (this.registryOptions.enabled && current.identity.registry) { + if (current.identity.registry) { if (selection) throw new Error("Registry updates use only the reviewed registry pin"); const resolved = await resolveRegistryPlugin( current.identity.registry, @@ -304,7 +306,7 @@ export class ManagedPluginSources { await this.assertCurrent(proposal, configuredPath); const identity = this.locate(proposal.id, configuredPath).identity; const record = this.records[proposal.id]; - if (this.registryOptions.enabled && identity.kind !== "directory" && identity.registry) { + if (identity.kind !== "directory" && identity.registry) { const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false); assertRegistryArtifactIdentity(identity, resolved.artifact); if (!isDeepStrictEqual(resolved.target, proposal.target)) @@ -436,10 +438,9 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s } return []; } -/** Bare owner/repo is GitHub shorthand unless registry references own that syntax. */ -function normalizeGitSource(source: string, bareGitHubShorthand: boolean): string { - const expandsShorthand = - bareGitHubShorthand || source.startsWith("github:") || source.startsWith("git:"); +/** Only explicit Git prefixes expand GitHub shorthand. */ +function normalizeGitSource(source: string): string { + const expandsShorthand = source.startsWith("github:") || source.startsWith("git:"); if (source.startsWith("github:")) { const shorthand = source.slice(7); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) { diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts index 179bdc5da7..009cac5a68 100644 --- a/packages/server/src/server/plugins/managed-source/registry.ts +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -1,13 +1,19 @@ import { - PublishedPluginDetailSchema, + PluginRegistryArtifactSchema, + PluginRegistryIdSchema, type PluginRegistries, type PluginRegistryIdentity, } from "@getpaseo/protocol/plugin-registry"; import type { PluginUpdateTarget } from "@getpaseo/protocol/messages"; +import { z } from "zod"; + +// Install reads only these fields; the rest of a published plugin is the directory's display data. +const RegistryInstallDocumentSchema = z.object({ + id: PluginRegistryIdSchema, + artifact: PluginRegistryArtifactSchema, +}); export interface RegistryOptions { - /** Off: bare owner/repo is GitHub shorthand and no registry is contacted. */ - enabled?: boolean; defaultUrl?: string; registries?: PluginRegistries; } @@ -29,14 +35,29 @@ export async function resolveRegistryPlugin( const headers: Record = {}; if (authorization) headers.Authorization = authorization; if (install) headers["X-Paseo-Install"] = "1"; - const response = await fetch(`${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`, { - headers, - redirect: "error", - signal: AbortSignal.timeout(30000), - }); + const url = `${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`; + let response: Response; + try { + response = await fetch(url, { + headers, + redirect: "error", + signal: AbortSignal.timeout(30000), + }); + } catch (cause) { + const guidance = install + ? `Plugin ${identity.id} was not installed. Retry or use an explicit npm: or github: source.` + : `Could not check updates for ${identity.id}. Retry later.`; + throw new Error(`Could not reach plugin registry ${url}. ${guidance}`, { cause }); + } + if (response.status === 404) { + const guidance = install + ? `If you intended a local directory, use ./${identity.id}. If you intended a GitHub source, use git:${identity.id}, or a full Git URL for another Git host.` + : "Check that the installed plugin is still published in this registry before updating."; + throw new Error(`Plugin ${identity.id} was not found in registry ${base.host}. ${guidance}`); + } if (!response.ok) throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`); - const plugin = PublishedPluginDetailSchema.parse(await response.json()); + const plugin = RegistryInstallDocumentSchema.parse(await response.json()); if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID"); const artifact = plugin.artifact; const target: PluginUpdateTarget = diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts index 3ff06e2c3e..399e350493 100644 --- a/packages/server/src/server/plugins/manifest.test.ts +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -24,6 +24,69 @@ describe("plugin manifest", () => { }); }); + it("reads display metadata and ignores future top-level fields", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + const manifest = { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["assets/screenshot.webp", "demo.mp4", "https://example.com/media?id=1"], + }; + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ ...manifest, futureField: { enabled: true } }), + ); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + }); + + it.each([ + { name: " " }, + { name: 42 }, + { icon: "" }, + { icon: 42 }, + { icon: "icon.svg" }, + { icon: "/icon.png" }, + { icon: "../icon.png" }, + { icon: "assets/../../icon.png" }, + { icon: "https://example.com/icon.png" }, + { icon: "C:/icon.png" }, + { icon: "assets\\icon.png" }, + { icon: "icon.png?query" }, + { media: "screenshot.png" }, + { media: [42] }, + { media: [""] }, + { media: ["../demo.mp4"] }, + { media: ["/demo.mp4"] }, + { media: ["http://example.com/demo.mp4"] }, + { media: ["https://"] }, + { media: ["//example.com/demo.mp4"] }, + { media: ["file:///demo.mp4"] }, + { media: ["data:image/png;base64,abc"] }, + { media: ["."] }, + { media: ["assets/"] }, + { id: "INVALID" }, + { description: 42 }, + { requirements: { paseoo: ">=0.11.0" } }, + ])("rejects invalid known fields: %j", async (fields) => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "example", futureField: true, ...fields }), + ); + await expect(readPluginManifest(directory)).rejects.toThrow(); + }); + + it("accepts omitted metadata and empty media", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + for (const manifest of [{ id: "example" }, { id: "example", media: [] }]) { + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest)); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + } + }); + it("reads and validates requirements before any plugin code runs", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); directories.push(directory); diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts index d2e13d614b..2312a2cb5a 100644 --- a/packages/server/src/server/plugins/manifest.ts +++ b/packages/server/src/server/plugins/manifest.ts @@ -8,14 +8,33 @@ const MANIFEST_FILENAME = "paseo-plugin.json"; const PluginBuildCommandSchema = z .array(z.string().refine((argument) => argument.trim().length > 0)) .min(1); -const PluginManifestSchema = z - .object({ - id: PluginIdSchema, - description: z.string().trim().min(1).optional(), - requirements: PluginRequirementsSchema.strict().optional(), - build: z.array(PluginBuildCommandSchema).min(1).optional(), - }) - .strict(); +// Manifest asset paths use forward slashes and stay inside the plugin package. +const PluginAssetPathSchema = z + .string() + .min(1) + .refine( + (value) => + value === value.trim() && + !value.startsWith("/") && + !/[\\:?#\p{Cc}]/u.test(value) && + !value.split("/").includes("..") && + !value.endsWith("/") && + value.split("/").at(-1) !== ".", + "Expected a relative file path inside the plugin package", + ); +const PluginManifestSchema = z.object({ + id: PluginIdSchema, + name: z.string().trim().min(1).optional(), + description: z.string().trim().min(1).optional(), + icon: PluginAssetPathSchema.refine( + (value) => /\.png$/i.test(value), + "Expected a relative path to a PNG", + ).optional(), + media: z.array(z.union([PluginAssetPathSchema, z.url({ protocol: /^https$/ })])).optional(), + // A misspelled requirement must not silently disable compatibility checks. + requirements: PluginRequirementsSchema.strict().optional(), + build: z.array(PluginBuildCommandSchema).min(1).optional(), +}); export type PluginManifest = z.infer; diff --git a/packages/server/src/server/plugins/plugin-process.test.ts b/packages/server/src/server/plugins/plugin-process.test.ts index 6a4380d503..ddee3cb291 100644 --- a/packages/server/src/server/plugins/plugin-process.test.ts +++ b/packages/server/src/server/plugins/plugin-process.test.ts @@ -37,3 +37,14 @@ it("keeps React out of the plugin host's runtime dependency graph", async () => }), ).resolves.toMatchObject({ errors: [] }); }); + +it("exposes CLI probes through the plugin host's server SDK", async () => { + const { evaluateBundle } = await import("./bundle-evaluator.js"); + const setup = evaluateBundle(`(require) => ({ default: async () => { + const { execCommand } = require("@getpaseo/plugin/server"); + const { stdout } = await execCommand(${JSON.stringify(process.execPath)}, ["-e", "process.stdout.write('plugin CLI ready')"]); + if (stdout !== "plugin CLI ready") throw new Error("CLI output was lost"); + } })`); + // This contribution uses no server context APIs; it exercises the host's real SDK binding. + await expect(setup(undefined as never)).resolves.toBeUndefined(); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index 37ab021831..eeea242788 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -190,7 +190,7 @@ test("legacy listing distinguishes labeled accounts and preserves unlabeled name ).toEqual(["claude (work)", "claude (personal)", "claude"]); }); -test("duplicate logins follow discovery order and fall back on unavailable, error and throws", async () => { +test("duplicate logins prefer discovery order across unavailable, error and thrown fetches", async () => { for (const failure of ["unavailable", "error", "throw"]) { const registry = new UsageSourceRegistry(); const calls: number[] = []; @@ -216,7 +216,7 @@ test("duplicate logins follow discovery order and fall back on unavailable, erro const reports = await registry.listReports(); expect(reports).toHaveLength(1); expect(reports[0]?.report.status).toBe("available"); - expect(calls).toEqual([0, 1]); + expect(calls).toEqual([0, 1, 2]); } }); @@ -338,7 +338,7 @@ test("session discovery reuses accounts, separates launches, and retains live ag await registry.listReports({ agentId: "one" }); await registry.listReports({ agentId: "two" }); expect(discoveries).toEqual(["/one", "/two"]); - expect(fetches).toBe(1); + expect(fetches).toBe(2); expect((await registry.listReports()).map((r) => r.id)).toEqual([ "claude:default", "claude:same", @@ -395,3 +395,164 @@ test("reports arrive independently and a hung fetch settles as an error before c entries[0]?.report, ); }); + +test("#6155: agent reports use only their session login despite host and sibling discoveries", async () => { + const sessions = new Map([ + ["codex-agent", { provider: "codex", env: {}, sessionKey: "codex-launch" }], + ["opencode-agent", { provider: "opencode", env: {}, sessionKey: "opencode-launch" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + const codexError = { status: "error", error: "Codex usage API returned 500" }; + const expired = { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-07-07T00:00:00.000Z", refreshedBy: "opencode" }, + }; + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => + (scope.kind === "global" ? ["codex", "opencode"] : [scope.provider]).map((harness) => ({ + key: "same", + harness, + input: { harness }, + })), + fetch: async (input) => + (input as { harness: string }).harness === "codex" ? codexError : expired, + }); + await registry.listReports(); + await registry.listReports({ agentId: "opencode-agent" }); + expect((await registry.listReports({ agentId: "codex-agent" }))[0]?.report).toEqual(codexError); +}); + +test("all failed host logins retain each source-supplied harness and remedy", async () => { + const registry = new UsageSourceRegistry(); + const failures = [ + { status: "error", error: "Codex usage API returned 500" }, + { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-07-07T00:00:00.000Z", refreshedBy: "opencode" }, + }, + { status: "unavailable", problem: { kind: "rejected", status: 401, refreshedBy: "pi" } }, + { status: "unavailable", problem: { kind: "rejected", status: 403, refreshedBy: "omp" } }, + ]; + const harnesses = ["Codex", "OpenCode", "Pi", "OMP"]; + registry.register( + source({ + id: "codex", + discover: async () => harnesses.map((harness, input) => ({ key: "same", harness, input })), + fetch: async (input) => failures[Number(input)], + }), + ); + const [entry] = await registry.listReports(); + expect(entry?.loginErrors).toEqual( + harnesses.map((harness, index) => ({ harness, report: failures[index] })), + ); +}); + +test("a working agent login never uses another harness's failure or host fallback cache", async () => { + const sessions = new Map([ + ["working", { provider: "codex", env: {}, sessionKey: "one" }], + ["broken", { provider: "opencode", env: {}, sessionKey: "two" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + const available = { + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }], + }; + const error = { status: "error", error: "OpenCode login rejected. Run opencode to refresh it." }; + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => + (scope.kind === "global" ? ["opencode", "codex"] : [scope.provider]).map((harness) => ({ + key: "same", + harness, + input: harness, + })), + fetch: async (input) => (input === "codex" ? available : error), + }); + const [host] = await registry.listReports(); + expect(host?.report).toEqual(available); + expect(host?.loginErrors).toBeUndefined(); + expect((await registry.listReports({ agentId: "working" }))[0]?.report).toEqual(available); + expect((await registry.listReports({ agentId: "broken" }))[0]?.report).toEqual(error); + expect( + (await registry.listReports({ agentId: "broken", forceRefresh: true }))[0]?.report, + ).toEqual(error); + expect( + (await registry.listReports({ reportIds: ["codex:same"], forceRefresh: true }))[0]?.report, + ).toEqual(available); +}); + +test("concurrent host and agent requests never share a different login's pending fetch", async () => { + let finish!: (report: unknown) => void; + const slow = new Promise((resolve) => { + finish = resolve; + }); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: () => true, + usageSession: () => ({ provider: "codex", env: {}, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => [{ key: "same", harness: "Codex", input: scope.kind }], + fetch: async (input) => + input === "global" ? slow : { status: "error", error: "Session login failed" }, + }); + const host = registry.listReports(); + const [agent] = await registry.listReports({ agentId: "agent" }); + expect(agent?.report).toEqual({ status: "error", error: "Session login failed" }); + finish({ status: "available", windows: [] }); + expect((await host)[0]?.report.status).toBe("available"); +}); + +test("duplicate logins across scopes appear only once in the host's errors", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: () => true, + usageSession: () => ({ provider: "codex", env: {}, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => [ + { + key: "same", + harness: "Codex", + input: + scope.kind === "global" + ? { path: "/auth", kind: "file" } + : { kind: "file", path: "/auth" }, + }, + ], + fetch: async () => ({ status: "error", error: "Login failed" }), + }); + await registry.listReports({ agentId: "agent" }); + expect((await registry.listReports())[0]?.loginErrors).toEqual([ + { harness: "Codex", report: { status: "error", error: "Login failed" } }, + ]); +}); + +test("a hung login records its own timeout and still tries the next login", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, undefined, 25); + registry.register({ + id: "codex", + label: "Codex", + discover: async () => [ + { key: "same", harness: "Codex", input: 0 }, + { key: "same", harness: "OpenCode", input: 1 }, + ], + fetch: async (input) => + input === 0 ? new Promise(() => {}) : { status: "error", error: "Login rejected" }, + }); + expect((await registry.listReports())[0]?.loginErrors).toEqual([ + { harness: "Codex", report: { status: "error", error: "Usage fetch timed out" } }, + { harness: "OpenCode", report: { status: "error", error: "Login rejected" } }, + ]); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index e0fa300dcf..c0c9d781e5 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { AgentUsageSession } from "../../agent/agent-sdk-types.js"; import type { UsageScope } from "@getpaseo/plugin/server/usage"; import { z } from "zod"; @@ -18,9 +19,14 @@ export interface UsageSource { fetch(input: unknown): Promise; } +interface Login { + input: unknown; + harness: string; +} + interface KnownReport { source: UsageSource; - inputs: [unknown, ...unknown[]]; + logins: [Login, ...Login[]]; label?: string; } @@ -75,6 +81,7 @@ export class UsageSourceRegistry { } for (const key of this.known.keys()) if (key.startsWith(`${id}:`)) this.known.delete(key); for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key); + for (const key of this.pending.keys()) if (key.startsWith(`${id}:`)) this.pending.delete(key); } async listReports(options: ListUsageReportsOptions = {}): Promise { @@ -82,8 +89,10 @@ export class UsageSourceRegistry { throw new Error("agentId and reportIds cannot be combined"); this.pruneAgents(); let ids: string[]; + let reports = this.known; if (options.agentId !== undefined) { ids = await this.discoverAgent(options.agentId); + reports = this.byAgent.get(options.agentId)?.reports ?? new Map(); } else if (options.reportIds !== undefined) { ids = options.reportIds; } else { @@ -93,7 +102,7 @@ export class UsageSourceRegistry { } return Promise.all( [...new Set(ids)].flatMap((id) => { - const known = this.known.get(id); + const known = reports.get(id); if (!known) return []; return [ this.fetchId(id, known, options.forceRefresh).then((entry) => { @@ -149,8 +158,14 @@ export class UsageSourceRegistry { for (const [id, report] of reports) { if (this.sources.get(report.source.id) !== report.source) continue; const known = this.known.get(id); - if (known) known.inputs.push(...report.inputs); - else this.known.set(id, { ...report, inputs: [...report.inputs] }); + if (!known) { + this.known.set(id, { ...report, logins: [...report.logins] }); + continue; + } + for (const login of report.logins) { + if (!known.logins.some((existing) => loginKey(existing) === loginKey(login))) + known.logins.push(login); + } } } } @@ -165,6 +180,7 @@ export class UsageSourceRegistry { z.object({ key: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/), label: z.string().optional(), + harness: z.string().min(1).optional(), input: z.json(), }), ) @@ -172,8 +188,12 @@ export class UsageSourceRegistry { for (const account of accounts) { const id = `${source.id}:${account.key}`; const known = reports.get(id); - if (known) known.inputs.push(account.input); - else reports.set(id, { source, inputs: [account.input], label: account.label }); + // COMPAT(usageLoginHarness): added in v0.11.0, remove after 2027-04-05 once plugin floor >= v0.11.0. + const login = { input: account.input, harness: account.harness ?? source.label }; + if (known) { + if (!known.logins.some((existing) => loginKey(existing) === loginKey(login))) + known.logins.push(login); + } else reports.set(id, { source, logins: [login], label: account.label }); } } catch (error) { this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed"); @@ -214,10 +234,14 @@ export class UsageSourceRegistry { } private fetchId(id: string, known: KnownReport, forceRefresh = false): Promise { - const cached = this.cache.get(id); + // Account identity groups cards; the ordered login set identifies a fetch result. + const cacheKey = `${id}:${createHash("sha256") + .update(JSON.stringify([known.label, known.logins.map(loginKey)])) + .digest("hex")}`; + const cached = this.cache.get(cacheKey); if (!forceRefresh && cached && this.now() - cached.at < this.ttlMs) return Promise.resolve(cached.entry); - const pending = this.pending.get(id); + const pending = this.pending.get(cacheKey); if (pending) return pending; const request = (async () => { const entry: UsageReportEntry = { @@ -227,14 +251,14 @@ export class UsageSourceRegistry { icon: known.source.icon, account: { label: known.label }, fetchedAt: new Date(this.now()).toISOString(), - report: await this.fetchBeforeDeadline(known), + ...(await this.fetchWithFallback(known)), }; - this.writeCache(id, entry); + if (this.sources.get(known.source.id) === known.source) this.writeCache(cacheKey, entry); return entry; })(); - this.pending.set(id, request); + this.pending.set(cacheKey, request); void request.finally(() => { - if (this.pending.get(id) === request) this.pending.delete(id); + if (this.pending.get(cacheKey) === request) this.pending.delete(cacheKey); }); return request; } @@ -247,7 +271,7 @@ export class UsageSourceRegistry { this.cache.set(id, { at, entry }); } - private async fetchBeforeDeadline(known: KnownReport): Promise { + private async fetchBeforeDeadline(source: UsageSource, input: unknown): Promise { let timer: ReturnType | undefined; const deadline = new Promise((resolve) => { timer = setTimeout( @@ -256,19 +280,29 @@ export class UsageSourceRegistry { ); }); try { - return await Promise.race([this.fetchWithFallback(known), deadline]); + return await Promise.race([this.fetchLogin(source, input), deadline]); } finally { clearTimeout(timer); } } - private async fetchWithFallback(known: KnownReport): Promise { - let report = await this.fetchLogin(known.source, known.inputs[0]); - for (const input of known.inputs.slice(1)) { - if (report.status === "available") break; - report = await this.fetchLogin(known.source, input); + private async fetchWithFallback( + known: KnownReport, + ): Promise> { + // Start each login within the same deadline window, then prefer discovery order. + // Sequential deadlines can exceed the client's RPC timeout before all errors arrive. + const attempts = known.logins.map((login) => + this.fetchBeforeDeadline(known.source, login.input), + ); + const loginErrors: NonNullable = []; + for (const [index, login] of known.logins.entries()) { + const report = await attempts[index]!; + if (report.status === "available") return { report }; + loginErrors.push({ harness: login.harness, report }); } - return report; + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once app floor >= v0.11.0. + // Keep the final single report so older apps still parse and display failed accounts. + return { report: loginErrors[loginErrors.length - 1]!.report, loginErrors }; } private async fetchLogin(source: UsageSource, input: unknown): Promise { @@ -303,3 +337,12 @@ function legacyError(report: UsageReport, now: number): string | null { if (report.status === "unavailable") return legacyProblem(report.problem, now); return null; } + +// Inputs are opaque JSON locators. Canonical equality deduplicates discovery across scopes. +function loginKey(login: Login): string { + return JSON.stringify(login, (_key, value) => + value !== null && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) + : value, + ); +} diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index 06775e5c5c..2dcf29b948 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import type pino from "pino"; +import pino from "pino"; import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; import { exportPublicKey, generateKeyPair } from "@getpaseo/relay"; import { startRelayTransport } from "./relay-transport"; @@ -146,6 +146,37 @@ describe("relay-transport control lifecycle", () => { vi.useRealTimers(); }); + test("releases a stalled encrypted data connection without restarting control", async () => { + vi.useFakeTimers(); + let attachments = 0; + controllers.push( + startRelayTransport({ + logger: pino({ level: "silent" }), + attachSocket: async () => { + attachments += 1; + }, + relayEndpoint: "relay.paseo.sh:443", + relayUseTls: true, + serverId: "srv_test", + daemonKeyPair: generateKeyPair(), + createWebSocket: relay.createWebSocket, + }), + ); + const control = relay.sockets[0]; + control.open(); + const connected = JSON.stringify({ type: "connected", connectionId: "stalled" }); + control.message(connected); + const stalled = relay.sockets[1]; + stalled.open(); + await vi.advanceTimersByTimeAsync(15_000); + expect(stalled.terminateCalls).toBe(1); + expect(attachments).toBe(0); + expect(control.terminateCalls).toBe(0); + control.message(connected); + expect(relay.sockets).toHaveLength(3); + expect(relay.sockets[2].url).toBe(stalled.url); + }); + test("logs relay_control_connected only after first valid control message", () => { const logger = createMockLogger(); const controller = startRelayTransport({ diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index ddf1a3c234..f7b73a5367 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -420,6 +420,10 @@ async function attachEncryptedSocket( attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise, metadata?: ExternalSocketMetadata, ): Promise { + const handshakeTimeout = setTimeout(() => { + logger.warn("relay_e2ee_handshake_timeout_terminating"); + socket.terminate(); + }, 15_000); try { const relayTransport = createRelayTransportAdapter(socket, logger); const emitter = new EventEmitter(); @@ -440,6 +444,7 @@ async function attachEncryptedSocket( emitter.emit("error", error); }, }); + clearTimeout(handshakeTimeout); const encryptedSocket = createEncryptedRelaySocket({ channel, emitter, @@ -459,6 +464,8 @@ async function attachEncryptedSocket( } catch { // ignore } + } finally { + clearTimeout(handshakeTimeout); } } diff --git a/packages/server/src/server/speech/providers/local/models.test.ts b/packages/server/src/server/speech/providers/local/models.test.ts new file mode 100644 index 0000000000..e40cc25384 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/models.test.ts @@ -0,0 +1,70 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + truncateSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { describe, expect, onTestFinished, test } from "vitest"; + +import { + ensureLocalSpeechModels, + getLocalSpeechModelDir, + listLocalSpeechModels, + listMissingLocalSpeechModels, +} from "./models.js"; + +const logger = pino({ level: "silent" }); +const modelId = "parakeet-tdt-0.6b-v2-int8"; +const spec = listLocalSpeechModels().find((model) => model.id === modelId)!; + +function writeCompleteModel(modelDir: string): void { + mkdirSync(modelDir, { recursive: true }); + for (const file of spec.requiredFiles) { + writeFileSync(path.join(modelDir, file), `complete ${file}`.padEnd(64_000, ".")); + } +} + +function setupModelsDirWithArchive(): { modelsDir: string; modelDir: string; archive: string } { + const root = mkdtempSync(path.join(tmpdir(), "paseo-local-models-")); + onTestFinished(() => rmSync(root, { recursive: true, force: true })); + const sourceRoot = path.join(root, "source"); + writeCompleteModel(path.join(sourceRoot, spec.extractedDir)); + const modelsDir = path.join(root, "models"); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + mkdirSync(path.dirname(archive), { recursive: true }); + execFileSync("tar", ["cf", archive, "-C", sourceRoot, spec.extractedDir]); + return { modelsDir, modelDir: getLocalSpeechModelDir(modelsDir, modelId), archive }; +} + +describe("local speech models", () => { + test("reinstalls a model whose extraction was interrupted", async () => { + // An interrupted extraction leaves the archive in .downloads and a truncated file in place. + const { modelsDir, modelDir, archive } = setupModelsDirWithArchive(); + writeCompleteModel(modelDir); + const encoder = path.join(modelDir, spec.requiredFiles[0]); + truncateSync(encoder, 1_000); + + expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([ + modelId, + ]); + + await ensureLocalSpeechModels({ modelsDir, modelIds: [modelId], logger }); + + expect(readFileSync(encoder, "utf8")).toBe( + `complete ${spec.requiredFiles[0]}`.padEnd(64_000, "."), + ); + expect(existsSync(archive)).toBe(false); + expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([]); + }); +}); diff --git a/packages/server/src/server/speech/providers/local/models.ts b/packages/server/src/server/speech/providers/local/models.ts index f6c7cc4e4e..a884be86d7 100644 --- a/packages/server/src/server/speech/providers/local/models.ts +++ b/packages/server/src/server/speech/providers/local/models.ts @@ -1,4 +1,8 @@ -import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./sherpa/model-downloader.js"; +import { + ensureSherpaOnnxModels, + getSherpaOnnxModelDir, + isSherpaOnnxModelInstalled, +} from "./sherpa/model-downloader.js"; import { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, @@ -30,6 +34,16 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo return getSherpaOnnxModelDir(modelsDir, modelId); } +export async function listMissingLocalSpeechModels(options: { + modelsDir: string; + modelIds: LocalSpeechModelId[]; +}): Promise { + const installed = await Promise.all( + options.modelIds.map((modelId) => isSherpaOnnxModelInstalled(options.modelsDir, modelId)), + ); + return options.modelIds.filter((_modelId, index) => !installed[index]); +} + export async function ensureLocalSpeechModels(options: { modelsDir: string; modelIds: LocalSpeechModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts index 993c959b4d..bc2e462a53 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.test.ts @@ -1,10 +1,21 @@ import { describe, expect, test } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + rmSync, + existsSync, + truncateSync, + readdirSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import { ensureSherpaOnnxModel, getSherpaOnnxModelDir } from "./model-downloader.js"; +import { getSherpaOnnxModelSpec } from "./model-catalog.js"; function makeTmpDir(): string { return mkdtempSync(path.join(tmpdir(), "paseo-speech-models-")); @@ -13,6 +24,67 @@ function makeTmpDir(): string { const logger = pino({ level: "silent" }); describe("sherpa model downloader", () => { + test("does not expose a partial model when tar extraction fails", async () => { + const root = makeTmpDir(); + const modelsDir = path.join(root, "models"); + const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19"); + const sourceDir = path.join(root, "source", spec.extractedDir); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + try { + mkdirSync(sourceDir, { recursive: true }); + mkdirSync(path.dirname(archive), { recursive: true }); + writeFileSync(path.join(sourceDir, "model.onnx"), Buffer.alloc(32_768, 1)); + execFileSync("tar", [ + "cf", + archive, + "-C", + path.dirname(sourceDir), + `${spec.extractedDir}/model.onnx`, + ]); + truncateSync(archive, 1_024); + await expect(ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger })).rejects.toThrow( + "tar exited", + ); + expect(existsSync(getSherpaOnnxModelDir(modelsDir, spec.id))).toBe(false); + expect(existsSync(archive)).toBe(true); + expect(readdirSync(modelsDir)).toEqual([".downloads"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("repairs a partial legacy extraction when its cached archive remains", async () => { + const root = makeTmpDir(); + const modelsDir = path.join(root, "models"); + const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19"); + const sourceDir = path.join(root, "source", spec.extractedDir); + const modelDir = getSherpaOnnxModelDir(modelsDir, spec.id); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + try { + for (const dir of [sourceDir, modelDir]) { + mkdirSync(path.join(dir, "espeak-ng-data"), { recursive: true }); + for (const filename of ["model.onnx", "voices.bin", "tokens.txt"]) { + writeFileSync(path.join(dir, filename), "complete model data"); + } + } + mkdirSync(path.dirname(archive), { recursive: true }); + execFileSync("tar", ["cf", archive, "-C", path.dirname(sourceDir), spec.extractedDir]); + writeFileSync(path.join(modelDir, "model.onnx"), "truncated"); + await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger }); + expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("getSherpaOnnxModelDir maps modelId to extractedDir", () => { const modelsDir = "/tmp/models"; expect(getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v2-int8")).toContain( diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index 8ce21f5e01..cdd286389f 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -90,6 +90,38 @@ async function extractTarArchive( }); } +interface SherpaOnnxModelPaths { + modelDir: string; + archivePath: string; + extractionDir: string; +} + +function getSherpaOnnxModelPaths( + modelsDir: string, + modelId: SherpaOnnxModelId, +): SherpaOnnxModelPaths { + const spec = getSherpaOnnxModelSpec(modelId); + const downloadsDir = path.join(modelsDir, ".downloads"); + return { + modelDir: path.join(modelsDir, spec.extractedDir), + archivePath: path.join(downloadsDir, path.basename(new URL(spec.archiveUrl).pathname)), + extractionDir: path.join(downloadsDir, `${spec.extractedDir}.extracting`), + }; +} + +// The archive is removed only as its model moves into place, so a model whose archive is +// still in .downloads did not finish installing and may hold truncated files. +export async function isSherpaOnnxModelInstalled( + modelsDir: string, + modelId: SherpaOnnxModelId, +): Promise { + const spec = getSherpaOnnxModelSpec(modelId); + const { modelDir, archivePath } = getSherpaOnnxModelPaths(modelsDir, modelId); + return ( + !(await isNonEmptyFile(archivePath)) && (await hasRequiredFiles(modelDir, spec.requiredFiles)) + ); +} + async function isNonEmptyFile(filePath: string): Promise { try { const s = await stat(filePath); @@ -110,18 +142,17 @@ export async function ensureSherpaOnnxModel( }); const spec = getSherpaOnnxModelSpec(options.modelId); - const modelDir = path.join(options.modelsDir, spec.extractedDir); - if (await hasRequiredFiles(modelDir, spec.requiredFiles)) { + const { modelDir, archivePath, extractionDir } = getSherpaOnnxModelPaths( + options.modelsDir, + options.modelId, + ); + if (await isSherpaOnnxModelInstalled(options.modelsDir, options.modelId)) { return modelDir; } logger.info({ modelsDir: options.modelsDir }, "Starting model download"); try { - const downloadsDir = path.join(options.modelsDir, ".downloads"); - const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname); - const archivePath = path.join(downloadsDir, archiveFilename); - if (!(await isNonEmptyFile(archivePath))) { await downloadToFile({ url: spec.archiveUrl, @@ -138,32 +169,40 @@ export async function ensureSherpaOnnxModel( }, "Extracting model archive", ); - await extractTarArchive(archivePath, options.modelsDir, options.signal); - - logger.info( - { - modelId: options.modelId, - modelDir, - }, - "Verifying downloaded model files", - ); - if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) { - throw new Error( - `Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`, - ); - } - - logger.info( - { - modelId: options.modelId, - archivePath, - }, - "Finalizing model artifacts", - ); + // Extract beside the archive and move into place once complete, so an interrupted + // extraction never leaves partial files at modelDir. + await rm(extractionDir, { recursive: true, force: true }); try { - await rm(archivePath, { force: true }); - } catch { - // ignore + await extractTarArchive(archivePath, extractionDir, options.signal); + const extractedModelDir = path.join(extractionDir, spec.extractedDir); + + logger.info( + { + modelId: options.modelId, + modelDir, + }, + "Verifying downloaded model files", + ); + if (!(await hasRequiredFiles(extractedModelDir, spec.requiredFiles))) { + throw new Error( + `Downloaded and extracted ${path.basename(archivePath)}, but required files are missing.`, + ); + } + + logger.info( + { + modelId: options.modelId, + archivePath, + }, + "Finalizing model artifacts", + ); + // A retained archive marks the model as not installed, so remove it before the model + // is moved into place. If removal fails, nothing at modelDir looks installed. + await rm(modelDir, { recursive: true, force: true }); + await rm(archivePath, { force: true, maxRetries: 3 }); + await rename(extractedModelDir, modelDir); + } finally { + await rm(extractionDir, { recursive: true, force: true }); } logger.info({ modelDir }, "Model download completed"); diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index 428ecd27aa..c9c21b3e89 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -36,8 +36,7 @@ vi.mock("./providers/openai/runtime.js", () => ({ vi.mock("./providers/local/models.js", () => ({ ensureLocalSpeechModels: ensureLocalSpeechModelsMock, - getLocalSpeechModelDir: vi.fn(() => ""), - listLocalSpeechModels: vi.fn(() => []), + listMissingLocalSpeechModels: vi.fn(async ({ modelIds }: { modelIds: string[] }) => modelIds), })); function createStubStt(id: string): SpeechToTextProvider { diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 6e56a9e955..932f87c077 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -1,14 +1,8 @@ -import { stat } from "node:fs/promises"; -import { join } from "node:path"; import type { Logger } from "pino"; import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js"; import type { LocalSpeechModelId } from "./providers/local/config.js"; -import { - ensureLocalSpeechModels, - getLocalSpeechModelDir, - listLocalSpeechModels, -} from "./providers/local/models.js"; +import { ensureLocalSpeechModels, listMissingLocalSpeechModels } from "./providers/local/models.js"; import { initializeLocalSpeechServices } from "./providers/local/runtime.js"; import { getOpenAiSpeechAvailability, @@ -76,48 +70,6 @@ function resolveRequestedSpeechProviders( }; } -async function hasRequiredLocalModelFile(filePath: string): Promise { - try { - const fileStat = await stat(filePath); - if (fileStat.isDirectory()) { - return true; - } - return fileStat.isFile() && fileStat.size > 0; - } catch { - return false; - } -} - -async function findMissingRequiredLocalModels(params: { - modelsDir: string | null; - requiredModelIds: LocalSpeechModelId[]; -}): Promise { - const { modelsDir, requiredModelIds } = params; - if (!modelsDir || requiredModelIds.length === 0) { - return []; - } - - const specsById = new Map(listLocalSpeechModels().map((model) => [model.id, model])); - const missing = new Set(); - - const checks = await Promise.all( - requiredModelIds.map(async (modelId) => { - const spec = specsById.get(modelId); - if (!spec) return { modelId, missing: true }; - const modelDir = getLocalSpeechModelDir(modelsDir, modelId); - const filePresence = await Promise.all( - spec.requiredFiles.map((relPath) => hasRequiredLocalModelFile(join(modelDir, relPath))), - ); - return { modelId, missing: !filePresence.every((present) => present) }; - }), - ); - for (const check of checks) { - if (check.missing) missing.add(check.modelId); - } - - return Array.from(missing); -} - function joinModelIds(modelIds: LocalSpeechModelId[]): string { if (modelIds.length === 0) { return "none"; @@ -493,10 +445,12 @@ export function createSpeechService(params: { }; const refreshMissingLocalModels = async (): Promise => { - missingLocalModelIds = await findMissingRequiredLocalModels({ - modelsDir: localModelConfig?.modelsDir ?? null, - requiredModelIds: localModelConfig?.defaultModelIds ?? [], - }); + missingLocalModelIds = localModelConfig + ? await listMissingLocalSpeechModels({ + modelsDir: localModelConfig.modelsDir, + modelIds: localModelConfig.defaultModelIds, + }) + : []; }; const reconcileServices = async (): Promise => { diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 2dc999c0c3..efccd3dfb6 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -510,9 +510,7 @@ it("starts the default shell through the worker and accepts quoted commands", as session.send({ type: "input", data: `${command}\r` }); - await waitForCondition(() => existsSync(markerPath), 10000); - - expect(readFileSync(markerPath, "utf8")).toBe("shell-ok"); + await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok"); }); it("lists subdirectory terminals when querying the workspace root", async () => { diff --git a/packages/server/src/utils/spawn.ts b/packages/server/src/utils/spawn.ts index 8fac115f00..b26104c63e 100644 --- a/packages/server/src/utils/spawn.ts +++ b/packages/server/src/utils/spawn.ts @@ -1,15 +1,6 @@ -import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; -import { extname } from "node:path"; -import { promisify } from "node:util"; - +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { spawnProcess as spawnCli, execCommand as execCli } from "@getpaseo/plugin/server"; import { createExternalCommandProcessEnv, type ProcessEnvRecord } from "../server/paseo-env.js"; -import { - isWindowsCommandScript, - quoteWindowsArgument, - quoteWindowsCommand, -} from "./windows-command.js"; - -const execFileAsync = promisify(execFile); interface ExternalEnvOptions { baseEnv?: ProcessEnvRecord; @@ -35,23 +26,6 @@ interface ExecCommandResult { stderr: string; } -function hasPathSeparator(value: string): boolean { - return value.includes("/") || value.includes("\\"); -} - -function shouldUseWindowsShell( - command: string, - requestedShell?: boolean | string, -): boolean | string { - if (isWindowsCommandScript(command)) { - return true; - } - if (requestedShell !== undefined) { - return requestedShell; - } - return process.platform === "win32" && !hasPathSeparator(command) && !extname(command); -} - export function spawnProcess( command: string, args: string[], @@ -59,12 +33,6 @@ export function spawnProcess( ): ChildProcess { const { baseEnv, env, envOverlay, ...spawnOptions } = options ?? {}; const resolvedBaseEnv = env ?? baseEnv ?? process.env; - const isWindows = process.platform === "win32"; - const shell = shouldUseWindowsShell(command, spawnOptions.shell); - - const shouldQuoteForShell = isWindows && shell !== false; - const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; - const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; const childEnv = options?.envMode === "internal" ? ({ ...resolvedBaseEnv, ...envOverlay } as NodeJS.ProcessEnv) @@ -74,12 +42,10 @@ export function spawnProcess( ...(envOverlay ? [envOverlay] : []), ); - return spawn(resolvedCommand, resolvedArgs, { + return spawnCli(command, args, { ...spawnOptions, env: childEnv, - shell, signal: options?.signal, - windowsHide: true, }); } @@ -90,11 +56,6 @@ export async function execCommand( ): Promise { const { baseEnv, env, envOverlay } = options ?? {}; const resolvedBaseEnv = env ?? baseEnv ?? process.env; - const isWindows = process.platform === "win32"; - const shell = shouldUseWindowsShell(command, options?.shell); - const shouldQuoteForShell = isWindows && shell !== false; - const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; - const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; const childEnv = options?.envMode === "internal" ? ({ ...resolvedBaseEnv, ...envOverlay } as NodeJS.ProcessEnv) @@ -104,14 +65,14 @@ export async function execCommand( ...(envOverlay ? [envOverlay] : []), ); - return execFileAsync(resolvedCommand, resolvedArgs, { + return execCli(command, args, { cwd: options?.cwd, env: childEnv, + shell: options?.shell, encoding: options?.encoding ?? "utf8", + signal: options?.signal, killSignal: options?.killSignal, timeout: options?.timeout, maxBuffer: options?.maxBuffer, - shell, - windowsHide: true, }) as Promise; } diff --git a/packages/website/README.md b/packages/website/README.md new file mode 100644 index 0000000000..5a938ca2f4 --- /dev/null +++ b/packages/website/README.md @@ -0,0 +1,22 @@ +# Paseo website + +## Plugin install counts + +Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or +`?intent=install`, using `/api/plugins/resolve//` or +`/plugins//.json`. Ordinary resolves do not count. These numbers +measure install intent, not successful artifact installation or npm downloads. + +The worker requires Cloudflare's `CF-Connecting-IP` header to count a report. +It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key +with a one hour TTL, never the raw IP. An existing key skips the count; repeats +do not extend the hour. Missing or empty IP headers do not count. + +Accepted reports increment the existing all-time total and UTC daily buckets in +`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day +and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped +to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts) +reads that endpoint into the published plugin details and index; the website +reads windowed counts from KV through its five-minute cache. Existing counts +remain intact. The KV guard is advisory: concurrent requests and KV propagation +are not serialized. diff --git a/packages/website/e2e/overview.fixture.json b/packages/website/e2e/overview.fixture.json new file mode 100644 index 0000000000..2553fe83c6 --- /dev/null +++ b/packages/website/e2e/overview.fixture.json @@ -0,0 +1,32 @@ +{ + "id": "security/overview", + "name": "", + "description": "", + "artifact": { + "kind": "npm", + "package": "@alhassanaraouf/paseo-base2tone-theme", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz", + "integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg==" + }, + "license": "MIT", + "repository": { + "url": "javascript:alert(1)" + }, + "categories": ["themes"], + "author": { + "github": "security", + "name": "" + }, + "media": [ + "data:image/svg+xml,", + "http://example.com/image.png" + ], + "submittedAt": "2026-09-24T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-24T17:12:05.467Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "## Overview security\n\n", + "icon": "data:image/png;base64,AAAA" +} diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 92a48d0c84..1efc20115c 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -1,4 +1,6 @@ -import { expect, test, type Page } from "playwright/test"; +import securityPlugin from "./overview.fixture.json" with { type: "json" }; +import { expect, test, type Page, type Locator } from "playwright/test"; +import registry from "./registry.fixture.json" with { type: "json" }; import { CATEGORIES } from "../src/plugins/categories"; async function openPlugins(page: Page) { @@ -6,6 +8,13 @@ async function openPlugins(page: Page) { await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); } +function pluginCards(page: Page, section: string): Locator { + return page + .getByRole("region", { name: section }) + .getByRole("link") + .filter({ hasNotText: /^See all$/ }); +} + test("browses from the directory into a category, a plugin, and its author", async ({ page, context, @@ -34,7 +43,7 @@ test("browses from the directory into a category, a plugin, and its author", asy await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1); await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toHaveCount(1); await expect( - page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), + page.getByRole("heading", { level: 2, name: "Behavior", exact: true }), ).toBeVisible(); await page.getByRole("button", { name: "Copy to clipboard" }).click(); await expect @@ -78,7 +87,7 @@ test("filters by search and clears to all plugins", async ({ page }) => { await page.goto("/"); await openPlugins(page); - await searchPlugins(page, "graphite"); + await submitSearch(page, "graphite"); await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); await expect( page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }), @@ -96,7 +105,7 @@ test("filters by search and clears to all plugins", async ({ page }) => { test("keeps the directory's ranking window when searching", async ({ page }) => { await page.goto("/plugins?window=month"); - await searchPlugins(page, "graphite"); + await submitSearch(page, "graphite"); await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/); await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute( "aria-current", @@ -120,13 +129,52 @@ test("clears the search with the clear button", async ({ page }) => { await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); }); -test("replaces history while typing a search", async ({ page }) => { +test("searches from the directory on submit, and Back returns to the directory", async ({ + page, +}) => { + await page.goto("/"); + await openPlugins(page); + const entries = await historyLength(page); + + await typeSearch(page, "gra"); + await expect(page).toHaveURL(/\/plugins$/); + expect(await historyLength(page)).toBe(entries); + + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + expect(await historyLength(page)).toBe(entries + 1); + + await page.goBack(); + await expect(page).toHaveURL(/\/plugins$/); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); +}); + +test("filters browse results as you type in one history entry", async ({ page }) => { await page.goto("/"); await page.goto("/plugins/all"); - await searchPlugins(page, "graphite"); - await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); + const entries = await historyLength(page); + + await typeSearch(page, "gra"); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + expect(await historyLength(page)).toBe(entries + 1); + await page.goBack(); - await expect(page).toHaveURL(/\/$/); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue(""); + + await page.goForward(); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra"); + + await page.reload(); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra"); }); test("keeps old category links working", async ({ page }) => { @@ -167,7 +215,7 @@ test.describe("search engine visits without JavaScript", () => { expect(response?.headers()["x-robots-tag"]).toBeUndefined(); await expect(page.getByText("paseo plugin install omercnet/fresh-worktrees")).toBeVisible(); await expect( - page.getByRole("heading", { level: 2, name: "Link to this section Behavior", exact: true }), + page.getByRole("heading", { level: 2, name: "Behavior", exact: true }), ).toBeVisible(); await expectPageMetadata( page, @@ -260,6 +308,19 @@ async function searchPlugins(page: Page, term: string) { await page.getByRole("searchbox", { name: "Search plugins" }).fill(term); } +async function submitSearch(page: Page, term: string) { + await searchPlugins(page, term); + await page.keyboard.press("Enter"); +} + +async function typeSearch(page: Page, term: string) { + await page.getByRole("searchbox", { name: "Search plugins" }).pressSequentially(term); +} + +async function historyLength(page: Page): Promise { + return page.evaluate(() => window.history.length); +} + async function expectPageMetadata(page: Page, title: string, path: string) { await expect(page).toHaveTitle(title); await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( @@ -292,7 +353,7 @@ test("keeps the directory unlinked until the coordinated announcement", async ({ await expect( page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }), ).toHaveCount(0); - await expect(page.getByRole("link", { name: "Community plugins" })).toHaveAttribute( + await expect(page.getByRole("link", { name: "Browse plugins" })).toHaveAttribute( "href", "https://paseo.cafe", ); @@ -306,6 +367,157 @@ test("keeps the directory unlinked until the coordinated announcement", async ({ test.describe("registry fixture layout", () => { test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture"); + test("uses density-aware card thumbnails and keeps detail screenshots original", async ({ + page, + }) => { + const plugin = registry.plugins.find((entry) => entry.id === "alhassanaraouf/base2tone")!; + const source = plugin.media[0]; + await openPlugins(page); + await expectThumbnailCard( + page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Base2Tone/ }), + source, + plugin.id, + ); + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /Base2Tone/ }); + await expectThumbnailCard(card, source, plugin.id); + await card.click(); + await expect( + page.getByRole("img", { name: "Base2Tone screenshot 1", exact: true }), + ).toHaveAttribute("src", source); + }); + + test("shows images and videos as gallery tiles in registry order and opens each in the viewer", async ({ + page, + }) => { + const plugin = registry.plugins.find((entry) => entry.id === "omercnet/dracula")!; + const [firstImage, video] = plugin.media; + await page.goto(`/plugins/${plugin.id}`); + const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + await expect(tiles).toHaveCount(3); + const layout = await tiles.evaluateAll(galleryTiles); + expect(layout.map((tile) => tile.href)).toEqual(plugin.media); + expect(new Set(layout.map((tile) => tile.size)).size).toBe(1); + const preview = tiles.nth(1).locator("video"); + await expect(preview).toHaveAttribute("preload", "metadata"); + await expect(preview).not.toHaveAttribute("controls"); + await expect(preview).not.toHaveAttribute("autoplay"); + expect(await preview.evaluate((element: HTMLVideoElement) => element.muted)).toBe(true); + + const viewer = page.getByRole("dialog"); + await tiles.nth(1).click(); + const playing = viewer.getByLabel("Dracula video 2"); + await expect(playing).toHaveAttribute("src", video); + await expect(playing).toHaveAttribute("controls", ""); + await expect.poll(() => playing.evaluate(isPaused)).toBe(false); + // The fixture video is 320x240, so it has to scale up to fill the viewer. + await expect.poll(() => playing.evaluate(viewerFill)).toBeCloseTo(1, 2); + await page.keyboard.press("Escape"); + await expect(viewer).toBeHidden(); + await expect(page.locator("dialog video")).toHaveCount(0); + + await tiles.first().click(); + await expect(viewer.getByRole("img", { name: "Dracula screenshot 1" })).toHaveAttribute( + "src", + firstImage, + ); + await viewer.getByRole("button", { name: "Close" }).click(); + await expect(viewer).toBeHidden(); + expect(page.context().pages()).toHaveLength(1); + + const newTab = page.context().waitForEvent("page"); + await tiles.first().click({ modifiers: ["ControlOrMeta"] }); + await expect(await newTab).toHaveURL(firstImage); + await expect(viewer).toBeHidden(); + }); + + test("peeks the next gallery tile at the strip's edge, and fills the row with two", async ({ + page, + }) => { + // Wide screens show two tiles and part of the third; phones show one and part of the second. + for (const [width, expected] of [ + [1280, [1, 1, "peeks"]], + [375, [1, "peeks", 0]], + ] as const) { + await page.setViewportSize({ width, height: 720 }); + await page.goto("/plugins/omercnet/dracula"); + const three = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + const shares = await three.evaluateAll(visibleShares); + expect(shares.map((share) => (share > 0.1 && share < 0.5 ? "peeks" : share))).toEqual( + expected, + ); + } + + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/plugins/gpambrozio/herald"); + const two = page.getByRole("link", { name: /^Herald screenshot \d$/ }); + expect(await two.evaluateAll(visibleShares)).toEqual([1, 1]); + }); + + test("steps through the viewer with arrow keys and buttons, wrapping at the ends", async ({ + page, + }) => { + await page.goto("/plugins/omercnet/dracula"); + const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + const viewer = page.getByRole("dialog"); + const shows = (name: string) => + expect( + viewer.getByRole("img", { name, exact: true }).or(viewer.getByLabel(name, { exact: true })), + ).toBeVisible(); + + await tiles.nth(1).click(); + await expect.poll(() => viewer.getByLabel("Dracula video 2").evaluate(isPaused)).toBe(false); + await page.keyboard.press("ArrowRight"); + await shows("Dracula screenshot 3"); + await expect(page.locator("dialog video")).toHaveCount(0); + await page.keyboard.press("ArrowRight"); + await shows("Dracula screenshot 1"); + await viewer.getByRole("img").click(); + await page.keyboard.press("ArrowLeft"); + await shows("Dracula screenshot 3"); + + await viewer.getByRole("button", { name: "Previous" }).click(); + await shows("Dracula video 2"); + await viewer.getByRole("button", { name: "Next" }).click(); + await shows("Dracula screenshot 3"); + await viewer.getByRole("button", { name: "Next" }).click(); + await shows("Dracula screenshot 1"); + await page.keyboard.press("Escape"); + await expect(viewer).toBeHidden(); + }); + + test("keeps a video that fails to load visible in the viewer", async ({ page }) => { + const plugin = registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!; + await page.route(plugin.media[0], (route) => route.abort()); + await page.goto(`/plugins/${plugin.id}`); + await page.getByRole("link", { name: "launchd Jobs video 1" }).click(); + const video = page.getByRole("dialog").getByLabel("launchd Jobs video 1"); + await expect(video).toBeVisible(); + await expect(video).toHaveCSS("opacity", "1"); + await expect(page.getByRole("button", { name: "Next" })).toHaveCount(0); + }); + + test("shows the plugin tile on cards when its media has no image", async ({ page }) => { + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /launchd Jobs/ }); + await expect(card.locator("img, video")).toHaveCount(0); + await card.click(); + await expect( + page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"), + ).toHaveAttribute( + "src", + registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!.media[0], + ); + }); + + test("features listed plugins above What's new in the registry's order", async ({ page }) => { + await openPlugins(page); + await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured"); + const featured = page.getByRole("region", { name: "Featured" }); + await expect(featured).toContainText("A selection of hand picked plugins"); + await expect(pluginCards(page, "Featured")).toHaveText([/Dracula/, /Herald/, /launchd Jobs/]); + }); + test("lists the nine categories in order with counts, and the newest plugins first", async ({ page, }) => { @@ -315,8 +527,132 @@ test.describe("registry fixture layout", () => { CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)), ); await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0"); - await expect( - page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }), - ).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]); + await expect(pluginCards(page, "What’s new")).toHaveText([ + /Base2Tone/, + /Sayr/, + /PromptKit/, + /Defer/, + ]); }); }); + +function galleryTiles(links: Element[]) { + return links.map((link) => ({ + href: link.getAttribute("href"), + size: `${link.clientWidth}x${link.clientHeight}`, + })); +} + +/** How much of each tile's width the gallery strip shows, rounded to hundredths. */ +function visibleShares(links: Element[]) { + const strip = links[0].parentElement!.getBoundingClientRect(); + return links.map((link) => { + const tile = link.getBoundingClientRect(); + const shown = Math.min(tile.right, strip.right) - Math.max(tile.left, strip.left); + return Math.round((Math.max(shown, 0) / tile.width) * 100) / 100; + }); +} + +/** The share of the viewer's room the media fills along its limiting axis; 1 fills it. */ +function viewerFill(media: HTMLElement) { + const box = media.getBoundingClientRect(); + return Math.max(box.width / (window.innerWidth * 0.9), box.height / (window.innerHeight * 0.85)); +} + +function isPaused(video: HTMLVideoElement) { + return video.paused; +} + +async function expectThumbnailCard(card: Locator, source: string, id: string) { + // Card screenshots are decorative and hidden from the accessibility tree. + const image = card.locator("img").first(); + const path = (width: number) => + `/plugins/thumb/${width}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`; + await expect(image).toHaveAttribute("src", path(592)); + await expect(image).toHaveAttribute("srcset", `${path(592)} 1x, ${path(1184)} 2x`); + await expect(image).toHaveAttribute("loading", "lazy"); + await expect(image).toHaveAttribute("decoding", "async"); + await image.scrollIntoViewIfNeeded(); + await expect + .poll( + () => + image.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0), + { timeout: 15_000 }, + ) + .toBe(true); + const box = await image.evaluate((element) => { + const rect = element.parentElement!.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }); + expect(box.width).toBeGreaterThan(0); + expect(box.width / box.height).toBeCloseTo(1.6, 2); +} + +test("keeps author content inert through SSR and hydration", async ({ + page, + request, +}, testInfo) => { + await verifyPluginOverviewResponse(request); + await openUntrustedPlugin(page); + await expectPluginOverviewSafe(page); + await page.reload(); + await expectPluginOverviewSafe(page); + await testInfo.attach("plugin-overview", { + body: await page.screenshot(), + contentType: "image/png", + }); +}); + +async function verifyPluginOverviewResponse(request: import("playwright/test").APIRequestContext) { + const response = await request.get("/plugins/security/overview"); + expect(response.status()).toBe(200); + const html = await response.text(); + expect(html).not.toContain(""); + expect(html).toContain("\\x3C/script>"); +} + +async function openUntrustedPlugin(page: Page) { + await page.goto("/plugins/security/overview"); + await expect(page.getByRole("heading", { name: "Overview security", exact: true })).toBeVisible(); +} + +async function expectPluginOverviewSafe(page: Page) { + expect( + await page.evaluate(() => (globalThis as { __overviewExecuted?: number }).__overviewExecuted), + ).toBeUndefined(); + // Security assertions inspect every emitted attribute, including inaccessible injected elements. + const violations = await page.locator(".docs-prose").evaluate((root) => { + const bad: string[] = []; + for (const element of root.querySelectorAll("*")) { + if ( + ["SCRIPT", "IFRAME", "SVG", "FORM", "INPUT", "META", "BASE", "STYLE"].includes( + element.tagName, + ) + ) + bad.push(element.tagName); + for (const attr of element.attributes) { + if (/^on|^style$|^srcdoc$/i.test(attr.name)) bad.push(attr.name); + if (["href", "src"].includes(attr.name) && !attr.value.startsWith("https://")) + bad.push(attr.value); + } + if ( + element.tagName === "A" && + (element.getAttribute("rel") !== "noopener noreferrer nofollow" || + element.getAttribute("target") !== "_blank") + ) + bad.push("unsafe anchor"); + } + return bad; + }); + expect(violations).toEqual([]); + await expect(page.getByRole("link", { name: "Source", exact: true })).toHaveCount(0); + await expect(page.getByRole("img", { name: /screenshot/ })).toHaveCount(0); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(securityPlugin.name); + await expect( + page.getByRole("link", { name: securityPlugin.author.name, exact: true }), + ).toHaveAttribute("href", "/plugins/security"); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + "content", + securityPlugin.description, + ); +} diff --git a/packages/website/e2e/registry-server.mjs b/packages/website/e2e/registry-server.mjs index 211fee9f38..228f3dd859 100644 --- a/packages/website/e2e/registry-server.mjs +++ b/packages/website/e2e/registry-server.mjs @@ -1,6 +1,15 @@ import { createServer } from "node:http"; import registry from "./registry.fixture.json" with { type: "json" }; +import securityPlugin from "./overview.fixture.json" with { type: "json" }; + +import overviewCorpus from "../../protocol/tests/fixtures/plugin-overview.json" with { type: "json" }; +securityPlugin.readme += + "\n\n" + + overviewCorpus + .map(({ markdown }) => markdown + "\n\n```html\n" + markdown + "\n```") + .join("\n\n"); + // Serve the published registry contract so SSR and browser navigation use the same data. createServer((request, response) => { response.setHeader("Content-Type", "application/json"); @@ -8,7 +17,9 @@ createServer((request, response) => { response.end(JSON.stringify(registry)); return; } - const plugin = registry.plugins.find((entry) => request.url === `/plugins/${entry.id}.json`); + const plugin = [securityPlugin, ...registry.plugins].find( + (entry) => request.url === `/plugins/${entry.id}.json`, + ); response.statusCode = plugin ? 200 : 404; response.end(JSON.stringify(plugin ?? { error: "Plugin not found" })); }).listen(8188, "127.0.0.1"); diff --git a/packages/website/e2e/registry.fixture.json b/packages/website/e2e/registry.fixture.json index 6b75599d3d..2f6560780c 100644 --- a/packages/website/e2e/registry.fixture.json +++ b/packages/website/e2e/registry.fixture.json @@ -5,6 +5,12 @@ "url": "https://plugins.paseo.sh" }, "generatedAt": "2026-10-04T15:55:47.882Z", + "featured": [ + "omercnet/dracula", + "gpambrozio/herald", + "acme/not-published", + "gpambrozio/launchd-jobs" + ], "categories": [ { "slug": "daemon-management", @@ -74,12 +80,12 @@ "name": "alhassanaraouf", "github": "alhassanaraouf" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png" ], "submittedAt": "2026-09-24T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-24T17:12:05.467Z", + "publishedAt": "2026-09-24T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md." @@ -106,13 +112,13 @@ "name": "Tom Lundy", "github": "dorasto" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png", "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T10:39:28.935Z", + "publishedAt": "2026-09-22T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks" @@ -139,13 +145,13 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png", "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-01T17:46:20.500Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions" @@ -172,13 +178,13 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png", "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-27T19:03:31.943Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel" @@ -205,12 +211,12 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ - "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png" + "media": [ + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4" ], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T17:56:41.061Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel" @@ -237,12 +243,12 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png" ], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-22T18:03:14.979Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter" @@ -269,10 +275,10 @@ "name": "gpambrozio", "github": "gpambrozio" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-19T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-01T22:25:58.192Z", + "publishedAt": "2026-09-19T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it" @@ -298,13 +304,13 @@ "name": "hungcuong9125", "github": "hungcuong9125" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png", "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-30T02:57:56.473Z", + "publishedAt": "2026-09-22T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer." @@ -331,13 +337,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:35.639Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster." @@ -364,13 +370,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:29.149Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace." @@ -397,13 +403,14 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png", + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.webm", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:18.185Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo." @@ -430,13 +437,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png" ], "submittedAt": "2026-09-18T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:06.720Z", + "publishedAt": "2026-09-18T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees" @@ -463,13 +470,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png" ], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:36.267Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling." @@ -496,10 +503,10 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:37:04.327Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces." @@ -526,13 +533,13 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png", "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png" ], "submittedAt": "2026-09-18T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:38:48.289Z", + "publishedAt": "2026-09-18T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client." @@ -559,10 +566,10 @@ "name": "Omer Cohen", "github": "omercnet" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-17T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-10-03T09:36:15.719Z", + "publishedAt": "2026-09-17T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host." @@ -588,12 +595,12 @@ "name": "Tom Gringauz", "github": "tomgrin10" }, - "screenshots": [ + "media": [ "https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png" ], "submittedAt": "2026-09-22T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-27T09:59:16.553Z", + "publishedAt": "2026-09-22T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset." @@ -619,10 +626,10 @@ "name": "Tom Gringauz", "github": "tomgrin10" }, - "screenshots": [], + "media": [], "submittedAt": "2026-09-22T00:00:00.000Z", "reviewedAt": "2026-10-03T00:00:00.000Z", - "publishedAt": "2026-09-23T10:36:50.112Z", + "publishedAt": "2026-09-22T00:00:00.000Z", "updatedAt": "2026-10-03T00:00:00.000Z", "installs": 0, "readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo." diff --git a/packages/website/package.json b/packages/website/package.json index 35f2bf395e..bf40c0e1a6 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.11.0-beta.4", + "version": "0.11.0-beta.5", "private": true, "type": "module", "scripts": { @@ -38,6 +38,7 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.1", + "parse5": "^7.3.0", "typescript": "^5.9.3", "vite": "^7.3.3", "vite-tsconfig-paths": "^5.1.4", diff --git a/packages/website/src/components/alternative-page.tsx b/packages/website/src/components/alternative-page.tsx index ec583fefaf..3322f869e1 100644 --- a/packages/website/src/components/alternative-page.tsx +++ b/packages/website/src/components/alternative-page.tsx @@ -32,7 +32,9 @@ function AlternativePageContent({ slug }: { slug: string }) { return ( - {page.content} +
+ {page.content} +
); } diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index a1fcc18ef3..a5b573a1d6 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -4,13 +4,12 @@ import { ArrowRight, Blocks, Bot, - BookOpen, Braces, - Coffee, Compass, ExternalLink, Gem, GitFork, + LayoutPanelLeft, Laptop, Lock, Merge, @@ -19,6 +18,7 @@ import { Smartphone, Terminal, Users, + Workflow, type LucideIcon, } from "lucide-react"; import { @@ -477,7 +477,7 @@ function TurnkeySection() {

Desktop app

- The one click experience, download the app and it just works + The one click experience, download the app and it just works, no server required

@@ -487,8 +487,8 @@ function TurnkeySection() {
@@ -793,92 +793,105 @@ function SdkAutomationExample() { function ExtensibleSection() { return ( - -
- - + +
+
+
+
+ +
+

Plugins

+
+ +
+ +
+
+ {PLUGIN_CAPABILITIES.map((capability) => ( + + ))} +
+
+ +
+

Paseo is Apache 2.0.

+ + + Fork the repo + + +
); } -interface ExtensibleCardLink { - href: string; - label: string; - icon: React.ComponentType<{ className?: string }>; - external?: boolean; - /** Accent links stand out without hover, for destinations worth noticing. */ - accent?: boolean; -} +const PLUGINS_BROWSE = PLUGINS_LINKED + ? { href: "/plugins", external: false } + : { href: "https://paseo.cafe", external: true }; -const PLUGIN_CARD_LINKS: ReadonlyArray = [ - { href: "/docs/plugins", label: "Plugin documentation", icon: BookOpen }, - PLUGINS_LINKED - ? { href: "/plugins", label: "Community plugins", icon: Blocks, accent: true } - : { - href: "https://paseo.cafe", - label: "Community plugins", - icon: Coffee, - external: true, - accent: true, - }, -]; - -const FORK_CARD_LINKS: ReadonlyArray = [ +const PLUGIN_CAPABILITIES: ReadonlyArray<{ + icon: LucideIcon; + title: string; + description: string; +}> = [ { - href: "https://github.com/getpaseo/paseo", - label: "View the repository", - icon: GitHubIcon, - external: true, + icon: LayoutPanelLeft, + title: "UI", + description: "Screens, workspace panels, sidebar items, slash commands, and themes", + }, + { + icon: Workflow, + title: "Agent lifecycle", + description: + "Change configuration, environment, and MCP servers. Answer permissions. Follow up when a turn ends", + }, + { + icon: Bot, + title: "Providers", + description: "Add a coding agent as a provider, used the same way as the built-in ones", }, ]; -function ExtensibleCard({ +function PluginCapabilityCard({ icon: Icon, title, description, - links, }: { icon: LucideIcon; title: string; description: string; - links: ReadonlyArray; }) { return ( -
-
- +
+
+
-

{title}

+

{title}

{description}

-
- {links.map((link) => ( - - - {link.label} - {link.external ? : null} - - ))} -
); } @@ -902,8 +915,7 @@ const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: { icon: Merge, title: "Unified", - description: - "Claude Code, Codex, OpenCode, Pi, Antigravity, Muse Code, and more, all used the same way.", + description: "Every coding agent, used the same way. Switch providers any time.", }, { icon: Blocks, @@ -923,17 +935,17 @@ function PhilosophySection() { title="Philosophy" description="What Paseo stands for, and what every feature is built on" > -
+
{PRINCIPLES.map((principle) => (
-
- +
+ +

{principle.title}

-

{principle.title}

-

{principle.description}

+

{principle.description}

))}
diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index 62daa6ebd0..1ab11f9bdb 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -16,7 +16,7 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) { const widthClasses = WIDTH_CLASSES[width]; const alternatives = getAlternativePages(); return ( -