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 @@
-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.",
+ "",
+ ``,
+ "",
+ "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\n\nAfter the image.`,
+ );
+
+ await selectAssistantImage(page);
+ await copySelection(page);
+
+ expect((await readRichClipboard(page)).plainText).toBe(``);
+
+ 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 (
+
");
+ });
+
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(
+ "",
+ );
+ });
+
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 [
+ '
",
].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(
+ "\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 `
+
+
);
}
@@ -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"
>
-