mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e94a5731b | ||
|
|
f13bb2136c | ||
|
|
2323ee6844 | ||
|
|
1e00f452ea | ||
|
|
a162e1075f | ||
|
|
8f1091da64 | ||
|
|
4dd0e50e78 | ||
|
|
dc785bb29a | ||
|
|
93bfb34e21 | ||
|
|
2d85fe4e04 | ||
|
|
1463396ebc | ||
|
|
d4ea77b910 | ||
|
|
f715221de8 | ||
|
|
aedba5ec27 | ||
|
|
bdc58fb07c | ||
|
|
3f1a04304d | ||
|
|
50cf244e86 | ||
|
|
aaeefdc00b | ||
|
|
c5deaa0516 | ||
|
|
72c180aaf7 | ||
|
|
66b2567be3 | ||
|
|
cb2b569bbd | ||
|
|
2ecf3ba783 | ||
|
|
4515061f88 | ||
|
|
3c478e5967 | ||
|
|
05637a7f05 | ||
|
|
13d21f60f1 | ||
|
|
fc3b2c4eb7 | ||
|
|
a9fef4879a | ||
|
|
a292b402cf | ||
|
|
64c9f263e8 | ||
|
|
48059aeae3 | ||
|
|
7fd469ae19 | ||
|
|
56d5680742 | ||
|
|
06bde1e57a | ||
|
|
396a2bc0b2 | ||
|
|
f5c55dfc55 | ||
|
|
a700d7875c | ||
|
|
c301d6e8aa | ||
|
|
4ede81e841 | ||
|
|
543e3712e1 | ||
|
|
b7c8be4081 | ||
|
|
2974d7c960 | ||
|
|
a7f7405c04 | ||
|
|
2dbfd1e0f8 | ||
|
|
81a5e5bc61 | ||
|
|
0255738ee7 | ||
|
|
e3db7c3397 | ||
|
|
fe10c40b9a | ||
|
|
b6e8ca8974 | ||
|
|
950e1f8f41 | ||
|
|
7a30305503 | ||
|
|
3ad878b47f | ||
|
|
15d774d4a1 | ||
|
|
5fd4072893 | ||
|
|
84054f8d72 | ||
|
|
f02cc16bb4 | ||
|
|
2f0cb2f54b | ||
|
|
4ed13fadb6 | ||
|
|
db303be3c2 | ||
|
|
3e2d77723e | ||
|
|
18412604dd | ||
|
|
bc6ddbfa97 | ||
|
|
27a95a5dec | ||
|
|
9760292650 | ||
|
|
d826d8109f | ||
|
|
62f79e88bb | ||
|
|
9da676d709 | ||
|
|
dfb8a1a2bc | ||
|
|
8ced059b4d | ||
|
|
da9fae172d | ||
|
|
f24bb523e8 | ||
|
|
cf2b3f4bb3 | ||
|
|
a50e14b69e | ||
|
|
7b43f21940 | ||
|
|
2f9a786061 | ||
|
|
6b00310f7b | ||
|
|
981bb4c409 | ||
|
|
3ee4c49e3c | ||
|
|
fbd1bc47e0 |
@@ -36,7 +36,7 @@ jobs:
|
|||||||
PASEO_HOME="$(mktemp -d)"
|
PASEO_HOME="$(mktemp -d)"
|
||||||
export PASEO_LISTEN=127.0.0.1:6767
|
export PASEO_LISTEN=127.0.0.1:6767
|
||||||
|
|
||||||
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
|
WRAPPER_LOG="$PASEO_HOME/paseo-cli.log"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||||
@@ -47,7 +47,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
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=$!
|
DAEMON_PID=$!
|
||||||
|
|
||||||
deadline=$((SECONDS + 30))
|
deadline=$((SECONDS + 30))
|
||||||
@@ -57,7 +57,22 @@ jobs:
|
|||||||
echo "$STATUS_JSON"
|
echo "$STATUS_JSON"
|
||||||
curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html"
|
curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html"
|
||||||
[[ -s "$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
|
fi
|
||||||
|
|
||||||
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||||
@@ -72,7 +87,7 @@ jobs:
|
|||||||
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
echo "::group::paseo-server stdout/stderr"
|
echo "::group::paseo daemon run stdout/stderr"
|
||||||
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,45 @@
|
|||||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||||
},
|
},
|
||||||
"overrides": [
|
"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}"],
|
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
+81
-60
@@ -1,63 +1,43 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.11.0-beta.3 - 2026-10-02
|
## 0.11.0-beta.5 - 2026-10-06
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Added a pin button to Usage window rows, shown on hover and filled when the window is pinned ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Changed usage source plugins to implement `discover()` and `fetch()` instead of `identify()` ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fixed a Usage card disappearing when its login expired; the card now shows the expiry and the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
|
||||||
- Fixed a Usage card disappearing when its login was rejected; the card now shows the HTTP status ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
|
||||||
- Fixed an extra Usage card appearing when a usage source failed to discover accounts ([#5876](https://github.com/getpaseo/paseo/pull/5876))
|
|
||||||
|
|
||||||
## 0.11.0-beta.2 - 2026-10-01
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- 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))
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Changed Usage to show one card per subscription account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
|
||||||
- Changed Usage to try the account's next login when one is missing, expired, or signed into a different account ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
|
||||||
- Changed the sidebar Usage summary to stay hidden until a usage window has data ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fixed built-in plugins, including usage sources, failing to start in packaged desktop apps ([#5844](https://github.com/getpaseo/paseo/pull/5844))
|
|
||||||
- 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 plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
|
||||||
|
|
||||||
## 0.11.0-beta.1 - 2026-10-01
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
|
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
|
||||||
- Added Antigravity as a provider, driving the installed `agy` CLI in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
|
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
|
||||||
- Added Usage to the sidebar footer, showing each account's percent or the windows you pin ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805))
|
- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921))
|
||||||
- Added a Usage screen that follows the account each session spends, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes and OpenCode Go ([#5465](https://github.com/getpaseo/paseo/pull/5465))
|
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
|
||||||
|
- 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, 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 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 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))
|
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
|
||||||
- Added Codex Fast and Ultrafast speeds from the Codex model catalog, with a lightning-bolt trigger on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708))
|
- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680))
|
||||||
- Added a Content width setting for the chat and Markdown preview ([#5680](https://github.com/getpaseo/paseo/pull/5680))
|
- Added `agents.providers.<id>.options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780))
|
||||||
- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753))
|
|
||||||
- Added provider option defaults in `config.json` under `agents.providers.<id>.options`, with per-agent overrides, for every provider ([#5780](https://github.com/getpaseo/paseo/pull/5780))
|
|
||||||
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
|
|
||||||
- Added sidebar header and footer items and screens to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
|
|
||||||
- Added `activeTurnBehavior` to the SDK's `send()` and `run()` so a caller can steer instead of interrupt an active turn ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
|
|
||||||
- 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 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
|
### Changed
|
||||||
|
|
||||||
- Moved Import session into New workspace ([#5805](https://github.com/getpaseo/paseo/pull/5805))
|
- 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 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))
|
||||||
|
- Changed the sidebar workspace row to show last activity by default on new installs ([#6087](https://github.com/getpaseo/paseo/pull/6087))
|
||||||
|
- Changed compact sidebar footer buttons to 44×44 tap targets ([#6044](https://github.com/getpaseo/paseo/pull/6044))
|
||||||
- Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds))
|
- Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds))
|
||||||
- Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750))
|
- Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750))
|
||||||
- Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
- Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||||
@@ -65,19 +45,36 @@
|
|||||||
|
|
||||||
### Fixed
|
### 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, 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))
|
||||||
- Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx))
|
- Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx))
|
||||||
- Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
- Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||||
- Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD))
|
- Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD))
|
||||||
- Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil))
|
- Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil))
|
||||||
- Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork))
|
- Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork))
|
||||||
- Fixed OMP failed tools, Task, Wait, web search, fetch, and `xd://` rows showing raw objects or generic labels ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
- Fixed OMP tool rows showing raw objects or generic labels for failed tools, Task, Wait, web search, and fetch ([#5550](https://github.com/getpaseo/paseo/pull/5550))
|
||||||
- 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 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 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 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 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 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 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 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))
|
- 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))
|
||||||
- Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae))
|
- Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae))
|
||||||
- Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae))
|
- Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae))
|
||||||
@@ -88,23 +85,47 @@
|
|||||||
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
|
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
|
||||||
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
|
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
|
||||||
- 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 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 schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
|
- 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))
|
||||||
|
- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae))
|
||||||
|
- 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 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 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 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 a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585))
|
||||||
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
|
- 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 `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727))
|
- Fixed the Android sidebar jumping back to the top when reopened after selecting a workspace ([#6058](https://github.com/getpaseo/paseo/pull/6058))
|
||||||
- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636))
|
|
||||||
- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703))
|
|
||||||
- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598))
|
|
||||||
- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8))
|
|
||||||
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
|
|
||||||
- Fixed the daemon logging `Agent not found` for every new agent started from a draft ([#5602](https://github.com/getpaseo/paseo/pull/5602))
|
|
||||||
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
|
||||||
- Fixed a dialog opened over another sheet on iPad failing to present ([#5772](https://github.com/getpaseo/paseo/pull/5772))
|
|
||||||
- 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 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 narrow width after the Explorer closes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
|
- Fixed iPad chat text keeping its old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
|
||||||
|
- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598))
|
||||||
|
- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703))
|
||||||
|
- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636))
|
||||||
|
- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||||
|
- Fixed `paseo agent delete --all` and `--cwd` leaving archived agents behind ([#6027](https://github.com/getpaseo/paseo/pull/6027) by [@3ae3ae](https://github.com/3ae3ae))
|
||||||
|
- Fixed `paseo agent` commands reporting a generic failure instead of `AGENT_NOT_FOUND` for an unknown agent ID ([#6053](https://github.com/getpaseo/paseo/pull/6053))
|
||||||
|
- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727))
|
||||||
|
- Fixed `paseo restart` exiting with `RESTART_NOT_CONFIRMED` when provider checks take more than a second ([#6009](https://github.com/getpaseo/paseo/pull/6009))
|
||||||
|
- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet))
|
||||||
|
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
||||||
|
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
|
||||||
|
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
|
||||||
|
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
|
||||||
|
- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
|
||||||
- Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815))
|
- Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815))
|
||||||
|
|
||||||
## 0.10.3 - 2026-10-02
|
## 0.10.3 - 2026-10-02
|
||||||
|
|||||||
@@ -39,8 +39,11 @@
|
|||||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
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.
|
- **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.
|
- **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.
|
- **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
|
## Plugins
|
||||||
|
|
||||||
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
|
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
|
||||||
TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install <source>`.
|
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
|
- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes.
|
||||||
machine and inside connected clients; install only code you trust.
|
- **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
|
## 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**.
|
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
|
```bash
|
||||||
npm install -g @getpaseo/cli
|
npm install -g @getpaseo/cli
|
||||||
paseo
|
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:
|
For full setup and configuration, see:
|
||||||
|
|
||||||
@@ -197,7 +207,7 @@ npm run typecheck
|
|||||||
|
|
||||||
## Sponsors
|
## 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.
|
||||||
|
|
||||||
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ Run the shared browser profile fixture with:
|
|||||||
PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop
|
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
|
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
|
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
|
tabs share cookies and local storage through one persistent session, and that the data is
|
||||||
|
|||||||
+2
-2
@@ -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`).
|
- **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`).
|
- **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: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. 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 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 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 (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`).
|
- **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`).
|
- **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`).
|
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||||
|
|||||||
+20
-15
@@ -117,15 +117,19 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
|
|||||||
|
|
||||||
## Install from a registry
|
## Install from a registry
|
||||||
|
|
||||||
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
|
`paseo plugin add owner/slug` installs the registry's reviewed artifact by default.
|
||||||
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
|
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
|
||||||
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
|
Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
|
||||||
|
|
||||||
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
|
|
||||||
artifact, and GitHub shorthand requires `github:`. Registry installs
|
|
||||||
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
|
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.
|
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
|
Use `host/owner/slug` for a registry at `https://host`, or set
|
||||||
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
|
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
|
||||||
Private registry credentials live in daemon config under
|
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
|
## Install a Git source
|
||||||
|
|
||||||
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
|
GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git
|
||||||
URL. An existing directory still wins over source resolution.
|
URL.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
paseo plugin install owner/repository
|
paseo plugin install git:owner/repository
|
||||||
paseo plugin install https://gitlab.com/group/repository.git
|
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
|
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
|
## Managed source ownership
|
||||||
|
|
||||||
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
|
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
|
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before
|
||||||
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
|
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
|
`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
|
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 |
|
| Entry | Owns | May depend on |
|
||||||
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
| `@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/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` | 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 |
|
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
|
||||||
@@ -488,9 +493,9 @@ SVG or URL.
|
|||||||
|
|
||||||
## Usage sources
|
## 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
|
## Contribute sidebar items
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -10,7 +10,9 @@ Keep a bundled provider in `plugins/<id>/` and register it through
|
|||||||
import rules belong to [plugins.md](plugins.md#built-in-plugins); the
|
import rules belong to [plugins.md](plugins.md#built-in-plugins); the
|
||||||
[public provider guide](../public-docs/plugins/providers.md) covers the provider contract.
|
[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.<provider-id>.command` and `env` before
|
executable resolution and applies `agents.providers.<provider-id>.command` and `env` before
|
||||||
connecting. Register the provider's icon with the plugin rather than adding it to the app's
|
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.
|
provider icon map. You do not need a core manifest entry or provider factory.
|
||||||
|
|||||||
+15
-17
@@ -95,7 +95,7 @@ release push as the changelog and version commit.
|
|||||||
There are two supported release paths:
|
There are two supported release paths:
|
||||||
|
|
||||||
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
|
1. **Direct stable release**: you are ready to ship the resolved release source to everyone immediately (default `origin/main`).
|
||||||
2. **Beta flow**: release candidates on the `beta` channel. Each beta carries its own changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
2. **Beta flow**: release candidates on the `beta` channel. Each beta folds into the series' single changelog entry, publishes npm only on the explicit `beta` dist-tag, and stays behind the Stable/Beta switch on `/download`.
|
||||||
|
|
||||||
Paseo has one linear release track even though npm dist-tags are independent
|
Paseo has one linear release track even though npm dist-tags are independent
|
||||||
pointers. The npm invariant is:
|
pointers. The npm invariant is:
|
||||||
@@ -194,7 +194,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
|||||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||||
- Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together.
|
- Require the Linux artifact CI checks with both restricted and usable user namespaces to pass before publication; see [packaged desktop smoke](testing.md#packaged-desktop-smoke). Keep the installed-package and AppImage checks together.
|
||||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||||
- **Each beta carries its own changelog entry.** `Release Notes Sync` mirrors the matching `## X.Y.Z-beta.N` entry into that prerelease body. Promotion collapses every beta entry for the version into one final stable entry. See the Changelog policy section.
|
- **A beta series has one changelog entry.** Each beta folds into it and renames its heading to the new `## X.Y.Z-beta.N`, which `Release Notes Sync` mirrors into that prerelease body. Promotion renames it to the stable heading. See the Changelog policy section.
|
||||||
|
|
||||||
Use the beta path when you need to:
|
Use the beta path when you need to:
|
||||||
|
|
||||||
@@ -414,7 +414,7 @@ The GitHub Release body is populated automatically by the `Release Notes Sync` w
|
|||||||
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
|
- Homebrew, the Play Store, the App Store, and `app.paseo.sh` have no beta. The Beta view drops those rows, and the whole Web section, rather than showing an inert "stable only" placeholder. When a surface gains a beta path — say a public TestFlight link — add its row back in `packages/website/src/routes/download.tsx`.
|
||||||
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
- The default download target only moves when you publish the final stable release tag like `v0.1.41`.
|
||||||
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
- The public `/changelog` page renders `CHANGELOG.md` as-is, so the in-flight `-beta.N` entry shows there once it lands on `main` — that's intended, it's where beta users check what's coming. Only the **default download target** stays pinned to the latest stable; the download links read GitHub's releases API, not the changelog, so a `-beta.N` heading on top never affects them.
|
||||||
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: promotion collapses the beta entries into one stable entry, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
- The download page's "What's new" link deep-links the **minor group** anchor (`/changelog#release-0.3`), not the exact entry: the beta entry is renamed on each beta and on promotion, so the minor group remains the durable target. A version with no entry in the bundled changelog — a tag whose changelog commit hasn't redeployed the site yet — links the plain `/changelog` instead of a dead anchor.
|
||||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases.
|
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on the `release: published` event emitted when a stable draft is published and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`. Its job condition excludes beta prereleases.
|
||||||
|
|
||||||
## Fixing a failed release build
|
## Fixing a failed release build
|
||||||
@@ -521,17 +521,20 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
|||||||
|
|
||||||
## Changelog policy
|
## Changelog policy
|
||||||
|
|
||||||
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
|
- `CHANGELOG.md` holds stable releases and at most one entry for the current beta series.
|
||||||
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
- The first beta of a version inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||||
- Each subsequent beta inserts a new top entry with the next beta number. Its notes cover the changes since the previous beta tag.
|
- Each subsequent beta rewrites that entry in place: the heading becomes the new beta number and date, and the body describes the full change from the previous stable release to this beta. Never add a second beta entry.
|
||||||
- Stable promotion replaces every beta entry for that version with one `## 0.1.60 - YYYY-MM-DD` entry.
|
- The entry is a list of changes, not of PRs. When a later beta reverts, reworks, or follows up on something an earlier beta added, rewrite the bullet to the current behavior or delete it. A stable user never saw the earlier beta behavior, so fixes to it are not "Fixed" bullets.
|
||||||
- The promoted stable entry covers the full diff from the previous stable tag and collapses internal iterations across the beta series.
|
- Anything that already shipped to stable through a patch release on an older line stays out of the beta entry.
|
||||||
|
- Stable promotion renames the entry to `## 0.1.60 - YYYY-MM-DD` after a final review.
|
||||||
|
|
||||||
|
Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry.
|
||||||
|
|
||||||
## Changelog ownership
|
## Changelog ownership
|
||||||
|
|
||||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||||
- **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding.
|
- **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding.
|
||||||
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
|
- Every entry, beta or stable, is drafted from the previous stable release to the release source. For a later beta, read the PRs since the previous beta tag, then rework the existing entry so each bullet still describes current behavior. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||||
|
|
||||||
## Changelog wording
|
## Changelog wording
|
||||||
|
|
||||||
@@ -563,7 +566,7 @@ that changed, the failure that was fixed, or the capability that was added? If t
|
|||||||
claims a general improvement, rewrite it with the concrete change.
|
claims a general improvement, rewrite it with the concrete change.
|
||||||
|
|
||||||
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
|
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
|
||||||
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
|
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working.
|
||||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||||
|
|
||||||
## Changelog conciseness
|
## Changelog conciseness
|
||||||
@@ -631,12 +634,7 @@ Use `git diff <latest-release-tag>..<release-source-sha>` as the review input. T
|
|||||||
|
|
||||||
Changelog scope follows the release being described:
|
Changelog scope follows the release being described:
|
||||||
|
|
||||||
- **First beta**: `previous stable tag → release source`
|
Every entry covers `previous stable release → release source`: first beta, later beta, direct stable release, and promotion alike. The beta entry always reads as the stable entry would if the beta were promoted today.
|
||||||
- **Later beta**: `previous beta tag → release source`
|
|
||||||
- **Direct stable release**: `previous stable tag → release source`
|
|
||||||
- **Stable promotion**: replace the full beta series with one entry covering `previous stable tag → release source`
|
|
||||||
|
|
||||||
Each beta entry records what its testers receive. Promotion produces the single stable record for the full jump from one stable version to the next.
|
|
||||||
|
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
@@ -644,7 +642,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
|||||||
|
|
||||||
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
|
||||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||||
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
|
- [ ] Fold this beta into the series' single `CHANGELOG.md` entry (heading `## X.Y.Z-beta.N - YYYY-MM-DD`, previous stable → release source), review it against the changelog policy, get approval, and commit it before cutting the release
|
||||||
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
|
||||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||||
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
- [ ] `npm run release:beta:patch`, `npm run release:beta:minor`, or `npm run release:beta:next` completes successfully
|
||||||
@@ -667,7 +665,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
|||||||
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
|
||||||
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
|
||||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
|
||||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
|
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion renames the series' `## X.Y.Z-beta.N` entry to `## X.Y.Z - YYYY-MM-DD` and re-checks it covers the full release
|
||||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||||
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
|
||||||
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
- [ ] `npm run release:patch`, `npm run release:minor`, or `npm run release:promote` completes successfully
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ buildNpmPackage {
|
|||||||
cp -a "$path" "$out/share/paseo-desktop/$path"
|
cp -a "$path" "$out/share/paseo-desktop/$path"
|
||||||
done < desktop-files.txt
|
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.
|
# Keep the same unpackaged monorepo layout expected by main.js.
|
||||||
cp package.json $out/share/paseo-desktop/
|
cp package.json $out/share/paseo-desktop/
|
||||||
mkdir -p $out/share/paseo-desktop/packages/app
|
mkdir -p $out/share/paseo-desktop/packages/app
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc=
|
sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4=
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ buildNpmPackage rec {
|
|||||||
cp -a "$path" "$out/lib/paseo/$path"
|
cp -a "$path" "$out/lib/paseo/$path"
|
||||||
done < daemon-files.txt
|
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
|
# Root package.json lets node resolve the workspace layout when the
|
||||||
# CLI/server bin starts from $out.
|
# CLI/server bin starts from $out.
|
||||||
cp package.json $out/lib/paseo/
|
cp package.json $out/lib/paseo/
|
||||||
|
|||||||
Generated
+31
-30
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -36413,7 +36413,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -37496,12 +37496,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.11.0-beta.3",
|
"@getpaseo/client": "0.11.0-beta.5",
|
||||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||||
"@getpaseo/server": "0.11.0-beta.3",
|
"@getpaseo/server": "0.11.0-beta.5",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37748,10 +37748,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||||
"@getpaseo/relay": "0.11.0-beta.3",
|
"@getpaseo/relay": "0.11.0-beta.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37762,7 +37762,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -38006,7 +38006,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -38902,7 +38902,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -38931,14 +38931,14 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^1.4.0",
|
"@agentclientprotocol/sdk": "^1.4.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.11.0-beta.3",
|
"@getpaseo/client": "0.11.0-beta.5",
|
||||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -38948,8 +38948,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.11.0-beta.3",
|
"@getpaseo/client": "0.11.0-beta.5",
|
||||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||||
"react": "~19.1.0",
|
"react": "~19.1.0",
|
||||||
"react-native": ">=0.81.5",
|
"react-native": ">=0.81.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -38971,7 +38971,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -38998,7 +38998,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -39216,16 +39216,16 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.11.0-beta.3",
|
"@getpaseo/client": "0.11.0-beta.5",
|
||||||
"@getpaseo/highlight": "0.11.0-beta.3",
|
"@getpaseo/highlight": "0.11.0-beta.5",
|
||||||
"@getpaseo/plugin": "0.11.0-beta.3",
|
"@getpaseo/plugin": "0.11.0-beta.5",
|
||||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||||
"@getpaseo/relay": "0.11.0-beta.3",
|
"@getpaseo/relay": "0.11.0-beta.5",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -40146,11 +40146,12 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
"@fontsource-variable/geist": "^5.3.0",
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
|
"@getpaseo/protocol": "*",
|
||||||
"@tanstack/react-router": "^1.170.16",
|
"@tanstack/react-router": "^1.170.16",
|
||||||
"@tanstack/react-start": "^1.168.26",
|
"@tanstack/react-start": "^1.168.26",
|
||||||
"framer-motion": "^12.35.2",
|
"framer-motion": "^12.35.2",
|
||||||
@@ -40165,8 +40166,7 @@
|
|||||||
"remark-directive": "^4.0.0",
|
"remark-directive": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"shiki": "^4.0.2",
|
"shiki": "^4.0.2",
|
||||||
"wrangler": "^4.75.0",
|
"wrangler": "^4.75.0"
|
||||||
"@getpaseo/protocol": "*"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
@@ -40174,6 +40174,7 @@
|
|||||||
"@types/react": "^19.1.6",
|
"@types/react": "^19.1.6",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"@vitejs/plugin-react": "^4.5.1",
|
"@vitejs/plugin-react": "^4.5.1",
|
||||||
|
"parse5": "^7.3.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.3",
|
"vite": "^7.3.3",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
@@ -41753,7 +41754,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"name": "@getpaseo/builtin-plugins",
|
"name": "@getpaseo/builtin-plugins",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/plugin": "*",
|
"@getpaseo/plugin": "*",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -120,6 +120,84 @@ async function selectAssistantElement(page: Page, selector: string): Promise<voi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select an element from the end of the text before it. A drag that starts in the gap
|
||||||
|
* above a block anchors there, so nothing outside the element is highlighted.
|
||||||
|
*/
|
||||||
|
async function selectAssistantElementFromEndOf(
|
||||||
|
page: Page,
|
||||||
|
startText: string,
|
||||||
|
selector: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
async function selectAssistantText(page: Page, text: string): Promise<void> {
|
||||||
await selectAssistantTextRange(page, text, text);
|
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);
|
const completeCodeClipboard = await readRichClipboard(page);
|
||||||
expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;');
|
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.
|
// Crossing the block boundary expresses an intent to carry its Markdown structure.
|
||||||
await selectAssistantAcrossCodeLines(page, "const", "After code.");
|
await selectAssistantAcrossCodeLines(page, "const", "After code.");
|
||||||
await copySelection(page);
|
await copySelection(page);
|
||||||
@@ -601,3 +692,55 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
|||||||
await agent.cleanup();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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<void> {
|
async function openSpeedSelector(page: Page): Promise<void> {
|
||||||
const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ });
|
const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ });
|
||||||
const features = page.getByRole("button", { name: "Open agent features", exact: true });
|
const features = page.getByRole("button", { name: "Open agent features", exact: true });
|
||||||
@@ -51,6 +55,13 @@ async function selectSpeed(page: Page, label: string): Promise<void> {
|
|||||||
else await expect(sheetTrigger).toContainText(label);
|
else await expect(sheetTrigger).toContainText(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectSpeedIconColor(page: Page, color: string): Promise<void> {
|
||||||
|
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<void> {
|
async function expectSpeedChoices(page: Page, choices: string[]): Promise<void> {
|
||||||
for (const choice of ["Normal", "Fast", "Ultrafast"]) {
|
for (const choice of ["Normal", "Fast", "Ultrafast"]) {
|
||||||
const option = page.getByText(choice, { exact: true }).last();
|
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 page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") });
|
||||||
}
|
}
|
||||||
await openSpeedSelector(page);
|
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 expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
||||||
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
|
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
|
||||||
await selectSpeed(page, "Ultrafast");
|
await selectSpeed(page, "Ultrafast");
|
||||||
|
await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR);
|
||||||
await openSpeedSelector(page);
|
await openSpeedSelector(page);
|
||||||
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
|
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
|
||||||
});
|
});
|
||||||
await selectSpeed(page, "Normal");
|
await selectSpeed(page, "Normal");
|
||||||
|
await expectSpeedIconColor(page, NORMAL_SPEED_COLOR);
|
||||||
if (viewport.name === "desktop") {
|
if (viewport.name === "desktop") {
|
||||||
await switchAgentModel(agent.id, "gpt-6-sol");
|
await switchAgentModel(agent.id, "gpt-6-sol");
|
||||||
await openSpeedSelector(page);
|
await openSpeedSelector(page);
|
||||||
|
|||||||
@@ -268,12 +268,18 @@ for (const theme of ["light", "dark"] as const) {
|
|||||||
await expect(card).toBeVisible();
|
await expect(card).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("Refresh replaces the card with the source's new report", async () => {
|
await test.step("Refresh keeps the card open and replaces it with the new report", async () => {
|
||||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
const refreshed = gate();
|
||||||
|
usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] });
|
||||||
const card = contextWindowDetails(page);
|
const card = contextWindowDetails(page);
|
||||||
await refreshUsageCard(card, "Claude");
|
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();
|
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 () => {
|
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 test.step("a host without usage reports shows only the context window", async () => {
|
||||||
await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot);
|
await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot);
|
||||||
// One request per open that had usage; this one sends none.
|
// 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);
|
const sheet = contextWindowDetails(page);
|
||||||
await refreshUsageCard(sheet, "Claude");
|
await refreshUsageCard(sheet, "Claude");
|
||||||
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
|
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||||
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
|
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
|
||||||
await shot("problem");
|
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 test.step("a host without usage reports shows only the context window", async () => {
|
||||||
await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot);
|
await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot);
|
||||||
expect(usage.agentRequests()).toHaveLength(2);
|
expect(usage.agentRequests()).toHaveLength(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
|
||||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
@@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows(
|
|||||||
|
|
||||||
await installPlugin(page, missingSource);
|
await installPlugin(page, missingSource);
|
||||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
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 expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
|
||||||
await capturePluginInstallForm(page, testInfo, "compact-error");
|
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.getByText("Removed npm-review", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,13 +183,16 @@ async function openCompactSidebar(page: Page): Promise<void> {
|
|||||||
await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible();
|
await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIN_TARGET_SIZE = 44;
|
||||||
|
|
||||||
async function expectFingerSizedFooterTargets(page: Page): Promise<void> {
|
async function expectFingerSizedFooterTargets(page: Page): Promise<void> {
|
||||||
const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button");
|
const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button");
|
||||||
await expect(buttons).toHaveCount(5);
|
await expect(buttons).toHaveCount(5);
|
||||||
for (const button of await buttons.all()) {
|
for (const button of await buttons.all()) {
|
||||||
const bounds = await button.boundingBox();
|
const bounds = await button.boundingBox();
|
||||||
expect(bounds?.width).toBeGreaterThanOrEqual(44);
|
// Layout can report a 44pt box as 43.99999, so compare at a hundredth of a point.
|
||||||
expect(bounds?.height).toBeGreaterThanOrEqual(44);
|
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.
|
// Glyphs stay at the composer toolbar size (some optically smaller); only the target grows.
|
||||||
const glyphWidth = await button.locator("svg").first().getAttribute("width");
|
const glyphWidth = await button.locator("svg").first().getAttribute("width");
|
||||||
expect(Number(glyphWidth)).toBeLessThanOrEqual(20);
|
expect(Number(glyphWidth)).toBeLessThanOrEqual(20);
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import {
|
|||||||
closeMobileAgentSidebar,
|
closeMobileAgentSidebar,
|
||||||
expectMobileAgentSidebarHidden,
|
expectMobileAgentSidebarHidden,
|
||||||
expectMobileAgentSidebarVisible,
|
expectMobileAgentSidebarVisible,
|
||||||
|
expectWorkspaceHoverCardClosed,
|
||||||
|
expectWorkspaceHoverCardOpen,
|
||||||
|
expectWorkspaceHoverCardStaysOpen,
|
||||||
|
focusWorkspaceRowWithKeyboard,
|
||||||
|
movePointerAwayFromWorkspaceHoverCard,
|
||||||
openMobileAgentSidebar,
|
openMobileAgentSidebar,
|
||||||
pinWorkspaceFromSidebar,
|
pinWorkspaceFromSidebar,
|
||||||
} from "../support/helpers/sidebar";
|
} 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 }) => {
|
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
|
||||||
const workspace = await seedMockAgentWorkspace({
|
const workspace = await seedMockAgentWorkspace({
|
||||||
repoPrefix: "sidebar-mark-unread-",
|
repoPrefix: "sidebar-mark-unread-",
|
||||||
|
|||||||
+87
-19
@@ -18,13 +18,15 @@ import {
|
|||||||
installLoginUsage,
|
installLoginUsage,
|
||||||
installCodexWindowUsage,
|
installCodexWindowUsage,
|
||||||
expectCodexReportedWindows,
|
expectCodexReportedWindows,
|
||||||
openUsage,
|
|
||||||
refreshLoginUsage,
|
refreshLoginUsage,
|
||||||
hoverUsageWindow,
|
hoverUsageWindow,
|
||||||
} from "../support/helpers/usage-login";
|
} from "../support/helpers/usage-login";
|
||||||
import {
|
import {
|
||||||
claudeAndCodexReports,
|
claudeAndCodexReports,
|
||||||
expectPinnedUsage,
|
expectPinnedUsage,
|
||||||
|
openCompactSidebar,
|
||||||
|
openUsageFromIcon,
|
||||||
|
openUsageFromItem,
|
||||||
pinRow,
|
pinRow,
|
||||||
openUsageOptions,
|
openUsageOptions,
|
||||||
closeUsageOptions,
|
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 ({
|
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -134,14 +136,13 @@ test.describe("usage screen", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
await openUsageFromItem(page);
|
||||||
await expect(page).toHaveURL(/\/usage$/);
|
|
||||||
await usage.waitForListRequests(1);
|
await usage.waitForListRequests(1);
|
||||||
|
|
||||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(hostFilter(page)).toHaveCount(0);
|
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("31%")).toBeVisible();
|
||||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||||
await expect(group.getByText("Unavailable", { 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 }],
|
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, {
|
const usage = await installUsageReportsFixture(page, {
|
||||||
lists: [
|
lists: [
|
||||||
(request) =>
|
(request) =>
|
||||||
@@ -187,7 +188,7 @@ test.describe("usage screen", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
|
await openUsageFromItem(page);
|
||||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||||
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
|
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.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto("/usage");
|
await gotoAppShell(page);
|
||||||
|
await openCompactSidebar(page);
|
||||||
|
await openUsageFromIcon(page);
|
||||||
await usage.waitForListRequests(1);
|
await usage.waitForListRequests(1);
|
||||||
|
|
||||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||||
@@ -238,8 +241,8 @@ test.describe("usage screen", () => {
|
|||||||
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
// Without reports the footer has no Usage item; its Usage icon opens the screen.
|
// Without reports the footer has no Usage item; its Usage icon opens Usage.
|
||||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click();
|
await openUsageFromIcon(page);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
|
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
|
||||||
@@ -250,7 +253,7 @@ test.describe("usage screen", () => {
|
|||||||
expect(usage.listRequests()).toHaveLength(0);
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
test.setTimeout(420_000);
|
test.setTimeout(420_000);
|
||||||
@@ -273,7 +276,7 @@ test.describe("usage screen", () => {
|
|||||||
|
|
||||||
// Nothing picked and no workspace open: the first host.
|
// Nothing picked and no workspace open: the first host.
|
||||||
await expectPinnedUsage(page, ["31% Weekly"]);
|
await expectPinnedUsage(page, ["31% Weekly"]);
|
||||||
await usageItem(page).click();
|
await openUsageFromItem(page);
|
||||||
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
|
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
|
||||||
|
|
||||||
await hostFilter(page).click();
|
await hostFilter(page).click();
|
||||||
@@ -282,7 +285,7 @@ test.describe("usage screen", () => {
|
|||||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||||
).toBeVisible({ timeout: 30_000 });
|
).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
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"]);
|
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||||
|
|
||||||
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
|
// 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,
|
port: secondary.port,
|
||||||
});
|
});
|
||||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||||
|
await openUsageFromItem(page);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||||
).toBeVisible({ timeout: 30_000 });
|
).toBeVisible({ timeout: 30_000 });
|
||||||
@@ -320,16 +324,16 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openUsage(page);
|
await openUsageFromIcon(page);
|
||||||
await expect(
|
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();
|
).toBeVisible();
|
||||||
await expect(page.getByText("Unavailable", { exact: true })).toBeVisible();
|
await expect(page.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||||
await qaScreenshot(page, "usage-expired-login");
|
await qaScreenshot(page, "usage-expired-login");
|
||||||
await fixture.setReport(loginWindows);
|
await fixture.setReport(loginWindows);
|
||||||
await refreshLoginUsage(page);
|
await refreshLoginUsage(page);
|
||||||
await expect(
|
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);
|
).toHaveCount(0);
|
||||||
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
||||||
const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / });
|
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 {
|
try {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openUsage(page);
|
await openCompactSidebar(page);
|
||||||
|
await openUsageFromIcon(page);
|
||||||
const card = page.getByTestId("usage-report-login-journey:account");
|
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")).toHaveCount(2);
|
||||||
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1");
|
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 page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await expect(usageItem(page)).toBeVisible();
|
await expect(usageItem(page)).toBeVisible();
|
||||||
await usageItem(page).click();
|
await openUsageFromItem(page);
|
||||||
await qaScreenshot(page, `codex-${shape}-card-and-summary`);
|
await qaScreenshot(page, `codex-${shape}-card-and-summary`);
|
||||||
await expectCodexReportedWindows(page, shape);
|
await expectCodexReportedWindows(page, shape);
|
||||||
await qaScreenshot(page, `codex-${shape}-pins`);
|
await qaScreenshot(page, `codex-${shape}-pins`);
|
||||||
@@ -439,7 +444,9 @@ for (const theme of ["light", "dark"] as const) {
|
|||||||
lists: [() => claudeAndCodexReports()],
|
lists: [() => claudeAndCodexReports()],
|
||||||
});
|
});
|
||||||
await page.setViewportSize(viewport);
|
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()}`);
|
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
||||||
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
|
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
const settings = page.getByTestId("usage-options-menu");
|
const settings = page.getByTestId("usage-options-menu");
|
||||||
@@ -474,3 +481,64 @@ async function captureSettingsState(
|
|||||||
await qaScreenshot(page, name);
|
await qaScreenshot(page, name);
|
||||||
await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" });
|
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);
|
||||||
|
}
|
||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
installUsageReportsFixture,
|
installUsageReportsFixture,
|
||||||
type UsageReportsFixture,
|
type UsageReportsFixture,
|
||||||
} from "../support/helpers/usage-reports";
|
} 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 = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
|
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
|
||||||
|
|
||||||
@@ -98,7 +102,8 @@ test.describe("usage settings", () => {
|
|||||||
// The shared percentages setting applies to the host section.
|
// The shared percentages setting applies to the host section.
|
||||||
await expect(page.getByTestId("usage-options-menu")).toBeVisible();
|
await expect(page.getByTestId("usage-options-menu")).toBeVisible();
|
||||||
const hostUsageUrl = page.url();
|
const hostUsageUrl = page.url();
|
||||||
await page.goto("/usage");
|
await gotoAppShell(page);
|
||||||
|
await openUsageFromIcon(page);
|
||||||
await showUsageAs(page, "remaining");
|
await showUsageAs(page, "remaining");
|
||||||
await page.goto(hostUsageUrl);
|
await page.goto(hostUsageUrl);
|
||||||
await expect(card.getByText("30% left")).toBeVisible();
|
await expect(card.getByText("30% left")).toBeVisible();
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import path from "node:path";
|
|||||||
import type { Locator, Page } from "@playwright/test";
|
import type { Locator, Page } from "@playwright/test";
|
||||||
import { expect, test } from "../support/fixtures";
|
import { expect, test } from "../support/fixtures";
|
||||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
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 { getServerId } from "../support/helpers/server-id";
|
||||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||||
import {
|
import {
|
||||||
@@ -14,28 +19,29 @@ import {
|
|||||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||||
import {
|
import {
|
||||||
claudeAndCodexReports,
|
claudeAndCodexReports,
|
||||||
closeUsageSheet,
|
closeUsage,
|
||||||
|
closeUsageFromBackdrop,
|
||||||
closeUsageOptions,
|
closeUsageOptions,
|
||||||
openUsageSheetFromIcon,
|
|
||||||
expectNoUsageItem,
|
expectNoUsageItem,
|
||||||
expectOnUsageScreen,
|
|
||||||
expectPinnedUsage,
|
expectPinnedUsage,
|
||||||
expectSummaryInSidebar,
|
expectSummaryInSidebar,
|
||||||
expectUnpinnableRows,
|
expectUnpinnableRows,
|
||||||
leaveUsageScreen,
|
|
||||||
openCompactSidebar,
|
openCompactSidebar,
|
||||||
openUsageScreenFromIcon,
|
openUsageFromIcon,
|
||||||
|
openUsageFromItem,
|
||||||
pinRow,
|
pinRow,
|
||||||
|
scrollUsage,
|
||||||
setSummaryInSidebar,
|
setSummaryInSidebar,
|
||||||
openUsageOptions,
|
openUsageOptions,
|
||||||
showUsageAs,
|
showUsageAs,
|
||||||
togglePin,
|
togglePin,
|
||||||
usageItem,
|
usageItem,
|
||||||
usageSheet,
|
usageModal,
|
||||||
installTallUsageSource,
|
installTallUsageSource,
|
||||||
} from "../support/helpers/usage-sidebar-item";
|
} from "../support/helpers/usage-sidebar-item";
|
||||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||||
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
|
||||||
|
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||||
|
|
||||||
const WIDE = { width: 1440, height: 900 };
|
const WIDE = { width: 1440, height: 900 };
|
||||||
const COMPACT = { width: 390, height: 844 };
|
const COMPACT = { width: 390, height: 844 };
|
||||||
@@ -75,49 +81,96 @@ test.describe("Usage item", () => {
|
|||||||
await expectNoUsageItem(page);
|
await expectNoUsageItem(page);
|
||||||
|
|
||||||
await test.step("the icon opens a sheet without leaving the current screen", async () => {
|
await test.step("the icon opens a sheet without leaving the current screen", async () => {
|
||||||
await openUsageSheetFromIcon(page);
|
await openUsageFromIcon(page);
|
||||||
await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible();
|
await expect(usageModal(page).getByText("Claude", { exact: true })).toBeVisible();
|
||||||
await expect(page).toHaveURL(startingUrl);
|
await expect(page).toHaveURL(startingUrl);
|
||||||
await qaScreenshot(page, "compact-usage-icon-sheet");
|
await qaScreenshot(page, "compact-usage-icon-sheet");
|
||||||
await setSummaryInSidebar(page, true);
|
await setSummaryInSidebar(page, true);
|
||||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
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 test.step("the summary opens the same sheet", async () => {
|
||||||
await usageItem(page).click();
|
await openUsageFromItem(page);
|
||||||
await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible();
|
await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
|
||||||
await expect(page).toHaveURL(startingUrl);
|
await expect(page).toHaveURL(startingUrl);
|
||||||
await qaScreenshot(page, "compact-usage-summary-sheet");
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
test.setTimeout(120_000);
|
const repo = await createTempGitRepo("usage-modal-");
|
||||||
const source = await installTallUsageSource();
|
const client = await connectNewWorkspaceDaemonClient();
|
||||||
try {
|
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 seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||||
await gotoAppShell(page);
|
await gotoWorkspace(page, project.workspaceId);
|
||||||
await openCompactSidebar(page);
|
const workspaceUrl = page.url();
|
||||||
await expect(usageItem(page)).toBeInViewport();
|
|
||||||
await usageItem(page).click();
|
await test.step("the summary opens a dialog over the workspace", async () => {
|
||||||
const content = page.getByTestId("sidebar-usage-sheet-content");
|
await openUsageFromItem(page);
|
||||||
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
|
const dialog = page.getByRole("dialog");
|
||||||
await waitForSettledPosition(content);
|
await expect(dialog.getByText("Claude", { exact: true })).toBeVisible();
|
||||||
const bounds = (await content.boundingBox())!;
|
await expect(
|
||||||
expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
dialog.getByRole("button", { name: "Refresh all", exact: true }),
|
||||||
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
|
).toBeVisible();
|
||||||
await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded();
|
await expect(page).toHaveURL(workspaceUrl);
|
||||||
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
|
await qaScreenshot(page, "desktop-usage-dialog");
|
||||||
await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded();
|
});
|
||||||
await expect(content.getByText("Window 1", { exact: true })).toBeInViewport();
|
|
||||||
|
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 {
|
} 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 ({
|
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -146,8 +199,7 @@ test.describe("Usage item", () => {
|
|||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||||
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
|
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
|
||||||
await usageItem(page).click();
|
await openUsageFromItem(page);
|
||||||
await expectOnUsageScreen(page);
|
|
||||||
await qaScreenshot(page, "default-pins-card");
|
await qaScreenshot(page, "default-pins-card");
|
||||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||||
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||||
@@ -172,17 +224,13 @@ test.describe("Usage item", () => {
|
|||||||
await expect(usageItem(page)).not.toHaveText("Usage");
|
await expect(usageItem(page)).not.toHaveText("Usage");
|
||||||
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
|
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
|
||||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible();
|
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", {
|
await qaScreenshot(page, "usage-card-refresh", {
|
||||||
kind: "element",
|
kind: "element",
|
||||||
locator: screen.getByTestId("usage-report-claude:default"),
|
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 showUsageAs(page, "remaining");
|
||||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||||
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
|
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
|
||||||
@@ -194,23 +242,15 @@ test.describe("Usage item", () => {
|
|||||||
).toBeVisible();
|
).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 test.step("on a phone the Usage item opens a bottom sheet", async () => {
|
||||||
|
await closeUsage(page);
|
||||||
await page.setViewportSize(COMPACT);
|
await page.setViewportSize(COMPACT);
|
||||||
await leaveUsageScreen(page);
|
|
||||||
await openCompactSidebar(page);
|
await openCompactSidebar(page);
|
||||||
await expect(usageItem(page)).toBeInViewport();
|
await expect(usageItem(page)).toBeInViewport();
|
||||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||||
await qaScreenshot(page, "compact-footer");
|
await qaScreenshot(page, "compact-footer");
|
||||||
await usageItem(page).click();
|
await openUsageFromItem(page);
|
||||||
const sheet = usageSheet(page);
|
const sheet = usageModal(page);
|
||||||
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
|
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
|
||||||
await expect(sheet.getByText("88% left")).toBeVisible();
|
await expect(sheet.getByText("88% left")).toBeVisible();
|
||||||
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
|
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
|
||||||
@@ -231,25 +271,20 @@ test.describe("Usage item", () => {
|
|||||||
await closeUsageOptions(page);
|
await closeUsageOptions(page);
|
||||||
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
|
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
|
||||||
await expect(sheet).toBeVisible();
|
await expect(sheet).toBeVisible();
|
||||||
await expect(page).not.toHaveURL(/\/usage$/);
|
|
||||||
await waitForSettledPosition(sheet);
|
await waitForSettledPosition(sheet);
|
||||||
const sheetBox = (await sheet.boundingBox())!;
|
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.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
|
||||||
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
|
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
|
||||||
await qaScreenshot(page, "compact-sheet");
|
await qaScreenshot(page, "compact-sheet");
|
||||||
await qaScreenshot(page, "phase7-compact-sheet");
|
await closeUsageFromBackdrop(page);
|
||||||
// Tap the backdrop above the sheet.
|
|
||||||
await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
|
|
||||||
await expect(sheet).toHaveCount(0);
|
|
||||||
await page.setViewportSize(WIDE);
|
await page.setViewportSize(WIDE);
|
||||||
await usageItem(page).click();
|
|
||||||
await expectOnUsageScreen(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("a reload keeps the pins and the toggle", async () => {
|
await test.step("a reload keeps the pins and the toggle", async () => {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
|
||||||
|
await openUsageFromItem(page);
|
||||||
await openUsageOptions(page);
|
await openUsageOptions(page);
|
||||||
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
|
||||||
"aria-selected",
|
"aria-selected",
|
||||||
@@ -260,13 +295,15 @@ test.describe("Usage item", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test.step("the Settings usage section shares the pins and the toggle", async () => {
|
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 openSettings(page);
|
||||||
await openSettingsHostSection(page, serverId, "usage");
|
await openSettingsHostSection(page, serverId, "usage");
|
||||||
const section = page.getByTestId("usage-card");
|
const section = page.getByTestId("usage-card");
|
||||||
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
|
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
|
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 });
|
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 togglePin(screen, "Codex", "Weekly");
|
||||||
await expectNoUsageItem(page);
|
await expectNoUsageItem(page);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
await openUsageFromIcon(page);
|
||||||
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
|
||||||
await expectNoUsageItem(page);
|
await expectNoUsageItem(page);
|
||||||
await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked();
|
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", "Session")).not.toBeChecked();
|
||||||
await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked();
|
await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked();
|
||||||
await qaScreenshot(page, "explicit-empty-pins-after-reload");
|
await qaScreenshot(page, "explicit-empty-pins-after-reload");
|
||||||
|
await closeUsage(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("Settings > Sidebar lists the Usage item", async () => {
|
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 expectNoUsageItem(page);
|
||||||
await expectFooterSeparator(page, false);
|
await expectFooterSeparator(page, false);
|
||||||
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
|
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
|
||||||
await page.locator('[data-testid="sidebar-usage-icon"]:visible').click();
|
await openUsageFromIcon(page);
|
||||||
await expectOnUsageScreen(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({
|
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");
|
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 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 expectNoUsageItem(page);
|
||||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
await expectUnpinnableRows(claude);
|
await expectUnpinnableRows(claude);
|
||||||
await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default"));
|
await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default"));
|
||||||
await expectSummaryInSidebar(page, false);
|
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 () => {
|
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 expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||||
await togglePin(screen, "Claude", "Weekly");
|
await togglePin(screen, "Claude", "Weekly");
|
||||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
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 () => {
|
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 expectNoUsageItem(page);
|
||||||
await expectUnpinnableRows(claude);
|
await expectUnpinnableRows(claude);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expectOnUsageScreen(page);
|
await openUsageFromIcon(page);
|
||||||
await expectSummaryInSidebar(page, false);
|
await expectSummaryInSidebar(page, false);
|
||||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
await expectUnpinnableRows(claude);
|
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(0).locator("svg path").first()).toHaveAttribute("d", claudePath);
|
||||||
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
|
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
|
||||||
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
|
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
|
||||||
await usageItem(page).click();
|
await openUsageFromItem(page);
|
||||||
await expectOnUsageScreen(page);
|
|
||||||
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
||||||
await expect(
|
await expect(
|
||||||
screen.getByTestId("usage-report-claude").locator("svg path").first(),
|
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(
|
await expect(
|
||||||
screen.getByTestId("usage-report-codex").locator("svg path").first(),
|
screen.getByTestId("usage-report-codex").locator("svg path").first(),
|
||||||
).toHaveAttribute("d", codexPath);
|
).toHaveAttribute("d", codexPath);
|
||||||
await qaScreenshot(page, "released-host-usage-screen");
|
await qaScreenshot(page, "released-host-usage-modal");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -134,8 +134,8 @@ export interface AgentUsageScript {
|
|||||||
stopReportingUsage(): void;
|
stopReportingUsage(): void;
|
||||||
/** The agent of each agent usage request, in order. */
|
/** The agent of each agent usage request, in order. */
|
||||||
agentRequests(): string[];
|
agentRequests(): string[];
|
||||||
/** Each report Refresh, by report ID. */
|
/** Each scoped agent Refresh, by agent ID. */
|
||||||
refreshedReports(): string[][];
|
refreshedAgents(): string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -162,8 +162,10 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
|
|||||||
supported = false;
|
supported = false;
|
||||||
},
|
},
|
||||||
agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []),
|
agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []),
|
||||||
refreshedReports: () =>
|
refreshedAgents: () =>
|
||||||
usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])),
|
usage
|
||||||
|
.listRequests()
|
||||||
|
.flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
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";
|
import { openPullRequestPanel } from "./workspace-tabs";
|
||||||
|
|
||||||
export async function openPrPane(page: Page): Promise<void> {
|
export async function openPrPane(page: Page): Promise<void> {
|
||||||
@@ -14,7 +14,7 @@ export async function expectPrPaneState(
|
|||||||
page: Page,
|
page: Page,
|
||||||
state: "open" | "merged" | "closed" | "draft",
|
state: "open" | "merged" | "closed" | "draft",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
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,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01;
|
|||||||
* Settings together at the end.
|
* Settings together at the end.
|
||||||
*/
|
*/
|
||||||
export async function expectFooterIconRow(page: Page): Promise<void> {
|
export async function expectFooterIconRow(page: Page): Promise<void> {
|
||||||
const boxes = (
|
// Read one browser-frame snapshot: the compact drawer can translate between separate RPCs.
|
||||||
await Promise.all(
|
const boxes = await page
|
||||||
FOOTER_ICON_TEST_IDS.map((testID) =>
|
.locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(","))
|
||||||
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
|
.evaluateAll((elements) =>
|
||||||
),
|
elements.map((element) => {
|
||||||
)
|
const { x, y, width, height } = element.getBoundingClientRect();
|
||||||
).map((box) => box!);
|
return { testID: element.getAttribute("data-testid"), x, y, width, height };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS);
|
||||||
const [first] = boxes;
|
const [first] = boxes;
|
||||||
for (const box of boxes) {
|
for (const box of boxes) {
|
||||||
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
|
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
|
||||||
|
|||||||
@@ -53,6 +53,37 @@ export async function showWorkspaceHoverCard(page: Page, workspaceId: string): P
|
|||||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function movePointerAwayFromWorkspaceHoverCard(page: Page): Promise<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
|
||||||
|
// Longer than the hover card's close grace period.
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await expectWorkspaceHoverCardOpen(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
|
||||||
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||||
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
|
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
|||||||
import { pluginRequirements } from "./plugin-fixture";
|
import { pluginRequirements } from "./plugin-fixture";
|
||||||
|
|
||||||
/** A real store-backed plugin on the worker's isolated daemon. */
|
/** 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 directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-"));
|
||||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||||
const previous = await client.getDaemonConfig();
|
const previous = await client.getDaemonConfig();
|
||||||
const store = path.join(directory, "report.json");
|
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 () => {
|
const cleanup = async () => {
|
||||||
try {
|
try {
|
||||||
await client.removePlugin("login-journey");
|
await client.removePlugin("login-journey");
|
||||||
@@ -35,9 +38,13 @@ export async function installLoginUsage(initialReport: UsageReport) {
|
|||||||
import {readFile} from "node:fs/promises";
|
import {readFile} from "node:fs/promises";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
export default function contribute(server) {
|
export default function contribute(server) {
|
||||||
server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}),
|
const read = async () => {
|
||||||
discover: async () => [{key: "account", input: {}}],
|
const value = JSON.parse(await readFile(${JSON.stringify(store)}, "utf8"));
|
||||||
fetch: async () => 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 () => {};
|
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) {
|
export async function refreshLoginUsage(page: Page) {
|
||||||
await page.getByRole("button", { name: "Refresh Claude", exact: true }).click();
|
await page.getByRole("button", { name: "Refresh Claude", exact: true }).click();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
|||||||
import { pluginRequirements } from "./plugin-fixture";
|
import { pluginRequirements } from "./plugin-fixture";
|
||||||
import { waitForSettledPosition } from "./sheet-layout";
|
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() {
|
export async function installTallUsageSource() {
|
||||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-"));
|
const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-"));
|
||||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||||
@@ -142,13 +142,9 @@ export function usageItem(page: Page): Locator {
|
|||||||
return visible(page, "sidebar-usage");
|
return visible(page, "sidebar-usage");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The compact usage sheet the Usage item opens. */
|
/** The Usage modal's body: a dialog on wide layouts, a bottom sheet on compact ones. */
|
||||||
export function usageSheet(page: Page): Locator {
|
export function usageModal(page: Page): Locator {
|
||||||
return visible(page, "usage-expanded");
|
return visible(page, "usage-modal-body");
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectOnUsageScreen(page: Page): Promise<void> {
|
|
||||||
await expect(page).toHaveURL(/\/usage$/);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
|
/** 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 });
|
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<void> {
|
* 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<void> {
|
||||||
await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 });
|
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. */
|
/** The footer's Usage item, its summary of pinned windows. */
|
||||||
export async function openUsageSheetFromIcon(page: Page): Promise<void> {
|
export async function openUsageFromItem(page: Page): Promise<void> {
|
||||||
await page.getByRole("button", { name: "Usage", exact: true }).click();
|
await usageItem(page).click();
|
||||||
await expect(usageSheet(page)).toBeVisible();
|
await expect(usageModal(page)).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeUsageSheet(page: Page): Promise<void> {
|
/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */
|
||||||
const sheet = usageSheet(page);
|
export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const sheet = usageModal(page);
|
||||||
await waitForSettledPosition(sheet);
|
await waitForSettledPosition(sheet);
|
||||||
const bounds = await sheet.boundingBox();
|
const bounds = await visible(page, "usage-modal").boundingBox();
|
||||||
if (!bounds) throw new Error("Usage sheet must be visible before closing it.");
|
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 page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2);
|
||||||
await expect(sheet).toHaveCount(0);
|
await expect(sheet).toHaveCount(0);
|
||||||
}
|
}
|
||||||
@@ -214,7 +232,7 @@ function summaryInSidebarSwitch(page: Page): Locator {
|
|||||||
return page.getByRole("switch", { name: "Summary in sidebar", exact: true });
|
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<void> {
|
export async function setSummaryInSidebar(page: Page, on: boolean): Promise<void> {
|
||||||
await openUsageOptions(page);
|
await openUsageOptions(page);
|
||||||
await summaryInSidebarSwitch(page).click();
|
await summaryInSidebarSwitch(page).click();
|
||||||
@@ -275,9 +293,3 @@ export async function refreshAllUsage(page: Page): Promise<void> {
|
|||||||
export async function openCompactSidebar(page: Page): Promise<void> {
|
export async function openCompactSidebar(page: Page): Promise<void> {
|
||||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
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<void> {
|
|
||||||
await page.getByRole("button", { name: "Back", exact: true }).first().click();
|
|
||||||
await expect(page).not.toHaveURL(/\/usage$/);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -70,6 +70,15 @@
|
|||||||
-webkit-app-region: no-drag !important;
|
-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
|
/* Suppress the browser's default focus outline — it appears on mouse
|
||||||
clicks and looks out of place against our themed surfaces. Keep a
|
clicks and looks out of place against our themed surfaces. Keep a
|
||||||
themed ring for keyboard users via :focus-visible. */
|
themed ring for keyboard users via :focus-visible. */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
|||||||
|
|
||||||
export const PLAN_MODE_FEATURE_ID = "plan_mode";
|
export const PLAN_MODE_FEATURE_ID = "plan_mode";
|
||||||
export const FAST_MODE_FEATURE_ID = "fast_mode";
|
export const FAST_MODE_FEATURE_ID = "fast_mode";
|
||||||
|
export const SPEED_FEATURE_ID = "service_tier";
|
||||||
|
|
||||||
export function isPlanningAgentMode(mode: Pick<AgentMode, "id" | "colorTier">): boolean {
|
export function isPlanningAgentMode(mode: Pick<AgentMode, "id" | "colorTier">): boolean {
|
||||||
return mode.colorTier === "planning" || mode.id === "plan" || mode.id.endsWith("#plan");
|
return mode.colorTier === "planning" || mode.id === "plan" || mode.id.endsWith("#plan");
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ vi.mock("react-native-unistyles", () => ({
|
|||||||
withUnistyles: (Component: React.ComponentType) => Component,
|
withUnistyles: (Component: React.ComponentType) => Component,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function userMessage(index: number): StreamItem {
|
function userMessage(index: number): Extract<StreamItem, { kind: "user_message" }> {
|
||||||
return {
|
return {
|
||||||
kind: "user_message",
|
kind: "user_message",
|
||||||
id: `message-${index}`,
|
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 (
|
||||||
|
<div ref={measure}>
|
||||||
|
<button type="button" onClick={load}>
|
||||||
|
{loaded ? "Loaded" : "Load"}
|
||||||
|
</button>
|
||||||
|
{item.id}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("createWebStreamStrategy", () => {
|
describe("createWebStreamStrategy", () => {
|
||||||
let root: Root | null = null;
|
let root: Root | null = null;
|
||||||
let container: HTMLDivElement | null = null;
|
let container: HTMLDivElement | null = null;
|
||||||
@@ -155,6 +179,75 @@ describe("createWebStreamStrategy", () => {
|
|||||||
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
|
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retains a row's DOM and local state when recent history becomes virtualized", () => {
|
||||||
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
const item = userMessage(0);
|
||||||
|
const renderRow = (rowItem: StreamItem) => <MeasuredTestRow item={rowItem} />;
|
||||||
|
const input: StreamRenderInput = {
|
||||||
|
agentId: "agent",
|
||||||
|
segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] },
|
||||||
|
boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false },
|
||||||
|
renderers: {
|
||||||
|
renderHistoryVirtualizedRow: renderRow,
|
||||||
|
renderHistoryMountedRow: renderRow,
|
||||||
|
renderLiveHeadRow: renderRow,
|
||||||
|
renderLiveAuxiliary: () => null,
|
||||||
|
},
|
||||||
|
listEmptyComponent: null,
|
||||||
|
viewportRef: React.createRef<StreamViewportHandle>(),
|
||||||
|
routeBottomAnchorRequest: null,
|
||||||
|
isAuthoritativeHistoryReady: true,
|
||||||
|
onNearBottomChange: () => {},
|
||||||
|
onNearHistoryStart: () => true,
|
||||||
|
isLoadingOlderHistory: false,
|
||||||
|
hasOlderHistory: false,
|
||||||
|
olderHistoryProgressKey: null,
|
||||||
|
scrollEnabled: true,
|
||||||
|
listStyle: null,
|
||||||
|
baseListContentContainerStyle: null,
|
||||||
|
forwardListContentContainerStyle: null,
|
||||||
|
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||||
|
};
|
||||||
|
act(() => root?.render(strategy.render(input)));
|
||||||
|
const row = container.querySelector('[data-history-row-id="message-0"]')!;
|
||||||
|
act(() => row.querySelector("button")!.click());
|
||||||
|
expect(row.querySelector("button")!.textContent).toBe("Loaded");
|
||||||
|
const viewport = container.querySelector('[data-testid="agent-chat-scroll"]')!;
|
||||||
|
Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 500 });
|
||||||
|
act(() =>
|
||||||
|
root?.render(
|
||||||
|
strategy.render({
|
||||||
|
...input,
|
||||||
|
segments: { historyVirtualized: [item], historyMounted: [userMessage(1)], liveHead: [] },
|
||||||
|
boundary: { ...input.boundary, hasVirtualizedHistory: true },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[data-history-row-id="message-0"]')).toBe(row);
|
||||||
|
expect(row.isConnected).toBe(true);
|
||||||
|
expect(row.querySelector("button")!.textContent).toBe("Loaded");
|
||||||
|
act(() =>
|
||||||
|
root?.render(
|
||||||
|
strategy.render({
|
||||||
|
...input,
|
||||||
|
segments: {
|
||||||
|
historyVirtualized: [{ ...item, text: "grown" }, userMessage(1)],
|
||||||
|
historyMounted: [userMessage(2)],
|
||||||
|
liveHead: [],
|
||||||
|
},
|
||||||
|
boundary: { ...input.boundary, hasVirtualizedHistory: true },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(container.querySelector('[data-history-row-id="message-1"]') as HTMLElement).style.transform,
|
||||||
|
).toBe("translateY(120px)");
|
||||||
|
expect(row.querySelector("button")!.textContent).toBe("Loaded");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the timeline width stable with an overlay scrollbar", () => {
|
it("keeps the timeline width stable with an overlay scrollbar", () => {
|
||||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
|
|||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||||
@@ -76,6 +77,8 @@ const historyStartSlotStyle: CSSProperties = {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" };
|
||||||
|
|
||||||
const streamRowStyle: CSSProperties = {
|
const streamRowStyle: CSSProperties = {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@@ -268,6 +271,23 @@ function isScrollContainerOverscrolledPastBottom(
|
|||||||
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
|
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Position updates do not change a row's content. Keep renderer work separate
|
||||||
|
// from virtual measurements while retaining the same component across lanes.
|
||||||
|
const StreamRowContent = React.memo(function StreamRowContent({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
items,
|
||||||
|
render,
|
||||||
|
}: {
|
||||||
|
revision?: StreamRenderInput["liveHeadRowRevision"];
|
||||||
|
item: StreamItem;
|
||||||
|
index: number;
|
||||||
|
items: StreamItem[];
|
||||||
|
render: StreamRenderInput["renderers"]["renderHistoryVirtualizedRow"];
|
||||||
|
}) {
|
||||||
|
return render(item, index, items);
|
||||||
|
});
|
||||||
|
|
||||||
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
|
||||||
const {
|
const {
|
||||||
segments: inputSegments,
|
segments: inputSegments,
|
||||||
@@ -425,6 +445,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: segments.historyVirtualized.length,
|
count: segments.historyVirtualized.length,
|
||||||
enabled: shouldUseVirtualizer,
|
enabled: shouldUseVirtualizer,
|
||||||
|
initialRect: {
|
||||||
|
width: scrollContainerRef.current?.clientWidth ?? 0,
|
||||||
|
height: scrollContainerRef.current?.clientHeight ?? 0,
|
||||||
|
},
|
||||||
getScrollElement: () => scrollContainerRef.current,
|
getScrollElement: () => scrollContainerRef.current,
|
||||||
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
|
||||||
estimateSize: (index: number) => {
|
estimateSize: (index: number) => {
|
||||||
@@ -536,25 +560,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
scheduler.schedule();
|
scheduler.schedule();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ResizeObserver runs before paint. Batch measurements and commit React's new
|
const measureVirtualizedRows = useStableEvent(() => {
|
||||||
// positions here so a resized image cannot overlap rows for an extra frame.
|
let resized = false;
|
||||||
// Use resizeItem exclusively; measureElement would install a second observer.
|
for (const element of contentRef.current?.querySelectorAll<HTMLElement>(
|
||||||
|
"[data-history-row-id][data-index]",
|
||||||
|
) ?? []) {
|
||||||
|
const index = Number(element.dataset.index);
|
||||||
|
const height = measureVirtualElement(element, undefined, rowVirtualizer);
|
||||||
|
if (rowVirtualizer.measurementsCache[index]?.size === height) continue;
|
||||||
|
rowVirtualizer.resizeItem(index, height);
|
||||||
|
resized = true;
|
||||||
|
}
|
||||||
|
return resized;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Measure the committed DOM rather than queued observer entries: flushSync can
|
||||||
|
// also commit an image's pending intrinsic dimensions, invalidating those entries.
|
||||||
const virtualRowObserver = useMemo(
|
const virtualRowObserver = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new ResizeObserver((entries) => {
|
new ResizeObserver(() => {
|
||||||
if (!isActiveRef.current) return;
|
if (!isActiveRef.current) return;
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
for (const entry of entries) {
|
measureVirtualizedRows();
|
||||||
const element = entry.target as HTMLElement;
|
|
||||||
if (!element.isConnected) continue;
|
|
||||||
rowVirtualizer.resizeItem(
|
|
||||||
Number(element.dataset.index),
|
|
||||||
measureVirtualElement(element, entry, rowVirtualizer),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
[rowVirtualizer],
|
[measureVirtualizedRows],
|
||||||
);
|
);
|
||||||
const measureVirtualizedRowElement = useCallback(
|
const measureVirtualizedRowElement = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
@@ -731,8 +761,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
// Apply row positions and their scroll correction in the same paint, including
|
// Apply row positions and their scroll correction in the same paint, including
|
||||||
// the ordinary-to-virtualized handoff and measurements of newly mounted rows.
|
// the ordinary-to-virtualized handoff and measurements of newly mounted rows.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
// Commit positions for every resized row before accepting reading geometry.
|
||||||
|
// Child layout can change during this commit, ahead of ResizeObserver delivery.
|
||||||
|
if (!isActive || measureVirtualizedRows()) return;
|
||||||
reconcileReadingPosition();
|
reconcileReadingPosition();
|
||||||
}, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]);
|
}, [
|
||||||
|
contentWidth,
|
||||||
|
isActive,
|
||||||
|
segments,
|
||||||
|
virtualRows,
|
||||||
|
measureVirtualizedRows,
|
||||||
|
reconcileReadingPosition,
|
||||||
|
]);
|
||||||
|
|
||||||
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
||||||
cancelPendingStickToBottom();
|
cancelPendingStickToBottom();
|
||||||
@@ -915,6 +955,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
evaluateHistoryStart();
|
evaluateHistoryStart();
|
||||||
}
|
}
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
|
flushSync(() => {
|
||||||
|
measureVirtualizedRows();
|
||||||
|
});
|
||||||
const nextGeometry = getObservedViewportGeometry(scrollContainer);
|
const nextGeometry = getObservedViewportGeometry(scrollContainer);
|
||||||
if (pendingResumeGeometryCheckRef.current) {
|
if (pendingResumeGeometryCheckRef.current) {
|
||||||
pendingResumeGeometryCheckRef.current = false;
|
pendingResumeGeometryCheckRef.current = false;
|
||||||
@@ -953,6 +996,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
scheduleHistoryStartPrependSettle,
|
scheduleHistoryStartPrependSettle,
|
||||||
scheduleStickToBottom,
|
scheduleStickToBottom,
|
||||||
updateScrollMetrics,
|
updateScrollMetrics,
|
||||||
|
measureVirtualizedRows,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -1171,13 +1215,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const virtualRowsContainerStyle = useMemo((): CSSProperties => {
|
const virtualRowsContainerStyle = useMemo(
|
||||||
return {
|
(): CSSProperties => ({
|
||||||
position: "relative",
|
|
||||||
width: "100%",
|
|
||||||
height: virtualTotalSize,
|
height: virtualTotalSize,
|
||||||
};
|
flexShrink: 0,
|
||||||
}, [virtualTotalSize]);
|
}),
|
||||||
|
[virtualTotalSize],
|
||||||
|
);
|
||||||
const renderVirtualRowStyle = useCallback(
|
const renderVirtualRowStyle = useCallback(
|
||||||
(start: number): CSSProperties => ({
|
(start: number): CSSProperties => ({
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -1198,12 +1242,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
data-message-id={getStreamItemMessageId(item)}
|
data-message-id={getStreamItemMessageId(item)}
|
||||||
style={streamRowStyle}
|
style={streamRowStyle}
|
||||||
>
|
>
|
||||||
{renderHistoryMountedRow(item, index, segments.historyMounted)}
|
<StreamRowContent
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
items={segments.historyMounted}
|
||||||
|
render={renderHistoryMountedRow}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
}, [renderHistoryMountedRow, segments.historyMounted]);
|
}, [renderHistoryMountedRow, segments.historyMounted]);
|
||||||
const liveHeadRows = useMemo(() => {
|
const liveHeadRows = useMemo(() => {
|
||||||
void liveHeadRowRevision;
|
|
||||||
return segments.liveHead.map((item, index) => (
|
return segments.liveHead.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -1211,7 +1259,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
data-message-id={getStreamItemMessageId(item)}
|
data-message-id={getStreamItemMessageId(item)}
|
||||||
style={streamRowStyle}
|
style={streamRowStyle}
|
||||||
>
|
>
|
||||||
{renderLiveHeadRow(item, index, segments.liveHead)}
|
<StreamRowContent
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
items={segments.liveHead}
|
||||||
|
revision={liveHeadRowRevision}
|
||||||
|
render={renderLiveHeadRow}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
|
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
|
||||||
@@ -1241,7 +1295,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
!liveAuxiliary;
|
!liveAuxiliary;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={viewportStyle}>
|
<div style={viewportStyle} data-window-content>
|
||||||
<div
|
<div
|
||||||
ref={handleScrollContainerRef}
|
ref={handleScrollContainerRef}
|
||||||
data-testid="agent-chat-scroll"
|
data-testid="agent-chat-scroll"
|
||||||
@@ -1251,13 +1305,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
>
|
>
|
||||||
<div ref={handleContentRef} style={contentContainerStyle}>
|
<div ref={handleContentRef} style={contentContainerStyle}>
|
||||||
{historyStartSlot}
|
{historyStartSlot}
|
||||||
{shouldUseVirtualizer ? (
|
<div style={streamRowsHostStyle}>
|
||||||
<div style={virtualRowsContainerStyle}>
|
<div style={virtualRowsContainerStyle} />
|
||||||
{virtualRows.map((virtualRow) => {
|
{[
|
||||||
|
...virtualRows.map((virtualRow) => {
|
||||||
const item = segments.historyVirtualized[virtualRow.index];
|
const item = segments.historyVirtualized[virtualRow.index];
|
||||||
if (!item) {
|
if (!item) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={virtualRow.key}
|
key={virtualRow.key}
|
||||||
@@ -1267,17 +1320,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
|||||||
ref={measureVirtualizedRowElement}
|
ref={measureVirtualizedRowElement}
|
||||||
style={renderVirtualRowStyle(virtualRow.start)}
|
style={renderVirtualRowStyle(virtualRow.start)}
|
||||||
>
|
>
|
||||||
{renderHistoryVirtualizedRow(
|
<StreamRowContent
|
||||||
item,
|
item={item}
|
||||||
virtualRow.index,
|
index={virtualRow.index}
|
||||||
segments.historyVirtualized,
|
items={segments.historyVirtualized}
|
||||||
)}
|
render={renderHistoryVirtualizedRow}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
}),
|
||||||
</div>
|
...mountedRows,
|
||||||
) : null}
|
]}
|
||||||
{mountedRows}
|
</div>
|
||||||
{liveAuxiliary}
|
{liveAuxiliary}
|
||||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -863,7 +863,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
pathname === "/new" ||
|
pathname === "/new" ||
|
||||||
pathname === "/sessions" ||
|
pathname === "/sessions" ||
|
||||||
pathname === "/schedules" ||
|
pathname === "/schedules" ||
|
||||||
pathname === "/usage" ||
|
|
||||||
routeHasKnownHost);
|
routeHasKnownHost);
|
||||||
|
|
||||||
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
|
||||||
@@ -896,7 +895,6 @@ function RootStack() {
|
|||||||
<Stack.Screen name="open-project" />
|
<Stack.Screen name="open-project" />
|
||||||
<Stack.Screen name="sessions" />
|
<Stack.Screen name="sessions" />
|
||||||
<Stack.Screen name="schedules" />
|
<Stack.Screen name="schedules" />
|
||||||
<Stack.Screen name="usage" />
|
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen name="h/[serverId]" />
|
<Stack.Screen name="h/[serverId]" />
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import { UsageScreen } from "@/usage";
|
|
||||||
|
|
||||||
export default function UsageRoute() {
|
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<UsageScreen />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -143,6 +143,52 @@ describe("assistant selection copy ranges", () => {
|
|||||||
expect(copiedMarkdown(selectRange(blocks[0]!, 6, blocks[1]!, 6))).toBe("paragraph.\n\nSecond");
|
expect(copiedMarkdown(selectRange(blocks[0]!, 6, blocks[1]!, 6))).toBe("paragraph.\n\nSecond");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Chrome anchors a drag that starts in the gap below an image at the start of the
|
||||||
|
// image's rendered internals: the background-image layer, ahead of the hidden `img`.
|
||||||
|
// Only the text after it is highlighted.
|
||||||
|
it("leaves out an image when the selection starts in the gap below it", () => {
|
||||||
|
const transcript = mountTranscript([
|
||||||
|
{
|
||||||
|
messageId: "message-1",
|
||||||
|
blocks: [
|
||||||
|
"Before the image.",
|
||||||
|
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><div><div style="background-image: url(blob:https://example.test/preview)"></div><img src="blob:https://example.test/preview" alt=""></div></div>',
|
||||||
|
"After the image.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const frame = fixtureElement(transcript, '[data-paseo-markdown-tag="img"]');
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(frame.firstElementChild!.firstElementChild!, 0);
|
||||||
|
range.setEnd(textNode(fixtureElement(transcript, '[data-paseo-markdown-tag="p"]', 2)), 9);
|
||||||
|
const selection = window.getSelection()!;
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
expect(copiedMarkdown(selection)).toBe("After the");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a failed image when the selection starts in its error text", () => {
|
||||||
|
const transcript = mountTranscript([
|
||||||
|
{
|
||||||
|
messageId: "message-1",
|
||||||
|
blocks: [
|
||||||
|
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><div>Image failed to load</div></div>',
|
||||||
|
"After the image.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const frame = fixtureElement(transcript, '[data-paseo-markdown-tag="img"]');
|
||||||
|
const selection = selectRange(
|
||||||
|
frame.firstElementChild!,
|
||||||
|
6,
|
||||||
|
fixtureElement(transcript, '[data-paseo-markdown-tag="p"]', 1),
|
||||||
|
9,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(copiedMarkdown(selection)).toBe("\n\nAfter the");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not replace the browser clipboard for a selection reaching a second message", () => {
|
it("does not replace the browser clipboard for a selection reaching a second message", () => {
|
||||||
const transcript = mountTranscript([
|
const transcript = mountTranscript([
|
||||||
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
|
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
|
||||||
@@ -320,6 +366,32 @@ describe("assistant selection copy ranges", () => {
|
|||||||
).toBe("6. Second bullet text");
|
).toBe("6. Second bullet text");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the next number when a drag crosses a code block nested in an item", () => {
|
||||||
|
const message = mountFixture();
|
||||||
|
const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]');
|
||||||
|
list.outerHTML = [
|
||||||
|
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
|
||||||
|
'<div data-paseo-markdown-tag="li">',
|
||||||
|
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
|
||||||
|
"<div><div><span>Install it:</span></div>",
|
||||||
|
'<div data-paseo-markdown-tag="pre" data-paseo-markdown-language="sh">',
|
||||||
|
'<div data-paseo-markdown-tag="code"><span>brew install foo</span></div>',
|
||||||
|
'<button data-paseo-markdown-ignore="true">Copy</button>',
|
||||||
|
"</div></div>",
|
||||||
|
"</div>",
|
||||||
|
'<div data-paseo-markdown-tag="li">',
|
||||||
|
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">2.</div>',
|
||||||
|
"<div><div><span>Done.</span></div></div>",
|
||||||
|
"</div>",
|
||||||
|
"</div>",
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
const content = copyAcross(message, "Install it:", "Done.");
|
||||||
|
|
||||||
|
expect(content?.plainText).toBe("Install it:\n\n```sh\nbrew install foo\n```\n\n2. Done.");
|
||||||
|
expect(content?.html).toContain("<div>2. Done.</div>");
|
||||||
|
});
|
||||||
|
|
||||||
it("retains nested markers when a drag includes the complete outer item", () => {
|
it("retains nested markers when a drag includes the complete outer item", () => {
|
||||||
const message = mountFixture();
|
const message = mountFixture();
|
||||||
const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
|
const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
|
||||||
@@ -385,6 +457,20 @@ describe("assistant selection copy ranges", () => {
|
|||||||
expect(content?.html).not.toContain("<pre>");
|
expect(content?.html).not.toContain("<pre>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 = '<div><div><img src="blob:https://example.test/preview" alt=""></div></div>';
|
||||||
|
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", () => {
|
it("copies code without a fence when every character is selected inside the block", () => {
|
||||||
const message = mountFixture();
|
const message = mountFixture();
|
||||||
const blockCode = fixtureElement(
|
const blockCode = fixtureElement(
|
||||||
@@ -405,10 +491,19 @@ describe("assistant selection copy ranges", () => {
|
|||||||
* Turndown, so those cases need a fixture shaped like the real thing.
|
* Turndown, so those cases need a fixture shaped like the real thing.
|
||||||
*/
|
*/
|
||||||
function highlightedFixture(language: string | null): string {
|
function highlightedFixture(language: string | null): string {
|
||||||
|
return [
|
||||||
|
'<div data-testid="assistant-message">',
|
||||||
|
'<div data-paseo-markdown-tag="p"><span>Before the block.</span></div>',
|
||||||
|
highlightedCodeBlock(language),
|
||||||
|
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
|
||||||
|
"</div>",
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightedCodeBlock(language: string | null): string {
|
||||||
const languageAttribute =
|
const languageAttribute =
|
||||||
language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`;
|
language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`;
|
||||||
return [
|
return [
|
||||||
'<div data-testid="assistant-message">',
|
|
||||||
`<div data-paseo-markdown-tag="pre"${languageAttribute}>`,
|
`<div data-paseo-markdown-tag="pre"${languageAttribute}>`,
|
||||||
'<span data-paseo-markdown-tag="code">',
|
'<span data-paseo-markdown-tag="code">',
|
||||||
"<span>const</span><span> answer</span><span> = 1;</span>",
|
"<span>const</span><span> answer</span><span> = 1;</span>",
|
||||||
@@ -419,8 +514,6 @@ function highlightedFixture(language: string | null): string {
|
|||||||
"</span>",
|
"</span>",
|
||||||
'<div data-paseo-markdown-ignore="true"><span>Copy</span></div>',
|
'<div data-paseo-markdown-ignore="true"><span>Copy</span></div>',
|
||||||
"</div>",
|
"</div>",
|
||||||
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
|
|
||||||
"</div>",
|
|
||||||
].join("");
|
].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 =
|
||||||
|
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><img src="blob:https://example.test/preview" alt=""></div>';
|
||||||
|
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", () => {
|
it("retains the fence when a complete code block is selected across its boundary", () => {
|
||||||
const message = mountHighlighted();
|
const message = mountHighlighted();
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
} from "@/utils/rich-clipboard";
|
} from "@/utils/rich-clipboard";
|
||||||
import {
|
import {
|
||||||
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
|
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
|
||||||
|
MARKDOWN_COPY_ALT_ATTRIBUTE,
|
||||||
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
|
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
|
||||||
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
|
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
|
||||||
MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE,
|
MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE,
|
||||||
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
|
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
|
||||||
|
MARKDOWN_COPY_SRC_ATTRIBUTE,
|
||||||
MARKDOWN_COPY_TAG_ATTRIBUTE,
|
MARKDOWN_COPY_TAG_ATTRIBUTE,
|
||||||
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
|
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
|
||||||
TRAILING_CODE_LINE_BREAKS,
|
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 messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`;
|
||||||
const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
|
const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
|
||||||
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
|
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({
|
const turndown = new TurndownService({
|
||||||
bulletListMarker: "-",
|
bulletListMarker: "-",
|
||||||
@@ -53,9 +56,7 @@ turndown.addRule("compactListItem", {
|
|||||||
if (parent?.nodeName !== "OL") {
|
if (parent?.nodeName !== "OL") {
|
||||||
return `${options.bulletListMarker} ${item}\n`;
|
return `${options.bulletListMarker} ${item}\n`;
|
||||||
}
|
}
|
||||||
const start = Number(parent.getAttribute("start") ?? 1);
|
return `${node.getAttribute("value")}. ${item}\n`;
|
||||||
const index = Array.from(parent.children).indexOf(node);
|
|
||||||
return `${start + index}. ${item}\n`;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,9 +67,9 @@ export function createAssistantSelectionClipboardContent(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const range = selection.getRangeAt(0);
|
const range = startAfterImageFrame(selection.getRangeAt(0));
|
||||||
const parts = selectedMessageParts(range);
|
const parts = selectedMessageParts(range)?.filter((part) => selectsContent(part.range));
|
||||||
if (!parts) {
|
if (!parts?.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +94,33 @@ export function createAssistantSelectionClipboardContent(
|
|||||||
return { ...content, html: flattenClipboardListMarkup(content.html) };
|
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.
|
* 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
|
* 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
|
* 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 {
|
function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null {
|
||||||
const region = closestCodeRegion(range.commonAncestorContainer, message);
|
const region = selectedCodeRegion(range, message);
|
||||||
if (!region) {
|
if (!region) {
|
||||||
return null;
|
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 {
|
function closestCodeRegion(node: Node, message: Element): Element | null {
|
||||||
const element = node instanceof Element ? node : node.parentElement;
|
const element = node instanceof Element ? node : node.parentElement;
|
||||||
const region = element?.closest(CODE_REGION_SELECTOR) ?? null;
|
const region = element?.closest(CODE_REGION_SELECTOR) ?? null;
|
||||||
@@ -281,7 +323,8 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
|
||||||
@@ -384,6 +427,31 @@ function hasSelectedAllContents(range: Range, element: Element, includeIgnored =
|
|||||||
return true;
|
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 {
|
function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean {
|
||||||
if (!includeIgnored) {
|
if (!includeIgnored) {
|
||||||
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
|
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
|
||||||
@@ -393,8 +461,9 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean)
|
|||||||
if (fragment.textContent) {
|
if (fragment.textContent) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const visibleVoidSelector = ["br", "hr"]
|
const visibleVoidSelector = ["br", "hr", "img"]
|
||||||
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
|
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
|
||||||
|
.concat("img")
|
||||||
.join(",");
|
.join(",");
|
||||||
return Boolean(fragment.querySelector(visibleVoidSelector));
|
return Boolean(fragment.querySelector(visibleVoidSelector));
|
||||||
}
|
}
|
||||||
@@ -488,14 +557,19 @@ function restoreMarkdownElements(container: HTMLElement): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const semanticElement = document.createElement(tagName);
|
const semanticElement = document.createElement(tagName);
|
||||||
if (tagName !== "br") {
|
if (tagName !== "br" && tagName !== "img") {
|
||||||
semanticElement.append(...element.childNodes);
|
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") {
|
if (tagName === "ol") {
|
||||||
const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE);
|
const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE);
|
||||||
if (start) {
|
if (start) {
|
||||||
semanticElement.setAttribute("start", start);
|
semanticElement.setAttribute("start", start);
|
||||||
}
|
}
|
||||||
|
numberOrderedListItems(semanticElement, Number(start ?? 1));
|
||||||
}
|
}
|
||||||
if (tagName === "pre") {
|
if (tagName === "pre") {
|
||||||
const language = element.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE);
|
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 {
|
function unwrapIncompleteTables(container: HTMLElement): void {
|
||||||
for (const table of container.querySelectorAll("table")) {
|
for (const table of container.querySelectorAll("table")) {
|
||||||
if (hasUsableTableColumnContract(table)) {
|
if (hasUsableTableColumnContract(table)) {
|
||||||
|
|||||||
@@ -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_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start";
|
||||||
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
|
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
|
||||||
export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align";
|
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.
|
* Trailing line breaks, with any indentation that followed the last one.
|
||||||
@@ -63,6 +65,18 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined
|
|||||||
} as const;
|
} 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) {
|
export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) {
|
||||||
const alignment =
|
const alignment =
|
||||||
typeof style === "string"
|
typeof style === "string"
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text, View } from "react-native";
|
import {
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
type PressableStateCallbackType,
|
||||||
|
type StyleProp,
|
||||||
|
type ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { Terminal } from "lucide-react-native";
|
import { Eye, EyeOff, Terminal } from "lucide-react-native";
|
||||||
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
|
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHostMutations, useHosts } from "@/runtime/host-runtime";
|
import { useHostMutations, useHosts } from "@/runtime/host-runtime";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Field, FormTextInput } from "@/components/ui/form-field";
|
import { Field, FormTextInput } from "@/components/ui/form-field";
|
||||||
|
import {
|
||||||
|
CONTROL_HEIGHTS,
|
||||||
|
createControlGeometry,
|
||||||
|
resolveControlInteractionStyles,
|
||||||
|
} from "@/components/ui/control-geometry";
|
||||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||||
@@ -15,18 +27,64 @@ import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
|||||||
|
|
||||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||||
const ThemedTerminal = withUnistyles(Terminal);
|
const ThemedTerminal = withUnistyles(Terminal);
|
||||||
|
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||||
|
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => {
|
||||||
helper: {
|
const geometry = createControlGeometry(theme);
|
||||||
color: theme.colors.foregroundMuted,
|
return {
|
||||||
fontSize: theme.fontSize.sm,
|
helper: {
|
||||||
},
|
color: theme.colors.foregroundMuted,
|
||||||
actions: {
|
fontSize: theme.fontSize.sm,
|
||||||
flexDirection: "row",
|
},
|
||||||
gap: theme.spacing[3],
|
passwordRow: {
|
||||||
marginTop: theme.spacing[2],
|
flexDirection: "row",
|
||||||
},
|
alignItems: "center",
|
||||||
}));
|
alignSelf: "stretch",
|
||||||
|
width: "100%",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
passwordInputWrap: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
// Boxed adornment next to the field: same height and radius as the field
|
||||||
|
// chrome of the active form size, and the shared interaction phases.
|
||||||
|
iconButtonSm: {
|
||||||
|
minHeight: CONTROL_HEIGHTS.compact,
|
||||||
|
width: CONTROL_HEIGHTS.compact,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
iconButtonMd: {
|
||||||
|
minHeight: CONTROL_HEIGHTS.field,
|
||||||
|
width: CONTROL_HEIGHTS.field,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
controlRest: {
|
||||||
|
...geometry.controlRest,
|
||||||
|
},
|
||||||
|
controlHover: {
|
||||||
|
...geometry.controlHover,
|
||||||
|
},
|
||||||
|
controlActive: {
|
||||||
|
...geometry.controlActive,
|
||||||
|
},
|
||||||
|
controlDisabled: {
|
||||||
|
...geometry.controlDisabled,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
marginTop: theme.spacing[2],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export interface AddRemoteSshHostModalProps {
|
export interface AddRemoteSshHostModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -51,14 +109,20 @@ export function AddRemoteSshHostModal({
|
|||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
|
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
|
||||||
const targetRef = useRef("");
|
const targetRef = useRef("");
|
||||||
|
const passwordRef = useRef("");
|
||||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||||
|
const passwordInputRef = useRef<EditingTextInputHandle>(null);
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.remoteSsh.title") }), [t]);
|
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.remoteSsh.title") }), [t]);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
targetRef.current = "";
|
targetRef.current = "";
|
||||||
|
passwordRef.current = "";
|
||||||
inputRef.current?.replaceText("");
|
inputRef.current?.replaceText("");
|
||||||
|
passwordInputRef.current?.replaceText("");
|
||||||
|
setIsPasswordVisible(false);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -94,7 +158,10 @@ export function AddRemoteSshHostModal({
|
|||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
result = await probeAndUpsertRemoteSshConnection(target);
|
result = await probeAndUpsertRemoteSshConnection({
|
||||||
|
...target,
|
||||||
|
password: passwordRef.current === "" ? undefined : passwordRef.current,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof DaemonConnectionTestError
|
error instanceof DaemonConnectionTestError
|
||||||
@@ -116,6 +183,34 @@ export function AddRemoteSshHostModal({
|
|||||||
const handleTargetChange = useCallback((value: string) => {
|
const handleTargetChange = useCallback((value: string) => {
|
||||||
targetRef.current = value;
|
targetRef.current = value;
|
||||||
}, []);
|
}, []);
|
||||||
|
const handlePasswordChange = useCallback((value: string) => {
|
||||||
|
passwordRef.current = value;
|
||||||
|
}, []);
|
||||||
|
const handleTogglePasswordVisibility = useCallback(() => {
|
||||||
|
setIsPasswordVisible((currentlyVisible) => !currentlyVisible);
|
||||||
|
}, []);
|
||||||
|
const iconButtonStyle = useCallback(
|
||||||
|
(
|
||||||
|
state: PressableStateCallbackType & { hovered?: boolean; focused?: boolean },
|
||||||
|
): StyleProp<ViewStyle> => [
|
||||||
|
isCompact ? styles.iconButtonMd : styles.iconButtonSm,
|
||||||
|
resolveControlInteractionStyles(
|
||||||
|
{
|
||||||
|
controlRest: styles.controlRest,
|
||||||
|
controlHover: styles.controlHover,
|
||||||
|
controlActive: styles.controlActive,
|
||||||
|
controlDisabled: styles.controlDisabled,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hovered: state.hovered,
|
||||||
|
pressed: state.pressed,
|
||||||
|
focused: state.focused,
|
||||||
|
disabled: isSaving,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[isCompact, isSaving],
|
||||||
|
);
|
||||||
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
|
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,6 +241,41 @@ export function AddRemoteSshHostModal({
|
|||||||
onSubmitEditing={handleSubmit}
|
onSubmitEditing={handleSubmit}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label={t("pairing.remoteSsh.fields.password")} testID="remote-ssh-password">
|
||||||
|
<View style={styles.passwordRow}>
|
||||||
|
<View style={styles.passwordInputWrap}>
|
||||||
|
<FormTextInput
|
||||||
|
ref={passwordInputRef}
|
||||||
|
size={isCompact ? "md" : "sm"}
|
||||||
|
testID="remote-ssh-password-input"
|
||||||
|
accessibilityLabel={t("pairing.remoteSsh.fields.password")}
|
||||||
|
initialValue=""
|
||||||
|
onChangeText={handlePasswordChange}
|
||||||
|
placeholder={t("pairing.remoteSsh.fields.optional")}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
secureTextEntry={!isPasswordVisible}
|
||||||
|
editable={!isSaving}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
style={iconButtonStyle}
|
||||||
|
onPress={handleTogglePasswordVisibility}
|
||||||
|
disabled={isSaving}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={
|
||||||
|
isPasswordVisible
|
||||||
|
? t("pairing.remoteSsh.passwordVisibility.hide")
|
||||||
|
: t("pairing.remoteSsh.passwordVisibility.show")
|
||||||
|
}
|
||||||
|
testID="remote-ssh-password-visibility-toggle"
|
||||||
|
>
|
||||||
|
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Field>
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<Button
|
<Button
|
||||||
style={FLEX_ONE_STYLE}
|
style={FLEX_ONE_STYLE}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) });
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@ import { useRevealedText } from "@/hooks/use-revealed-text";
|
|||||||
import { colorMarkdownLinkChildren } from "@/components/markdown/link-children";
|
import { colorMarkdownLinkChildren } from "@/components/markdown/link-children";
|
||||||
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
|
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
|
||||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||||
|
import { getTurnDurationLabel } from "./assistant-turn-footer-label";
|
||||||
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||||||
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
|
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 { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import {
|
import {
|
||||||
markdownCopyDataSet,
|
markdownCopyDataSet,
|
||||||
|
markdownCopyImageDataSet,
|
||||||
markdownCopyOrderedListDataSet,
|
markdownCopyOrderedListDataSet,
|
||||||
markdownCopyTableCellDataSet,
|
markdownCopyTableCellDataSet,
|
||||||
type MarkdownCopyInlineTag,
|
type MarkdownCopyInlineTag,
|
||||||
@@ -628,6 +630,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
durationMs,
|
durationMs,
|
||||||
onFork,
|
onFork,
|
||||||
}: AssistantTurnFooterProps) {
|
}: AssistantTurnFooterProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const [pressedReveal, setPressedReveal] = useState(false);
|
const [pressedReveal, setPressedReveal] = useState(false);
|
||||||
const revealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const revealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -643,10 +646,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
|||||||
|
|
||||||
const durationLabel = useMemo(
|
const durationLabel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
durationMs !== undefined && durationMs !== null
|
durationMs !== undefined && durationMs !== null ? getTurnDurationLabel(durationMs, t) : "",
|
||||||
? `Worked for ${formatDuration(durationMs)}`
|
[durationMs, t],
|
||||||
: "",
|
|
||||||
[durationMs],
|
|
||||||
);
|
);
|
||||||
const timestampLabel = useMemo(
|
const timestampLabel = useMemo(
|
||||||
() => (completedAt ? formatMessageTimestamp(completedAt) : ""),
|
() => (completedAt ? formatMessageTimestamp(completedAt) : ""),
|
||||||
@@ -884,10 +885,11 @@ function AssistantMarkdownImage({
|
|||||||
],
|
],
|
||||||
[containerStyle, imageSizeStyle],
|
[containerStyle, imageSizeStyle],
|
||||||
);
|
);
|
||||||
|
const copyDataSet = useMemo(() => markdownCopyImageDataSet(source, alt), [source, alt]);
|
||||||
|
|
||||||
if (image.status === "failed") {
|
if (image.status === "failed") {
|
||||||
return (
|
return (
|
||||||
<View style={stateFrameStyle}>
|
<View style={stateFrameStyle} dataSet={copyDataSet}>
|
||||||
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
|
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -895,14 +897,14 @@ function AssistantMarkdownImage({
|
|||||||
|
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return (
|
return (
|
||||||
<View style={stateFrameStyle}>
|
<View style={stateFrameStyle} dataSet={copyDataSet}>
|
||||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={frameStyle}>
|
<View style={frameStyle} dataSet={copyDataSet}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityLabel={t("composer.attachments.openImage")}
|
accessibilityLabel={t("composer.attachments.openImage")}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ActivityIndicator, View, type ViewStyle } from "react-native";
|
import { ActivityIndicator, View, type ViewStyle } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
||||||
import { ProjectIconView } from "@/components/project-icon-view";
|
import { ProjectIconView } from "@/components/project-icon-view";
|
||||||
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
|
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import {
|
import {
|
||||||
@@ -170,6 +171,7 @@ function ProjectStatusBadge({
|
|||||||
statusBucket: SidebarStateBucket;
|
statusBucket: SidebarStateBucket;
|
||||||
backdrop: SidebarSurfaceBackdrop;
|
backdrop: SidebarSurfaceBackdrop;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout,
|
// Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout,
|
||||||
// so nesting it inside would clip it against the very thing that was meant to separate it from
|
// so nesting it inside would clip it against the very thing that was meant to separate it from
|
||||||
// the icon. It anchors to the same corner instead, growing around the centre the dot had.
|
// the icon. It anchors to the same corner instead, growing around the centre the dot had.
|
||||||
@@ -177,7 +179,7 @@ function ProjectStatusBadge({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
role="status"
|
role="status"
|
||||||
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
|
accessibilityLabel={getStatusBucketLabel(statusBucket, t)}
|
||||||
style={styles.statusRingAnchor}
|
style={styles.statusRingAnchor}
|
||||||
testID="project-status-badge"
|
testID="project-status-badge"
|
||||||
>
|
>
|
||||||
@@ -188,7 +190,7 @@ function ProjectStatusBadge({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
role="status"
|
role="status"
|
||||||
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
|
accessibilityLabel={getStatusBucketLabel(statusBucket, t)}
|
||||||
style={[styles.statusBadge, getStatusBadgeBackdropStyle(backdrop)]}
|
style={[styles.statusBadge, getStatusBadgeBackdropStyle(backdrop)]}
|
||||||
testID="project-status-badge"
|
testID="project-status-badge"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
|
import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
useSidebarWorkspacesList,
|
useSidebarWorkspacesList,
|
||||||
@@ -54,6 +55,7 @@ export function SidebarModelProvider({
|
|||||||
active?: boolean;
|
active?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const list = useSidebarWorkspacesList({ enabled: active });
|
const list = useSidebarWorkspacesList({ enabled: active });
|
||||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||||
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
|
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
|
||||||
@@ -150,6 +152,7 @@ export function SidebarModelProvider({
|
|||||||
pinnedCollapsed,
|
pinnedCollapsed,
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
collapsedWorkspaceGroupKeys,
|
collapsedWorkspaceGroupKeys,
|
||||||
|
t,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
@@ -161,6 +164,7 @@ export function SidebarModelProvider({
|
|||||||
pinnedKeys,
|
pinnedKeys,
|
||||||
pinnedWorkspaceOrder,
|
pinnedWorkspaceOrder,
|
||||||
filteredWorkspaceEntriesByKey,
|
filteredWorkspaceEntriesByKey,
|
||||||
|
t,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const projection = useMemo(() => buildSidebarProjection(projectionInput), [projectionInput]);
|
const projection = useMemo(() => buildSidebarProjection(projectionInput), [projectionInput]);
|
||||||
|
|||||||
@@ -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
|
* 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,
|
* `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to,
|
||||||
* and `SidebarPopoverSurface` for the body.
|
* and `SidebarPopoverSurface` for the body.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { i18n } from "@/i18n/i18next";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
SidebarProjectEntry,
|
SidebarProjectEntry,
|
||||||
@@ -85,6 +86,7 @@ function projectionInput(options?: {
|
|||||||
pinnedCollapsed: options?.pinnedCollapsed ?? false,
|
pinnedCollapsed: options?.pinnedCollapsed ?? false,
|
||||||
collapsedProjectKeys: new Set<string>(),
|
collapsedProjectKeys: new Set<string>(),
|
||||||
collapsedWorkspaceGroupKeys: new Set<string>(),
|
collapsedWorkspaceGroupKeys: new Set<string>(),
|
||||||
|
t: i18n.t,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||||
import {
|
import {
|
||||||
splitPinnedSidebarGroups,
|
splitPinnedSidebarGroups,
|
||||||
@@ -45,6 +46,7 @@ export interface SidebarProjectionInput {
|
|||||||
pinnedCollapsed: boolean;
|
pinnedCollapsed: boolean;
|
||||||
collapsedProjectKeys: ReadonlySet<string>;
|
collapsedProjectKeys: ReadonlySet<string>;
|
||||||
collapsedWorkspaceGroupKeys: ReadonlySet<string>;
|
collapsedWorkspaceGroupKeys: ReadonlySet<string>;
|
||||||
|
t: TFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSidebarProjection(input: SidebarProjectionInput): SidebarProjection {
|
export function buildSidebarProjection(input: SidebarProjectionInput): SidebarProjection {
|
||||||
@@ -100,7 +102,7 @@ function buildWorkspaceGroups(
|
|||||||
return [];
|
return [];
|
||||||
case "status":
|
case "status":
|
||||||
return statusWorkspaceGroups(
|
return statusWorkspaceGroups(
|
||||||
buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey),
|
buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey, input.t),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ function StatusGroupHeader({
|
|||||||
group: SidebarWorkspaceGroup;
|
group: SidebarWorkspaceGroup;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const toggleWorkspaceGroupCollapsed = useSidebarCollapsedSectionsStore(
|
const toggleWorkspaceGroupCollapsed = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.toggleWorkspaceGroupCollapsed,
|
(state) => state.toggleWorkspaceGroupCollapsed,
|
||||||
@@ -431,7 +432,7 @@ function StatusGroupHeader({
|
|||||||
<View onPointerEnter={handleHoverIn} onPointerLeave={handleHoverOut}>
|
<View onPointerEnter={handleHoverIn} onPointerLeave={handleHoverOut}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||||
accessibilityLabel={`${group.label} group`}
|
accessibilityLabel={t("sidebar.statusGroupAccessibility", { label: group.label })}
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
style={rowStyle}
|
style={rowStyle}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
|
|||||||
@@ -380,6 +380,7 @@ export function SidebarWorkspaceContextMenu({
|
|||||||
serviceLabel: serviceSummary
|
serviceLabel: serviceSummary
|
||||||
? t(workspaceServiceLabelKey(serviceSummary), { name: serviceSummary.name })
|
? t(workspaceServiceLabelKey(serviceSummary), { name: serviceSummary.name })
|
||||||
: null,
|
: null,
|
||||||
|
t,
|
||||||
});
|
});
|
||||||
const workspaceTarget = useMemo<WorkspaceLabelTarget>(
|
const workspaceTarget = useMemo<WorkspaceLabelTarget>(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { i18n } from "@/i18n/i18next";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
resolveSidebarWorkspaceAccessibilityLabel,
|
resolveSidebarWorkspaceAccessibilityLabel,
|
||||||
@@ -39,6 +40,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
|
|||||||
workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" },
|
workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" },
|
||||||
workspaceTitleSource: "title",
|
workspaceTitleSource: "title",
|
||||||
hostBadgeLabel: "Build host",
|
hostBadgeLabel: "Build host",
|
||||||
|
t: i18n.t,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(label).toBe("Investigate search, Build host");
|
expect(label).toBe("Investigate search, Build host");
|
||||||
@@ -56,6 +58,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
|
|||||||
hostBadgeLabel: "Build host",
|
hostBadgeLabel: "Build host",
|
||||||
pullRequestLabel: "Pull request 42",
|
pullRequestLabel: "Pull request 42",
|
||||||
serviceLabel: "Service web running",
|
serviceLabel: "Service web running",
|
||||||
|
t: i18n.t,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(label).toBe(
|
expect(label).toBe(
|
||||||
@@ -69,6 +72,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
|
|||||||
workspaceTitleSource: "title",
|
workspaceTitleSource: "title",
|
||||||
leadingProjectName: "Search project",
|
leadingProjectName: "Search project",
|
||||||
hostBadgeLabel: "Build host",
|
hostBadgeLabel: "Build host",
|
||||||
|
t: i18n.t,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(label).toBe("Search project, Investigate search, Build host");
|
expect(label).toBe("Search project, Investigate search, Build host");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
|
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: {
|
export function resolveSidebarWorkspacePrimaryLabel(input: {
|
||||||
workspace: Pick<SidebarWorkspaceEntry, "name" | "currentBranch">;
|
workspace: Pick<SidebarWorkspaceEntry, "name" | "currentBranch">;
|
||||||
@@ -19,6 +20,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: {
|
|||||||
hostBadgeLabel?: string | null;
|
hostBadgeLabel?: string | null;
|
||||||
pullRequestLabel?: string | null;
|
pullRequestLabel?: string | null;
|
||||||
serviceLabel?: string | null;
|
serviceLabel?: string | null;
|
||||||
|
t: TFunction;
|
||||||
}): string {
|
}): string {
|
||||||
return [
|
return [
|
||||||
input.leadingProjectName,
|
input.leadingProjectName,
|
||||||
@@ -28,7 +30,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: {
|
|||||||
input.serviceLabel,
|
input.serviceLabel,
|
||||||
input.workspace.statusBucket === "done"
|
input.workspace.statusBucket === "done"
|
||||||
? null
|
? null
|
||||||
: STATUS_BUCKET_LABELS[input.workspace.statusBucket],
|
: getStatusBucketLabel(input.workspace.statusBucket, input.t),
|
||||||
]
|
]
|
||||||
.filter((label): label is string => Boolean(label))
|
.filter((label): label is string => Boolean(label))
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ function WebHoverCard({
|
|||||||
const contentRef = useRef<View>(null);
|
const contentRef = useRef<View>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible.
|
||||||
|
const keyboardFocusedRef = useRef(false);
|
||||||
|
|
||||||
const clearGraceTimer = useCallback(() => {
|
const clearGraceTimer = useCallback(() => {
|
||||||
if (graceTimerRef.current) {
|
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(() => {
|
const scheduleClose = useCallback(() => {
|
||||||
if (focusInside()) return;
|
if (keyboardFocusInside()) return;
|
||||||
if (graceTimerRef.current) return;
|
if (graceTimerRef.current) return;
|
||||||
graceTimerRef.current = setTimeout(() => {
|
graceTimerRef.current = setTimeout(() => {
|
||||||
graceTimerRef.current = null;
|
graceTimerRef.current = null;
|
||||||
|
if (keyboardFocusInside()) return;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, CLOSE_GRACE_MS);
|
}, CLOSE_GRACE_MS);
|
||||||
}, [focusInside]);
|
}, [keyboardFocusInside]);
|
||||||
|
|
||||||
const openNow = useCallback(() => {
|
const openNow = useCallback(() => {
|
||||||
clearGraceTimer();
|
clearGraceTimer();
|
||||||
@@ -169,16 +178,20 @@ function WebHoverCard({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
const focusEntered = (event: FocusEvent) => {
|
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)) {
|
if (trigger.contains(target)) {
|
||||||
openNow();
|
openNow();
|
||||||
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
|
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
|
||||||
clearGraceTimer();
|
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 focusLeft = (event: FocusEvent) => {
|
||||||
const next = event.relatedTarget as Node | null;
|
const next = event.relatedTarget as Node | null;
|
||||||
if (
|
if (
|
||||||
|
keyboardFocusedRef.current &&
|
||||||
!trigger.contains(next) &&
|
!trigger.contains(next) &&
|
||||||
!(contentRef.current as unknown as HTMLElement | null)?.contains(next)
|
!(contentRef.current as unknown as HTMLElement | null)?.contains(next)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import type {
|
|||||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||||
import {
|
import {
|
||||||
getFeatureHighlightColor,
|
getFeatureHighlightColor,
|
||||||
|
isFeatureActive,
|
||||||
getFeatureTooltip,
|
getFeatureTooltip,
|
||||||
getAgentControlHintKey,
|
getAgentControlHintKey,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
@@ -211,8 +212,7 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getFeatureIconColor(
|
function getFeatureIconColor(
|
||||||
featureId: string,
|
feature: AgentFeature,
|
||||||
enabled: boolean,
|
|
||||||
palette: {
|
palette: {
|
||||||
blue: { 400: string };
|
blue: { 400: string };
|
||||||
green: { 400: string };
|
green: { 400: string };
|
||||||
@@ -220,11 +220,11 @@ function getFeatureIconColor(
|
|||||||
},
|
},
|
||||||
foregroundMuted: string,
|
foregroundMuted: string,
|
||||||
): string {
|
): string {
|
||||||
if (!enabled) {
|
if (!isFeatureActive(feature)) {
|
||||||
return foregroundMuted;
|
return foregroundMuted;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (getFeatureHighlightColor(featureId)) {
|
switch (getFeatureHighlightColor(feature.id)) {
|
||||||
case "blue":
|
case "blue":
|
||||||
return palette.blue[400];
|
return palette.blue[400];
|
||||||
case "green":
|
case "green":
|
||||||
@@ -1335,8 +1335,7 @@ function DesktopFeatureItem({
|
|||||||
<AgentControlTrigger
|
<AgentControlTrigger
|
||||||
icon={FeatureIcon}
|
icon={FeatureIcon}
|
||||||
iconColor={getFeatureIconColor(
|
iconColor={getFeatureIconColor(
|
||||||
feature.id,
|
feature,
|
||||||
feature.value,
|
|
||||||
theme.colors.palette,
|
theme.colors.palette,
|
||||||
theme.colors.foregroundMuted,
|
theme.colors.foregroundMuted,
|
||||||
)}
|
)}
|
||||||
@@ -1370,6 +1369,11 @@ function DesktopFeatureItem({
|
|||||||
<AgentControlTrigger
|
<AgentControlTrigger
|
||||||
ref={featureAnchorRef}
|
ref={featureAnchorRef}
|
||||||
icon={FeatureIcon}
|
icon={FeatureIcon}
|
||||||
|
iconColor={getFeatureIconColor(
|
||||||
|
feature,
|
||||||
|
theme.colors.palette,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
|
)}
|
||||||
surface="toolbar"
|
surface="toolbar"
|
||||||
label={feature.label}
|
label={feature.label}
|
||||||
value={selectedOption?.label ?? feature.label}
|
value={selectedOption?.label ?? feature.label}
|
||||||
@@ -1453,8 +1457,7 @@ function SheetFeatureItem({
|
|||||||
ref={featureAnchorRef}
|
ref={featureAnchorRef}
|
||||||
icon={FeatureIcon}
|
icon={FeatureIcon}
|
||||||
iconColor={getFeatureIconColor(
|
iconColor={getFeatureIconColor(
|
||||||
feature.id,
|
feature,
|
||||||
feature.value,
|
|
||||||
theme.colors.palette,
|
theme.colors.palette,
|
||||||
theme.colors.foregroundMuted,
|
theme.colors.foregroundMuted,
|
||||||
)}
|
)}
|
||||||
@@ -1490,6 +1493,11 @@ function SheetFeatureItem({
|
|||||||
<AgentControlTrigger
|
<AgentControlTrigger
|
||||||
ref={featureAnchorRef}
|
ref={featureAnchorRef}
|
||||||
icon={FeatureIcon}
|
icon={FeatureIcon}
|
||||||
|
iconColor={getFeatureIconColor(
|
||||||
|
feature,
|
||||||
|
theme.colors.palette,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
|
)}
|
||||||
surface="sheet"
|
surface="sheet"
|
||||||
label={feature.label}
|
label={feature.label}
|
||||||
value={selectedOption?.label ?? feature.label}
|
value={selectedOption?.label ?? feature.label}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
getFeatureHighlightColor,
|
getFeatureHighlightColor,
|
||||||
getFeatureTooltip,
|
getFeatureTooltip,
|
||||||
getAgentControlHintKey,
|
getAgentControlHintKey,
|
||||||
|
isFeatureActive,
|
||||||
normalizeModelId,
|
normalizeModelId,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
@@ -35,9 +36,32 @@ describe("feature metadata helpers", () => {
|
|||||||
|
|
||||||
it("maps feature highlight colors by feature id", () => {
|
it("maps feature highlight colors by feature id", () => {
|
||||||
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
|
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
|
||||||
|
expect(getFeatureHighlightColor("service_tier")).toBe("yellow");
|
||||||
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
|
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
|
||||||
expect(getFeatureHighlightColor("other")).toBe("default");
|
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", () => {
|
describe("normalizeModelId", () => {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
|
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 ExplainedAgentControl = "mode" | "model" | "thinking";
|
||||||
export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow";
|
export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow";
|
||||||
@@ -35,9 +39,16 @@ export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip
|
|||||||
return feature.tooltip ?? feature.label;
|
return feature.tooltip ?? feature.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFeatureActive(feature: AgentFeature): boolean {
|
||||||
|
if (feature.type === "toggle") return feature.value;
|
||||||
|
const selectedOption = feature.options.find((option) => option.id === feature.value);
|
||||||
|
return selectedOption !== undefined && !selectedOption.isDefault;
|
||||||
|
}
|
||||||
|
|
||||||
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
|
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
|
||||||
switch (featureId) {
|
switch (featureId) {
|
||||||
case FAST_MODE_FEATURE_ID:
|
case FAST_MODE_FEATURE_ID:
|
||||||
|
case SPEED_FEATURE_ID:
|
||||||
return "yellow";
|
return "yellow";
|
||||||
case "auto_accept":
|
case "auto_accept":
|
||||||
return "green";
|
return "green";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||||
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
|
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
|
||||||
import { KeyboardTranslateView } from "@/keyboard/shift";
|
import { KeyboardTranslateView } from "@/keyboard/shift";
|
||||||
import { createContext, useCallback, useContext, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||||
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
|
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -15,7 +15,11 @@ import Animated, {
|
|||||||
type SharedValue,
|
type SharedValue,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useKeyboardShift } from "@/keyboard/shift";
|
import { useKeyboardShift } from "@/keyboard/shift";
|
||||||
import { updateComposerCapacity, type ComposerCapacity } from "./internal/capacity";
|
import {
|
||||||
|
resolveCenteredClearance,
|
||||||
|
updateComposerCapacity,
|
||||||
|
type ComposerCapacity,
|
||||||
|
} from "./internal/capacity";
|
||||||
|
|
||||||
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
|
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
|
||||||
|
|
||||||
@@ -34,11 +38,12 @@ function ComposerViewport({
|
|||||||
}: ComposerViewportProps) {
|
}: ComposerViewportProps) {
|
||||||
const measuredHeight = useSharedValue(0);
|
const measuredHeight = useSharedValue(0);
|
||||||
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
|
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
|
||||||
const { layoutShift } = useKeyboardShift();
|
const { layoutShift, bottomInset: safeAreaBottom } = useKeyboardShift();
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => ({
|
() => ({
|
||||||
height: measuredHeight.value,
|
height: measuredHeight.value,
|
||||||
bottomInset,
|
bottomInset,
|
||||||
|
safeAreaBottom: safeAreaBottom.value,
|
||||||
keyboardShift: layoutShift.value,
|
keyboardShift: layoutShift.value,
|
||||||
centered,
|
centered,
|
||||||
}),
|
}),
|
||||||
@@ -79,6 +84,24 @@ function ComposerViewportContent({ style, ...props }: ViewProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The keyboard only needs to move a centered form once it reaches the form's resting bottom. */
|
||||||
|
function useCenteredClearance(safeAreaBottom: number) {
|
||||||
|
const [viewportHeight, setViewportHeight] = useState(0);
|
||||||
|
const [formBottom, setFormBottom] = useState(0);
|
||||||
|
const measureViewport = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
setViewportHeight(event.nativeEvent.layout.height);
|
||||||
|
}, []);
|
||||||
|
const measureForm = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
const { y, height } = event.nativeEvent.layout;
|
||||||
|
setFormBottom(y + height);
|
||||||
|
}, []);
|
||||||
|
return {
|
||||||
|
value: resolveCenteredClearance({ viewportHeight, safeAreaBottom, formBottom }),
|
||||||
|
measureViewport,
|
||||||
|
measureForm,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface ComposerDockProps {
|
interface ComposerDockProps {
|
||||||
children: [ReactNode, ReactNode, ReactNode?];
|
children: [ReactNode, ReactNode, ReactNode?];
|
||||||
centered?: boolean;
|
centered?: boolean;
|
||||||
@@ -91,6 +114,7 @@ export function ComposerDock({
|
|||||||
}: ComposerDockProps) {
|
}: ComposerDockProps) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
|
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
|
||||||
|
const centeredClearance = useCenteredClearance(insets.bottom);
|
||||||
// Preserve the existing centered form's visual balance on tablets.
|
// Preserve the existing centered form's visual balance on tablets.
|
||||||
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
|
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||||
if (centered) {
|
if (centered) {
|
||||||
@@ -99,8 +123,13 @@ export function ComposerDock({
|
|||||||
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
|
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
|
||||||
bottomInset={bottomInset}
|
bottomInset={bottomInset}
|
||||||
centered
|
centered
|
||||||
|
onLayout={centeredClearance.measureViewport}
|
||||||
>
|
>
|
||||||
<KeyboardTranslateView style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}>
|
<KeyboardTranslateView
|
||||||
|
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
|
||||||
|
bottomClearance={centeredClearance.value}
|
||||||
|
onLayout={centeredClearance.measureForm}
|
||||||
|
>
|
||||||
<ComposerViewportContent style={dockStyles.composer}>
|
<ComposerViewportContent style={dockStyles.composer}>
|
||||||
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
|
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveComposerCapacity, updateComposerCapacity } from "./capacity";
|
import {
|
||||||
|
resolveCenteredClearance,
|
||||||
|
resolveComposerCapacity,
|
||||||
|
updateComposerCapacity,
|
||||||
|
} from "./capacity";
|
||||||
|
|
||||||
describe("composer viewport", () => {
|
describe("composer viewport", () => {
|
||||||
it("preserves the editing capacity when the keyboard closes", () => {
|
it("preserves the editing capacity when the keyboard closes", () => {
|
||||||
const viewport = { height: 582, bottomInset: 24, centered: false };
|
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
|
||||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
||||||
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
|
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
|
||||||
expect(open.capacity).toBe(245);
|
expect(open.capacity).toBe(245);
|
||||||
@@ -15,6 +19,7 @@ describe("composer viewport", () => {
|
|||||||
const open = updateComposerCapacity(undefined, {
|
const open = updateComposerCapacity(undefined, {
|
||||||
height: 582,
|
height: 582,
|
||||||
bottomInset: 24,
|
bottomInset: 24,
|
||||||
|
safeAreaBottom: 0,
|
||||||
centered: false,
|
centered: false,
|
||||||
keyboardShift: 308,
|
keyboardShift: 308,
|
||||||
});
|
});
|
||||||
@@ -22,6 +27,7 @@ describe("composer viewport", () => {
|
|||||||
updateComposerCapacity(open, {
|
updateComposerCapacity(open, {
|
||||||
height: 650,
|
height: 650,
|
||||||
bottomInset: 24,
|
bottomInset: 24,
|
||||||
|
safeAreaBottom: 0,
|
||||||
centered: false,
|
centered: false,
|
||||||
keyboardShift: 0,
|
keyboardShift: 0,
|
||||||
}).capacity,
|
}).capacity,
|
||||||
@@ -30,6 +36,7 @@ describe("composer viewport", () => {
|
|||||||
updateComposerCapacity(open, {
|
updateComposerCapacity(open, {
|
||||||
height: 0,
|
height: 0,
|
||||||
bottomInset: 24,
|
bottomInset: 24,
|
||||||
|
safeAreaBottom: 0,
|
||||||
centered: false,
|
centered: false,
|
||||||
keyboardShift: 0,
|
keyboardShift: 0,
|
||||||
}),
|
}),
|
||||||
@@ -39,6 +46,7 @@ describe("composer viewport", () => {
|
|||||||
const height = resolveComposerCapacity({
|
const height = resolveComposerCapacity({
|
||||||
height: 582,
|
height: 582,
|
||||||
bottomInset: 24,
|
bottomInset: 24,
|
||||||
|
safeAreaBottom: 0,
|
||||||
keyboardShift: 308,
|
keyboardShift: 308,
|
||||||
centered: false,
|
centered: false,
|
||||||
});
|
});
|
||||||
@@ -46,23 +54,80 @@ describe("composer viewport", () => {
|
|||||||
expect(582 - 24 - 308 - height).toBe(5);
|
expect(582 - 24 - 308 - height).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a centered tablet form below the header after translation", () => {
|
it("keeps a centered tablet form below the header once the keyboard reaches it", () => {
|
||||||
|
const viewport = 1000;
|
||||||
|
const bottomInset = 80;
|
||||||
|
const keyboardShift = 300;
|
||||||
const height = resolveComposerCapacity({
|
const height = resolveComposerCapacity({
|
||||||
height: 1000,
|
height: viewport,
|
||||||
bottomInset: 80,
|
bottomInset,
|
||||||
keyboardShift: 300,
|
safeAreaBottom: 0,
|
||||||
|
keyboardShift,
|
||||||
centered: true,
|
centered: true,
|
||||||
});
|
});
|
||||||
expect(height).toBe(310);
|
expect(height).toBe(695);
|
||||||
expect((1000 - 80 - height) / 2 - 300).toBe(5);
|
const restingTop = (viewport - bottomInset - height) / 2;
|
||||||
|
const bottomClearance = resolveCenteredClearance({
|
||||||
|
viewportHeight: viewport,
|
||||||
|
safeAreaBottom: 0,
|
||||||
|
formBottom: restingTop + height,
|
||||||
|
});
|
||||||
|
expect(restingTop - Math.max(0, keyboardShift - bottomClearance)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a centered form room to edit on a short foldable viewport", () => {
|
||||||
|
expect(
|
||||||
|
resolveComposerCapacity({
|
||||||
|
height: 725,
|
||||||
|
bottomInset: 72,
|
||||||
|
safeAreaBottom: 0,
|
||||||
|
keyboardShift: 313,
|
||||||
|
centered: true,
|
||||||
|
}),
|
||||||
|
).toBe(407);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rests a centered form on the keyboard above the bottom safe area", () => {
|
||||||
|
// The keyboard shift excludes the safe area that the keyboard also covers.
|
||||||
|
const geometry = {
|
||||||
|
height: 660,
|
||||||
|
bottomInset: 60,
|
||||||
|
safeAreaBottom: 32,
|
||||||
|
keyboardShift: 288,
|
||||||
|
centered: true,
|
||||||
|
};
|
||||||
|
const height = resolveComposerCapacity(geometry);
|
||||||
|
const restingTop = (geometry.height - geometry.bottomInset - height) / 2;
|
||||||
|
const formBottom = restingTop + height;
|
||||||
|
const clearance = resolveCenteredClearance({
|
||||||
|
viewportHeight: geometry.height,
|
||||||
|
safeAreaBottom: geometry.safeAreaBottom,
|
||||||
|
formBottom,
|
||||||
|
});
|
||||||
|
const translation = Math.max(0, geometry.keyboardShift - clearance);
|
||||||
|
const keyboardTop = geometry.height - geometry.safeAreaBottom - geometry.keyboardShift;
|
||||||
|
expect(formBottom - translation).toBe(keyboardTop);
|
||||||
|
expect(restingTop - translation).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the measured viewport before the first keyboard opening", () => {
|
it("uses the measured viewport before the first keyboard opening", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }),
|
resolveComposerCapacity({
|
||||||
|
height: 582,
|
||||||
|
bottomInset: 24,
|
||||||
|
safeAreaBottom: 0,
|
||||||
|
keyboardShift: 0,
|
||||||
|
centered: false,
|
||||||
|
}),
|
||||||
).toBe(553);
|
).toBe(553);
|
||||||
expect(
|
expect(
|
||||||
resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }),
|
resolveComposerCapacity({
|
||||||
|
height: 300,
|
||||||
|
bottomInset: 0,
|
||||||
|
safeAreaBottom: 0,
|
||||||
|
keyboardShift: 200,
|
||||||
|
centered: false,
|
||||||
|
}),
|
||||||
).toBe(95);
|
).toBe(95);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
interface ComposerGeometry {
|
interface ComposerGeometry {
|
||||||
height: number;
|
height: number;
|
||||||
bottomInset: number;
|
bottomInset: number;
|
||||||
|
/** The keyboard also covers this space below its shift. */
|
||||||
|
safeAreaBottom: number;
|
||||||
keyboardShift: number;
|
keyboardShift: number;
|
||||||
centered: boolean;
|
centered: boolean;
|
||||||
}
|
}
|
||||||
@@ -12,11 +14,25 @@ export interface ComposerCapacity {
|
|||||||
|
|
||||||
export function resolveComposerCapacity(input: ComposerGeometry): number {
|
export function resolveComposerCapacity(input: ComposerGeometry): number {
|
||||||
"worklet";
|
"worklet";
|
||||||
// A centered form grows upward by half its height. Reserve both halves so
|
// A centered form only translates by the keyboard overlap, so once the keyboard
|
||||||
// translating it still leaves five layout points below the header.
|
// reaches it the form sits on the keyboard and fits above it, not twice over.
|
||||||
const clearance = input.keyboardShift + 5;
|
// A bottom-anchored composer pads the safe area inside its own capacity.
|
||||||
const reservedSpace = input.centered ? clearance * 2 : clearance;
|
const available = input.centered
|
||||||
return Math.max(0, input.height - input.bottomInset - reservedSpace);
|
? Math.min(
|
||||||
|
input.height - input.bottomInset,
|
||||||
|
input.height - input.safeAreaBottom - input.keyboardShift,
|
||||||
|
)
|
||||||
|
: input.height - input.bottomInset - input.keyboardShift;
|
||||||
|
return Math.max(0, available - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Space below a centered form that the keyboard fills before the form moves. */
|
||||||
|
export function resolveCenteredClearance(input: {
|
||||||
|
viewportHeight: number;
|
||||||
|
safeAreaBottom: number;
|
||||||
|
formBottom: number;
|
||||||
|
}): number {
|
||||||
|
return Math.max(0, input.viewportHeight - input.safeAreaBottom - input.formBottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateComposerCapacity(
|
export function updateComposerCapacity(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
|
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
|
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
@@ -71,7 +72,8 @@ export function ChecksSection({
|
|||||||
loadingCheckKeys,
|
loadingCheckKeys,
|
||||||
onAddLogsToChat,
|
onAddLogsToChat,
|
||||||
}: ChecksSectionProps) {
|
}: ChecksSectionProps) {
|
||||||
const summary = useMemo(() => summarizeChecks(checks), [checks]);
|
const { t } = useTranslation();
|
||||||
|
const summary = useMemo(() => summarizeChecks(checks, t), [checks, t]);
|
||||||
const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState();
|
const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -91,13 +93,14 @@ export function ChecksSection({
|
|||||||
</Text>
|
</Text>
|
||||||
{summary.parts.length > 0 ? (
|
{summary.parts.length > 0 ? (
|
||||||
<Text style={styles.detail} numberOfLines={1} testID="pr-pane-check-summary">
|
<Text style={styles.detail} numberOfLines={1} testID="pr-pane-check-summary">
|
||||||
|
{summary.detailLead}
|
||||||
{summary.parts.map((part, index) => (
|
{summary.parts.map((part, index) => (
|
||||||
<Text key={part.status}>
|
<Text key={part.status}>
|
||||||
{index > 0 ? ", " : ""}
|
{index > 0 ? ", " : ""}
|
||||||
<Text testID={PART_TEST_ID[part.status]}>{part.text}</Text>
|
<Text testID={PART_TEST_ID[part.status]}>{part.text}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
{` ${summary.countNoun}`}
|
{summary.detailTrail}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -186,6 +189,7 @@ function CheckRow({
|
|||||||
isAddingLogsToChat: boolean;
|
isAddingLogsToChat: boolean;
|
||||||
onAddLogsToChat: (check: PrPaneCheck) => void;
|
onAddLogsToChat: (check: PrPaneCheck) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
void openExternalUrl(check.url);
|
void openExternalUrl(check.url);
|
||||||
}, [check.url]);
|
}, [check.url]);
|
||||||
@@ -217,7 +221,9 @@ function CheckRow({
|
|||||||
onPress={handleAddLogsToChat}
|
onPress={handleAddLogsToChat}
|
||||||
style={styles.addButton}
|
style={styles.addButton}
|
||||||
>
|
>
|
||||||
{isAddingLogsToChat ? "Adding..." : "Add to chat"}
|
{isAddingLogsToChat
|
||||||
|
? t("workspace.git.pr.actions.addingToChat")
|
||||||
|
: t("workspace.git.pr.actions.addToChat")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{check.timing && <Text style={sectionKitStyles.checkDuration}>{check.timing}</Text>}
|
{check.timing && <Text style={sectionKitStyles.checkDuration}>{check.timing}</Text>}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { i18n } from "@/i18n/i18next";
|
||||||
import type { CheckStatus } from "./check-status";
|
import type { CheckStatus } from "./check-status";
|
||||||
import { summarizeChecks } from "./checks-summary";
|
import { summarizeChecks } from "./checks-summary";
|
||||||
import type { PrPaneCheck } from "./data";
|
import type { PrPaneCheck } from "./data";
|
||||||
@@ -9,7 +10,7 @@ function check(name: string, status: CheckStatus): PrPaneCheck {
|
|||||||
|
|
||||||
describe("summarizeChecks", () => {
|
describe("summarizeChecks", () => {
|
||||||
it("reports no checks without claiming the run passed", () => {
|
it("reports no checks without claiming the run passed", () => {
|
||||||
const summary = summarizeChecks([]);
|
const summary = summarizeChecks([], i18n.t);
|
||||||
|
|
||||||
expect(summary.outcome).toBe("none");
|
expect(summary.outcome).toBe("none");
|
||||||
expect(summary.headline).toBe("No checks");
|
expect(summary.headline).toBe("No checks");
|
||||||
@@ -18,11 +19,10 @@ describe("summarizeChecks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("leads with failures when anything failed", () => {
|
it("leads with failures when anything failed", () => {
|
||||||
const summary = summarizeChecks([
|
const summary = summarizeChecks(
|
||||||
check("build", "success"),
|
[check("build", "success"), check("lint", "failure"), check("e2e", "pending")],
|
||||||
check("lint", "failure"),
|
i18n.t,
|
||||||
check("e2e", "pending"),
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
expect(summary.outcome).toBe("failure");
|
expect(summary.outcome).toBe("failure");
|
||||||
expect(summary.headline).toBe("Some checks were not successful");
|
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", () => {
|
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.outcome).toBe("pending");
|
||||||
expect(summary.headline).toBe("Some checks haven't completed yet");
|
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", () => {
|
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.outcome).toBe("success");
|
||||||
expect(summary.headline).toBe("All checks have passed");
|
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", () => {
|
it("keeps the checks of each group together under a counted label", () => {
|
||||||
const summary = summarizeChecks([
|
const summary = summarizeChecks(
|
||||||
check("build", "failure"),
|
[check("build", "failure"), check("lint", "success"), check("test", "failure")],
|
||||||
check("lint", "success"),
|
i18n.t,
|
||||||
check("test", "failure"),
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
const [failing, successful] = summary.groups;
|
const [failing, successful] = summary.groups;
|
||||||
expect(failing?.label).toBe("2 failing checks");
|
expect(failing?.label).toBe("2 failing checks");
|
||||||
@@ -60,9 +62,34 @@ describe("summarizeChecks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("says check rather than checks for a run of one", () => {
|
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");
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { classifyCheck, type CheckPresentation } from "@/git/check-presentation";
|
import { classifyCheck, type CheckPresentation } from "@/git/check-presentation";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
import type { PrPaneCheck } from "./data";
|
import type { PrPaneCheck } from "./data";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,27 +17,10 @@ const PRESENTATION_ORDER = [
|
|||||||
"ignored",
|
"ignored",
|
||||||
] as const satisfies readonly CheckPresentation[];
|
] as const satisfies readonly CheckPresentation[];
|
||||||
|
|
||||||
/** How each status reads inside a count phrase: "2 failing", "4 in progress". */
|
|
||||||
const PRESENTATION_NOUN: Record<CheckPresentation, string> = {
|
|
||||||
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. */
|
/** The worst thing happening in the run, which is what the headline and the ring report. */
|
||||||
export type ChecksOutcome = "actionRequired" | "failure" | "pending" | "success" | "none";
|
export type ChecksOutcome = "actionRequired" | "failure" | "pending" | "success" | "none";
|
||||||
|
|
||||||
const OUTCOME_HEADLINE: Record<ChecksOutcome, string> = {
|
const COPY = "workspace.git.pr.checksOverview";
|
||||||
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",
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ChecksCountPart {
|
export interface ChecksCountPart {
|
||||||
status: CheckPresentation;
|
status: CheckPresentation;
|
||||||
@@ -58,8 +42,12 @@ export interface ChecksSummary {
|
|||||||
headline: string;
|
headline: string;
|
||||||
/** The count phrases behind `detail`, kept apart so the header can label each one. */
|
/** The count phrases behind `detail`, kept apart so the header can label each one. */
|
||||||
parts: readonly ChecksCountPart[];
|
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. */
|
/** The whole line: "2 failing, 4 in progress, 1 successful checks". Empty with no checks. */
|
||||||
detail: string;
|
detail: string;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -72,7 +60,7 @@ export interface ChecksSummary {
|
|||||||
* header, the ring, and the grouped list all read from one derivation instead of each
|
* 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.
|
* 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 groups: ChecksGroup[] = [];
|
||||||
const parts: ChecksCountPart[] = [];
|
const parts: ChecksCountPart[] = [];
|
||||||
|
|
||||||
@@ -83,24 +71,27 @@ export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary {
|
|||||||
}
|
}
|
||||||
groups.push({
|
groups.push({
|
||||||
status,
|
status,
|
||||||
label: `${matching.length} ${PRESENTATION_NOUN[status]} ${countNoun(matching.length)}`,
|
label: t(`${COPY}.${matching.length === 1 ? "groupOne" : "groupMany"}.${status}`, {
|
||||||
|
count: matching.length,
|
||||||
|
}),
|
||||||
checks: matching,
|
checks: matching,
|
||||||
});
|
});
|
||||||
parts.push({
|
parts.push({
|
||||||
status,
|
status,
|
||||||
count: matching.length,
|
count: matching.length,
|
||||||
text: `${matching.length} ${PRESENTATION_NOUN[status]}`,
|
text: t(`${COPY}.count.${status}`, { count: matching.length }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const outcome = selectOutcome(checks);
|
const outcome = selectOutcome(checks);
|
||||||
const noun = countNoun(checks.length);
|
const { lead, trail } = detailFrame(checks.length, t);
|
||||||
return {
|
return {
|
||||||
outcome,
|
outcome,
|
||||||
headline: OUTCOME_HEADLINE[outcome],
|
headline: t(`${COPY}.headline.${outcome}`),
|
||||||
parts,
|
parts,
|
||||||
countNoun: noun,
|
detailLead: lead,
|
||||||
detail: parts.length === 0 ? "" : `${parts.map((part) => part.text).join(", ")} ${noun}`,
|
detailTrail: trail,
|
||||||
|
detail: parts.length === 0 ? "" : `${lead}${parts.map((part) => part.text).join(", ")}${trail}`,
|
||||||
total: checks.length,
|
total: checks.length,
|
||||||
groups,
|
groups,
|
||||||
};
|
};
|
||||||
@@ -131,6 +122,13 @@ function selectOutcome(checks: readonly PrPaneCheck[]): ChecksOutcome {
|
|||||||
return "success";
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import {
|
|||||||
isPipelineActiveStatus,
|
isPipelineActiveStatus,
|
||||||
mapPipelineStatus,
|
mapPipelineStatus,
|
||||||
} from "@/git/forges/gitlab";
|
} from "@/git/forges/gitlab";
|
||||||
|
import { i18n } from "@/i18n/i18next";
|
||||||
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
|
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
|
||||||
import {
|
import {
|
||||||
deriveAvatarColor,
|
deriveAvatarColor,
|
||||||
formatAge,
|
formatAge,
|
||||||
getActivityVerb,
|
getActivityVerbKey,
|
||||||
getStateLabel,
|
getStateLabelKey,
|
||||||
mapPrPaneData,
|
mapPrPaneData,
|
||||||
} from "./data";
|
} from "./data";
|
||||||
|
|
||||||
@@ -779,37 +780,34 @@ describe("formatAge", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getStateLabel", () => {
|
const english = i18n.getFixedT("en");
|
||||||
|
const french = i18n.getFixedT("fr");
|
||||||
|
|
||||||
|
describe("getStateLabelKey", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["open", "Open"],
|
["open", "Open", "Ouverte"],
|
||||||
["draft", "Draft"],
|
["draft", "Draft", "Brouillon"],
|
||||||
["merged", "Merged"],
|
["merged", "Merged", "Fusionnée"],
|
||||||
["closed", "Closed"],
|
["closed", "Closed", "Fermée"],
|
||||||
] as const)("maps %s → %s", (state, expected) => {
|
] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => {
|
||||||
expect(getStateLabel(state)).toBe(expected);
|
expect(english(getStateLabelKey(state))).toBe(englishLabel);
|
||||||
|
expect(french(getStateLabelKey(state))).toBe(frenchLabel);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getActivityVerb", () => {
|
describe("getActivityVerbKey", () => {
|
||||||
it("returns Commented for comment kind", () => {
|
it.each([
|
||||||
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
|
[{ kind: "comment" }, "Commented", "A commenté"],
|
||||||
});
|
[{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"],
|
||||||
|
[
|
||||||
it("returns Approved for approved review", () => {
|
{ kind: "review", reviewState: "changes_requested" },
|
||||||
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns Requested changes for changes_requested review", () => {
|
|
||||||
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
|
|
||||||
"Requested changes",
|
"Requested changes",
|
||||||
);
|
"A demandé des modifications",
|
||||||
});
|
],
|
||||||
|
[{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"],
|
||||||
it("returns Reviewed for a commented review with body (generic case)", () => {
|
[{ kind: "review" }, "Reviewed", "A relu"],
|
||||||
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
|
] 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);
|
||||||
it("returns Reviewed when reviewState is undefined", () => {
|
|
||||||
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -318,16 +318,18 @@ function parsePullRequestNumber(url: string): number | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStateLabel(state: PrState): string {
|
export function getStateLabelKey(state: PrState) {
|
||||||
if (state === "draft") return "Draft";
|
if (state === "draft") return "workspace.git.pr.states.draft";
|
||||||
if (state === "merged") return "Merged";
|
if (state === "merged") return "workspace.git.pr.states.merged";
|
||||||
if (state === "closed") return "Closed";
|
if (state === "closed") return "workspace.git.pr.states.closed";
|
||||||
return "Open";
|
return "workspace.git.pr.states.open";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
|
export function getActivityVerbKey(item: Pick<PrPaneActivity, "kind" | "reviewState">) {
|
||||||
if (item.kind === "comment") return "Commented";
|
if (item.kind === "comment") return "workspace.git.pr.activity.commented";
|
||||||
if (item.reviewState === "approved") return "Approved";
|
if (item.reviewState === "approved") return "workspace.git.pr.activity.approved";
|
||||||
if (item.reviewState === "changes_requested") return "Requested changes";
|
if (item.reviewState === "changes_requested") {
|
||||||
return "Reviewed";
|
return "workspace.git.pr.activity.requestedChanges";
|
||||||
|
}
|
||||||
|
return "workspace.git.pr.activity.reviewed";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ import {
|
|||||||
canAddPullRequestActivityToChat,
|
canAddPullRequestActivityToChat,
|
||||||
} from "./context-attachment";
|
} from "./context-attachment";
|
||||||
import { ChecksSection, getCheckIdentity } from "./checks-section";
|
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 { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
|
||||||
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
|
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
|
||||||
import { CheckPresentationIcon } from "@/git/check-presentation.view";
|
import { CheckPresentationIcon } from "@/git/check-presentation.view";
|
||||||
@@ -538,7 +538,7 @@ export function PullRequestPane({
|
|||||||
<View style={styles.metaLine}>
|
<View style={styles.metaLine}>
|
||||||
<StateIcon size={14} uniProps={statePresentation.iconColor} />
|
<StateIcon size={14} uniProps={statePresentation.iconColor} />
|
||||||
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
|
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
|
||||||
{getStateLabel(data.state)}
|
{t(getStateLabelKey(data.state))}
|
||||||
</Text>
|
</Text>
|
||||||
{nativeHeaderMeta}
|
{nativeHeaderMeta}
|
||||||
{repoIdentity ? (
|
{repoIdentity ? (
|
||||||
@@ -568,7 +568,7 @@ export function PullRequestPane({
|
|||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
title="Activity"
|
title={t("workspace.git.pr.sections.activity")}
|
||||||
open={activityOpen}
|
open={activityOpen}
|
||||||
onToggle={handleToggleActivity}
|
onToggle={handleToggleActivity}
|
||||||
summary={
|
summary={
|
||||||
@@ -592,13 +592,13 @@ export function PullRequestPane({
|
|||||||
onPress={handleAddAllToChat}
|
onPress={handleAddAllToChat}
|
||||||
disabled={activityLoading}
|
disabled={activityLoading}
|
||||||
>
|
>
|
||||||
Add all to chat
|
{t("workspace.git.pr.actions.addAllToChat")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{activityLoading ? <PrActivitySkeleton /> : null}
|
{activityLoading ? <PrActivitySkeleton /> : null}
|
||||||
{!activityLoading && visibleEntries.length === 0 ? (
|
{!activityLoading && visibleEntries.length === 0 ? (
|
||||||
<Text style={sectionKitStyles.emptyText}>No activity yet</Text>
|
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{!activityLoading
|
{!activityLoading
|
||||||
? visibleEntries.map(({ entry, collapsed }) => (
|
? visibleEntries.map(({ entry, collapsed }) => (
|
||||||
@@ -702,14 +702,14 @@ function ActivityKebab({
|
|||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={kebabTriggerStyle}
|
style={kebabTriggerStyle}
|
||||||
accessibilityLabel="Comment actions"
|
accessibilityLabel={t("workspace.git.pr.accessibility.commentActions")}
|
||||||
>
|
>
|
||||||
{renderKebabTriggerIcon}
|
{renderKebabTriggerIcon}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" width={200}>
|
<DropdownMenuContent align="end" width={200}>
|
||||||
{attachEnabled && canAddPullRequestActivityToChat(activity) ? (
|
{attachEnabled && canAddPullRequestActivityToChat(activity) ? (
|
||||||
<DropdownMenuItem leading={ADD_TO_CHAT_MENU_ICON} onSelect={handleAddToChat}>
|
<DropdownMenuItem leading={ADD_TO_CHAT_MENU_ICON} onSelect={handleAddToChat}>
|
||||||
Add to chat
|
{t("workspace.git.pr.actions.addToChat")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{activity.body.trim() !== "" ? (
|
{activity.body.trim() !== "" ? (
|
||||||
@@ -760,7 +760,8 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ActivityVerb({ activity }: { activity: PrPaneActivity }) {
|
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") {
|
if (activity.kind === "review" && activity.reviewState === "approved") {
|
||||||
return (
|
return (
|
||||||
<View style={styles.verbGroup}>
|
<View style={styles.verbGroup}>
|
||||||
@@ -815,6 +816,7 @@ function SingleActivityCard({
|
|||||||
entry: Extract<PrTimelineEntry, { kind: "single" }>;
|
entry: Extract<PrTimelineEntry, { kind: "single" }>;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { activity } = entry;
|
const { activity } = entry;
|
||||||
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
|
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
|
||||||
useRevealOnHover();
|
useRevealOnHover();
|
||||||
@@ -884,7 +886,7 @@ function SingleActivityCard({
|
|||||||
leftIcon={MessageSquarePlus}
|
leftIcon={MessageSquarePlus}
|
||||||
onPress={handleAddToChat}
|
onPress={handleAddToChat}
|
||||||
>
|
>
|
||||||
Add to chat
|
{t("workspace.git.pr.actions.addToChat")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -935,6 +937,7 @@ function ReviewCard({
|
|||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
collapsedEntryIds: ReadonlySet<string>;
|
collapsedEntryIds: ReadonlySet<string>;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { review, threads } = entry;
|
const { review, threads } = entry;
|
||||||
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
|
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
|
||||||
useRevealOnHover();
|
useRevealOnHover();
|
||||||
@@ -961,7 +964,7 @@ function ReviewCard({
|
|||||||
onPress={handleAddToChat}
|
onPress={handleAddToChat}
|
||||||
style={styles.checkAddButton}
|
style={styles.checkAddButton}
|
||||||
>
|
>
|
||||||
Add to chat
|
{t("workspace.git.pr.actions.addToChat")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
@@ -995,7 +998,7 @@ function ReviewCard({
|
|||||||
leftIcon={MessageSquarePlus}
|
leftIcon={MessageSquarePlus}
|
||||||
onPress={handleAddToChat}
|
onPress={handleAddToChat}
|
||||||
>
|
>
|
||||||
Add to chat
|
{t("workspace.git.pr.actions.addToChat")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1063,8 +1066,12 @@ function ThreadBlock({
|
|||||||
? formatPullRequestThreadPath(thread.location)
|
? formatPullRequestThreadPath(thread.location)
|
||||||
: t("workspace.git.pr.thread.discussion")}
|
: t("workspace.git.pr.thread.discussion")}
|
||||||
</Text>
|
</Text>
|
||||||
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
|
{thread.isResolved ? (
|
||||||
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
|
<StatusBadge label={t("workspace.git.pr.thread.resolved")} variant="success" />
|
||||||
|
) : null}
|
||||||
|
{thread.location?.isOutdated ? (
|
||||||
|
<StatusBadge label={t("workspace.git.pr.thread.outdated")} />
|
||||||
|
) : null}
|
||||||
<View style={styles.headerTrailing}>
|
<View style={styles.headerTrailing}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<View style={styles.threadCount}>
|
<View style={styles.threadCount}>
|
||||||
@@ -1080,7 +1087,7 @@ function ThreadBlock({
|
|||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={kebabTriggerStyle}
|
style={kebabTriggerStyle}
|
||||||
accessibilityLabel="Thread actions"
|
accessibilityLabel={t("workspace.git.pr.accessibility.threadActions")}
|
||||||
>
|
>
|
||||||
{renderKebabTriggerIcon}
|
{renderKebabTriggerIcon}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -1124,7 +1131,7 @@ function ThreadBlock({
|
|||||||
leftIcon={MessageSquarePlus}
|
leftIcon={MessageSquarePlus}
|
||||||
onPress={handleAddThreadToChat}
|
onPress={handleAddThreadToChat}
|
||||||
>
|
>
|
||||||
Add to chat
|
{t("workspace.git.pr.actions.addToChat")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { i18n } from "@/i18n/i18next";
|
||||||
import type { SidebarWorkspaceEntry } from "./sidebar-workspaces-view-model";
|
import type { SidebarWorkspaceEntry } from "./sidebar-workspaces-view-model";
|
||||||
import {
|
import {
|
||||||
buildStatusGroups,
|
buildStatusGroups,
|
||||||
buildStatusShortcutIndex,
|
buildStatusShortcutIndex,
|
||||||
STATUS_BUCKET_LABELS,
|
getStatusBucketLabel,
|
||||||
STATUS_BUCKET_ORDER,
|
STATUS_BUCKET_ORDER,
|
||||||
type StatusGroup,
|
type StatusGroup,
|
||||||
} from "./sidebar-status-view-model";
|
} from "./sidebar-status-view-model";
|
||||||
@@ -55,7 +56,7 @@ describe("buildStatusGroups", () => {
|
|||||||
ws({ workspaceKey: "srv:running-ws", statusBucket: "running", name: "running-ws" }),
|
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.map((g) => g.bucket)).toEqual(["needs_input", "running", "done"]);
|
||||||
expect(groups[0]?.label).toBe("Needs input");
|
expect(groups[0]?.label).toBe("Needs input");
|
||||||
@@ -69,7 +70,7 @@ describe("buildStatusGroups", () => {
|
|||||||
ws({ workspaceKey: "srv:b", statusBucket: "running" }),
|
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"]);
|
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"]);
|
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 }),
|
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([
|
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual([
|
||||||
"srv:ts",
|
"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"]);
|
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:2", "srv:3", "srv:1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty array for no workspaces", () => {
|
it("returns empty array for no workspaces", () => {
|
||||||
const groups = buildStatusGroups([], emptyProjectNames);
|
const groups = buildStatusGroups([], emptyProjectNames, i18n.t);
|
||||||
expect(groups).toEqual([]);
|
expect(groups).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,11 +181,11 @@ describe("buildStatusGroups", () => {
|
|||||||
ws({ workspaceKey: "srv:dn", statusBucket: "done", statusEnteredAt: null }),
|
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.bucket)).toEqual(STATUS_BUCKET_ORDER);
|
||||||
expect(groups.map((g) => g.label)).toEqual(
|
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
|
// Each group has exactly one row with the matching bucket
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
@@ -230,3 +231,20 @@ describe("buildStatusShortcutIndex", () => {
|
|||||||
expect(index.size).toBe(0);
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
import { STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state";
|
import { STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state";
|
||||||
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
|
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
|
||||||
|
|
||||||
@@ -5,13 +6,17 @@ export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
|
|||||||
|
|
||||||
export { STATUS_BUCKET_ORDER };
|
export { STATUS_BUCKET_ORDER };
|
||||||
|
|
||||||
export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
|
const STATUS_BUCKET_LABEL_KEYS = {
|
||||||
needs_input: "Needs input",
|
needs_input: "sidebar.statusBucket.needsInput",
|
||||||
failed: "Failed",
|
failed: "sidebar.statusBucket.failed",
|
||||||
attention: "Ready to review",
|
attention: "sidebar.statusBucket.readyToReview",
|
||||||
running: "Working",
|
running: "sidebar.statusBucket.working",
|
||||||
done: "Done",
|
done: "sidebar.statusBucket.done",
|
||||||
};
|
} as const satisfies Record<StatusBucket, string>;
|
||||||
|
|
||||||
|
export function getStatusBucketLabel(bucket: StatusBucket, t: TFunction): string {
|
||||||
|
return t(STATUS_BUCKET_LABEL_KEYS[bucket]);
|
||||||
|
}
|
||||||
|
|
||||||
export interface StatusGroup {
|
export interface StatusGroup {
|
||||||
bucket: StatusBucket;
|
bucket: StatusBucket;
|
||||||
@@ -22,6 +27,7 @@ export interface StatusGroup {
|
|||||||
export function buildStatusGroups(
|
export function buildStatusGroups(
|
||||||
workspaces: SidebarWorkspaceEntry[],
|
workspaces: SidebarWorkspaceEntry[],
|
||||||
projectNamesByViewKey: Map<string, string>,
|
projectNamesByViewKey: Map<string, string>,
|
||||||
|
t: TFunction,
|
||||||
): StatusGroup[] {
|
): StatusGroup[] {
|
||||||
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
|
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
|
||||||
|
|
||||||
@@ -42,7 +48,7 @@ export function buildStatusGroups(
|
|||||||
if (!rows || rows.length === 0) continue;
|
if (!rows || rows.length === 0) continue;
|
||||||
|
|
||||||
rows.sort((a, b) => compareStatusRows(a, b, projectNamesByViewKey));
|
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;
|
return groups;
|
||||||
|
|||||||
@@ -74,6 +74,45 @@ const untranslatedLocalFallbacks = [
|
|||||||
"Unable to save desktop settings.",
|
"Unable to save desktop settings.",
|
||||||
] as const;
|
] 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[] {
|
function collectSourceFiles(directory: string): string[] {
|
||||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
const path = join(directory, entry.name);
|
const path = join(directory, entry.name);
|
||||||
@@ -183,10 +222,53 @@ describe("translation resources", () => {
|
|||||||
expect(ko.desktop.daemon.status.notRunning).toBe("실행 중이 아님");
|
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", () => {
|
it("labels the immediate add-to-chat action without an ellipsis", () => {
|
||||||
expect(en.workspace.fileActions.addToChat).toBe("Add to chat");
|
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", () => {
|
it("keeps local connection fallback errors translated", () => {
|
||||||
expect(findUntranslatedConnectionErrors()).toEqual([]);
|
expect(findUntranslatedConnectionErrors()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -382,6 +382,9 @@ export const ar: TranslationResources = {
|
|||||||
completed: "اكتملت",
|
completed: "اكتملت",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
turnFooter: {
|
||||||
|
workedFor: "عمل لمدة {{duration}}",
|
||||||
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "الضغط...",
|
loading: "الضغط...",
|
||||||
auto: "يتم ضغط السياق تلقائيًا",
|
auto: "يتم ضغط السياق تلقائيًا",
|
||||||
@@ -970,6 +973,47 @@ export const ar: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
viewPullRequest: "عرض",
|
viewPullRequest: "عرض",
|
||||||
openOn: "فتح على {{brand}}",
|
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: {
|
checksSummary: {
|
||||||
passedLabel: "نجح",
|
passedLabel: "نجح",
|
||||||
@@ -983,17 +1027,21 @@ export const ar: TranslationResources = {
|
|||||||
checks: "الشيكات",
|
checks: "الشيكات",
|
||||||
pipeline: "خط المعالجة",
|
pipeline: "خط المعالجة",
|
||||||
reviews: "التعليقات",
|
reviews: "التعليقات",
|
||||||
|
activity: "النشاط",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noJobs: "لا توجد مهام",
|
noJobs: "لا توجد مهام",
|
||||||
loadingPipeline: "جارٍ تحميل خط المعالجة...",
|
loadingPipeline: "جارٍ تحميل خط المعالجة...",
|
||||||
pipelineJobsLoadFailed: "تعذر تحميل مهام خط المعالجة",
|
pipelineJobsLoadFailed: "تعذر تحميل مهام خط المعالجة",
|
||||||
allowedToFail: "مسموح بالفشل",
|
allowedToFail: "مسموح بالفشل",
|
||||||
|
noActivity: "لا يوجد نشاط بعد",
|
||||||
},
|
},
|
||||||
approvals: "{{given}} من {{required}} موافقات",
|
approvals: "{{given}} من {{required}} موافقات",
|
||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "سحب الطلب #{{number}}",
|
pullRequest: "سحب الطلب #{{number}}",
|
||||||
pullRequest_mr: "طلب دمج !{{number}}",
|
pullRequest_mr: "طلب دمج !{{number}}",
|
||||||
|
commentActions: "إجراءات التعليق",
|
||||||
|
threadActions: "إجراءات سلسلة النقاش",
|
||||||
checkStatus: {
|
checkStatus: {
|
||||||
passed: "ناجح",
|
passed: "ناجح",
|
||||||
failed: "فاشل",
|
failed: "فاشل",
|
||||||
@@ -1022,6 +1070,8 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
thread: {
|
thread: {
|
||||||
discussion: "سلسلة المناقشة",
|
discussion: "سلسلة المناقشة",
|
||||||
|
resolved: "تم الحل",
|
||||||
|
outdated: "قديم",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
statusLoadFailed: "غير قادر على تحميل حالة طلب السحب",
|
statusLoadFailed: "غير قادر على تحميل حالة طلب السحب",
|
||||||
@@ -1088,6 +1138,14 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
statusGroupAccessibility: "مجموعة {{label}}",
|
||||||
|
statusBucket: {
|
||||||
|
needsInput: "تحتاج إدخالاً",
|
||||||
|
failed: "فشل",
|
||||||
|
readyToReview: "جاهزة للمراجعة",
|
||||||
|
working: "قيد العمل",
|
||||||
|
done: "تم",
|
||||||
|
},
|
||||||
display: {
|
display: {
|
||||||
trigger: "تفضيلات العرض",
|
trigger: "تفضيلات العرض",
|
||||||
heading: "العرض",
|
heading: "العرض",
|
||||||
@@ -1703,6 +1761,12 @@ export const ar: TranslationResources = {
|
|||||||
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
|
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
|
||||||
fields: {
|
fields: {
|
||||||
target: "مضيف SSH",
|
target: "مضيف SSH",
|
||||||
|
password: "كلمة مرور الدايمون",
|
||||||
|
optional: "خياري",
|
||||||
|
},
|
||||||
|
passwordVisibility: {
|
||||||
|
show: "إظهار كلمة المرور",
|
||||||
|
hide: "إخفاء كلمة المرور",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
cancel: "إلغاء",
|
cancel: "إلغاء",
|
||||||
|
|||||||
@@ -379,6 +379,9 @@ export const en = {
|
|||||||
completed: "Completed",
|
completed: "Completed",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
turnFooter: {
|
||||||
|
workedFor: "Worked for {{duration}}",
|
||||||
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "Compacting...",
|
loading: "Compacting...",
|
||||||
auto: "Context automatically compacted",
|
auto: "Context automatically compacted",
|
||||||
@@ -978,6 +981,47 @@ export const en = {
|
|||||||
actions: {
|
actions: {
|
||||||
viewPullRequest: "View",
|
viewPullRequest: "View",
|
||||||
openOn: "Open on {{brand}}",
|
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: {
|
checksSummary: {
|
||||||
passedLabel: "passed",
|
passedLabel: "passed",
|
||||||
@@ -991,17 +1035,21 @@ export const en = {
|
|||||||
checks: "Checks",
|
checks: "Checks",
|
||||||
pipeline: "Pipeline",
|
pipeline: "Pipeline",
|
||||||
reviews: "Reviews",
|
reviews: "Reviews",
|
||||||
|
activity: "Activity",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noJobs: "No jobs",
|
noJobs: "No jobs",
|
||||||
loadingPipeline: "Loading pipeline…",
|
loadingPipeline: "Loading pipeline…",
|
||||||
pipelineJobsLoadFailed: "Could not load pipeline jobs",
|
pipelineJobsLoadFailed: "Could not load pipeline jobs",
|
||||||
allowedToFail: "allowed to fail",
|
allowedToFail: "allowed to fail",
|
||||||
|
noActivity: "No activity yet",
|
||||||
},
|
},
|
||||||
approvals: "{{given}} of {{required}} approvals",
|
approvals: "{{given}} of {{required}} approvals",
|
||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "Pull request #{{number}}",
|
pullRequest: "Pull request #{{number}}",
|
||||||
pullRequest_mr: "Merge request !{{number}}",
|
pullRequest_mr: "Merge request !{{number}}",
|
||||||
|
commentActions: "Comment actions",
|
||||||
|
threadActions: "Thread actions",
|
||||||
checkStatus: {
|
checkStatus: {
|
||||||
passed: "Passed",
|
passed: "Passed",
|
||||||
failed: "Failed",
|
failed: "Failed",
|
||||||
@@ -1030,6 +1078,8 @@ export const en = {
|
|||||||
},
|
},
|
||||||
thread: {
|
thread: {
|
||||||
discussion: "Discussion thread",
|
discussion: "Discussion thread",
|
||||||
|
resolved: "Resolved",
|
||||||
|
outdated: "Outdated",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
statusLoadFailed: "Unable to load pull request status",
|
statusLoadFailed: "Unable to load pull request status",
|
||||||
@@ -1096,6 +1146,14 @@ export const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
statusGroupAccessibility: "{{label}} group",
|
||||||
|
statusBucket: {
|
||||||
|
needsInput: "Needs input",
|
||||||
|
failed: "Failed",
|
||||||
|
readyToReview: "Ready to review",
|
||||||
|
working: "Working",
|
||||||
|
done: "Done",
|
||||||
|
},
|
||||||
display: {
|
display: {
|
||||||
trigger: "Display preferences",
|
trigger: "Display preferences",
|
||||||
heading: "Display",
|
heading: "Display",
|
||||||
@@ -1727,6 +1785,12 @@ export const en = {
|
|||||||
helper: "Connect to a Paseo daemon running on the remote host.",
|
helper: "Connect to a Paseo daemon running on the remote host.",
|
||||||
fields: {
|
fields: {
|
||||||
target: "SSH host",
|
target: "SSH host",
|
||||||
|
password: "Daemon password",
|
||||||
|
optional: "Optional",
|
||||||
|
},
|
||||||
|
passwordVisibility: {
|
||||||
|
show: "Show password",
|
||||||
|
hide: "Hide password",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
@@ -2098,7 +2162,7 @@ export const en = {
|
|||||||
globalTitle: "Enable plugins",
|
globalTitle: "Enable plugins",
|
||||||
globalHint: "Global switch for every configured plugin",
|
globalHint: "Global switch for every configured plugin",
|
||||||
sourceLabel: "Plugin source",
|
sourceLabel: "Plugin source",
|
||||||
sourcePlaceholder: "Directory, Git URL, or npm package",
|
sourcePlaceholder: "owner/slug, directory, Git URL, or npm package",
|
||||||
docs: "Docs",
|
docs: "Docs",
|
||||||
install: "Install plugin",
|
install: "Install plugin",
|
||||||
installing: "Installing…",
|
installing: "Installing…",
|
||||||
|
|||||||
@@ -387,6 +387,9 @@ export const es: TranslationResources = {
|
|||||||
completed: "Completada",
|
completed: "Completada",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
turnFooter: {
|
||||||
|
workedFor: "Trabajó durante {{duration}}",
|
||||||
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "Compactando...",
|
loading: "Compactando...",
|
||||||
auto: "Contexto compactado automáticamente",
|
auto: "Contexto compactado automáticamente",
|
||||||
@@ -1003,6 +1006,47 @@ export const es: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
viewPullRequest: "Ver",
|
viewPullRequest: "Ver",
|
||||||
openOn: "Abrir en {{brand}}",
|
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: {
|
checksSummary: {
|
||||||
passedLabel: "pasó",
|
passedLabel: "pasó",
|
||||||
@@ -1016,17 +1060,21 @@ export const es: TranslationResources = {
|
|||||||
checks: "cheques",
|
checks: "cheques",
|
||||||
pipeline: "Pipeline",
|
pipeline: "Pipeline",
|
||||||
reviews: "Reseñas",
|
reviews: "Reseñas",
|
||||||
|
activity: "Actividad",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noJobs: "Sin trabajos",
|
noJobs: "Sin trabajos",
|
||||||
loadingPipeline: "Cargando pipeline...",
|
loadingPipeline: "Cargando pipeline...",
|
||||||
pipelineJobsLoadFailed: "No se pudieron cargar los trabajos del pipeline",
|
pipelineJobsLoadFailed: "No se pudieron cargar los trabajos del pipeline",
|
||||||
allowedToFail: "permitido fallar",
|
allowedToFail: "permitido fallar",
|
||||||
|
noActivity: "Aún no hay actividad",
|
||||||
},
|
},
|
||||||
approvals: "{{given}} de {{required}} aprobaciones",
|
approvals: "{{given}} de {{required}} aprobaciones",
|
||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "Solicitud de extracción n.°{{number}}",
|
pullRequest: "Solicitud de extracción n.°{{number}}",
|
||||||
pullRequest_mr: "Solicitud de fusión !{{number}}",
|
pullRequest_mr: "Solicitud de fusión !{{number}}",
|
||||||
|
commentActions: "Acciones del comentario",
|
||||||
|
threadActions: "Acciones del hilo",
|
||||||
checkStatus: {
|
checkStatus: {
|
||||||
passed: "Superado",
|
passed: "Superado",
|
||||||
failed: "Fallido",
|
failed: "Fallido",
|
||||||
@@ -1045,16 +1093,18 @@ export const es: TranslationResources = {
|
|||||||
open: "Abierto",
|
open: "Abierto",
|
||||||
},
|
},
|
||||||
activity: {
|
activity: {
|
||||||
commented: "Comentado",
|
commented: "Comentó",
|
||||||
approved: "Aprobado",
|
approved: "Aprobó",
|
||||||
requestedChanges: "Cambios solicitados",
|
requestedChanges: "Solicitó cambios",
|
||||||
reviewed: "Revisado",
|
reviewed: "Revisó",
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
justNow: "En este momento",
|
justNow: "En este momento",
|
||||||
},
|
},
|
||||||
thread: {
|
thread: {
|
||||||
discussion: "Hilo de discusión",
|
discussion: "Hilo de discusión",
|
||||||
|
resolved: "Resuelto",
|
||||||
|
outdated: "Desactualizado",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción",
|
statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción",
|
||||||
@@ -1124,6 +1174,14 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
statusGroupAccessibility: "Grupo {{label}}",
|
||||||
|
statusBucket: {
|
||||||
|
needsInput: "Necesita datos",
|
||||||
|
failed: "Con error",
|
||||||
|
readyToReview: "Para revisar",
|
||||||
|
working: "En ejecución",
|
||||||
|
done: "Terminado",
|
||||||
|
},
|
||||||
display: {
|
display: {
|
||||||
trigger: "Preferencias de visualización",
|
trigger: "Preferencias de visualización",
|
||||||
heading: "Visualización",
|
heading: "Visualización",
|
||||||
@@ -1749,6 +1807,12 @@ export const es: TranslationResources = {
|
|||||||
helper: "Conéctate a un daemon de Paseo en el host remoto.",
|
helper: "Conéctate a un daemon de Paseo en el host remoto.",
|
||||||
fields: {
|
fields: {
|
||||||
target: "Host SSH",
|
target: "Host SSH",
|
||||||
|
password: "Contraseña del daemon",
|
||||||
|
optional: "Opcional",
|
||||||
|
},
|
||||||
|
passwordVisibility: {
|
||||||
|
show: "Mostrar contraseña",
|
||||||
|
hide: "Ocultar contraseña",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
cancel: "Cancelar",
|
cancel: "Cancelar",
|
||||||
|
|||||||
+1207
-1146
File diff suppressed because it is too large
Load Diff
@@ -387,6 +387,9 @@ export const ja: TranslationResources = {
|
|||||||
completed: "完了",
|
completed: "完了",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
turnFooter: {
|
||||||
|
workedFor: "作業時間 {{duration}}",
|
||||||
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "コンテキストを圧縮中...",
|
loading: "コンテキストを圧縮中...",
|
||||||
auto: "コンテキストが自動的に圧縮されました",
|
auto: "コンテキストが自動的に圧縮されました",
|
||||||
@@ -983,6 +986,47 @@ export const ja: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
viewPullRequest: "表示",
|
viewPullRequest: "表示",
|
||||||
openOn: "{{brand}}で開く",
|
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: {
|
checksSummary: {
|
||||||
passedLabel: "成功",
|
passedLabel: "成功",
|
||||||
@@ -996,17 +1040,21 @@ export const ja: TranslationResources = {
|
|||||||
checks: "チェック",
|
checks: "チェック",
|
||||||
pipeline: "パイプライン",
|
pipeline: "パイプライン",
|
||||||
reviews: "レビュー",
|
reviews: "レビュー",
|
||||||
|
activity: "アクティビティ",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noJobs: "ジョブなし",
|
noJobs: "ジョブなし",
|
||||||
loadingPipeline: "パイプラインを読み込み中...",
|
loadingPipeline: "パイプラインを読み込み中...",
|
||||||
pipelineJobsLoadFailed: "パイプラインのジョブを読み込めませんでした",
|
pipelineJobsLoadFailed: "パイプラインのジョブを読み込めませんでした",
|
||||||
allowedToFail: "失敗を許可",
|
allowedToFail: "失敗を許可",
|
||||||
|
noActivity: "まだアクティビティはありません",
|
||||||
},
|
},
|
||||||
approvals: "{{given}} / {{required}} 承認",
|
approvals: "{{given}} / {{required}} 承認",
|
||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "プルリクエスト#{{number}}",
|
pullRequest: "プルリクエスト#{{number}}",
|
||||||
pullRequest_mr: "マージリクエスト !{{number}}",
|
pullRequest_mr: "マージリクエスト !{{number}}",
|
||||||
|
commentActions: "コメントの操作",
|
||||||
|
threadActions: "スレッドの操作",
|
||||||
checkStatus: {
|
checkStatus: {
|
||||||
passed: "成功",
|
passed: "成功",
|
||||||
failed: "失敗",
|
failed: "失敗",
|
||||||
@@ -1035,6 +1083,8 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
thread: {
|
thread: {
|
||||||
discussion: "ディスカッションスレッド",
|
discussion: "ディスカッションスレッド",
|
||||||
|
resolved: "解決済み",
|
||||||
|
outdated: "古い",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
statusLoadFailed: "プルリクエストのステータスを読み込めません",
|
statusLoadFailed: "プルリクエストのステータスを読み込めません",
|
||||||
@@ -1101,6 +1151,14 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
statusGroupAccessibility: "{{label}} グループ",
|
||||||
|
statusBucket: {
|
||||||
|
needsInput: "入力待ち",
|
||||||
|
failed: "失敗",
|
||||||
|
readyToReview: "レビュー待ち",
|
||||||
|
working: "実行中",
|
||||||
|
done: "完了",
|
||||||
|
},
|
||||||
display: {
|
display: {
|
||||||
trigger: "表示設定",
|
trigger: "表示設定",
|
||||||
heading: "表示",
|
heading: "表示",
|
||||||
@@ -1719,6 +1777,12 @@ export const ja: TranslationResources = {
|
|||||||
helper: "リモートホストで動作する Paseo デーモンに接続します。",
|
helper: "リモートホストで動作する Paseo デーモンに接続します。",
|
||||||
fields: {
|
fields: {
|
||||||
target: "SSH ホスト",
|
target: "SSH ホスト",
|
||||||
|
password: "デーモンのパスワード",
|
||||||
|
optional: "任意",
|
||||||
|
},
|
||||||
|
passwordVisibility: {
|
||||||
|
show: "パスワードを表示",
|
||||||
|
hide: "パスワードを非表示",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
cancel: "キャンセル",
|
cancel: "キャンセル",
|
||||||
|
|||||||
@@ -383,6 +383,9 @@ export const ko: TranslationResources = {
|
|||||||
completed: "완료됨",
|
completed: "완료됨",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
turnFooter: {
|
||||||
|
workedFor: "작업 시간 {{duration}}",
|
||||||
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "압축하는 중...",
|
loading: "압축하는 중...",
|
||||||
auto: "컨텍스트가 자동으로 압축되었습니다",
|
auto: "컨텍스트가 자동으로 압축되었습니다",
|
||||||
@@ -977,6 +980,47 @@ export const ko: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
viewPullRequest: "보기",
|
viewPullRequest: "보기",
|
||||||
openOn: "{{brand}}에서 열기",
|
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: {
|
checksSummary: {
|
||||||
passedLabel: "통과",
|
passedLabel: "통과",
|
||||||
@@ -990,17 +1034,21 @@ export const ko: TranslationResources = {
|
|||||||
checks: "검사",
|
checks: "검사",
|
||||||
pipeline: "파이프라인",
|
pipeline: "파이프라인",
|
||||||
reviews: "리뷰",
|
reviews: "리뷰",
|
||||||
|
activity: "활동",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noJobs: "작업 없음",
|
noJobs: "작업 없음",
|
||||||
loadingPipeline: "파이프라인 로드 중…",
|
loadingPipeline: "파이프라인 로드 중…",
|
||||||
pipelineJobsLoadFailed: "파이프라인 작업을 로드할 수 없습니다.",
|
pipelineJobsLoadFailed: "파이프라인 작업을 로드할 수 없습니다.",
|
||||||
allowedToFail: "실패가 허용됨",
|
allowedToFail: "실패가 허용됨",
|
||||||
|
noActivity: "아직 활동이 없습니다",
|
||||||
},
|
},
|
||||||
approvals: "{{required}} 중 {{given}} 승인",
|
approvals: "{{required}} 중 {{given}} 승인",
|
||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "풀 리퀘스트 #{{number}}",
|
pullRequest: "풀 리퀘스트 #{{number}}",
|
||||||
pullRequest_mr: "병합 요청 !{{number}}",
|
pullRequest_mr: "병합 요청 !{{number}}",
|
||||||
|
commentActions: "댓글 작업",
|
||||||
|
threadActions: "스레드 작업",
|
||||||
checkStatus: {
|
checkStatus: {
|
||||||
passed: "통과",
|
passed: "통과",
|
||||||
failed: "실패",
|
failed: "실패",
|
||||||
@@ -1029,6 +1077,8 @@ export const ko: TranslationResources = {
|
|||||||
},
|
},
|
||||||
thread: {
|
thread: {
|
||||||
discussion: "토론 스레드",
|
discussion: "토론 스레드",
|
||||||
|
resolved: "해결됨",
|
||||||
|
outdated: "오래됨",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다",
|
statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다",
|
||||||
@@ -1095,6 +1145,14 @@ export const ko: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
statusGroupAccessibility: "{{label}} 그룹",
|
||||||
|
statusBucket: {
|
||||||
|
needsInput: "입력 필요",
|
||||||
|
failed: "실패",
|
||||||
|
readyToReview: "검토 대기",
|
||||||
|
working: "실행 중",
|
||||||
|
done: "완료",
|
||||||
|
},
|
||||||
display: {
|
display: {
|
||||||
trigger: "표시 설정",
|
trigger: "표시 설정",
|
||||||
heading: "표시",
|
heading: "표시",
|
||||||
@@ -1711,6 +1769,12 @@ export const ko: TranslationResources = {
|
|||||||
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
|
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
|
||||||
fields: {
|
fields: {
|
||||||
target: "SSH 호스트",
|
target: "SSH 호스트",
|
||||||
|
password: "데몬 비밀번호",
|
||||||
|
optional: "선택 사항",
|
||||||
|
},
|
||||||
|
passwordVisibility: {
|
||||||
|
show: "비밀번호 표시",
|
||||||
|
hide: "비밀번호 숨기기",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
cancel: "취소",
|
cancel: "취소",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const pluginSettings = {
|
|||||||
globalTitle: "تفعيل الإضافات",
|
globalTitle: "تفعيل الإضافات",
|
||||||
globalHint: "مفتاح عام لكل الإضافات المضبوطة",
|
globalHint: "مفتاح عام لكل الإضافات المضبوطة",
|
||||||
sourceLabel: "مصدر الإضافة",
|
sourceLabel: "مصدر الإضافة",
|
||||||
sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm",
|
sourcePlaceholder: "owner/slug, مجلد أو رابط Git أو حزمة npm",
|
||||||
docs: "الوثائق",
|
docs: "الوثائق",
|
||||||
install: "تثبيت الإضافة",
|
install: "تثبيت الإضافة",
|
||||||
installing: "جارٍ التثبيت…",
|
installing: "جارٍ التثبيت…",
|
||||||
@@ -82,7 +82,7 @@ export const pluginSettings = {
|
|||||||
globalTitle: "Activar plugins",
|
globalTitle: "Activar plugins",
|
||||||
globalHint: "Interruptor global para todos los plugins configurados",
|
globalHint: "Interruptor global para todos los plugins configurados",
|
||||||
sourceLabel: "Fuente del plugin",
|
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",
|
docs: "Documentación",
|
||||||
install: "Instalar plugin",
|
install: "Instalar plugin",
|
||||||
installing: "Instalando…",
|
installing: "Instalando…",
|
||||||
@@ -137,7 +137,7 @@ export const pluginSettings = {
|
|||||||
globalTitle: "Activer les plugins",
|
globalTitle: "Activer les plugins",
|
||||||
globalHint: "Interrupteur global pour tous les plugins configurés",
|
globalHint: "Interrupteur global pour tous les plugins configurés",
|
||||||
sourceLabel: "Source du plugin",
|
sourceLabel: "Source du plugin",
|
||||||
sourcePlaceholder: "Dossier, URL Git ou paquet npm",
|
sourcePlaceholder: "owner/slug, Dossier, URL Git ou paquet npm",
|
||||||
docs: "Documentation",
|
docs: "Documentation",
|
||||||
install: "Installer le plugin",
|
install: "Installer le plugin",
|
||||||
installing: "Installation…",
|
installing: "Installation…",
|
||||||
@@ -163,7 +163,7 @@ export const pluginSettings = {
|
|||||||
globalEnabled: "Plugins activés",
|
globalEnabled: "Plugins activés",
|
||||||
globalDisabled: "Plugins désactivé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é.",
|
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
|
||||||
states: {
|
states: {
|
||||||
offlineTitle: "L’hôte des plugins est hors ligne",
|
offlineTitle: "L’hôte des plugins est hors ligne",
|
||||||
@@ -192,7 +192,7 @@ export const pluginSettings = {
|
|||||||
globalTitle: "プラグインを有効化",
|
globalTitle: "プラグインを有効化",
|
||||||
globalHint: "設定済みプラグインすべての共通スイッチ",
|
globalHint: "設定済みプラグインすべての共通スイッチ",
|
||||||
sourceLabel: "プラグインソース",
|
sourceLabel: "プラグインソース",
|
||||||
sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ",
|
sourcePlaceholder: "owner/slug, ディレクトリ、Git URL、npm パッケージ",
|
||||||
docs: "ドキュメント",
|
docs: "ドキュメント",
|
||||||
install: "プラグインをインストール",
|
install: "プラグインをインストール",
|
||||||
installing: "インストール中…",
|
installing: "インストール中…",
|
||||||
@@ -246,7 +246,7 @@ export const pluginSettings = {
|
|||||||
globalTitle: "플러그인 활성화",
|
globalTitle: "플러그인 활성화",
|
||||||
globalHint: "설정된 모든 플러그인의 전역 스위치",
|
globalHint: "설정된 모든 플러그인의 전역 스위치",
|
||||||
sourceLabel: "플러그인 소스",
|
sourceLabel: "플러그인 소스",
|
||||||
sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지",
|
sourcePlaceholder: "owner/slug, 디렉터리, Git URL 또는 npm 패키지",
|
||||||
docs: "문서",
|
docs: "문서",
|
||||||
install: "플러그인 설치",
|
install: "플러그인 설치",
|
||||||
installing: "설치 중…",
|
installing: "설치 중…",
|
||||||
@@ -301,7 +301,7 @@ export const pluginSettings = {
|
|||||||
globalTitle: "Ativar plugins",
|
globalTitle: "Ativar plugins",
|
||||||
globalHint: "Controle global para todos os plugins configurados",
|
globalHint: "Controle global para todos os plugins configurados",
|
||||||
sourceLabel: "Fonte do plugin",
|
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",
|
docs: "Documentação",
|
||||||
install: "Instalar plugin",
|
install: "Instalar plugin",
|
||||||
installing: "Instalando…",
|
installing: "Instalando…",
|
||||||
@@ -356,7 +356,7 @@ export const pluginSettings = {
|
|||||||
globalTitle: "Включить плагины",
|
globalTitle: "Включить плагины",
|
||||||
globalHint: "Общий переключатель для всех настроенных плагинов",
|
globalHint: "Общий переключатель для всех настроенных плагинов",
|
||||||
sourceLabel: "Источник плагина",
|
sourceLabel: "Источник плагина",
|
||||||
sourcePlaceholder: "Каталог, URL Git или пакет npm",
|
sourcePlaceholder: "owner/slug, Каталог, URL Git или пакет npm",
|
||||||
docs: "Документация",
|
docs: "Документация",
|
||||||
install: "Установить плагин",
|
install: "Установить плагин",
|
||||||
installing: "Установка…",
|
installing: "Установка…",
|
||||||
@@ -410,7 +410,7 @@ export const pluginSettings = {
|
|||||||
globalTitle: "启用插件",
|
globalTitle: "启用插件",
|
||||||
globalHint: "所有已配置插件的全局开关",
|
globalHint: "所有已配置插件的全局开关",
|
||||||
sourceLabel: "插件来源",
|
sourceLabel: "插件来源",
|
||||||
sourcePlaceholder: "目录、Git URL 或 npm 包",
|
sourcePlaceholder: "owner/slug, 目录、Git URL 或 npm 包",
|
||||||
docs: "文档",
|
docs: "文档",
|
||||||
install: "安装插件",
|
install: "安装插件",
|
||||||
installing: "正在安装…",
|
installing: "正在安装…",
|
||||||
|
|||||||
@@ -386,6 +386,9 @@ export const ptBR: TranslationResources = {
|
|||||||
completed: "Concluída",
|
completed: "Concluída",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
turnFooter: {
|
||||||
|
workedFor: "Trabalhou por {{duration}}",
|
||||||
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "Compactando...",
|
loading: "Compactando...",
|
||||||
auto: "Contexto compactado automaticamente",
|
auto: "Contexto compactado automaticamente",
|
||||||
@@ -993,6 +996,47 @@ export const ptBR: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
viewPullRequest: "Ver",
|
viewPullRequest: "Ver",
|
||||||
openOn: "Abrir no {{brand}}",
|
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: {
|
checksSummary: {
|
||||||
passedLabel: "passou",
|
passedLabel: "passou",
|
||||||
@@ -1006,17 +1050,21 @@ export const ptBR: TranslationResources = {
|
|||||||
checks: "Verificações",
|
checks: "Verificações",
|
||||||
pipeline: "Pipeline",
|
pipeline: "Pipeline",
|
||||||
reviews: "Revisões",
|
reviews: "Revisões",
|
||||||
|
activity: "Atividade",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noJobs: "Sem jobs",
|
noJobs: "Sem jobs",
|
||||||
loadingPipeline: "Carregando pipeline...",
|
loadingPipeline: "Carregando pipeline...",
|
||||||
pipelineJobsLoadFailed: "Não foi possível carregar os jobs do pipeline",
|
pipelineJobsLoadFailed: "Não foi possível carregar os jobs do pipeline",
|
||||||
allowedToFail: "permitido falhar",
|
allowedToFail: "permitido falhar",
|
||||||
|
noActivity: "Nenhuma atividade ainda",
|
||||||
},
|
},
|
||||||
approvals: "{{given}} de {{required}} aprovações",
|
approvals: "{{given}} de {{required}} aprovações",
|
||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "Pull request #{{number}}",
|
pullRequest: "Pull request #{{number}}",
|
||||||
pullRequest_mr: "Merge request !{{number}}",
|
pullRequest_mr: "Merge request !{{number}}",
|
||||||
|
commentActions: "Ações do comentário",
|
||||||
|
threadActions: "Ações da conversa",
|
||||||
checkStatus: {
|
checkStatus: {
|
||||||
passed: "Aprovado",
|
passed: "Aprovado",
|
||||||
failed: "Falhou",
|
failed: "Falhou",
|
||||||
@@ -1036,15 +1084,17 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
activity: {
|
activity: {
|
||||||
commented: "Comentou",
|
commented: "Comentou",
|
||||||
approved: "Aprovado",
|
approved: "Aprovou",
|
||||||
requestedChanges: "Solicitou alterações",
|
requestedChanges: "Solicitou alterações",
|
||||||
reviewed: "Revisado",
|
reviewed: "Revisou",
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
justNow: "agora mesmo",
|
justNow: "agora mesmo",
|
||||||
},
|
},
|
||||||
thread: {
|
thread: {
|
||||||
discussion: "Tópico de discussão",
|
discussion: "Tópico de discussão",
|
||||||
|
resolved: "Resolvido",
|
||||||
|
outdated: "Desatualizado",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
statusLoadFailed: "Não foi possível carregar o status da pull request",
|
statusLoadFailed: "Não foi possível carregar o status da pull request",
|
||||||
@@ -1114,6 +1164,14 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
statusGroupAccessibility: "Grupo {{label}}",
|
||||||
|
statusBucket: {
|
||||||
|
needsInput: "Precisa de resposta",
|
||||||
|
failed: "Com falha",
|
||||||
|
readyToReview: "Para revisar",
|
||||||
|
working: "Em execução",
|
||||||
|
done: "Concluído",
|
||||||
|
},
|
||||||
display: {
|
display: {
|
||||||
trigger: "Preferências de exibição",
|
trigger: "Preferências de exibição",
|
||||||
heading: "Exibição",
|
heading: "Exibição",
|
||||||
@@ -1734,6 +1792,12 @@ export const ptBR: TranslationResources = {
|
|||||||
helper: "Conecte-se a um daemon Paseo no host remoto.",
|
helper: "Conecte-se a um daemon Paseo no host remoto.",
|
||||||
fields: {
|
fields: {
|
||||||
target: "Host SSH",
|
target: "Host SSH",
|
||||||
|
password: "Senha do daemon",
|
||||||
|
optional: "Opcional",
|
||||||
|
},
|
||||||
|
passwordVisibility: {
|
||||||
|
show: "Mostrar senha",
|
||||||
|
hide: "Ocultar senha",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
cancel: "Cancelar",
|
cancel: "Cancelar",
|
||||||
|
|||||||
@@ -386,6 +386,9 @@ export const ru: TranslationResources = {
|
|||||||
completed: "Завершена",
|
completed: "Завершена",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
turnFooter: {
|
||||||
|
workedFor: "Время работы: {{duration}}",
|
||||||
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "Сжатие контекста...",
|
loading: "Сжатие контекста...",
|
||||||
auto: "Контекст сжат автоматически",
|
auto: "Контекст сжат автоматически",
|
||||||
@@ -987,6 +990,47 @@ export const ru: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
viewPullRequest: "Просмотреть",
|
viewPullRequest: "Просмотреть",
|
||||||
openOn: "Открыть на {{brand}}",
|
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: {
|
checksSummary: {
|
||||||
passedLabel: "успешно",
|
passedLabel: "успешно",
|
||||||
@@ -1000,17 +1044,21 @@ export const ru: TranslationResources = {
|
|||||||
checks: "Проверки",
|
checks: "Проверки",
|
||||||
pipeline: "Пайплайн",
|
pipeline: "Пайплайн",
|
||||||
reviews: "Ревью",
|
reviews: "Ревью",
|
||||||
|
activity: "Активность",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noJobs: "Нет заданий",
|
noJobs: "Нет заданий",
|
||||||
loadingPipeline: "Загрузка пайплайна...",
|
loadingPipeline: "Загрузка пайплайна...",
|
||||||
pipelineJobsLoadFailed: "Не удалось загрузить задания пайплайна",
|
pipelineJobsLoadFailed: "Не удалось загрузить задания пайплайна",
|
||||||
allowedToFail: "допускается сбой",
|
allowedToFail: "допускается сбой",
|
||||||
|
noActivity: "Активности пока нет",
|
||||||
},
|
},
|
||||||
approvals: "Одобрено: {{given}} из {{required}}",
|
approvals: "Одобрено: {{given}} из {{required}}",
|
||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "PR #{{number}}",
|
pullRequest: "PR #{{number}}",
|
||||||
pullRequest_mr: "MR !{{number}}",
|
pullRequest_mr: "MR !{{number}}",
|
||||||
|
commentActions: "Действия с комментарием",
|
||||||
|
threadActions: "Действия с обсуждением",
|
||||||
checkStatus: {
|
checkStatus: {
|
||||||
passed: "Успешно",
|
passed: "Успешно",
|
||||||
failed: "Ошибка",
|
failed: "Ошибка",
|
||||||
@@ -1039,6 +1087,8 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
thread: {
|
thread: {
|
||||||
discussion: "Ветка обсуждения",
|
discussion: "Ветка обсуждения",
|
||||||
|
resolved: "Решено",
|
||||||
|
outdated: "Устарело",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
statusLoadFailed: "Не удалось загрузить статус PR",
|
statusLoadFailed: "Не удалось загрузить статус PR",
|
||||||
@@ -1105,6 +1155,14 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
statusGroupAccessibility: "Группа «{{label}}»",
|
||||||
|
statusBucket: {
|
||||||
|
needsInput: "Ожидает ввода",
|
||||||
|
failed: "Ошибка",
|
||||||
|
readyToReview: "На проверку",
|
||||||
|
working: "В работе",
|
||||||
|
done: "Готово",
|
||||||
|
},
|
||||||
display: {
|
display: {
|
||||||
trigger: "Настройки отображения",
|
trigger: "Настройки отображения",
|
||||||
heading: "Отображение",
|
heading: "Отображение",
|
||||||
@@ -1732,6 +1790,12 @@ export const ru: TranslationResources = {
|
|||||||
helper: "Подключитесь к демону Paseo на удалённом хосте.",
|
helper: "Подключитесь к демону Paseo на удалённом хосте.",
|
||||||
fields: {
|
fields: {
|
||||||
target: "Хост SSH",
|
target: "Хост SSH",
|
||||||
|
password: "Пароль демона",
|
||||||
|
optional: "Необязательно",
|
||||||
|
},
|
||||||
|
passwordVisibility: {
|
||||||
|
show: "Показать пароль",
|
||||||
|
hide: "Скрыть пароль",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
cancel: "Отмена",
|
cancel: "Отмена",
|
||||||
|
|||||||
@@ -382,6 +382,9 @@ export const zhCN: TranslationResources = {
|
|||||||
completed: "已完成",
|
completed: "已完成",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
turnFooter: {
|
||||||
|
workedFor: "工作了 {{duration}}",
|
||||||
|
},
|
||||||
compaction: {
|
compaction: {
|
||||||
loading: "正在压缩...",
|
loading: "正在压缩...",
|
||||||
auto: "上下文已自动压缩",
|
auto: "上下文已自动压缩",
|
||||||
@@ -962,6 +965,47 @@ export const zhCN: TranslationResources = {
|
|||||||
actions: {
|
actions: {
|
||||||
viewPullRequest: "查看",
|
viewPullRequest: "查看",
|
||||||
openOn: "在 {{brand}} 上打开",
|
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: {
|
checksSummary: {
|
||||||
passedLabel: "通过",
|
passedLabel: "通过",
|
||||||
@@ -975,17 +1019,21 @@ export const zhCN: TranslationResources = {
|
|||||||
checks: "Checks",
|
checks: "Checks",
|
||||||
pipeline: "流水线",
|
pipeline: "流水线",
|
||||||
reviews: "Reviews",
|
reviews: "Reviews",
|
||||||
|
activity: "动态",
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noJobs: "无作业",
|
noJobs: "无作业",
|
||||||
loadingPipeline: "正在加载流水线...",
|
loadingPipeline: "正在加载流水线...",
|
||||||
pipelineJobsLoadFailed: "无法加载流水线作业",
|
pipelineJobsLoadFailed: "无法加载流水线作业",
|
||||||
allowedToFail: "允许失败",
|
allowedToFail: "允许失败",
|
||||||
|
noActivity: "暂无动态",
|
||||||
},
|
},
|
||||||
approvals: "{{given}} / {{required}} 批准",
|
approvals: "{{given}} / {{required}} 批准",
|
||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "Pull request #{{number}}",
|
pullRequest: "Pull request #{{number}}",
|
||||||
pullRequest_mr: "合并请求 !{{number}}",
|
pullRequest_mr: "合并请求 !{{number}}",
|
||||||
|
commentActions: "评论操作",
|
||||||
|
threadActions: "讨论串操作",
|
||||||
checkStatus: {
|
checkStatus: {
|
||||||
passed: "成功",
|
passed: "成功",
|
||||||
failed: "失败",
|
failed: "失败",
|
||||||
@@ -998,22 +1046,24 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
draft: "Draft",
|
draft: "草稿",
|
||||||
merged: "已 merge",
|
merged: "已合并",
|
||||||
closed: "已关闭",
|
closed: "已关闭",
|
||||||
open: "Open",
|
open: "开放",
|
||||||
},
|
},
|
||||||
activity: {
|
activity: {
|
||||||
commented: "已评论",
|
commented: "已评论",
|
||||||
approved: "已批准",
|
approved: "已批准",
|
||||||
requestedChanges: "请求修改",
|
requestedChanges: "请求修改",
|
||||||
reviewed: "已 review",
|
reviewed: "已审查",
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
justNow: "刚刚",
|
justNow: "刚刚",
|
||||||
},
|
},
|
||||||
thread: {
|
thread: {
|
||||||
discussion: "讨论主题",
|
discussion: "讨论主题",
|
||||||
|
resolved: "已解决",
|
||||||
|
outdated: "已过时",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
statusLoadFailed: "无法加载 Pull Request 状态",
|
statusLoadFailed: "无法加载 Pull Request 状态",
|
||||||
@@ -1080,6 +1130,14 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
statusGroupAccessibility: "{{label}} 分组",
|
||||||
|
statusBucket: {
|
||||||
|
needsInput: "需要输入",
|
||||||
|
failed: "失败",
|
||||||
|
readyToReview: "待查看",
|
||||||
|
working: "运行中",
|
||||||
|
done: "已完成",
|
||||||
|
},
|
||||||
display: {
|
display: {
|
||||||
trigger: "显示偏好",
|
trigger: "显示偏好",
|
||||||
heading: "显示",
|
heading: "显示",
|
||||||
@@ -1683,6 +1741,12 @@ export const zhCN: TranslationResources = {
|
|||||||
helper: "连接到远程主机上运行的 Paseo 守护进程。",
|
helper: "连接到远程主机上运行的 Paseo 守护进程。",
|
||||||
fields: {
|
fields: {
|
||||||
target: "SSH 主机",
|
target: "SSH 主机",
|
||||||
|
password: "守护进程密码",
|
||||||
|
optional: "可选",
|
||||||
|
},
|
||||||
|
passwordVisibility: {
|
||||||
|
show: "显示密码",
|
||||||
|
hide: "隐藏密码",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ import { useKeyboardShift } from "./context";
|
|||||||
interface KeyboardTranslateViewProps extends ViewProps {
|
interface KeyboardTranslateViewProps extends ViewProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||||
|
bottomClearance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeyboardTranslateView({
|
export function KeyboardTranslateView({
|
||||||
children,
|
children,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
bottomClearance = 0,
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}: KeyboardTranslateViewProps) {
|
}: KeyboardTranslateViewProps) {
|
||||||
const { shift } = useKeyboardShift();
|
const { shift } = useKeyboardShift();
|
||||||
const keyboardStyle = useAnimatedStyle(() => ({
|
const keyboardStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ translateY: enabled ? -shift.value : 0 }],
|
transform: [{ translateY: enabled ? -Math.max(0, shift.value - bottomClearance) : 0 }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,19 +6,31 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
interface KeyboardTranslateViewProps extends ViewProps {
|
interface KeyboardTranslateViewProps extends ViewProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** Space below the view's resting position that the keyboard may fill before the view moves. */
|
||||||
|
bottomClearance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeyboardTranslateView({
|
export function KeyboardTranslateView({
|
||||||
children,
|
children,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
bottomClearance = 0,
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}: KeyboardTranslateViewProps) {
|
}: KeyboardTranslateViewProps) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { height, progress } = useKeyboardAnimation();
|
const { height, progress } = useKeyboardAnimation();
|
||||||
const translateY = useMemo(
|
const translateY = useMemo(
|
||||||
() => Animated.add(height, Animated.multiply(progress, insets.bottom)),
|
() =>
|
||||||
[height, insets.bottom, progress],
|
Animated.add(
|
||||||
|
Animated.add(height, Animated.multiply(progress, insets.bottom)),
|
||||||
|
bottomClearance,
|
||||||
|
).interpolate({
|
||||||
|
inputRange: [-1, 0],
|
||||||
|
outputRange: [-1, 0],
|
||||||
|
extrapolateLeft: "extend",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
}),
|
||||||
|
[bottomClearance, height, insets.bottom, progress],
|
||||||
);
|
);
|
||||||
const keyboardStyle = useMemo(
|
const keyboardStyle = useMemo(
|
||||||
() => ({ transform: [{ translateY: enabled ? translateY : 0 }] }),
|
() => ({ transform: [{ translateY: enabled ? translateY : 0 }] }),
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { View, type ViewProps } from "react-native";
|
|||||||
interface KeyboardTranslateViewProps extends ViewProps {
|
interface KeyboardTranslateViewProps extends ViewProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
bottomClearance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeyboardTranslateView({
|
export function KeyboardTranslateView({
|
||||||
children,
|
children,
|
||||||
enabled: _enabled,
|
enabled: _enabled,
|
||||||
|
bottomClearance: _bottomClearance,
|
||||||
...props
|
...props
|
||||||
}: KeyboardTranslateViewProps) {
|
}: KeyboardTranslateViewProps) {
|
||||||
return <View {...props}>{children}</View>;
|
return <View {...props}>{children}</View>;
|
||||||
|
|||||||
@@ -3919,6 +3919,53 @@ describe("HostRuntimeStore", () => {
|
|||||||
store.syncHosts([]);
|
store.syncHosts([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("authenticates Remote SSH probes with the entered daemon password and stores it verbatim", async () => {
|
||||||
|
const store = createPairingStore({
|
||||||
|
password: " s3cret ",
|
||||||
|
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
|
||||||
|
});
|
||||||
|
answerHostConfirmations(store, false);
|
||||||
|
|
||||||
|
await store.boot();
|
||||||
|
await store.probeAndUpsertRemoteSshConnection({
|
||||||
|
host: "deploy@example.com",
|
||||||
|
password: " s3cret ",
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = store.getHosts().find((entry) => entry.serverId === "srv_ssh");
|
||||||
|
expect(host?.password).toBe(" s3cret ");
|
||||||
|
expect(host?.connections[0]).toMatchObject({
|
||||||
|
type: "remoteSsh",
|
||||||
|
host: "deploy@example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The fix promises the saved password is sent on every connection: a later
|
||||||
|
// cycle must authenticate from the stored profile alone.
|
||||||
|
await store.runProbeCycleNow("srv_ssh");
|
||||||
|
await waitForHostOnline(store, "srv_ssh");
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects Remote SSH probes without the daemon password, the way the daemon does", async () => {
|
||||||
|
const store = createPairingStore({
|
||||||
|
password: "s3cret",
|
||||||
|
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
|
||||||
|
});
|
||||||
|
answerHostConfirmations(store, false);
|
||||||
|
|
||||||
|
await store.boot();
|
||||||
|
const failure = await store
|
||||||
|
.probeAndUpsertRemoteSshConnection({ host: "deploy@example.com" })
|
||||||
|
.then(
|
||||||
|
() => null,
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
expect(failure).toBeInstanceOf(DaemonAuthenticationError);
|
||||||
|
expect((failure as DaemonAuthenticationError).reason).toBe("password_required");
|
||||||
|
expect(store.getHosts().find((entry) => entry.serverId === "srv_ssh")).toBeUndefined();
|
||||||
|
store.syncHosts([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
||||||
const store = createPairingStore({ hostname: "mbp" });
|
const store = createPairingStore({ hostname: "mbp" });
|
||||||
answerHostConfirmations(store, true);
|
answerHostConfirmations(store, true);
|
||||||
|
|||||||
@@ -1916,10 +1916,14 @@ export class HostRuntimeStore {
|
|||||||
host: string;
|
host: string;
|
||||||
sshPort?: number;
|
sshPort?: number;
|
||||||
daemonPort?: number;
|
daemonPort?: number;
|
||||||
|
password?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||||
return this.probeAndUpsertConnection({
|
return this.probeAndUpsertConnection({
|
||||||
label: input.label,
|
label: input.label,
|
||||||
|
// The daemon hashes and compares the password verbatim (`paseo daemon
|
||||||
|
// set-password` keeps what it reads), so whitespace is significant here.
|
||||||
|
password: input.password ? input.password : undefined,
|
||||||
connection: createRemoteSshHostConnection(input),
|
connection: createRemoteSshHostConnection(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2844,6 +2848,7 @@ export interface HostMutations {
|
|||||||
host: string;
|
host: string;
|
||||||
sshPort?: number;
|
sshPort?: number;
|
||||||
daemonPort?: number;
|
daemonPort?: number;
|
||||||
|
password?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||||
beginLinkPairing: () => LinkPairing;
|
beginLinkPairing: () => LinkPairing;
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ export function AgentUsage({
|
|||||||
<>
|
<>
|
||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
{view.kind === "ready" ? (
|
{view.kind === "ready" ? (
|
||||||
<AgentUsageCards serverId={serverId} reports={view.reports} refreshable={refreshable} />
|
<AgentUsageCards
|
||||||
|
serverId={serverId}
|
||||||
|
agentId={agentId}
|
||||||
|
reports={view.reports}
|
||||||
|
refreshable={refreshable}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.message}>
|
<Text style={styles.message}>
|
||||||
{view.kind === "loading" ? usageCopy.loading : view.message}
|
{view.kind === "loading" ? usageCopy.loading : view.message}
|
||||||
@@ -39,10 +44,12 @@ export function AgentUsage({
|
|||||||
|
|
||||||
function AgentUsageCards({
|
function AgentUsageCards({
|
||||||
serverId,
|
serverId,
|
||||||
|
agentId,
|
||||||
reports,
|
reports,
|
||||||
refreshable,
|
refreshable,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
reports: readonly UsageReportEntry[];
|
reports: readonly UsageReportEntry[];
|
||||||
refreshable: boolean;
|
refreshable: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -51,6 +58,7 @@ function AgentUsageCards({
|
|||||||
<UsageCard
|
<UsageCard
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
agentId={agentId}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
display={display}
|
display={display}
|
||||||
compact
|
compact
|
||||||
|
|||||||
@@ -40,11 +40,24 @@ function reportContent(report: UsageReport) {
|
|||||||
return { windows: [], balances: [], details: [], message };
|
return { windows: [], balances: [], details: [], message };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reportMessages(entry: UsageReportEntry): string[] {
|
||||||
|
if (entry.report.status === "available") return [];
|
||||||
|
if (entry.loginErrors)
|
||||||
|
return entry.loginErrors.map(
|
||||||
|
(login) => `${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 ThemedRotateCw = withUnistyles(RotateCw);
|
||||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||||
|
|
||||||
export function UsageCard({
|
export function UsageCard({
|
||||||
serverId,
|
serverId,
|
||||||
|
agentId,
|
||||||
entry,
|
entry,
|
||||||
display,
|
display,
|
||||||
pinnable,
|
pinnable,
|
||||||
@@ -52,6 +65,7 @@ export function UsageCard({
|
|||||||
compact = false,
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
agentId?: string;
|
||||||
entry: UsageReportEntry;
|
entry: UsageReportEntry;
|
||||||
display: UsageDisplay;
|
display: UsageDisplay;
|
||||||
/** Whether each window row pins the window to the sidebar. */
|
/** Whether each window row pins the window to the sidebar. */
|
||||||
@@ -61,13 +75,14 @@ export function UsageCard({
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const isCompact = useIsCompactFormFactor();
|
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.
|
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
|
||||||
const showsFreshnessInline = isNative || isCompact || !refreshable;
|
const showsFreshnessInline = isNative || isCompact || !refreshable;
|
||||||
const usage = entry.report;
|
const usage = entry.report;
|
||||||
const status = statusText(usage);
|
const status = statusText(usage);
|
||||||
const footer = entry.account.label ?? null;
|
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(
|
const containerStyle = useMemo(
|
||||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||||
@@ -110,9 +125,9 @@ export function UsageCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{message ? (
|
{messages.length > 0 ? (
|
||||||
<Text style={styles.error} numberOfLines={3}>
|
<Text style={styles.error} testID="usage-login-error">
|
||||||
{message}
|
{messages.join("\n")}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* One host's usage and the title-row controls that go with it, for the Usage modal.
|
||||||
* compact usage sheet.
|
|
||||||
*/
|
*/
|
||||||
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
|
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
|
||||||
view: UsageView;
|
view: UsageView;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import {
|
import {
|
||||||
resolveUsageHostId,
|
resolveUsageHostId,
|
||||||
resolveUsageScreenHostId,
|
resolveUsageModalHostId,
|
||||||
type UsageHost,
|
type UsageHost,
|
||||||
type UsageHostChoice,
|
type UsageHostChoice,
|
||||||
} from "./model";
|
} 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.
|
* is saved on the device, so the sidebar Usage row and later visits show the same host.
|
||||||
*/
|
*/
|
||||||
export function useUsageHostSelection(): {
|
export function useUsageHostSelection(): {
|
||||||
@@ -55,5 +55,5 @@ export function useUsageHostSelection(): {
|
|||||||
},
|
},
|
||||||
[updateSettings],
|
[updateSettings],
|
||||||
);
|
);
|
||||||
return { serverId: resolveUsageScreenHostId(choice), connectedHosts, select };
|
return { serverId: resolveUsageModalHostId(choice), connectedHosts, select };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export { AgentUsage } from "./agent-usage";
|
export { AgentUsage } from "./agent-usage";
|
||||||
export { HostUsageSection } from "./host-usage-section";
|
export { HostUsageSection } from "./host-usage-section";
|
||||||
export { useHostReportsUsage } from "./queries";
|
export { useHostReportsUsage } from "./queries";
|
||||||
export { UsageScreen } from "./usage-screen";
|
|
||||||
export {
|
export {
|
||||||
UsageSidebarItem,
|
UsageSidebarItem,
|
||||||
UsageSidebarRoot,
|
UsageSidebarRoot,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
replaceReport,
|
replaceReport,
|
||||||
resolveUsageRefresh,
|
resolveUsageRefresh,
|
||||||
resolveUsageHostId,
|
resolveUsageHostId,
|
||||||
resolveUsageScreenHostId,
|
resolveUsageModalHostId,
|
||||||
resolveUsageView,
|
resolveUsageView,
|
||||||
settleReports,
|
settleReports,
|
||||||
upsertReport,
|
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", () => {
|
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",
|
"old",
|
||||||
);
|
);
|
||||||
expect(
|
expect(resolveUsageModalHostId({ pickedServerId: "offline", activeServerId: "b", hosts })).toBe(
|
||||||
resolveUsageScreenHostId({ pickedServerId: "offline", activeServerId: "b", hosts }),
|
"b",
|
||||||
).toBe("b");
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("otherwise shows the host the sidebar row reads", () => {
|
it("otherwise shows the host the sidebar row reads", () => {
|
||||||
for (const pickedServerId of [null, "a", "offline"]) {
|
for (const pickedServerId of [null, "a", "offline"]) {
|
||||||
for (const activeServerId of ["b", null, "old", "offline"]) {
|
for (const activeServerId of ["b", null, "old", "offline"]) {
|
||||||
const choice = { pickedServerId, activeServerId, hosts };
|
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(
|
expect(
|
||||||
resolveUsageScreenHostId({
|
resolveUsageModalHostId({
|
||||||
pickedServerId: null,
|
pickedServerId: null,
|
||||||
activeServerId: null,
|
activeServerId: null,
|
||||||
hosts: hosts.slice(0, 2),
|
hosts: hosts.slice(0, 2),
|
||||||
}),
|
}),
|
||||||
).toBe("old");
|
).toBe("old");
|
||||||
expect(
|
expect(
|
||||||
resolveUsageScreenHostId({ pickedServerId: null, activeServerId: null, hosts: [] }),
|
resolveUsageModalHostId({ pickedServerId: null, activeServerId: null, hosts: [] }),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
* The host the Usage modal 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
|
* report usage so the modal says to update it; else the sidebar row's host; else the first
|
||||||
* connected host.
|
* 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 connected = choice.hosts.filter((host) => host.isConnected);
|
||||||
const picked = connected.find((host) => host.serverId === choice.pickedServerId);
|
const picked = connected.find((host) => host.serverId === choice.pickedServerId);
|
||||||
return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null;
|
return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null;
|
||||||
|
|||||||
@@ -98,10 +98,14 @@ async function getReport(
|
|||||||
serverId: string,
|
serverId: string,
|
||||||
reportId: string,
|
reportId: string,
|
||||||
forceRefresh = false,
|
forceRefresh = false,
|
||||||
|
agentId?: string,
|
||||||
): Promise<UsageReportEntry | null> {
|
): Promise<UsageReportEntry | null> {
|
||||||
return (
|
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
|
* Refreshes a host report, or the agent's session reports. The result replaces the card
|
||||||
* in every list of its host that holds it, the host's and each agent's, so every surface showing
|
* in the requesting view. Host and agent views can use different logins of the same account.
|
||||||
* it moves together; until then the previous report stays on screen.
|
|
||||||
*/
|
*/
|
||||||
export function useReportRefresh(
|
export function useReportRefresh(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
reportId: string,
|
reportId: string,
|
||||||
|
agentId?: string,
|
||||||
): { refresh: () => void; refreshState: UsageRefresh } {
|
): { refresh: () => void; refreshState: UsageRefresh } {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => getReport(serverId, reportId, true),
|
mutationFn: () => getReport(serverId, reportId, true, agentId),
|
||||||
onSuccess: (report) => {
|
onSuccess: (report) => {
|
||||||
queryClient.setQueriesData<UsageReportEntry[]>(
|
queryClient.setQueryData<UsageReportEntry[]>(
|
||||||
{ queryKey: hostUsageQueryKey(serverId) },
|
agentId === undefined
|
||||||
|
? usageReportsQueryKey(serverId)
|
||||||
|
: agentUsageQueryKey(serverId, agentId),
|
||||||
(reports) => (reports ? replaceReport(reports, reportId, report) : reports),
|
(reports) => (reports ? replaceReport(reports, reportId, report) : reports),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { router } from "expo-router";
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
Fragment,
|
Fragment,
|
||||||
@@ -17,13 +16,9 @@ import {
|
|||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
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 { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||||
import { buildUsageRoute } from "@/utils/host-routes";
|
import { useUsagePreferences } from "./display";
|
||||||
import { useHostUsageWithControls } from "./controls";
|
import { useUsageHostId } from "./hosts";
|
||||||
import { useUsagePreferences, type UsageDisplay } from "./display";
|
|
||||||
import { useUsageHostId, useUsageHostSelection } from "./hosts";
|
|
||||||
import { useUsageHostReports } from "./queries";
|
import { useUsageHostReports } from "./queries";
|
||||||
import { UsageSourceIcon } from "./source-icon";
|
import { UsageSourceIcon } from "./source-icon";
|
||||||
import { UsageMeter } from "./meter";
|
import { UsageMeter } from "./meter";
|
||||||
@@ -35,8 +30,7 @@ import {
|
|||||||
type PinnedUsageLayout,
|
type PinnedUsageLayout,
|
||||||
type PinnedUsageSource,
|
type PinnedUsageSource,
|
||||||
} from "./pinned";
|
} from "./pinned";
|
||||||
import type { UsageHost } from "./model";
|
import { UsageModal } from "./usage-modal";
|
||||||
import { UsageBody } from "./usage-section";
|
|
||||||
|
|
||||||
/** Each summary window with data on the usage host, under its source; empty while none has. */
|
/** Each summary window with data on the usage host, under its source; empty while none has. */
|
||||||
function useUsageSummary(): readonly PinnedUsageSource[] {
|
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
|
* 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.
|
* while no summary window has data, since the footer's Usage icon already opens Usage.
|
||||||
* Pressing it opens the Usage screen; on compact layouts it opens the usage sheet instead.
|
* Pressing it opens the Usage modal.
|
||||||
*/
|
*/
|
||||||
export function UsageSidebarItem() {
|
export function UsageSidebarItem() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -66,30 +60,25 @@ export function UsageSidebarItem() {
|
|||||||
|
|
||||||
const OpenSidebarUsageContext = createContext<(() => void) | null>(null);
|
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 }) {
|
export function UsageSidebarRoot({ children }: { children: ReactNode }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { display } = useUsagePreferences();
|
|
||||||
const isCompact = useIsCompactFormFactor();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [sheetMounted, setSheetMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const openUsage = useCallback(() => {
|
const openUsage = useCallback(() => {
|
||||||
if (isCompact) {
|
setMounted(true);
|
||||||
setSheetMounted(true);
|
setOpen(true);
|
||||||
setOpen(true);
|
}, []);
|
||||||
return;
|
const closeUsage = useCallback(() => setOpen(false), []);
|
||||||
}
|
const unmountUsage = useCallback(() => setMounted(false), []);
|
||||||
router.push(buildUsageRoute());
|
|
||||||
}, [isCompact]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OpenSidebarUsageContext.Provider value={openUsage}>
|
<OpenSidebarUsageContext.Provider value={openUsage}>
|
||||||
<SidebarPopoverRoot open={isCompact && open} onOpenChange={setOpen}>
|
{children}
|
||||||
{children}
|
{mounted ? <UsageModal visible={open} onClose={closeUsage} onDismiss={unmountUsage} /> : null}
|
||||||
{isCompact && sheetMounted ? (
|
|
||||||
<UsageSheet title={t(builtinSidebarNavLabelKey("usage"))} display={display} />
|
|
||||||
) : null}
|
|
||||||
</SidebarPopoverRoot>
|
|
||||||
</OpenSidebarUsageContext.Provider>
|
</OpenSidebarUsageContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,57 +90,6 @@ export function useOpenSidebarUsage(): () => void {
|
|||||||
return openUsage;
|
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 (
|
|
||||||
<HostUsageSheet
|
|
||||||
key={serverId}
|
|
||||||
title={title}
|
|
||||||
serverId={serverId}
|
|
||||||
hosts={connectedHosts}
|
|
||||||
onSelectHost={select}
|
|
||||||
display={display}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<SidebarPopoverSurface
|
|
||||||
section="footer"
|
|
||||||
title={title}
|
|
||||||
sheetTrailing={controls}
|
|
||||||
testID="sidebar-usage-sheet"
|
|
||||||
>
|
|
||||||
<View style={styles.sheetBody} testID="usage-expanded">
|
|
||||||
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
|
|
||||||
</View>
|
|
||||||
</SidebarPopoverSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string {
|
function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string {
|
||||||
const windows = sources.flatMap((source) => source.windows);
|
const windows = sources.flatMap((source) => source.windows);
|
||||||
return `${label}: ${windows.map((window) => window.label).join(", ")}`;
|
return `${label}: ${windows.map((window) => window.label).join(", ")}`;
|
||||||
@@ -343,8 +281,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
windowLabel: {
|
windowLabel: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
sheetBody: {
|
|
||||||
padding: theme.spacing[3],
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<UsageModalFrame {...props}>
|
||||||
|
<UsageMessage text={usageCopy.noHosts} />
|
||||||
|
</UsageModalFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Keyed by host so a refresh in flight on one host never shows on another's cards.
|
||||||
|
return (
|
||||||
|
<HostUsageModal
|
||||||
|
key={serverId}
|
||||||
|
{...props}
|
||||||
|
serverId={serverId}
|
||||||
|
hosts={connectedHosts}
|
||||||
|
onSelectHost={select}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<UsageModalFrame {...props} actions={controls}>
|
||||||
|
<View testID={`usage-host-${serverId}`}>
|
||||||
|
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
|
||||||
|
</View>
|
||||||
|
</UsageModalFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<AdaptiveModalSheet
|
||||||
|
header={header}
|
||||||
|
visible={visible}
|
||||||
|
onClose={onClose}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
desktopMaxWidth={640}
|
||||||
|
contextBridge={null}
|
||||||
|
testID="usage-modal"
|
||||||
|
>
|
||||||
|
<View testID="usage-modal-body">{children}</View>
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <UsagePage>{null}</UsagePage>;
|
|
||||||
return <FocusedUsageScreen />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsagePage({ actions, children }: { actions?: ReactNode; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<PageLayout title={usageCopy.title} onBack={leaveUsage} actions={actions} testID="usage-screen">
|
|
||||||
{children}
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FocusedUsageScreen() {
|
|
||||||
const { serverId, connectedHosts, select } = useUsageHostSelection();
|
|
||||||
if (!serverId) {
|
|
||||||
return (
|
|
||||||
<UsagePage>
|
|
||||||
<UsageMessage text={usageCopy.noHosts} />
|
|
||||||
</UsagePage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<HostUsage key={serverId} serverId={serverId} hosts={connectedHosts} onSelectHost={select} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<UsagePage actions={controls}>
|
|
||||||
<View testID={`usage-host-${serverId}`}>
|
|
||||||
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
|
|
||||||
</View>
|
|
||||||
</UsagePage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -428,10 +428,6 @@ export function buildSchedulesRoute() {
|
|||||||
return "/schedules" as const;
|
return "/schedules" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildUsageRoute() {
|
|
||||||
return "/usage" as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildOpenProjectRoute() {
|
export function buildOpenProjectRoute() {
|
||||||
return "/open-project" as const;
|
return "/open-project" as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { i18n } from "@/i18n/i18next";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
SidebarProjectEntry,
|
SidebarProjectEntry,
|
||||||
@@ -211,6 +212,7 @@ describe("buildStatusSidebarShortcutModel", () => {
|
|||||||
["p1", "Project 1"],
|
["p1", "Project 1"],
|
||||||
["p2", "Project 2"],
|
["p2", "Project 2"],
|
||||||
]),
|
]),
|
||||||
|
i18n.t,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,7 +249,7 @@ describe("buildStatusSidebarShortcutModel", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const model = 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"]),
|
collapsedStatusGroupKeys: new Set(["needs_input"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.11.0-beta.3",
|
"version": "0.11.0-beta.5",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.11.0-beta.3",
|
"@getpaseo/client": "0.11.0-beta.5",
|
||||||
"@getpaseo/protocol": "0.11.0-beta.3",
|
"@getpaseo/protocol": "0.11.0-beta.5",
|
||||||
"@getpaseo/server": "0.11.0-beta.3",
|
"@getpaseo/server": "0.11.0-beta.5",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export function addAttachOptions(cmd: Command): Command {
|
|||||||
.argument("<id>", "Agent ID (or prefix)");
|
.argument("<id>", "Agent ID (or prefix)");
|
||||||
}
|
}
|
||||||
import { connectToDaemon } from "../../utils/client.js";
|
import { connectToDaemon } from "../../utils/client.js";
|
||||||
|
import { waitForStop } from "../../utils/wait-for-stop.js";
|
||||||
import {
|
import {
|
||||||
fetchProjectedTimelineItems,
|
fetchProjectedTimelineItems,
|
||||||
LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||||
@@ -157,31 +158,10 @@ export async function runAttachCommand(
|
|||||||
await unsubscribe.ready;
|
await unsubscribe.ready;
|
||||||
console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`);
|
console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`);
|
||||||
|
|
||||||
// Handle Ctrl+C to detach gracefully
|
await waitForStop();
|
||||||
let detached = false;
|
console.log("\n\nDetaching from agent...");
|
||||||
const detach = () => {
|
unsubscribe();
|
||||||
if (detached) return;
|
await client.close();
|
||||||
detached = true;
|
|
||||||
|
|
||||||
console.log("\n\nDetaching from agent...");
|
|
||||||
unsubscribe();
|
|
||||||
client
|
|
||||||
.close()
|
|
||||||
.then(() => {
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on("SIGINT", detach);
|
|
||||||
process.on("SIGTERM", detach);
|
|
||||||
|
|
||||||
// Keep the process alive
|
|
||||||
await new Promise(() => {
|
|
||||||
// Wait indefinitely until interrupted
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.close().catch(() => {});
|
await client.close().catch(() => {});
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { connectToDaemon } from "../../utils/client.js";
|
import { connectToDaemon } from "../../utils/client.js";
|
||||||
|
import { waitForStop } from "../../utils/wait-for-stop.js";
|
||||||
import type { CommandOptions } from "../../output/index.js";
|
import type { CommandOptions } from "../../output/index.js";
|
||||||
import {
|
import {
|
||||||
fetchProjectedTimelineItems,
|
fetchProjectedTimelineItems,
|
||||||
@@ -224,16 +225,7 @@ async function runFollowMode(
|
|||||||
await unsubscribe.ready;
|
await unsubscribe.ready;
|
||||||
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||||
|
|
||||||
// Wait for interrupt
|
await waitForStop();
|
||||||
await new Promise<void>((resolve) => {
|
unsubscribe();
|
||||||
const cleanup = () => {
|
|
||||||
unsubscribe();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on("SIGINT", cleanup);
|
|
||||||
process.on("SIGTERM", cleanup);
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.close();
|
await client.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,14 +358,21 @@ test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])(
|
|||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
});
|
});
|
||||||
const exited = new Promise((resolve) => child!.once("exit", resolve));
|
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
|
await expect
|
||||||
.poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 })
|
.poll(
|
||||||
.toBe(true);
|
async () => {
|
||||||
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
|
supervisorPid = (await readDaemonInstance(home))?.pid;
|
||||||
|
return supervisorPid;
|
||||||
|
},
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
)
|
||||||
|
.toBeTypeOf("number");
|
||||||
await f.ok(["status", "--home", home]);
|
await f.ok(["status", "--home", home]);
|
||||||
child.kill("SIGINT");
|
child.kill("SIGINT");
|
||||||
await exited;
|
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");
|
expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped");
|
||||||
} finally {
|
} finally {
|
||||||
child?.kill("SIGTERM");
|
child?.kill("SIGTERM");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user